JVM JIT编译器详解:从热点探测到线上性能调优
前几天帮一个团队排查线上服务CPU突增的问题现象很典型某个接口在流量高峰时CPU使用率冲到了90%以上扩容也没用火焰图里找不到明显的业务热点线程倒是有一堆带CompilerThread字样的线程在疯狂干活。很多人对JVM的即时编译器JITJust-In-Time Compiler认识还停留在Java是半编译半解释的语言这个层面一旦真的遇到和JIT相关的线上故障连从哪儿下手看日志都不知道。这篇文章我想把JIT在JVM里的真实工作方式完整串一遍——热点探测、分层编译、方法内联、逃逸分析、CodeCache、编译日志和逆优化这些平时最容易忽略的细节。读了之后你能建立起一套明确的实操路径怎么判断瓶颈是不是JIT引起的怎么打开编译日志读懂每一列怎么调整参数和代码形态让JVM把热点代码编译得更快。1. 两条执行路线JVM为什么坚持先解释、再编译1.1 解释执行与即时编译的分工逻辑JVM执行Java字节码时其实同时维护了两套执行机制。第一套是解释器逐条把字节码翻译成机器指令执行。优点是启动极快不需要等待任何编译动作类加载完就能跑缺点是每条字节码都有解释开销同一个方法被调用一千次它就解释执行一千次纯靠解释器跑业务逻辑效率上不去。第二套就是JIT编译器。它会挑出高频执行的热点方法把整个方法的字节码一次性编译成本地机器码之后每次调用都直接执行机器码彻底绕开解释器。编译一次的成本虽然不低但对于高频方法来说编译成本很快就能被后续的执行加速摊薄。所以JVM的设计哲学是启动阶段用解释器兜底让程序先跑起来运行中持续统计每个方法的调用热度把真正值得编译的高频方法捞出来交给JIT。这比类加载后立刻全部编译成机器码的静态编译方案更省内存和启动时间也比全部解释执行的方案有更好的峰值性能。1.2 热点探测机制计数器与编译阈值JVM靠两类计数器识别热点方法调用计数器Invocation Counter和回边计数器Backedge Counter。方法调用计数器统计一个方法被调用的次数解释器每执行一次该方法调用就加一当计数超过编译阈值时这个方法就会被提交给编译线程排队。回边计数器则专门盯循环统计方法内循环回边即一轮循环体执行完毕跳回循环头的次数用来发现单个方法调用次数不多、但循环体本身执行了海量次数的场景。后者触发的编译叫OSR编译On-Stack Replacement栈上替换能在循环仍在执行时把正在解释执行的栈帧无缝切换到编译后的机器码上这是对长循环场景的关键优化。经典默认阈值是Client模式C1编译器下-XX:CompileThreshold1500Server模式C2编译器下-XX:CompileThreshold10000。开启分层编译后编译阈值的语义会随当前编译层级动态变化不是简单的一个固定数字但计数阈值触发编译的模型不变。需要注意一个细节方法调用计数器存在热度衰减机制。JVM在某个时间段内发现计数器增加的速度放缓就会把计数值做衰减避免一个历史热点在热度下降后还占用编译资源。这类似缓存中的LRU思想——只编译当前真正热的方法而不是曾经热过的方法。1.3 分层编译怎么把C1和C2串起来HotSpot里实际存在两个核心编译器C1和C2。C1编译速度快生成的机器码质量一般C2走激进优化编译速度慢但生成代码的执行效率高。从JDK 8开始默认开启分层编译-XX:TieredCompilation把编译过程拆成多个层级第0层纯解释执行收集Profile数据方法调用次数、分支走向、实际类型分布。第1层C1编译无Profile收集代码质量中等。第2层C1编译带Profile收集。第3层C1编译带完整Profile供后续C2拿来做决策。第4层C2编译使用前面收集的Profile做激进优化。这套机制的核心思想是分步投入。C2成本高、风险大不能直接拿冷方法练手所以先让C1快速编译出中等质量的机器码顶住性能同时继续收集Profile等数据足够充分后再由C2出手。这样兼顾了预热速度和最终峰值性能。C2还维护非计数循环的优化手段——当某个循环的迭代次数足够多C2会直接对循环体做版本化优化并生成循环不变量外提、循环展开等改写。这些细节在编译日志里都会体现后面讲日志的时候会看到。1.4 别把编译概念搞混javac、JIT、AOT不少开发者在排查问题时会把静态编译器和JIT混在一起尤其是搜报错时容易跑偏。javac是前端静态编译器负责把.java源码翻译成.class字节码JIT是运行时编译器负责在JVM进程内把字节码翻译成机器码AOT如jaotc以及GraalVM的native-image则是提前把字节码或源码编译成机器码绕开运行时编译。三者解决的问题完全不同。网上经常有人问编译单个java文件时报java: OutOfMemoryError: insufficient memory怎么办。这个报错绝大多数来自IDE的构建进程或javac进程本身典型的诱因是注解处理器内存占用过高、构建进程的-Xmx设置过小。它跟JIT的CodeCache不足不是一回事CodeCache不足通常以CodeCache is full警告加CPU飙升的形式出现而不是以编译进程OOM的形式出现。定位这类问题先确认报错来自哪个进程再决定是调整IDE构建进程的JVM参数还是检查注解处理器不要一上来就改应用运行时的-XX:ReservedCodeCacheSize。相同点倒是有一个Kotlin、Groovy、Scala这些JVM语言编译出的字节码最终也都要走JIT这一层所以下面讲的优化逻辑对所有JVM语言都适用。2. JIT手里的三张王牌方法内联、逃逸分析、Profile驱动优化2.1 方法内联把调用栈直接焊死方法内联是JIT最重要、收益最稳定的优化之一。原理不复杂当JIT编译一个方法时如果它发现某个被调用的方法是热的且方法体足够小就会把这个被调方法的字节码直接拼进调用方的方法体里后续执行时不再发生真正的方法调用。代价只是少了一次方法调用和一次返回但带来的额外收益更大——拼进来之后调用方和被打包进来的代码之间可以做统一的数据流分析、常量传播和寄存器分配这是跨方法边界做不了的。可以用一个生活化类比内联之前每调用一次方法就像每次都要走到工具箱前拿出螺丝刀拧螺丝内联之后等于把螺丝刀直接焊在框架上不用再来回走动。HotSpot有两个关键参数控制内联尺寸-XX:MaxInlineSize默认35字节-XX:FreqInlineSize默认325字节。前者针对冷方法后者针对高频方法C2结合Profile认为某个方法足够热时会放宽到325字节的门槛。超过这个门槛的大方法即使很热也可能放弃内联。这对写代码的启示非常直接方法要保持小而清晰。一个热点链路里的方法动不动四五百行JIT再想内联也无能为力。把大方法拆成若干小方法不仅人读起来舒服JIT也更容易做内联决策。注意不要矫枉过正——拆得过碎的链式调用比如A调B、B调C、C调D每个才几行会让内联决策变得复杂过度抽象反而降低优化空间。2.2 逃逸分析栈上分配与锁消除逃逸分析Escape Analysis判断一个对象的作用域是否逃逸出了当前方法或当前线程。如果一个对象只在一个方法内创建和使用不被返回、不存入集合、不传递给其他线程那它就是不逃逸的。JIT在确认对象不逃逸后可以做三件事标量替换Scalar Replacement把一个对象的字段拆成独立变量直接放到寄存器或栈上整个对象根本不真正分配。栈上分配Stack Allocation在栈上分配对象内存方法返回时随栈帧一起释放。HotSpot的栈上分配相对受限OpenJDK里大量走的是标量替换路线但效果类似——减少堆分配和GC压力。锁消除Lock Elimination如果对象不逃逸对它的synchronized加锁就没有并发意义JIT会直接把锁去掉。举例循环里new一个仅作为临时计算的订单对象不返回、不存列表在C2眼里这个对象可以被拆成若干个基础数据类型变量循环体执行时一个堆对象都不会new。我见过不少开发者在热点代码里手动复用对象来减轻GC压力实际上在逃逸分析生效的情况下这种手动优化画蛇添足反而可能让对象逃逸丢掉优化机会。逃逸分析受-XX:DoEscapeAnalysis控制JDK 8默认开启标量替换和锁消除分别对应-XX:EliminateAllocations和-XX:EliminateLocks默认也是开启的。2.3 Profile数据驱动乐观优化与去优化分层编译里第0层解释器收集Profile这件事很多人不理解它到底收集的是什么。简单说解释器边执行边记录三类关键信息方法被调用多少次、条件分支往哪边走、某个虚方法/接口方法实际调用的是哪个类。JIT拿到这些信息后会做乐观假设。比如Profile显示一个接口方法10000次调用里有9999次走的是RedisCache实现C2就会按这个方法就是RedisCache实现来优化把虚方法分发优化成直接调用省掉方法表查找开销。这就是所谓守护内联——假设背后有个守护条件如果将来真的出现了另一个实现类当前编译结果必须作废。守护条件一旦打破JIT就会触发逆优化Deoptimization把正在执行编译板的线程拉回解释器重新收集Profile必要时重新编译。这是一个自动纠错机制不是bug但它频繁发生时说明代码形态的运行时可变性太高。2.4 对业务代码的启示把三张王牌放在一起看会得到一个很清晰的结论JIT偏爱小方法、窄接口、类型稳定、低动态性的代码。反射调用、字符串拼接JDK里用的是StringBuilder的append链、动态代理调用、频繁生成新类都会降低Profile的稳定性增加逆优化和重新编译的频率。真正值得JIT深度优化的热点链路代码形态越直白越好——具体类型、小方法、稳定的条件分支C2才有充分的空间施展内联和逃逸分析。3. 让JIT开口说话编译日志解析与线上监控3.1 打开JIT日志的推荐参数组合想理解JIT到底对你的代码做了什么最直接的办法是让它把每个编译决策打出来。JDK 8下常用这一组-XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInlining-XX:PrintCompilation打印每一次编译动作的概要-XX:PrintInlining需要配合诊断参数解锁打印内联决策细节。注意这两者输出量不小适合压测或预发环境不要直接在核心生产环境长期全量打开。JDK 11之后官方推荐用统一日志系统-Xlog:jitcompilationdebug需要更完整的过程记录时打开编译日志落到文件-XX:LogCompilation -XX:LogFile/path/to/jit.log这个日志是XML格式信息量极大人直接读不友好可以配合工具分析。我个人的习惯是先用-XX:PrintCompilation拿到编译概览发现问题后针对性地开-XX:PrintInlining或完整-XX:LogCompilation做深挖。如果要做系统性的日志分析JITWatch是很好用的开源工具可以把XML编译日志解析成可视化的内联树、热点方法和参数决策对入门非常有帮助。3.2 看懂PrintCompilation每一行-XX:PrintCompilation的输出大致长这样不同JDK版本列有细节差异但大体一致72 1 3 java.lang.String::hashCode (55 bytes) 80 2 3 java.lang.String::equals (81 bytes) 192 36 4 com.example.UserService::findUser (120 bytes) 300 42 % 3 com.example.RetryScheduler::runLoop 18 (240 bytes) 512 57 4 com.example.UserService::findUser (120 bytes) made not entrant逐列解释第一列JVM启动后经过的毫秒时间。第二列编译任务ID从1开始递增。第三列编译层级和状态标记。数字表示编译层级3通常是C1完整Profile编译4是C2编译%表示OSR编译栈上替换s表示同步方法!表示方法有异常处理器。后面的部分类名、方法名、字节码大小。结尾的made not entrant这个编译结果已经不再被新调用使用但正在栈上执行旧版本的方法还能跑完。再往后出现made zombie表示编译代码彻底死亡可以被回收。从示例能看出一个完整故事findUser先被C1以层级3编译60多毫秒之后C2又以层级4编译了升级版。runLoop的%标记表明它是一个长循环方法走的是OSR编译。等某个假设被打破旧编译结果被标记made not entrant——这就是4.3节要展开的逆优化。3.3 用PrintInlining看内联决策-XX:PrintInlining的输出会把每个内联尝试列出来重点看中括号里的结论 12 com.example.OrderService::getPrice (12 bytes) inline (hot) 20 com.example.OrderService::applyDiscount (30 bytes) too big 35 com.example.OrderService::checkStatus (80 bytes) callee is too biginline (hot)说明这个方法被判定为高频方法成功内联too big或callee is too big说明方法体积超过内联尺寸阈值被放弃。排查为什么某个热点方法没有被优化时这段日志的价值极高——很多时候不是你代码逻辑不行纯粹是方法太大或调用链太复杂导致内联失败。3.4 线上监控jstat和JFR配合日志适合离线分析线上实时看编译状态用两招就够了。第一招是jstatjstat -compiler pid输出里Compiled是累计编译方法数Failed是编译失败次数FailedType和FailedMethod会给出最近一次失败的类型和方法。如果Failed数量随着运行时长持续上涨就需要深挖了。再看最近编译了哪些方法jstat -printcompilation pid第二招是JDK Flight RecorderJFR。JDK 11以后自带JFR可以直接开一段短时录制-XX:StartFlightRecordingfilenameapp.jfr,duration60s,settingsprofileJFR里的Hot Methods事件会按CPU和编译时间排序展示热点方法对确认编译端是否存在异常投入非常直观而且对业务线程的采样开销很小生产环境短时录制是可行的。3.5 日志定位实例方法太大了我曾经遇到过一次典型的排查服务正常运行但压测时CompilerThread的CPU居高不下。打开-XX:PrintCompilation发现某个核心服务方法不断以不同层级反复编译同一方法ID在日志里出现了七八次。随后打开-XX:PrintInlining发现这个方法体积超过900字节对它周边的助手方法全部标记为callee is too big。问题的本质不是JIT抽风而是这个方法把太多分支和子流程塞在了一个方法里。重构思路是把每个分支拆成独立小方法最长路径控制在100字节以内。改完后再压测编译日志里too big消失同样的流量下CompilerThread的CPU占用明显下降业务线程的P99也顺带变稳了。这就是日志的价值不打开日志你可能还在怀疑GC参数、怀疑操作系统实际瓶颈却是编译端在低效空转。4. JIT暗坑CodeCache耗尽、编译线程失衡与逆优化4.1 CodeCache不足的连锁反应CodeCache是一块独立的非堆内存区域专门保存JIT编译生成的机器码以及JVM内部的本地方法代码。很多配置指南会叫你调大-XX:ReservedCodeCacheSize但如果不理解CodeCache满了会发生什么调大也只是碰运气。CodeCache耗尽时HotSpot会打印类似这样的警告Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full. Compiler has been disabled.意思是新方法不再被编译所有非热点方法回到纯解释执行。表现上最典型的就是接口耗时在无明显流量变化的情况下突然变差CPU使用率却不降反升——解释执行比机器码执行更耗CPU。JDK 8时代-XX:ReservedCodeCacheSize默认在240MB左右对绝大多数普通应用是够用的。但以下情况需要主动调大大量使用CGLIB/JDK动态代理、Groovy/JS脚本引擎、MyBatis等运行期生成大量代理类的框架。这类系统里CodeCache增长快容量建议放宽到512MB甚至更高。JDK 11之后默认值和使用策略有调整具体以-XX:PrintFlagsFinal输出为准。还需要区分一个常见的误判CodeCache不足时进程不会抛OutOfMemoryError而是以编译器被禁用的警告加性能劣化为主真正抛OutOfMemoryError且提示Java heap space的是堆内存问题排查方向完全不同。4.2 编译线程数量CICompilerCount怎么设置JIT编译不是瞬间完成的编译请求会进入队列由专门的编译线程处理。线程太多会挤占业务线程的CPU太少会导致热点方法排队很久预热变慢。先看当前JVM实际生效的编译线程数java -XX:PrintFlagsFinal -version | grep CICompilerCount不同JDK版本和CPU核数下默认值不一样常见的是核数的一半上下。对标准8核16G的微服务我一般建议-XX:CICompilerCount2起步4核小机器设2也合理32核以上大机器不要盲目设到16以上编译线程是辅助线程不是越多越好。有一种场景需要反向操作如果你的热点代码很集中、编译量不大但编译线程频繁抢占业务CPU可以调低CICompilerCount强制限制编译并发。反之如果系统在运行期动态生成了大量代理类编译队列积压严重适当调高编译线程数反而能更快消化编译压力。4.3 逆优化与zombie代码为什么越优化越慢第2.3节提到C2的激进优化建立在Profile假设之上。当假设被打破JIT会把当前编译版本标记为made not entrant已经入栈的帧允许执行完但新的调用不再进入这个编译版本。当所有栈帧都退出后标记变为made zombie编译产物可以被回收。日志里出现少量made not entrant是正常现象不用紧张。但如果它在短时间内密集出现就要警惕了这说明运行时行为与Profile收集期发生了明显偏差C2反复编译、废弃、再编译编译线程白忙一场业务线程也没有得到稳定的机器码收益。最常见的高危操作有三个。第一热点方法里用反射调用真实类型频繁变化的接口实现第二用Arthas等工具热更新类redefineClasses导致已有编译版本批量失效第三动态代理类层出不穷让Profile的单态假设不断失效。处置思路不是关掉JIT而是从代码层面降低动态性反射改直调、代理类尽量复用、非必要不在生产环境热更新类。4.4 编译风暴预热不足的直接结果还有一个常见的线上事故模式新版本上线后服务立刻被全量流量灌入大量的热点方法同时进入编译队列CompilerThread瞬间满负荷业务线程的CPU被挤占接口雪崩。这本质是预热不足导致的编译风暴。对策有两个层面。调度层面上线时用压测或影子流量预热10到30分钟让JIT在低负载阶段把热点编译完成再切正式流量。参数层面如果预估负载来得快可以适当调大CICompilerCount让编译队列更快消化也可以调低CompileThreshold让更多方法更早进入编译。但要提醒一句调低阈值不是免费的。编译触发越早越频繁编译线程的CPU开销就越高CodeCache消耗也越快。要从日志里确认当前排队和编译开销再决定是调阈值还是靠预热解决。5. 面向业务的JIT优化路线图先定位后调参再改码5.1 如何判断当前瓶颈确实与JIT相关调整JIT参数之前先确认瓶颈在编译端而不是业务代码、GC或锁竞争。三个典型信号第一CompilerThread的CPU占用长期较大。用top -H -p pid查看线程名如果CompilerThread*排在前列基本可以锁定编译端。第二jstat -compiler的Failed数量持续增长或jstat -printcompilation显示编译方法数和失败数异常高。第三-XX:PrintCompilation日志里同一方法反复出现、大量made not entrant、大量too big内联失败。这三个信号出现任何一个再往下走调优都不会跑偏。没有信号支撑就改参数很容易把原本正常的编译行为改出问题。5.2 参数层面的调整清单下面这张表是我给标准微服务做JIT调优时常用的参考项参数值按实际观测调整不要盲目照抄参数作用常见建议-XX:TieredCompilation开启分层编译JDK 8默认开启不要轻易关闭-XX:CompileThresholdC2编译触发阈值默认10000编译压力大时可调大至15000-20000-XX:ReservedCodeCacheSizeCodeCache容量普通服务256m够用动态代理多的服务512m起步-XX:CICompilerCount编译线程数8核机2-4编译队列积压严重时调大-XX:PrintCompilation打印编译概要压测/预发打开生产慎用-XX:LogCompilation输出完整XML编译日志配合JITWatch做深挖以一个8核16G的标准服务为例我会默认保留分层编译只加这行最基础的JIT相关配置-XX:ReservedCodeCacheSize256m -XX:CICompilerCount2 -XX:CompileThreshold10000先这样跑一轮压测看编译日志和JFR再继续调。任何参数的调整都要能对应到一个可观测的现象改完复测没有效果就改回去。5.3 代码形态层面写给JIT看的代码参数调优终究有上限代码形态才是决定JIT收益的根本。第一重点方法保持小。热点链路里尽量避免几百行的巨型方法把每个分支逻辑拆成交互清晰的子方法。一个理想的热点方法应该像一个清晰的流程节点调用关系扁平每段逻辑控制在几行到几十行。第二减少热点链路上的反射和动态代理。反射调用会打断C2的守护内联动态代理带来的新类型会污染Profile数据。能用具体类型写死的地方不滥用接口使用枚举或switch替代反射分发。第三避免运行期频繁产生新类。Groovy脚本、CGLIB代理每执行一次就生成新的类定义会让JIT不停面临新Profile和新编译任务。能用编译期确定类型解决的不要在运行时动态生成。第四警惕工具操作对编译结果的破坏。Arthas的redefine、热部署插件、探针的字节码增强都会使已编译代码失效压测时要排除这些干扰因素否则你看到的编译日志是被搅乱的版本。5.4 长期运行系统的JIT巡检清单JIT不是配置一次就一劳永逸的。发布节奏快、依赖升级频繁的系统至少要建立三个检查点。每次发版后用压测流量跑20到30分钟抓一份-XX:PrintCompilation日志关注三件事是否有编译失败、是否有大量made not entrant、是否有高频方法反复编译。这三个指标任何一个异常都值得在发布前解决而不是等线上出事故再排查。每季度做一次JFR录制对比。同样负载下Hot Methods的编译类目和编译总量有没有明显漂移。版本升级比如JDK 8升JDK 11、JDK 17后更要留意JIT在编译器实现和分层策略上都有调整旧的调优参数可能已经失效。最后CodeCache的使用率要纳入监控范围。用JFR或原生内存跟踪定期观测CodeCache占用趋势如果它持续走高且逼近上限说明动态代码生成量在增长除了调大容量更要把根源找出来。我在实际项目里的体会是JIT优化最忌讳一上来就改参数。把PrintCompilation日志和JFR录下来读一遍通常能发现是CodeCache设小、方法过大、动态代理刷屏还是编译线程分配失衡——多数情况下改一两处就够。最后一个非常实用的小技巧上线压测时把编译日志里出现大量made not entrant当作一个信号第一时间检查动态类生成和热更新操作这比上线后看CPU曲线再回头查要省事得多。JIT本身不复杂难的是愿意先花半小时把日志打开。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →