ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【从0到1学习JVM · 13】点进JDK源码只有一个分号?搞懂本地方法栈与JNI机制

【从0到1学习JVM · 13】点进JDK源码只有一个分号?搞懂本地方法栈与JNI机制 前言看 JDK 源码时很多核心方法点进去只有一个分号方法体直接消失了。Java 到底怎么跨语言调用 C/C本地方法栈又是如何管理这些调用的本文用真实链路带你拆解透彻。文章目录前言一、点进源码只有一个分号实现代码去哪了二、为什么Java必须留一扇通往C语言的后门2.1 Java代码无法直接操作底层硬件2.2 极致的执行性能与底层计算2.3 遗留系统与现有资产的复用三、本地方法栈的执行机制与HotSpot的合二为一3.1 本地方法栈的角色与分工3.2 HotSpot为什么把两个栈合二为一了四、本地方法报错会怎样栈溢出与进程崩溃4.1 本地方法栈的两类标准内存异常4.2 JNI 调用引发进程崩溃的破坏力写在最后一、点进源码只有一个分号实现代码去哪了刚开始翻看 JDK 源码的同学大概率都遇到过这种让人摸不着头脑的场景你想看Thread.start()底层到底是怎么创建系统线程的点进去一路追最后在Thread.java里看到了这样一行代码packagecom.crayontech.jvm;publicclassNativeMethodDemo{// 只有一个 native 修饰符后面直接跟着分号连大括号都没有privatenativevoidstart0();}或者你想看看每个 Java 对象都有的hashCode()是怎么算出来的在Object.java里看到的同样只有一行publicnativeinthashCode();方法声明后面没有大括号直接用一个分号收尾。初学者往往会纳闷实现代码到底去哪了难道 JVM 里藏着什么见不得人的黑魔法其实一点也不神秘。这行代码上的关键在于native关键字。一旦一个方法被声明为native就意味着这个方法是一个本地方法Native Method。它的签名在 Java 里定义但真正的逻辑实现完全不归 Java 管全由底层的 C、C 或汇编语言写好提前编译成了操作系统能够直接识别的动态链接库在 Linux 下是.so文件在 Windows 下是.dll文件在 macOS 下是.dylib文件。当 Java 程序运行时虚拟机通过JNIJava Native InterfaceJava 本地接口去加载这些动态链接库并建立 Java 方法与 C/C 函数之间的映射关系。操作系统原生运行时Native OSJava 运行时环境JVM 沙箱动态加载与符号查找Thread.start() / Object.hashCode()native 方法声明(仅有方法签名分号结尾)JNI 本地接口桥接层(Java Native Interface)动态链接库 (.so / .dll / .dylib)C/C 实现函数(JVM_StartThread / ObjectSynchronizer)操作系统的底层系统调用(pthread_create / 硬件时钟 / 内存总线)native方法就像是 Java 打开的一扇通往原生底层世界的暗门。二、为什么Java必须留一扇通往C语言的后门Java 诞生之初主打的旗号是“一次编写到处运行”依靠虚拟机沙箱隔绝了操作系统的差异。既然要跨平台和安全为什么还要在语言规范里特意保留native这种打破沙箱的机制原因很现实主要集中在三个纯 Java 根本绕不开的硬骨头问题上2.1 Java代码无法直接操作底层硬件Java 代码运行在 JVM 抽象出的虚拟指令集之上它是没有权限直接操作 CPU 寄存器、操作显卡驱动或者向物理内存指定地址写入数据的。但是现代操作系统管理着进程、物理线程、虚拟内存、网络网卡与磁盘 I/O。当你在 Java 里调用new Thread().start()时JVM 必须向操作系统内核发起系统调用例如 Linux 上的clone()或pthread_create()让操作系统调度器切出一段真正的内核线程。Java 虚拟机自己是用 C 写的它必须通过本地方法才能拿到操作系统的原生句柄完成线程创建、文件读写、套接字通信以及控制台输出。2.2 极致的执行性能与底层计算在密码学哈希、音视频编解码、3D 图形渲染、大矩阵数学计算以及高并发原子操作等场景下即便是拥有强大 JIT 编译器的 Java也很难做到像 C/C 配合特定 CPU 架构的 SIMD单指令多数据流向量指令那样贴近硬件极限。通过 JNI 调用现成且经过数十年工业级优化的 C/C 基础库比如 OpenCV、FFmpeg、BLAS 等Java 生态既能享受到上层高生产力又能直接复用底层的极速算力。2.3 遗留系统与现有资产的复用在 Java 面世之前各大金融机构、工业软件和嵌入式设备里已经积累了海量经过严苛验证的 C/C 代码库。如果把这些老系统全部用 Java 重新写一遍成本巨大不说还容易引入未知的风险隐患。有了 JNI 和本地方法Java 就可以直接当作一个胶水语言在上层编写清晰优美的业务逻辑底层照样无缝驱动老旧的 C 库。典型的 JDK 内部核心 Native 场景Java 依赖 Native 方法的三大核心诉求系统内核交互创建物理线程、直接物理内存分配、硬件时钟计算性能极限SIMD 向量加速、音视频解码、高频硬件指令历史代码复用复用成熟的 C/C 工业软件与驱动接口Thread.start0() - 操作系统创建线程Unsafe.allocateMemory() - 直接向 OS 申请堆外内存Math.sin() / StrictMath - 直接调用底层 FPU 浮点运算器CRC32 / AES 硬件加速指令AWT / Swing 窗口绘制 - 底层 X11 / Win32 / Cocoa 接口三、本地方法栈的执行机制与HotSpot的合二为一理解了本地方法接下来就能看懂本地方法栈Native Method Stack到底是用来干什么的了。3.1 本地方法栈的角色与分工前面我们拆解过 Java 虚拟机栈每当线程调用一个 Java 方法时JVM 就会在虚拟机栈里压入一个栈帧用来存放这个 Java 方法的局部变量表、操作数栈、动态链接和方法出口。那么当线程执行到一个native方法时会发生什么由于本地方法是用 C/C 写的根本不包含 Java 字节码操作数栈和局部变量表的结构自然也就不适用了。这时候就需要一块专门的内存空间来支撑 C/C 函数的执行——这就是本地方法栈。本地方法栈同样是线程私有的生命周期随着线程创建而产生随着线程销毁而终结。当一个线程调用本地方法时它的执行上下文就从 Java 虚拟机的掌控区切换到了底层 C 运行时环境本地方法栈负责维护 C/C 函数调用时需要的栈帧包括 C 函数的参数、局部变量、返回地址以及寄存器备份C 语言里的指针寻址、内存操作都在这个栈空间和系统内存中展开如果这个本地方法执行过程中又反向回调了 Java 方法执行上下文还会从本地方法栈再次切回到 Java 虚拟机栈。底层 C/C 动态库本地方法栈Java 虚拟机栈工作线程底层 C/C 动态库本地方法栈Java 虚拟机栈工作线程发生执行上下文切换Java - Native恢复 Java 运行时上下文调用业务方法 calculate() - 压入 Java 栈帧1执行 Java 字节码运算2调用 native 方法 start0()3进入 JNI 桥接 - 压入 Native 栈帧C 语言上下文4执行 C 函数 JVM_StartThread()5底层系统调用完成返回结果6弹出 Native 栈帧7弹出 calculate() 栈帧调用链结束83.2 HotSpot为什么把两个栈合二为一了在阅读官方文档时有一个很容易产生认知偏差的地方《Java 虚拟机规范》对本地方法栈的具体实现做出了非常宽泛的规定允许虚拟机实现者自由选择本地方法栈的语言、底层数据结构如果 JVM 根本不支持调用 native 方法甚至可以完全不提供本地方法栈如果支持 native 方法规范要求在逻辑概念上将 Java 虚拟机栈与本地方法栈区分开来。在许多经典的虚拟机设计中这两个栈是各管各的分别开辟独立的内存连续区域。但在我们现在最常用、最主流的HotSpot 虚拟机中官方做了一个极其务实的选择直接将 Java 虚拟机栈与本地方法栈合二为一HotSpot 工程优化合并HotSpot 虚拟机实现物理统一统一栈内的交替栈帧Java 栈帧calculate()Native 栈帧start0() (JNI 调用)Java 栈帧main()统一线程调用栈物理上使用同一个 C 原生调用栈Java 虚拟机规范逻辑设计Java 虚拟机栈仅负责 Java 字节码栈帧本地方法栈负责 C/C 原生栈帧为什么 HotSpot 要这么干因为 HotSpot 本身就是用 C 开发并编译为平台机器码的程序。在操作系统层面一个物理线程从创建伊始操作系统就会天然为它分配一个连续的机器调用栈C Runtime Stack。既然底层已经有了一个现成的机器栈再去额外开辟一块孤立的内存当本地方法栈既增加了栈指针切换与内存对齐的开销也加大了内存管理的碎片率。因此在 HotSpot 源码中栈空间合二为一Java 方法栈帧和 Native 方法的 C 栈帧直接交替存放在操作系统分配给该线程的同一个调用栈上调优参数合二为一HotSpot 中没有专门配置本地方法栈大小的独立参数我们在启动时配置的-Xss参数或者-XX:ThreadStackSize直接统筹决定了这整根合并栈的容量上限。四、本地方法报错会怎样栈溢出与进程崩溃既然本地方法能像普通方法一样运行那它在遇到极端异常时表现会和 Java 方法一样吗我们平时调 Java 代码哪怕栈深度不够了顶多是主线程抓到一个StackOverflowError只要 catch 住整个进程通常不会死掉。但本地方法栈一旦出事性质就截然不同了。4.1 本地方法栈的两类标准内存异常与虚拟机栈一样规范中明确定义了本地方法栈可能抛出的两类错误StackOverflowError栈深度溢出如果本地方法栈采用固定大小而线程请求的栈容量超过了允许的最大深度就会抛出StackOverflowError。例如用 JNI 调用的 C 语言函数内部写了一个没有终止条件的死递归把线程的栈空间耗尽时就会触发。OutOfMemoryError内存耗尽如果本地方法栈支持动态扩展但在向操作系统申请更多内存扩展栈容量时操作系统物理内存已经告罄或者并发创建了太多线程导致操作系统再也分不出新的线程栈空间JVM 就会抛出OutOfMemoryError。4.2 JNI 调用引发进程崩溃的破坏力Java 虚拟机给开发者提供了一个安全沙箱数组越界有ArrayIndexOutOfBoundsException空对象访问有NullPointerException哪怕内存泄漏也有 GC 日志和 OOM 堆转储。但是只要调用逻辑跨过了 JNI 边界进入 C/C 代码这个安全沙箱就彻底失效了。来看一个简单的 JNI 示例来感受这种反差packagecom.crayontech.jvm;publicclassNativeCrashSimulator{static{// 加载打包好的底层动态链接库System.loadLibrary(native_calc);}// 声明本地方法publicnativeintexecuteNativeCalculation(int[]data);publicstaticvoidmain(String[]args){NativeCrashSimulatorsimulatornewNativeCrashSimulator();// 传递一个 null 指针或者错误长度进去intresultsimulator.executeNativeCalculation(null);System.out.println(计算结果result);}}如果在底层 C 函数的实现中写代码的人没有做防御性判空直接拿传进来的野指针解引用写入数据// 对应的底层 C 代码片段JNIEXPORT jint JNICALLJava_com_crayontech_jvm_NativeCrashSimulator_executeNativeCalculation(JNIEnv*env,jobject obj,jintArray array){// 如果没有使用 (*env)-GetIntArrayElements 进行安全校验直接裸指针读写int*ptrNULL;*ptr100;// 经典的野指针解引用引发非法内存访问return100;}在 Java 代码里你哪怕在外面套了十层try ... catch (Throwable t)也绝对拦不住这个错误。因为程序此时直接触发了操作系统的段错误Linux 下的SIGSEGVWindows 下的EXCEPTION_ACCESS_VIOLATION这根本不属于 Java 虚拟机能捕获的语言级异常。操作系统会直接向 JVM 发送致命信号强制终止当前进程。整个 Java 应用程序会在瞬间直接闪退控制台只会留下一份类似于hs_err_pidpid.log的 JVM 致命崩溃日志。JNI 本地方法致命错误C 代码野指针 / 缓冲区溢出 / 内存越界操作系统捕获非法物理地址访问抛出 SIGSEGV 信号生成 hs_err_pid 崩溃日志JVM 进程被操作系统直接杀死普通 Java 方法异常处理Java 代码逻辑错误 / 空指针 / 递归越界进入 JVM 异常捕获机制try-catch打印堆栈信息工作线程可恢复进程安全运行这也是为什么在日常企业级业务开发中主流技术架构很少推荐业务开发人员自行手写 JNI 代码。一旦底层 C 代码出现哪怕一处内存泄漏或缓冲区溢出毁掉的就绝不止单个线程整个容器或者微服务节点都会跟着一起当场崩溃。写在最后本地方法栈在日常业务开发中存在感极低但它却是 JVM 连接真实物理世界的生命通道。从底层的操作系统线程调度、高精度时间戳获取到堆外物理内存管理Java 之所以能在提供极致安全开发体验的同时保持强大的底层控制力全靠本地方法与本地方法栈在幕后默默支撑。搞懂了本地方法栈下一次在源码里看到只有分号的native方法时你就知道它背后的 C 语言齿轮是如何转动的了。如果这篇拆解对你理解 JVM 运行时数据区有所启发记得点个关注追更本系列的后续深度拆解
返回列表