
1. 从一段“越跑越快”的代码说起第一次对JIT产生实感是在排查一个服务启动后前几分钟响应特别慢的问题。当时现象很怪同一段业务逻辑压测跑到第3分钟平均耗时从80多毫秒降到了20毫秒出头CPU占用反而还降了一点。代码没改、数据没变、机器没扩容那到底是谁在背后“偷偷优化”了这段代码答案就是JIT即时编译器——Java虚拟机里最容易被忽视、却又实实在在影响每一个线上服务性能的组件。很多人对JVM的理解停留在“堆、栈、方法区”“GC怎么调参”这一层觉得JIT是高级话题、是面试八股日常工作碰不到。其实恰恰相反GC决定的是内存怎么回收而JIT决定的是你写的代码最终跑成什么样。你写的for循环、你调的String拼接、你写的多态调用在JIT眼里都只是中间表示它完全可以把你写的代码改得“面目全非”——前提是它认为改了更快。这篇文章想解决的问题很具体把JIT这件事从“知道有这回事”推进到“能看懂编译日志、能判断某段热点代码到底被优化成了什么样、能在性能调优时想到去动JIT这一层”。适合已经写过一段时间Java、大致知道类加载和内存模型、但对解释执行和编译执行的边界还比较模糊的开发者。文中会涉及不少参数和日志分析我会尽量把每个参数为什么这么设、日志里每一行在说什么讲清楚毕竟JIT这块最怕的就是背了一堆-XX:PrintCompilation却不知道输出该往哪看。需要先说明一点不同JDK发行版比如基于OpenJDK 8、11、17、21的各家构建在JIT实现细节、默认参数、日志格式上都有差异文中涉及的参数以主流长期支持版本为准具体数值和日志字段建议在自己环境里实测对照。另外JIT的行为和硬件平台强相关x86和ARM上的优化策略、寄存器分配都有区别这一点后文会专门提。2. 解释执行与编译执行之间那条模糊的线2.1 为什么JVM不干脆全部编译成机器码这是个很自然的第一反应既然编译成机器码快为什么不学C那样在程序运行前就把所有代码编译好答案藏在Java的设计目标里——跨平台和动态性。Java代码编译成字节码字节码是面向虚拟机的指令集不是任何真实CPU的指令集。同一个class文件在x86机器和ARM机器上都能跑靠的就是虚拟机在中间做翻译。翻译有两种做法。一种是解释执行取一条字节码查表跳到对应的处理逻辑执行完再取下一字节码。这种方式启动极快不需要任何预热但每条指令的执行都要附带一次“查表分发”的开销简单指令的实际执行时间可能还不到分发开销的十分之一。另一种是编译执行把一整段字节码翻译成目标平台的机器码之后直接跑机器码没有解释开销但编译本身要花时间而且编译结果要占用额外内存存放。如果一开始就全量编译等于放弃了启动速度也放弃了“只编译真正被执行的代码”这个巨大的优化空间。绝大多数Java应用里真正被频繁执行的代码只占很小的比例——有统计说法是不到20%的方法承载了80%以上的执行时间这个比例在不同业务里波动很大但“热点集中”这个结论基本成立。把冷代码也编译成机器码纯属浪费编译时间和内存。所以JVM选了一条折中路线先解释执行一边跑一边统计哪些代码热只把热的编译掉。这就是JITJust-In-Time即时编译的核心思路也是Java能做到“启动不算慢、长期跑起来又不慢”的根本原因。2.2 分层编译C1和C2不是二选一而是接力早期JVM里C1和C2是互斥的要么用客户端编译器求快要么用服务端编译器求好。现在的分层编译Tiered Compilation把两者串成了一条流水线JDK 8之后默认开启一共分5个层级。层级编译器特点典型触发场景0解释器无编译开销执行慢可采集全部profile方法首次执行1C1全量编译但无profile采集速度最快极简单方法如getter2C1带有限的profile采集方法调用次数、循环回边调用次数较少的热点3C1带完整profile采集为C2准备数据主流的热点方法4C2基于profile做深度优化编译慢但代码质量高真正的高频热点这个表格值得多看两眼因为它是理解JIT行为的关键。方法被执行到一定次数后先被C1在3层编译一次此时代码已经比解释执行快很多而且编译过程中收集的profile数据比如某个接口调用点实际出现过哪几个实现类、某个分支走了多少次、数组长度大概是多少会被记录下来。等调用次数继续增长C2拿着这份profile再做一次深度优化产出最终的高质量机器码。提示分层编译下一段代码可能被编译多次。日志里看到同一个方法出现好几条编译记录是正常现象不要以为出问题了。这种“先粗后精”的设计解决了一个现实矛盾C2编译很慢如果一个方法只被调用几百次C2编译消耗的时间可能超过它省下的执行时间得不偿失。C1先顶上保证中等热度的代码也能跑到不错的性能等热度真正上来了再让C2精加工。2.3 热点探测的两种计数器JVM怎么判断“热”靠计数器主要是两个方法调用计数器和回边计数器。方法调用计数器统计方法被调用的次数每次调用加一严格说是加上一个与调用相关的权重超过阈值就触发编译。这里有个细节容易误解这个计数器不是简单的加法计数而是有一个衰减机制在一段时间内调用次数没达到阈值计数会减半。这个设计的目的很直接——如果某个方法只是启动阶段集中被调用了一阵之后彻底冷掉那没必要为它做C2编译把编译线程占住。衰减机制让“曾经热过”和“持续热”区分开来。回边计数器统计的是循环体往回跳的次数也就是方法内部循环的迭代次数。为什么单独搞一个因为存在这种情况一个方法只被调用一次但里面有个跑一百万次的循环。方法调用计数器永远到不了阈值可这段循环体本身是绝对的热点。回边计数器就是为了抓住这种情况它达到阈值后会触发一种叫栈上替换OSROn-Stack Replacement的编译。OSR这个词听起来玄乎其实逻辑很朴素方法正在解释执行到一半循环跑到第50万次JIT决定这里该编译了。但方法还没执行完栈上还有它的栈帧不能等它返回再换。于是JIT编译出一份专门针对“从循环第N次继续执行”的机器码直接把当前栈帧里的状态迁移过去从下一条循环继续跑机器码。这就是“栈上”替换的含义——不重新调用就地替换。注意OSR编译产出的是针对特定字节码位置的版本和普通方法编译的版本不是同一份日志里通常带着%标记看到别慌。这两个计数器的阈值可以通过参数调整但在分层编译开启的情况下直接调这两个阈值的收益通常不如调编译线程数或代码缓存大小。原因在于分层编译自己有一套更复杂的触发逻辑简单地降低-XX:CompileThreshold反而可能让C2过早介入编译了一堆其实还不够热的代码。3. C2的优化组合拳它到底把代码改成了什么样3.1 方法内联一切优化的前提如果只能记住C2的一个优化那必须是方法内联。内联做的事情很简单把被调用方法的字节码直接复制到调用点消除方法调用本身的开销。但它的意义远不止省下一次压栈出栈——内联是其他几乎所有优化的前提条件。原因很直白优化器需要看到更大范围的代码才能做有效的优化。假设有个方法calc()里面调了isValid()如果没有内联C2在编译calc()时只能看到一个invokevirtual指令它不知道isValid()里面是什么也就无法做常量折叠、无法消除不可能走到的分支。一旦内联isValid()的代码被摊平到calc()里C2就能看到“哦这个条件在这个上下文中恒为true”然后把else分支整个删掉。内联是有取舍的。方法体太大内联进来会让编译出的机器码急剧膨胀指令缓存命中率下降反而变慢。所以C2有一套内联策略小方法字节码小于几十字节几乎无条件内联稍大的看调用频率和热度超大的比如超过325字节可通过-XX:MaxInlineSize调整基本不内联。热点方法的阈值更宽松一些由-XX:FreqInlineSize控制默认值在几百字节这个量级。这里有个实操中很有用的点内联失败是性能问题的一个重要嫌疑。比如你用某个框架发现某段逻辑跑得远不如预期可以用-XX:UnlockDiagnosticVMFiles配合-XX:PrintInlining打出内联决策日志看看到底是哪个方法没被内联、原因是什么。常见的原因包括方法太大、调用点多导致被调用方法版本太多多态内联失败、被调用方法还没加载完成等。3.2 逃逸分析与标量替换对象真的分配在堆上吗“Java对象一定分配在堆上”——这句话在JIT面前是不成立的。逃逸分析Escape Analysis会在编译时分析一个对象的引用会不会“逃出”当前方法或当前线程如果不会就可以做一系列激进优化。最典型的场景是这样的方法内部new了一个小对象只在这个方法里用了几个字段方法返回时这个对象就没用了。逃逸分析判定它不逃逸于是做标量替换Scalar Replacement把这个对象的字段直接拆成几个局部变量对象本身根本不分配。原来一次对象分配加上后续的GC压力变成几个寄存器或栈上变量的读写。另一个场景是锁消除。如果分析发现某个对象只被当前线程访问那对它加的synchronized就没有意义直接去掉。这在用StringBuffer做局部字符串拼接时特别明显——StringBuffer的方法都是同步的但如果是方法内的局部变量加锁完全是浪费。// 写起来是这样 public String build(int id) { StringBuffer sb new StringBuffer(); sb.append(user-).append(id); return sb.toString(); } // JIT眼里的等价物大致是没有任何锁对象可能压根没分配要说清楚的是逃逸分析不是万能的。它受限于分析精度和编译预算复杂的控制流、对象被存入集合、被传给不确定的方法都会导致分析失败退回保守处理。所以我个人的态度是写代码时不要刻意为了逃逸分析去扭曲结构但知道有这回事之后可以避免写出明显阻断分析的代码比如无谓地把局部对象塞进成员变量。3.3 基于profile的推测性优化与去优化这是C2最“大胆”的地方也是最需要理解的机制。C2在做深度优化时不完全依赖静态分析而是拿着C1阶段收集的运行时数据做推测。比如某个接口调用点profile显示过去一万次调用全部是ArrayList的实例C2就会生成一份“假设这里永远是ArrayList”的优化代码把虚调用直接变成对具体类的直接调用甚至进一步内联ArrayList的方法。问题来了万一第一万零一次传进来一个LinkedList呢代码就错了。JVM的解法是保留一条后路——去优化Deoptimization。生成的机器码里会埋检查点一旦假设不成立立刻跳出去把当前栈帧的状态恢复成解释器能理解的形式退回到解释执行同时把这个方法的编译结果标记为失效。去优化在日志里表现为made not entrant不再可进入和made zombie彻底废弃这样的字样。线上如果频繁出现去优化性能会有明显抖动因为代码反复在“编译——去优化——重新编译”之间循环。常见的触发原因包括某个接口突然出现了新的实现类、某个分支的走向发生了剧烈变化、反射或动态代理带来了类型不稳定。提示排查性能抖动时如果看到大量made not entrant集中在某个时间点往往意味着那个时间点有新的类被加载或新的实现类开始被使用。理解了去优化就能理解为什么“预热”对Java服务这么重要——不是JIT需要时间慢慢变快这么简单而是C2基于profile做的推测需要足够多的样本才能稳定。样本不足时优化出的代码容易被打脸然后就是编译、去优化、再编译的反复折腾。3.4 循环优化与向量化让CPU的并行能力真正用起来循环是性能优化永恒的焦点C2在循环上做了大量工作。最基础的几项循环展开把循环体复制几份减少循环控制开销、循环无关代码外提把循环内不随迭代变化的计算提到循环外、循环剥离把循环头尾的特殊情况单独处理让主体循环更干净。再往深一层是自动向量化。现代CPU有SIMD指令一次能处理多个数据比如一条指令同时算4个float或8个int。C2在某些条件下能把标量循环转换成向量指令理论上有几倍的加速。但条件比较苛刻循环次数要可预知或可安全处理尾部、数据要连续访问、不能有跨迭代的依赖、不能有副作用。实际业务代码里能自动向量化的循环比例不高这也是为什么做数值计算时很多人宁愿手写或者用专门的库。数组边界检查消除也是循环优化里的重头戏。Java的数组访问天然带边界检查每次arr[i]都要比较i和arr.length。在循环里这个检查重复了成千上万次。C2会分析循环的边界条件如果能证明i始终在合法范围内比如for (int i 0; i arr.length; i)这种标准写法就把检查全部消掉。这也是为什么写循环时推荐用arr.length而不是自己缓存的变量——倒不是说缓存变量有错而是标准写法更容易被优化器识别。4. 把编译行为“看”出来日志、参数与工具链4.1 编译日志到底怎么读光知道原理不够实际调优得能看到JIT在干什么。最基础的入口是-XX:PrintCompilation可以配合-XX:UnlockDiagnosticVMOptions使用更多诊断参数。java -XX:PrintCompilation -XX:UnlockDiagnosticVMOptions \ -XX:PrintInlining -XX:PrintCodeCache \ -jar demo-app.jarPrintCompilation的输出每行大致是这样的结构132 45 3 com.example.OrderService::calcTotal (156 bytes) 133 46 % 4 com.example.BatchJob::runLoop (89 bytes)从左往右第一列是时间戳毫秒从JVM启动算起第二列是编译任务的ID接着是标记位然后是编译层级最后是方法名和字节码大小。标记位里最常见的几个%表示这是OSR编译s表示方法是synchronized的!表示方法有异常处理器b表示发生阻塞编译线程被占用n表示是native包装方法。编译层级那一列直接对应前面的表格3层是C1带完整profile4层是C2。如果一个方法反复出现“3层编译、4层编译、又回3层”这种摇摆说明它的热度判断不稳定或者发生了去优化。PrintInlining的输出更细它挂在对应方法的编译记录下面用缩进表示内联层次每行末尾会写明内联决策的原因比如inline (hot)、too big、already compiled into a big method、hot method too big、no static binding等。这些原因字面意思基本就是诊断线索看到too big就考虑调FreqInlineSize看到no static binding就是多态导致内联失败。4.2 代码缓存被忽视的容量问题JIT编译出来的机器码存在哪里代码缓存Code Cache一块独立于堆的本地内存区域。这块区域有大小上限一旦写满JIT就停止编译所有新热点代码只能继续解释执行——性能会断崖式下跌而且日志里会冒出CodeCache is full. Compiler has been disabled.。JDK 8的默认代码缓存大约240MB这个值放在今天一些大型应用里并不宽裕。分层编译下C1和C2的代码分区域存放可以通过参数调整-XX:ReservedCodeCacheSize512m # 预留总大小 -XX:InitialCodeCacheSize64m # 初始大小 -XX:UseCodeCacheFlushing # 允许在快满时清理冷代码UseCodeCacheFlushing值得开着它会在代码缓存接近满的时候把那些已经去优化、或者长时间没被进入的方法的机器码清掉腾空间给新代码。这个机制在JDK 8后期和之后的版本里默认开启早期版本需要注意确认。监控代码缓存使用情况可以用jcmd pid Compiler.CodeHeap_Aggregate不同版本命令名略有差异或者JMX里的CodeCache相关MBean。线上服务如果发现某段时间后性能缓慢下降、PrintCompilation也不再有新输出首要怀疑对象就是代码缓存打满。4.3 什么时候该动编译线程数JIT编译本身是在后台线程里做的编译线程数默认按CPU核数推算。在核数很多的大机器上编译线程多有助于快速把热点代码编译出来但在核数少或者容器里限制了CPU的机器上编译线程可能和业务线程抢CPU。-XX:CICompilerCount4 # 总编译线程数其中约三分之一归C1调整这个参数的实际经验是容器环境CPU被cgroup限制下JVM探测到的核数可能偏大导致编译线程开得过多。如果观察到启动阶段业务线程响应明显变慢、而CPU被打满可以适当降低编译线程数。反过来如果压测时发现热点代码编译得慢、长时间停留在解释执行可以适当增加。注意分层编译开启时CICompilerCount最小是2而且C1和C2的线程数分配有内部比例不是简单的一分为二。这个参数的收益不像GC参数那么立竿见影属于“有问题时能救急、没问题时别乱动”的类型。我见过最典型的问题是容器里给了2核但JVM按宿主机64核去开编译线程结果启动阶段业务线程被严重挤压把一个启动慢误判成了业务代码问题。5. 那些年踩过的JIT坑5.1 基准测试写错了结论全错拿System.currentTimeMillis()夹住一段代码跑个几十次求平均这种测法在JIT环境下基本没用。原因明摆着前几十次可能还在解释执行后面C1编译了再后面C2编译了你测的平均值是个混合物什么也说明不了。更麻烦的是死代码消除。如果被测方法的计算结果没被使用C2完全可能把整个计算删掉——它是对的因为结果确实不影响程序行为。于是你测出来“这段代码零耗时”。正确的做法是用JMH这类专门为JVM设计的基准测试框架。它做的事包括充分预热让C2完成编译、用Blackhole消费结果防止死代码消除、跑多轮并给出统计置信区间、用注解控制状态State避免常量折叠。举个小例子Benchmark BenchmarkMode(Mode.AverageTime) OutputTimeUnit(TimeUnit.NANOSECONDS) public void testConcat(Blackhole bh) { String s a b c; bh.consume(s); }自己手工写循环做对比测试不是不可以但至少要做到预热足够轮次、每轮都消费结果、多种实现交替跑避免缓存和频率影响、跑够多轮取稳定区间。这些琐碎的事JMH都替你做了没有理由不用。5.2 依赖去优化“偶然”跑快的写法有一种代码写法在压测里经常表现异常好上线后却问题不断依靠一个特定类型在这个版本里永远传进来C2优化得很彻底但业务上这个类型是可能变的。一旦变了去优化发生性能掉下来而且这个掉落是“突然”的很难和代码变更关联起来。判断是否属于这种情况可以看PrintCompilation里同一方法是否反复编译、是否有大量made not entrant。也可以在压测时故意制造类型多样性比如接口多传几个实现看性能曲线是否不稳定。如果稳定但慢那是算法问题如果不稳定多半和JIT的推测性优化有关。我的建议是不要为了追求压测数字去设计“只有一种实现”的接口调用路径而应该按业务真实情况设计。JIT的优化是基于真实运行时的人为制造的不真实场景优化出来的结果也不真实。5.3 反射和动态代理带来的编译开销反射调用Method.invoke在早期JDK里慢得离谱后来通过生成字节码动态访问器加上JIT优化前若干次调用的开销已经大幅下降。但要注意反射调用的调用点会随着目标方法的不同变成“多版本”这使得内联和类型推测变得困难。动态代理同理每一个代理类都是运行时生成的代理方法里通常是转发逻辑这类方法的profile容易变得类型不稳定。大量使用动态代理的框架各类AOP、远程调用在热点路径上会带来额外的编译负担和去优化风险。这里能给的实操建议是极高频的调用路径尽量避免反射或者把反射结果缓存成MethodHandleMethodHandle一旦被JIT识别内联效果明显好于反射。如果必须用反射尽量让调用的目标方法稳定别在同一个调用点调用一大堆不同的方法。5.4 代码缓存打满后的“慢性病”这个坑的表现非常隐蔽服务启动后一切正常运行几个小时后QPS缓慢下降CPU不高、GC正常、内存正常各种指标看着都没问题但就是慢了。如果没有开PrintCompilation根本想不到是代码缓存的问题。判定方法很简单用jcmd看代码缓存使用率或者直接开-XX:PrintCodeCacheJVM退出时会打印各区域的使用情况。如果used接近reserved基本可以确认。治疗办法就是加大ReservedCodeCacheSize并确保UseCodeCacheFlushing开启。顺便说这类问题在微服务架构里更容易出现——每个服务都是独立的JVM各自有各自的代码缓存服务数量一多单实例配置不当的影响面会被放大。5.5 用参数“暴力调优”的误区网上流传着不少“JVM调优参数模板”动不动就是-XX:CompileThreshold1000、-XX:-TieredCompilation这种。这些参数不是不能调但要知道后果。关掉分层编译-XX:-TieredCompilation意味着只用C2启动会明显变慢因为所有热点都要等C2慢慢编译而且C2拿不到C1收集的profile优化质量也要打折扣。这个参数唯一合理的场景是明确知道自己在做什么的极端低延迟场景而且要做充分的对比测试。大幅降低CompileThreshold则会让C2过早介入编译大量不太热的代码编译线程被占满、代码缓存被快速填满最后可能适得其反。在分层编译下这个参数的作用本来就被弱化了。我的一般原则是先动堆和GC收益明确、影响可预测再看代码和架构收益最大JIT参数放在最后且每次只动一个、动完做完整对比测试。JIT调优的收益通常没有想象中那么大但调错的代价可能很大因为它影响的是所有代码的执行方式。6. 从“知道”到“用上”的一条实践路径如果要把这篇文章的内容落到实际工作上我建议按这个顺序推进先在自己负责的服务上开-XX:PrintCompilation和-XX:PrintCodeCache跑一轮看看热点方法编译了哪些、代码缓存用了多少建立对“自己服务的JIT画像”的直觉然后针对性能敏感的那几个接口用JMH写基准测试把“感觉快”变成“数据上快”并且可复现再往后当遇到性能抖动或不明原因的变慢时把去优化、代码缓存、内联失败这几个怀疑对象排进排查清单。需要提前打预防针的是JIT这东西的“可解释性”不如GC。GC有明确的日志、明确的回收行为你能看到每次回收前后的内存变化。JIT的优化是概率性的、依赖运行时的同样的代码在不同压力下可能编译出不同结果。所以我的经验是不要追求“精确控制JIT做什么”而是追求“在它做错或者没做的时候能发现”。能发现就已经解决了大部分问题。最后分享一个我常用的排查套路线上服务出现“越跑越慢”按这个顺序看下来基本能覆盖八九成情况——代码缓存使用率、made not entrant出现频率、GC频率和暂停时间的趋势、线程数是否异常增长。这四个指标两个和JIT直接相关两个排除掉其他可能走完一遍心里就有数了。至于那些写在文章里的具体参数值永远记得在自己的环境和版本上验证一遍别人的数字只是起点不是答案。