最近又在帮朋友准备 JVM 面试,聊到 JIT 这一块,发现大家普遍有个问题:概念背得滚瓜烂熟,什么“热点代码”“方法内联”张口就来,但真要问一句“它到底怎么工作的”、“线上遇到问题怎么排查”,基本就卡壳了。我自己早年也这样,八股背了不少,直到真正遇到一个线上接口响应时间忽高忽低的问题,才老老实实把 JIT 这块补扎实了。所以这篇就把 JIT 从原理到实践,从面试题到排查技巧,一次讲透。
这篇文章适合几类人看:准备大厂 JVM 面试的、已经在写 Java 想搞清楚性能问题根因的、以及做服务治理偶尔要调 JVM 参数但一直不求甚解的。我会先把 JIT 要解决的核心问题摆出来,再讲它怎么发现热点、怎么优化代码,然后给出一线排查时会用的命令和参数,最后串一遍面试高频题。不会讲太深到编译原理的层面,但保证你看完能跟人聊明白,也能上手查问题。
1. JIT 到底在解决什么问题
1.1 解释执行慢在哪
Java 程序能跨平台,靠的是字节码。.class 文件里存的不是机器能直接跑的指令,而是一套 JVM 自己认识的中间表示。程序跑起来以后,解释器逐条把字节码翻译成本地机器码再执行。
问题就在这个“逐条翻译”上。同一个方法,如果被调用一万次,解释器就要翻译一万次。这就像你每次做饭都去翻菜谱,而不是把步骤背下来,浪费时间不说,还容易出错。更关键的是,解释器每次翻译都是机械的,不会根据上下文做任何优化——同一个表达式每次都原样执行,明明结果没变,它也不会帮你缓存。
所以早期 Java 被吐槽“慢”,很大程度就慢在解释执行上。但纯编译方式又失去了跨平台的优势——总不能每个平台都单独编译一份吧。JIT 的思路是:程序跑起来以后,JVM 自己判断哪些代码是热点,再把这些热点代码编译成本地机器码缓存起来,下次直接执行机器码。这就兼顾了启动速度和运行性能。
1.2 JIT 跟 AOT、解释器三者的关系
要理解 JIT 的定位,得先摆清楚这三者的关系:
- 解释器(Interpreter):启动最快,不占额外内存,但每个方法每次调用都要翻译。
- JIT 编译器(Just-In-Time Compiler):运行期编译,把热点字节码编译成机器码,后续直接执行,运行效率高。
- AOT 编译器(Ahead-Of-Time Compiler):在程序运行前就编译成机器码,启动最快,但丧失了运行时 Profile 信息,无法针对“代码实际是怎么跑的”做优化。
JVM 实际采用的是解释器和 JIT 混合的模式。启动阶段用解释器,让程序快速跑起来,同时后台统计各方法的调用次数,发现热点后交给 JIT 编译。这么做的好处是启动快、内存省,热点还能跑到接近原生代码的速度。
注意:Java 9 引入的 AOT(jaotc)并没有大规模铺开,主要原因是它编译时不知道代码在真实环境中的行为特征,优化效果往往不如 JIT。所以在大多数业务系统中,JIT 依然是运行期性能的核心。
1.3 HotSpot 里到底有几种 JIT 编译器
这也是面试特别爱考的点。HotSpot 里有三种编译模式:
- C1(Client Compiler):编译速度快,但优化程度低。适合对启动时间敏感、生命周期短的程序。
- C2(Server Compiler):编译慢,但做了大量深度优化,适合长时间运行的服务端程序。
- Graal JIT:JDK 9 之后以实验性质引入,用 Java 写的 JIT 编译器,目前在大内存、ARM 等场景有一些优势,但还没有完全取代 C2。
早期 JVM 通过 -client 和 -server 参数二选一。JDK 8 以后默认开启分层编译(Tiered Compilation),不再二选一,而是把 C1 和 C2 结合起来用:先跑 C1 快速编译,让代码先有一定优化,等 HotSpot 判定这个方法热度足够高,再交给 C2 做深度优化。
我实际测过一个服务,在 JDK 8 下关闭分层编译(-XX:-TieredCompilation),结果吞吐量下降了不少,启动反而没明显变化。分层编译在某些场景下还有坑,后面 3.1 节我会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 热点探测:JIT 怎么知道该编译谁
2.1 方法调用计数器和回边计数器
JIT 不是所有代码都编译,只编译“热点代码”。HotSpot 里判断热点的核心是两个计数器:
- 方法调用计数器(Invocation Counter):统计方法被调用的次数。
- 回边计数器(Back Edge Counter):统计方法里循环体执行到“回边”的次数,回边指循环体的末尾跳回开头的那条指令。
这两个计数器和我们直觉想的不太一样。回边计数器统计的不是循环次数本身,而是“循环是否可能长时间运行”的信号。比如一个方法里有 while(true),调用计数器可能只加了 1,但回边计数会一直涨,JVM 会因此认为这是个热点循环,优先把它后面的代码编译掉。
2.2 触发阈值和 CounterDecay
方法调用计数器的默认阈值是 10000 次(-XX:CompileThreshold),也就是说一个方法被调用超过 10000 次,才会成为热点候选。但这有个前提——这个计数不是永久累积的。
HotSpot 有一个 CounterDecay(计数器衰减) 机制:如果在一段时间内(默认 GC 周期的一半?具体时间点和版本有关)方法调用次数没达到阈值,计数器会按比例衰减一半。这么做是为了防止“程序启动期间高频调用、后面长期不调用”的方法占据编译资源。
可以把 CounterDecay 理解成“热度冷却”:你在短时间内反复调用一个方法,它才会热起来;如果只是某段时间频繁,后期就没热度了,计数也会降下来。这也是为什么压测时“预热”那么重要——刚开始跑的时候,JVM 还没完成热点编译,各项指标都是失真的。
注意:回边计数器没有衰减机制,因为一段循环要么持续执行,要么很快结束,没有“中间态”。
2.3 编译线程和编译队列
当计数器超过阈值,方法不会立刻被编译,而是进入编译队列,由后台编译线程处理。这里有一个细节:编译是异步的。JVM 发现热点后,先把它放入队列,继续用解释器执行,直到编译完成再切换到机器码。
这个异步机制带来一个很重要的现象——方法热到一定程度,性能反而会短暂下降。因为解释器还在跑,编译线程也在抢 CPU,两个都在干活。线上表现为“某次请求突然变慢”,但过几百毫秒又恢复了。如果你能看到 -XX:+PrintCompilation 日志,会发现这个时间点恰好有编译任务在跑。
编译队列的长度和编译线程数都可以控制:
-XX:CICompilerCount:编译线程数,默认值跟 CPU 核数相关。不是越多越好,线程切换也有成本。-XX:CompileThreshold:编译阈值,调低可以让更多方法进入编译,但编译线程忙不过来时会有反效果。
3. JIT 里的“神级”优化:每个都值得记住
3.1 方法内联:最朴素但最有用的优化
方法内联的意思非常直白:把被调方法的代码体直接“搬”到调用方里面,省去方法调用的开销。方法调用本身有什么开销?不只是压栈弹栈,还有参数传递、返回值处理、方法查找、异常表查找等等。
JIT 做内联有讲究,不是把所有方法都内联——那样会让代码爆炸。它只内联“热点小方法”。判断标准一个是方法体够不够小(默认 325 字节,-XX:MaxInlineSize),另一个是调用够不够热。
这里要提一下 虚空分派(Virtual Dispatch)的优化。Java 里面几乎都是虚方法,理论上运行时才知道实际调用哪个实现。JIT 会做 CHA(Class Hierarchy Analysis),分析继承关系,如果发现某个方法在当前类加载体系下只有一个实现,就会把它当作非虚方法直接内联。如果后面加载了新类打破了这种唯一性,JVM 会撤销这个编译版本,重新编译。
我干活时碰到过一个反例:一个接口有几十个实现类,每次调用都走虚方法分发,而每个实现类的方法都很短。看起来方法体很小,应该能内联吧?但 CHA 发现目标实现不唯一,内联失败,性能一直上不去。最后能做到的办法是把大接口拆小,或者用 final 关键字限制继承关系。面试官如果追问内联失效的场景,你把这例子讲出来会加分。
3.2 逃逸分析:栈上分配、标量替换、锁消除
逃逸分析(Escape Analysis)是 JIT 里最体现“运行时优化价值”的一项技术。简单说,它的工作是判断一个对象会不会“逃逸”出方法。如果对象只在方法内部使用,不传给别人、不存到堆里、不返回给调用方,那这个对象就不逃逸。
基于不逃逸的分析,JIT 可以做出三个优化:
- 栈上分配(Stack Allocation):对象不放在堆上,而是在栈帧里分配空间,方法结束即自动释放,完全避开了 GC 的压力。
- 标量替换(Scalar Replacement):更激进的做法,把对象拆成它的成员变量,直接在寄存器或栈上处理这些变量,连对象都不创建。
- 锁消除(Lock Elimination):如果对象不逃逸,加锁就没有意义,因为不可能有其他线程访问它,JIT 会把锁去掉。
我以前写过一段千万级循环里 new Object() 的代码,开启了逃逸分析(JDK 8 默认开启)以后,GC 次数竟然没有任何变化——因为对象全被标量替换了,压根没在堆上分配过。这就是标量替换的威力。
注意:逃逸分析是建立在“JIT 编译时”的,不是全程序分析。如果方法太复杂,JIT 无法判断对象是否逃逸,就不会做这些优化。平时写代码时,尽量把方法写短,让局部对象只在局部作用域使用,就是在帮 JIT 做逃逸分析。
3.3 循环优化:程序性能的大头
业务系统里最耗 CPU 的往往是循环,所以 JIT 对循环的优化非常看重。常见的几种技术:
- 循环不变量外提(Loop-Invariant Code Motion):循环体里如果有个表达式的结果不随循环变化,每次循环都计算一遍纯属浪费。JIT 把它提到循环外面只算一次。典型例子是获取
list.size()——如果 size 在循环里不变,编译器会只取一次。 - 循环展开(Loop Unrolling):为了减少循环控制语句的开销,JIT 会把循环体复制几份,一次循环做多次操作,然后把循环条件跨大步检测。
- 循环剥离(Loop Peeling):把循环的前几次单独拿出来执行,这样剩余部分可能有更规整的模式,方便做向量化或消除边界检查。
这些优化的触发都依赖回边计数器。如果一个方法里有个大循环但不怎么被调用,回边计数上不去,JIT 再想优化也没法发力。这种感觉就像你要给一个热门景点修路,但这个景点根本没人去,没必要修。
4. 把“JIT 八股”落到实际:命令、参数与排查方法
4.1 观察 JIT:-XX:+PrintCompilation
我强烈建议你把 -XX:+PrintCompilation 加上跑一次压测。它的输出每一行都代表一次编译事件,字段大致是:
bash复制timestamp compile_id attributes (level) method
一个典型输出:
code复制 12345 21 % 3 com.example.service.OrderService::getOrderInfo @ 9 (35 bytes)
12346 22 4 com.example.service.OrderService::calcPrice (25 bytes)
- 第 1 列是编译完成时刻。
- 第 2 列的
%表示被编译的是一个 OSR(On-Stack Replacement)版本,也就是正在执行循环时切换到的编译版本。 - 第 3 个数字是编译层级,
3表示 C1 编译,4表示 C2 编译。 - 最后是方法名和字节码大小。
我一般是配合压测工具用:先看哪些方法被编译了,再对照代码找热点。有一次线上接口慢,我加了这个参数,发现一个 BigDecimal 累加的方法被编译到 3 级,但一直没升到 4 级,原因是方法太大超过了 C2 的编译限制。查下来是我的代码在循环里反复 new 对象导致方法体膨胀,改成复用对象后,方法体缩小,C2 终于接手,性能立刻上来。
4.2 分层编译的“回退”问题(一个高频故障)
分层编译的原理是:先用 C1 快速编译,让代码先有基础优化,等热度足够再升级到 C2。听起来很合理,但有个隐患——C2 编译失败或超出系统限制时,会回退到 C1 甚至解释器。
最典型的问题是 CodeCache 满了。CodeCache 是存放编译后机器码的内存区域,默认大小在 JDK 8 里是 240MB(不同版本不同)。如果满了,JIT 会停止编译,代码回退到解释执行,性能瞬间雪崩,但 CPU 占用率却可能飙升。
我遇到过一次生产事故就是这样:服务跑了大半个月,某天开始响应时间陡增 10 倍,CPU 也居高不下。查 GC 日志一切正常,最后看 jstat -codecache 才发现 CodeCache 用满,日志里还有 CodeCache is full. Compiler has been disabled 的警告。
解决分三层:
- 临时缓解:重启服务(治标不治本)。
- 扩大容量:
-XX:ReservedCodeCacheSize=512M,给足空间。 - 根治问题:检查是不是有大量动态生成类,比如 CGLIB 代理类、反射调用频繁,导致编译方法数量暴增。如果代理类很多,考虑调整代理机制。
注意:JDK 8 以后有
-XX:+UseCodeCacheFlushing(默认开启),它会尝试清掉不用的编译代码,但代价是后续可能重新编译,效果有限。
4.3 控制 JIT 行为:CompileCommand 和 CompileOnly
有些场景下你不想让 JIT 碰某个方法,或者想强制它提前编译某个方法。这时候用 -XX:CompileCommand:
bash复制# 强制编译某个方法
-XX:CompileCommand=compileonly,com/example/OrderService::getOrderInfo
# 排除某个方法,不让它被 JIT 编译
-XX:CompileCommand=exclude,com/example/RemoteService::call
compileonly 这个参数要小心用,它会让所有其他方法都不被编译,性能往往会掉很多。我一般只在小范围调试时用它。排在前面多是写临时小工具去给某个方法做性能验证,生产环境几乎不用。
如果只想排除某个方法,用 exclude 就行。真实场景里,我遇到过某个方法一旦被 C2 优化就触发一个编译器 bug,导致进程崩溃,当时就是先定位到是 C2 编译问题,然后用 -XX:CompileCommand=exclude 临时绕过,再升级 JDK 解决的。
还有一个隐藏参数值得关注:-XX:MaxInlineSize 和 -XX:FreqInlineSize。前者控制“普通方法”内联大小,后者控制“热点方法”的内联大小(热点方法允许更大)。如果应用中很多小 getter/setter,可以考虑调大这两个值让 JIT 更容易内联。
4.4 实战排查步骤:当怀疑跟 JIT 有关时
给你一套我自己整理的排查流程,照着做能少走弯路。
- 看 GC 日志:排除 GC 抖动。如果 GC 正常但响应忽高忽低,概率是 JIT 在作怪。
- 打开 PrintCompilation:快速看看有没有大规模编译事件,特别是 C2 编译那些耗时长的。
- 查 CodeCache:用
jstat -codecache看使用率,或者查日志里有没有 CodeCache 相关 warning。 - 火焰图对比:压测前后各出一份火焰图。如果某个方法在“编译完成后”明显变得更慢,很可能被编译坏了(极少数,但 C2 的 bug 是存在的)。
- 临时绕过:锁定可疑方法后用
CompileCommand=exclude或调整 CodeCache 大小,观察是否复现。
这套流程我用了很多次,基本能覆盖 80% 的 JIT 相关线上问题。
4.5 一个实际案例:接口响应时间不稳定的根因
之前维护过一个订单查询接口,压测时 QPS 上不去,响应时间像“心电图”一样,一会 20ms,一会 500ms,完全没规律。GC 没压力、CPU 也不高,搞得人很烦躁。
后来加了 -XX:+PrintCompilation 一看,问题浮出水面:接口里一个 format() 方法反复被调用,热度一上来,JIT 正在对它做 OSR 编译。编译期间,这个方法的执行路径还没切换到机器码,走的是解释器路径,所以部分请求特别慢。编译完成后又恢复稳定,但随后又有别的方法进入编译队列,于是“心电图”不断出现。
解决方案不是关掉 JIT——那是疯了。而是让这个接口的代码减少动态分派、避免敏感调用点。具体做法是把一些多态调用改成 switch 或静态绑定,然后在压测时提前做好预热。最终响应时间从“心电图”变成了稳定在 30ms 左右。
这给了我很深的印象:JIT 不是幽灵,它只是一个需要理解的“另一个开发者”。它的优化策略和调优倾向,你都应该当成一段代码去理解,而不是等到线上出事再去猜。
5. 面试高频题串讲与避坑指南
5.1 高频题清单及参考回答
1. JIT 和解释器的区别?为什么不用纯编译方式?
回答框架:解释器启动快、无编译等待,但执行慢;JIT 通过热点统计把高频代码编译成本地机器码,执行快。纯 AOT 编译缺少运行时信息,无法做逃逸分析、动态分派优化,而且平台相关性和类动态加载冲突。所以 HotSpot 默认是混合模式,启动用解释器,跑热了就编译。
2. C1 和 C2 有什么区别?分层编译是什么?
C1 编译快,优化少;C2 编译慢,优化强。分层编译让代码先经过 C1 快速获得基础优化,再在热度足够时升级到 C2 做深度优化。对应三个层级:0 层解释器、1-3 层 C1(不同 profiling 状态)、4 层 C2。
3. 方法内联在什么条件下会失效?
接口多个实现导致 CHA 找不到唯一目标、方法体超过 MaxInlineSize、调用点不够热、被 -XX:CompileCommand=exclude 排除等。补充一点:JDK 8 以后的 -XX:+PrintInlining 可以直接看内联决策(只对 C2 有效)。
4. 逃逸分析能解决什么问题?
避坑关键:它能支持栈上分配、标量替换、锁消除。但 Java 8 默认开启的只是“对象不逃逸的简单分析”,不等于所有对象都不上堆。如果一个对象传给别的方法,它逃逸了,那就只能走堆分配。所以不要试图“利用逃逸分析”来大量创建对象,逻辑上说不通。
5. OSR(On-Stack Replacement)是什么?
当一个方法处于解释器执行中,且恰好内部有长循环被判定为热点,JVM 可以编译完这个循环后,在运行时“替换”当前正在执行的栈帧到编译版本。这是回边计数器存在的意义。OSR 是理解“接口响应时间不稳定”的关键。
5.2 八股中最容易踩的三个坑
第一个坑:背诵输出式回答。
面试官如果问到 JIT 相关八股,最忌讳的就是把概念背给他们听,因为很多候选人背完“逃逸分析可以让对象上栈”就得意地笑,但问“逃逸分析是不是一定能把对象分配在栈上”,他们就蒙了。正确答案是:不保证。JIT 只有在能证明对象不逃逸,且栈分配在成本上有利时才会做。
第二个坑:忽略参数对行为的影响。
CompileThreshold 不是只调大调小而已,不同版本默认值不同、分层编译下它的意义也不同,不懂就乱调,可能让 C2 长期不工作。
第三个坑:不知道 JIT 优化是“概率性”的。
JIT 优化和解释器不同,它基于 Profile 采样,Profile 没有覆盖到的路径不做优化。所以同一个方法,冷热路径不同,编译结果也可能不同。线上有的慢、有的快,有时候真不是代码问题,是 JIT 状态不一致导致的。
5.3 白送你一个小技巧:用 -XX:+PrintInlining 查看内联决策
调试内联相关问题时,-XX:+PrintCompilation 只能告诉你“编没编译”,不能告诉你“为什么内联失败”。这时候用 -XX:+PrintInlining:
bash复制java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -version
输出会给出每个调用点的内联决策,比如:
code复制@ 12 com.example.Service::call (25 bytes) inline (hot)
@ 20 com.example.SubService::doWork (300 bytes) too big
too big 就是方法体超过内联大小限制,hot 是因为调用次数够热才内联。这个输出对判断“为什么性能上不去”特别有帮助,强烈建议试试。
6. 我自己的几个 JIT 实操心得
平时写代码时,有没有什么原则可以“顺着” JIT 的偏好走?有,而且我实践下来效果很明显。
第一,别怕写“小方法”。 很多人觉得方法调用有开销,所以喜欢把一个超级大的方法写出来,减少“多余调用”。这恰恰是跟 JIT 对着干。JIT 的优化目标是小方法——方法越小,越可能被内联,逃逸分析越容易分析。大方法反而让它束手束脚。
第二,循环里尽量少 new 对象,也尽量别在循环里做复杂多态调用。 这句话我经常说,但解释一下为什么:循环里的新对象每轮迭代都分配,即使逃逸分析能标量替换,也需要 JIT 先判断所有使用路径都安全。循环拉长、分支变多,分析难度就直线上升,很容易放弃优化。同理,多态调用会让 CHA 分析失效。把这两个因素从循环里拿掉,JIT 的优化效率会高非常多。
第三,关注 CodeCache,不要等雪崩了才想起来。 我的经验值:如果服务用 CGLIB、动态代理较多,有条件就尽量用 -XX:ReservedCodeCacheSize=512M,并且监控面板上加上 CodeCache 使用率。看不到 CodeCache 和 JIT 日志,等于在盲开性能车。
第四,压测一定要有预热阶段。 很多团队压测直接全量压,一上来就是解释器跑,性能比生产差很多;跑了一会儿热起来,数据又“突然变好”,让人误以为代码优化有效。正确做法是压测前先跑 5-10 分钟低并发预热,等 PrintCompilation 日志平静下来再开始正式压测。
第五,JDK 版本的底层实现差异巨大。 比如 JDK 8 默认开分层编译,但有些时候 C2 的退出策略和 JDK 11 的 ZGC 下行为不太一样。如果你在调优时用了网上的参数,一定先在本地压测验证,再上生产。我之前就被一个“锦上添花”的参数坑过,那个参数在低版本 JDK 下根本不存在,直接启动失败。
说到这儿想再补一句:JIT 这东西,很多人面试前背一遍,背完就忘,觉得它离自己很远。但只要你写过长时间跑的服务、排查过偶发性能抖动,就一定跟它打过照面。希望这篇文章不只是帮你在面试时多拿一分,而是让你下一次遇到“莫名其妙的慢”时,脑子里多一根弦:去查查 JIT 编译状态,去翻翻 CodeCache 用量,去看一眼 PrintCompilation 日志。很多时候,性能问题的根因不在业务代码,而藏在你从来没仔细看过的 JVM 内部逻辑里。
以后面试官再问“JIT 什么时候会退化”,你就可以淡定地告诉他:CodeCache 满了会退化,C2 编译失败会退化,CounterDecay 让热度不够了也会退化,而且每一步都有日志可查。八股背得再好,不如亲手查过一个问题。
