我最初意识到JNI、安全点和循环优化这三件事有关系,不是从哪本教科书上看到的,而是在一次线上事故的GC日志里读出来的。当时有个对接硬件厂商SDK的Java服务,每秒钟都要通过JNI往本地C库发一批二进制数据,平时压力不大时一切正常,但一到核心链路做压测,GC停顿就从原来的几毫秒飙到几十毫秒,甚至出现秒级卡顿。团队里所有人第一反应都是堆内存太小,或者对象分配太多,可GC日志翻来覆去看,堆明明很健康,年轻代回收也频繁但不慢。直到有人加了安全点统计参数,才发现问题根本不在GC本身,而在“线程到达安全点”这件事上拖了后腿。
那次之后我才彻底明白:JNI、安全点、循环优化,很多人把它们当成三个孤立的技术点,面试题里也经常分开考,但它们在生产环境里根本不是独立存在的。JVM的一切暂停、GC、偏向锁撤销、线程快照,都需要所有Java线程先走到一个“安全点”上;JIT编译出来的热点代码会在循环回边和方法调用处插入安全点检查。你写一个毫秒级的大循环,或者通过JNI跑了一段不被JVM“看见”的native逻辑,都会直接影响整条JVM线程的停顿行为。这篇文章就把这三件事串起来讲清楚,从原理到实操,再到面试怎么答,尽量一次说透。
1. 三个关键词之间的逻辑网:为什么它们总是纠缠在一起
1.1 一次线上事故的完整复盘
先说当时的具体场景。那个服务里有一个NativeDataSender类,核心逻辑是每100毫秒从Java侧拿一批计算好的字节数组,然后调用native void sendBytes(byte[] data),把这个数组拷贝到C库的环形缓冲区里。代码本身没问题,单看JNI函数也就是GetByteArrayElements加一个memcpy,理论耗时微秒级。真正的坑出现在JNI调用前后的Java循环里。
为了拼接数据,我在Java侧写了一个几十万次的循环,里面做了大量字符串拼接和临时数组创建。原本想象中JIT会把这段代码优化得不错,可实际运行起来,循环内频繁触发安全点检查,线程经常要停下来配合GC做根扫描。再加上JNI接口本身在native代码执行期间不响应安全点,Java线程在native侧待的时间越久,其他线程等待它进入安全点的时间就越长。于是整个服务就出现了典型的“假GC停顿”现象:GC明明只用了10毫秒,但应用停顿报告里写的是200毫秒。
这件事让我意识到,任何性能问题都不能只看单点。你优化了GC、优化了JIT,但只要安全点机制卡住,所有努力都会在最后一步变成用户可见的延迟。
1.2 三条线的传导逻辑
我把这个传导关系总结成一句话:JNI会让线程在native侧“隐形”,循环密度会决定安全点检查的频率,而安全点的进入速度则决定了JVM停顿时间的上限。
具体来说,JVM的垃圾回收器在做STW(Stop The World)时,要等所有Java线程进入安全点。安全点对应的字节码位置通常包括方法调用、循环回边、异常跳转等。你在for循环里写一个累加操作,循环每次往回跳都要问一次“现在安全吗”;你平时写代码根本感觉不到这个检查,但它真实存在。JNI呢,更特殊:一旦线程进入native方法,JVM没法像Java方法那样随时中断它,只能等native函数返回或者主动调用JNIEnv的某些接口。所以JNI调用就像隧道,线程在隧道里时,外部世界拿它一点办法都没有。
反过来,循环优化做得越好,JIT可能对循环做向量化、展开、剥离,一个几千次的循环可能被转换成十几条SIMD指令,安全点检查次数也可能随之变化。这就会引出一个非常反直觉的问题:越优化的循环,安全点行为越难预测。这也是为什么我们讨论高健壮性Java应用时,必须把这三件事放在同一张桌上讨论,而不是各自看各自的。
1.3 什么人在什么场景下最需要这些内容
文章虽然叫“深入理解”,但我在写的时候尽量按照实战经验来组织,适合三类读者:
- 第一类是后端性能排查人员,遇到GC停顿异常、线程卡顿但堆内存健康的情况,需要检查安全点,甚至定位到JNI代码。
- 第二类是中间件开发者或者做SDK集成、做音视频处理、做硬件控制这类必须和native库打交道的Java工程师。
- 第三类是准备大厂Java面试的人,JNI、JIT、安全点都是高端面试题里反复出现的挖坑点。
不管你是哪一类,只要能读懂后面的思路,至少能让你在面对“为什么GC停顿比预期高很多”这类问题时,多一个排查维度。
2. JNI开发关键点与CLion环境搭建:从定位到实用坑位
2.1 JNI在真实项目里的正确定位
说到JNI,很多Java开发者对它有一种两极分化的态度。一部分人觉得“Java调C是不是很高级、很酷”,另一部分人觉得“JNI就是性能毒药,尽量别碰”。这两种看法其实都错了。JNI本身并不是性能毒药,它只是把控制权交给native代码,而native代码不受JVM内存模型和运行时机制约束。JNI真正的问题在于它破坏了JVM的两个核心假设:线程可以随时安全点停住,以及对象可以自由移动。
所以,一个经验丰富的Java工程师不会回避JNI,而是会主动控制JNI的“影响半径”。怎么控制呢?最核心的思路就是:尽量让JNI调用只负责一小段纯计算或纯数据交换,不要把一个完整的业务逻辑都丢到native里。如果native侧要执行几百毫秒的复杂逻辑,那整个JVM线程就等于几百毫秒不可暂停,所有其他线程都要陪着等。我见过一个项目把JSON解析也写进C库,理由是“C比Java快”,结果一次解析虽然快了几毫秒,但GC安全点等待时间反而涨了上百毫秒,完全得不偿失。
2.2 在CLion里配置JNI开发环境
如果你需要自己写JNI代码,我特别推荐在CLion里搭建环境,因为CLion对CMake、调试器和代码跳转的支持很完整,尤其是当你需要对照jni.h查看函数签名时,比纯命令行方便太多。这里给出一套我实测下来很稳的配置流程。
第一步,确定JDK的安装路径。这个路径决定了jni.h和jni_md.h的位置。Windows和Linux略有差异,我用的是Linux,路径一般在$JAVA_HOME/include和$JAVA_HOME/include/linux。
第二步,创建CMake工程。CLion默认使用CMake,所以项目的核心文件就是CMakeLists.txt。一个最简单的配置大致长这样:
cmake_minimum_required(VERSION 3.20) project(jni_study C) set(CMAKE_C_STANDARD 11) # 引入JNI头文件目录 include_directories($ENV{JAVA_HOME}/include) include_directories($ENV{JAVA_HOME}/include/linux) # 编译生成动态库,注意SHARED关键字 add_library(native_bridge SHARED native_bridge.c)这里有个很容易踩的坑:如果JAVA_HOME环境变量没配好,CMake会静默跳过jni.h的搜索,之后代码里就会出现一堆红色波浪线。建议在CMake里加一个判断,直接打印变量值来确认路径是否正确:
message(STATUS "JAVA_HOME is: $ENV{JAVA_HOME}")第三步,从Java侧生成头文件。我习惯先在Java类里声明native方法,然后用javac -h生成对应的C头文件,这样可以保证函数签名绝对一致,不会出现JNI方法名拼写错误。
javac -h . com/example/NativeBridge.java生成的头文件里会出现类似Java_com_example_NativeBridge_sendBytes这样的声明。你把头文件include进C代码,然后实现这个函数就行。注意不要自己去手写那个方法名,拼写错了JVM在运行时会报UnsatisfiedLinkError,排查起来很费劲。
第四步,编译和运行。在CLion里直接构建,然后让Java程序通过System.loadLibrary("native_bridge")加载动态库。如果构建出来的库不在Java的java.library.path里,你需要在JVM启动参数里指定,比如-Djava.library.path=/path/to/your/build。
2.3 踩过三轮才记住的JNI坑
JNI看似简单,其实坑非常密集。我挑几个在实际项目里最容易遇到的细节,说下我的处理方式。
第一个坑是局部引用生命周期。JNI的局部引用默认只在本机native方法返回之前有效,而且不能无限创建,超过一定量会抛LocalRefOverflowError。很多人在循环里反复调用NewStringUTF或FindClass,循环次数一大就直接溢出。我建议在循环体里主动调用DeleteLocalRef清理,或者用PushLocalFrame和PopLocalFrame成对管理引用。
for (int i = 0; i < count; i++) { jstring str = (*env)->NewStringUTF(env, "tmp"); // 用完立即释放 (*env)->DeleteLocalRef(env, str); }第二个坑是GetByteArrayElements的拷贝行为。这个方法可能返回原始指针,也可能返回一份拷贝,取决于JVM内部的实现。如果你拿到这个指针后长时间持有,可能会导致JVM无法移动这个数组对象,影响GC布局。正确做法是尽快操作、尽快用ReleaseByteArrayElements释放,不要把它存在一个全局变量里反复使用。
第三个坑是异常处理。Java侧的异常不会自动传递到C侧,反之亦然。你必须在native方法返回前检查ExceptionCheck,否则一旦Java代码里抛出异常,JNI调用就直接崩掉了,日志里往往只有hs_err文件,非常难看。
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); return NULL; }第四个坑是线程归属。JNIEnv是线程局部的,你不能把一个JNIEnv保存在全局变量里给另一个线程用。需要用全局引用+AttachCurrentThread把当前线程挂到JVM上才能获取新的JNIEnv。这个坑多见于用线程池处理native回调的架构。
2.4 JNI性能损耗的真实分布
很多面试题会问“JNI为什么慢”,标准答案是“JNI边界有开销,包括参数转换、引用管理、安全检查”,但实际损耗分布往往被抽象了。真正耗时的不是调用本身,而是:
- 参数转换。Java基本类型还好,字符串、数组、对象都要转换成本地表示,这是最明显的成本。
- 局部引用表的扩容。大量引用会触发表的扩张,这是最容易忽略的隐藏开销。
- 安全点交互。这部分我在下章展开,它对性能的影响往往比JNI本身还大。
所以我在设计JNI接口时,会尽量把数据打包成大字节数组传给native,而不是让Java侧反复调用多次小函数。能用int就用int,能传原始数组就不要传单个对象。减少边界穿越次数,比任何本地微优化都有效。
3. 安全点机制:从概念到线上诊断,再到循环灾难
3.1 安全点究竟在保护什么
安全点(Safepoint)听起来像是JVM一个可选的内部状态,但其实它是JVM运行时的地基。GC要做根扫描和对象移动的时候,必须保证Java线程的栈是稳定的,不能刚扫描完一半,线程又跑去执行下一步修改了栈。所以JVM要等所有线程都到达一个“不会继续改变Java栈状态”的代码位置,这个位置就是安全点。
另一个容易被忽略的场景是偏向锁撤销和类重定义。Java线程持有偏向锁时,如果另一个线程尝试竞争同一把锁,JVM需要撤销偏向状态,前提是所有拥有过这把锁的线程都到达安全点。这意味着不只是GC,连System.gc()、甚至某些内联优化也和安全点挂钩。
安全点机制本身并不复杂,简单说就是:JVM发起一个“请停一下”的请求,线程响应请求,到达安全点,然后报告“我已就绪”。等所有线程都报告完成,JVM就开始干活。
3.2 循环与安全点:为什么长循环是重灾区
前面提到,JIT会在循环回边插入安全点检查。这本来是为了防止线程长时间不响应安全点,比如一个巨大的for循环跑几毫秒甚至更长,如果没有安全点检查,其他线程就只能干等这个循环结束。
问题出在插桩的方式上。JVM并不是每一条循环指令都检查,而是在满足一定条件时才检查。对大部分循环来说,这个检查的开销微乎其微。但有一种循环会出问题:循环体本身非常短,循环次数非常大。比如:
long sum = 0; for (long i = 0; i < 10_000_000_000L; i++) { sum += i; }这种循环如果JIT没有把它压倒底层的向量指令,那么每轮循环都会经过一次回边安全点检查。当JVM发起一次GC时,正在跑这种循环的线程可能没那么快响应暂停信号,计数器要多累积几次才能真正跳出循环,停在某个安全点位置。于是GC等待时间暴涨,应用停顿时间远大于GC实际执行时间。
还有更极端的:你在循环里调用了一个不被JVM内联的native方法,那每次循环都会从Java状态切到native状态,再从native状态切回来。这个切换过程本身就要进行安全点协商,导致每次调用都伴随微停顿。循环一多,延迟自然就上来了。
3.3 安全点日志的解读方法
线上排查安全点问题,首先要打开JVM的停顿时间日志。我常用的几个参数组合是:
-XX:+PrintGCApplicationStoppedTime -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1输出里你会看到类似这样的行:
vmop [threads: total=34, initially_running=0, wait_to_block=33]最重要的字段是wait_to_block,它表示有多少线程还在等待进入阻塞状态。如果这个数字很大,说明有线程迟迟没有进入安全点。然后再看Spin和Block时间,Block时间越长,基本可以断定某一线程卡在非安全点代码或者native代码里。
我建议你注意一个细节:不要只看Total time for which application threads were stopped,那个是总停顿;要看Stopping threads took之类的时间,它才是安全点同步开销。如果这个开销占了总停顿的70%以上,你基本可以排除堆大小和GC算法的问题,转向线程状态和JNI代码。
3.4 常见安全点停顿来源分析
根据我的排查经验,安全点停顿的来源可以归纳为四类:
- JNI执行过长。线程在native函数里跑太久,JVM等不到它进入安全点。解决办法是缩短单次JNI调用时间,或者让native代码定期回调Java侧进行“检查点”操作。
- 非安全点热点循环。某些JIT编译后的循环没有被安全点插桩,导致线程长时间不响应。这种情况在JDK老版本中更明显,新版JIT已大幅改善,但仍存在边界场景。
- 偏向锁撤销。大量线程在锁竞争场景下触发偏向锁撤销,每次撤销都要求相关线程到达安全点,产生频率极高的微型停顿。
- 分配压力触发GC。这个更常见,但从安全点角度看,只是正常现象,不是病根。
排查时要结合线程dump看。线程状态如果是RUNNABLE且在native方法栈帧上,优先怀疑JNI;如果是Java循环栈帧,优先怀疑循环内安全点插桩问题。
4. 循环优化:JIT编译器的魔法与它的安全点边界
4.1 从解释执行到热点编译
聊循环优化之前,一定要先理解JVM的混合执行模式。Java代码不是天生“编译型”的,也不是完全“解释型”的。HotSpot虚拟机启动时先解释执行,同时监控每个方法的热度。当方法被调用次数足够多,超过阈值(默认C1阈值和C2阈值不同),JIT编译器就会把字节码编译成机器码。
JIT编译分两层。C1编译的代码启动快、优化少,适合刚达到热点阈值的代码;C2编译的代码优化深度大、生成速度快,但编译耗时也高,适合长期的性能敏感路径。我们讨论循环优化,多数场景是指C2编译器对热点方法里循环的深度优化。
一个循环要进入C2优化,通常需要这个循环成为真正的“热点循环”。怎么算热点?JVM内部用的是基于计数的热点探测,方法进入次数和回边次数都会累加。这也是为什么短循环、紧密循环更容易被优化,因为它们更容易迅速累积回边次数。
4.2 循环优化中的几种关键技术
C2对循环优化的技术非常丰富,我挑几个在实际代码里最常见的展开讲。
循环剥离(Loop Unswitching)。编译器会把循环体中与循环变量无关的条件判断提到循环外,生成两条不同的循环分支。例如:
for (int i = 0; i < n; i++) { if (flag) { result[i] = a[i] + 1; } else { result[i] = a[i] - 1; } }剥离后变成两个独立循环,一个处理flag == true,一个处理flag == false。这样每个循环体更紧凑,更容易触发后续优化。
循环展开(Loop Unrolling)。减少循环控制指令的开销,一次循环执行多个迭代。比如原来每次迭代做2个单位操作,展开4次后,每次循环控制只能触发一次跳转,却做了8个单位操作,条件分支占比大幅下降。
向量化(Vectorization)。现代CPU提供了SIMD指令,一条指令能同时处理4个或8个数值。C2会对满足条件的循环做向量化。前提是循环内访问的是连续内存,比如数组;每次迭代的计算不依赖前一个迭代的结果。
逃逸分析(Escape Analysis)。严格说不是循环专属优化,但对循环影响很大。循环体内创建的对象如果不会逃逸出线程和方法,JVM可以消除这个对象分配,直接把字段拆成局部变量(标量替换),让循环内部几乎不产生任何GC压力。这就是为什么有些看起来“狂new对象”的代码,实际GC反而很干净,因为JIT已经把分配砍掉了。
4.3 循环优化效果差的几类编码习惯
理论上,JIT应该能把很多循环优化到位,但前提是代码提供了足够的优化空间。我总结了几类让优化失败甚至倒退的写法。
第一类是在循环内频繁调用不可内联的方法。不可内联的方法包括多态虚方法、超大的方法、native方法。多态虚方法需要做查表调度,而native方法直接阻碍优化,因为编译器不知道native内部会不会修改共享状态。如果循环里调用了native方法,循环优化基本就断送了大半。
第二类是循环内大量异常检查。比如每轮循环都Integer.parseInt或者Character.isSurrogate这类边界检查密集的操作。编译器确实可能把这些检查捕捉到,但一旦检查路径太多,优化就变保守了。
第三类是循环体内创建并共享可变对象。如果一个对象在循环中创建之后又被其他方法收走,逃逸分析就被破坏了。要享受逃逸分析红利,尽量让对象只在当前循环和当前线程内使用。
这里我分享一个实测案例。有一段代码对长度10万的数组做数值运算,我在循环内创建了一个临时double[]用于累积结果,然后每轮结束都往ArrayList里add这个数组的引用。因为数组逃逸了,逃逸分析失效,GC压力立刻变大,吞吐量下降接近40%。改成循环外申请一个矩阵池,效果立刻回来。
4.4 循环优化与安全点的冲突平衡
现在我们回到这条逻辑网的中心。既然JVM希望在热点循环里插入安全点检查,又希望循环优化做得越激进越好,两者之间其实存在天然的矛盾。安全点检查本身会让循环代码变得更复杂,阻碍某些优化;而优化得太彻底的循环,又可能降低线程响应安全点的频率。
JVM的应对策略是在“安全点轮询”这项技术上做文章,基本思路是让安全检查变成对一块特殊内存页的读访问。安全点时,JVM通过保护该页触发一个信号,从而让正在运行安全点轮询的线程停下来。这样每轮回边的检查成本被控制到了极低的程度。
但你要留意的是,这不是银弹。如果某条线程恰好执行在一条长native路径上,或者在一个未插桩安全点检查的极老代码块上,只要一次安全检查没被触发,等待线程依然会卡住。所以作为开发者,你不能依赖JVM把所有循环都优化成“安全点友好”的样子,更实际的思路是:让最关键、最久的循环具备主动让出机制,比如在循环体内定期调用一个空方法或者检查一个轻量状态,帮助JVM获得安全点机会。
5. 构建高健壮性Java应用的整合方案
5.1 缓冲层设计:把JNI调用控制在少量线程里
理解了前面四个章节,整合方案其实水到渠成。我强烈建议在架构层面做一个“JNI隔离区”:不要让业务线程直接调用native方法,而是让专门的、数量可控的worker线程去执行JNI调用。
举个例子。还是前面那个发送数据的服务,我最开始让40个业务线程直接进JNI,native侧并没有性能问题,但每次GC,JVM都要等最多40个线程从native回来,等待时间被剧烈放大。后来我改成单线程JNI调用池:业务线程把数据放到一个有界队列,一个专门的JNI线程负责消费并执行native调用。结果单次JNI调用吞吐量基本没变,但GC停顿的“wait_to_block”时间下降了近一个数量级。
为什么有效?因为JVM只需要等一个线程进入安全点,而不是40个。这个“缓冲层”把JNI的影响半径从全应用缩小到了一个线程。
5.2 循环内不要做的事情清单
拥有了JNI缓冲区之后,循环本身的安全点健康度也该纳入Code Review标准。我个人在团队里推行过一份“循环内不要做”清单,这里分享出来:
- 不要在循环内调用JNI方法。哪怕只有几微秒,累计起来也会对安全点进入造成显著阻碍。
- 不要在循环内创建逃逸对象。对象不一定逃逸出方法才叫逃逸,逃逸出当前迭代也算。
- 不要在循环内调用可能触发偏向锁撤销的同步块,或者尽量用无锁/虚拟线程思路替代。
- 不要在大循环内做
System.currentTimeMillis()高频调用。它本身有开销,而且会对JIT优化产生干扰,尤其是被内联后可能破坏循环的其他优化。 - 如果想在循环内定期检查终止条件,建议用一个局部布尔量承载状态,不要每轮都读一个
volatile变量。
这套清单不是什么高深理论,就是实际排查中一个个积攒起来的。你只要能避免其中两三项,大多数安全点和GC联动问题都不会出现。
5.3 一个可复用的排查流程
如果你已经遇到了疑似安全点问题,我给一条标准排查路径,按顺序执行能省不少时间:
第一,先看总停顿。用-XX:+PrintGCApplicationStoppedTime确认停顿是集中在GC还是非GC。第二步,打开安全点统计,定位wait_to_block大的那一分钟,回溯线程可能是谁。第三步,查线程dump,找RUNNABLE且栈在JNI方法上的线程。如果是JNI问题,就去改架构加缓冲层;如果不是,再看循环内有没有高频安全点检查。
这个过程里一个容易错过的细节是:不是所有安全点统计输出都能直接对应到问题时间点。建议你在关键接口打点日志,把业务上下文的traceId和JVM停顿时间打印出来做关联。否则安全点日志告诉你“停了500ms”,但你没法定位这500ms砸在了哪个业务请求上,排查效率会大打折扣。
5.4 数据一致性在JNI+循环场景中的隐性风险
最后还要提醒一个容易被忽略的点:数据一致性。Java内存模型保证的是Java线程之间的可见性,但它管不到native侧。如果你把Java对象引用传到C库里,C库直接修改了对象的底层内容,Java侧看到的可能是一个中间状态,因为这个修改完全绕过了Java同步机制。
我有一个习惯:传给JNI的数据,一律先拷贝成byte[],在native侧只把这些byte[]当作不可变缓冲处理,任何修改只发生在native分配的临时内存里。这样即使并发执行JNI调用,也不会因为native线程和Java线程抢同一份数据而出现脏读。
6. 面试视角:这些知识如何变成真正的竞争力
6.1 高频面试题的回答框架
这部分内容也是很多“Java面试宝典”里高频出现的话题。真正的高手面试官不会问“什么是JNI”,而是会把JNI、JIT、安全点糅合起来问。这里我给出几道我遇到过的真题和回答思路。
面试官问:JNI为什么会让系统变慢?你可以回答慢在三个层面:首先,边界转换开销,包括引用转换和参数传递;然后,安全点同步开销,因为native线程不响应安全点;最后,可诊断性开销,native崩溃不产生Java异常栈。不要只答“调用本身慢”,那太浅了。
面试官又问:JVM的GC停顿里,为什么有时候堆大小不变,停顿时间却增加了?你可以把安全点等待、wait_to_block、JNI驻留线程这些概念抛出来。能提到JVM的安全点统计参数和现场排查思路,会明显加分。
我在面试时还会问候选人一道场景题:一个循环超长,GC很频繁,你如何优化?很多候选人会说“线程池”“分批处理”,但这些其实都不是JVM层面的正确答案。更好的是回答:分析循环是否是热点、是否被JIT优化、循环中是否创建逃逸对象、是否有安全点插桩缺失,再考虑改写循环结构让JIT做循环展开或向量化。最后还可以加一句:加一个循环内定期让步点,辅助线程快速进入安全点。
6.2 面试官考察的真实意图
这些问题的考察点不只是“你知不知道”,而是你有没有一套完整的“现象→日志→假设→验证→改造”的能力。面试官问JNI,不期待你把所有JNI函数都背出来,而是想知道你有没有能力处理跨语言边界的问题;问安全点,也不是为了背概念,而是看你在GC调优时会不会单调地加大Heap。理解背后的意图,你的回答自然就会更立体。
如果你正在准备Java面试,建议把这一套内容整理成自己的知识树:JVM内存结构、GC算法、JIT编译、安全点、JNI边界,然后在这些节点之间画箭头,找实际项目案例来印证。面试时表达得“经验感”越强,考官越把你当成有真实项目经历的人,而不是背八股文的候选人。
我记得以前面试过一个候选人,他把JIT、逃逸分析、标量替换讲得非常流利,但当我追问“如果你在线上遇到一个由于安全点导致的GC停顿,你会怎么用日志区分是JNI引起的还是循环引起的”时,他愣住了。这说明他掌握的是碎片化知识点,不是工程能力。面试里的大多数追问,其实就是想看到你能否把概念串起来。
6.3 平时如何训练这些“手感”
想在实战里建立手感,除了读JVM源码注释,我更推荐一个方法:自己造一个小压测程序,故意写坏代码,然后看日志。比如写一个超长循环不做任何JNI调用,开启安全点日志,观察停顿;再加一个JNI调用,重复相同操作,对比wait_to_block的变化。做过一次完整对比,你才能真正理解安全点等待到底长什么样。纸上得来终觉浅,这件事必须亲自动手。
我个人的体会是,JNI、安全点、循环优化这三个词,刚开始看是三个完全独立的考点,但做线上性能排查越久,越觉得它们共同指向一个问题:Java应用的高健壮性,本质上是让JVM在所有线程都能快速、可控地进入一致状态的能。而高健壮性不是靠某个高深的调优参数,而是靠设计上的隔离、代码上的克制,以及排查时愿意多翻一层日志的习惯。
最后再分享一个小技巧:下次你的应用出现诡异停顿,先别急着加内存、换GC,试着打开安全点统计看一眼。答案可能不在GC算法里,而在某一段你从没注意过的JNI调用和大循环中。我自己那次定位事故,最后的修复居然只是改了一行循环控制条件,少跑一半无用迭代,加上一个JNI调用池,几十毫秒的停顿就回到了个位数。这种“不发参数、只改代码”的调优方式,才是最有成就感的。