JIT:为什么 Java 会「越跑越快」
更新时间:2026-09-20。JIT 相关的 逃逸分析与栈上分配 已有专篇,本篇讲编译流程全貌。性能压测要预热的根因就在这里,配合 perf 火焰图 理解本地代码热点。
一、解释执行 + 编译执行混合
JVM 启动时先由解释器逐条解释字节码(启动快、但跑得慢)。随着某些方法/循环被反复执行(成为「热点」),JIT 编译器把这部分字节码直接编译成本地机器码并缓存,后续调用走机器码,速度接近 C/C++。这就是「预热后变快」的原因。
二、C1 与 C2:分层编译(Tiered Compilation)
现代 HotSpot 默认开启分层编译,用两个编译器逐级优化:
| 层级 | 编译器 | 编译速度 | 优化程度 | 目的 |
|---|---|---|---|---|
| Tier 0 | 解释器 | — | 无 | 快速启动、收集 profile |
| Tier 1–3 | C1(Client) | 快 | 轻量(基础内联、去锁) | 尽快拿到可用机器码 |
| Tier 4 | C2(Server) | 慢 | 激进(深度内联、向量化、逃逸分析) | 长期峰值性能 |
热方法通常先被 C1 快速编译顶上,等 profiling 信息攒够再由 C2 重编成高度优化版本。历史上的 -Xint(只解释)、-Xcomp(尽量编译)、-client/-server 现在基本不需要手动设,Tiered 默认开启。JDK 10+ 还可在 Linux/x64 用 Graal 编译器(-XX:+UseJVMCICompiler)作为新的顶层编译器。
三、热点探测:基于计数器
HotSpot 不靠采样,而靠两类计数器:
- 方法调用计数器:方法被调用次数;
- 回边计数器(back-edge):循环回跳次数,让长时间运行的循环体也能在运行中被编译,叫 OSR(On-Stack Replacement,栈上替换)——正在解释执行的栈帧直接换成编译版本。
超过阈值(-XX:CompileThreshold 等,分层编译下是动态值)就触发编译。所以冷启动微基准完全不准:前几千次还在解释/C1 里跑。
四、C2 的典型优化
- 方法内联:把小方法体直接嵌进调用处,消除调用开销并为后续优化创造条件(最重要的优化之一,受
-XX:MaxInlineSize/FreqInlineSize影响); - 逃逸分析:对象不逃出方法/线程则可做栈上分配、标量替换、锁消除(同步省略),详见逃逸分析篇;
- 虚调用去虚化/单态内联:只有一个实现时把动态分派变成直接调用;
- 循环展开、边界检查消除、自动向量化(SIMD)、无用代码消除。
这也是为什么「写得笨」的 Stream/增强 for 在预热后可能和手写循环一样快——JIT 抹平了大量表层差异。
五、去优化(deoptimization):性能会「回退」
如果乐观假设被打破(如单态调用突然出现第二个实现、类被加载改变了继承关系、profiled 类型不再成立、ClassLoader 变化),JVM 会 deopt:丢弃机器码退回解释器/C1 重新收集、重编译。表现为压测曲线偶发抖动。频繁加载类、滥用反射、ClassLoader 反复创建都会增加 deopt。用 -XX:+PrintCompilation(JDK8)或统一 JFR/-Xlog:jit+compilation 能看到编译与「made not entrant / made zombie」(作废)事件。
六、实践含义
- 压测必须预热:跑够时间/次数让热点进入 C2,再计时;用 JMH 它会自动处理预热、防止死代码消除(配合
Blackhole); - 不要用「第一次请求耗时」评估长期吞吐;
- 小方法、
final/稳定类型更利于内联与去虚化; - 想彻底不要预热、启动即峰值的场景(短生命周期、Serverless、容器冷启动)才考虑 AOT:GraalVM Native Image 在构建期把程序编成原生可执行文件,代价是牺牲部分运行时优化、反射/动态加载要额外配置。
总结
JVM 混合「解释器快速启动 + JIT 对热点生成本地码」,默认分层编译让 C1 快速产出轻优化代码、C2 再对攒够 profile 的热点做深度优化;热点由方法调用与循环回边计数器触发,长循环靠 OSR 在运行中替换;C2 的威力来自内联、逃逸分析(栈上分配/标量替换/锁消除)、去虚化与向量化,因此预热后写法差异常被抹平;当乐观假设被打破会 deopt 回解释器重编译,造成偶发抖动;所以性能测试必须充分预热(用 JMH),而追求启动即峰值、无预热的场景才用 GraalVM Native Image 这类 AOT。