JIT 即时编译是什么?解释器和编译器如何配合?什么是分层编译?
简化版
Java 代码先编译成字节码,运行时由 JVM 执行。执行方式有两种:解释器逐条翻译字节码执行(启动快,但每次都要翻译,慢);JIT(Just-In-Time)即时编译器把「热点代码」(被反复执行的方法/循环)直接编译成本地机器码(编译慢,但之后执行极快)。JVM 采用「解释器 + JIT 混合模式」:一开始用解释器快速启动,同时统计每段代码的执行次数,一旦某段代码「变热」(超过阈值),就用 JIT 编译成机器码缓存起来,后续直接执行机器码。分层编译是让 C1(快速编译、优化少)和 C2(慢速编译、深度优化)配合,兼顾启动速度和峰值性能。
详细版
两种执行方式的取舍:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 解释执行 | 启动快、省内存、无需编译 | 每次都要翻译字节码,执行慢 |
| JIT 编译执行 | 编译成机器码后执行极快 | 编译要花时间和内存,冷代码编译不划算 |
混合模式的智慧:不是所有代码都值得编译——只编译「热点代码」(被反复执行的,占运行时间大头的少量代码,符合二八定律)。冷代码(只执行几次)继续解释执行,避免白白花编译成本。
如何识别热点代码——热点探测(基于计数器):
每个方法维护两个计数器:
① 方法调用计数器:方法被调用的次数
② 回边计数器(Back Edge Counter):循环体执行的次数(循环"跳回"叫回边)
任一计数器超过阈值 → 判定为热点 → 触发 JIT 编译
循环很热但方法只调一次时,靠回边计数器触发 OSR(栈上替换)编译
分层编译(Tiered Compilation,JDK 8+ 默认)——C1 和 C2 配合:
| 层级 | 编译器 | 特点 |
|---|---|---|
| Level 0 | 解释器 | 纯解释执行 |
| Level 1-3 | C1(Client 编译器) | 编译快、优化较简单、带不同程度的性能统计 |
| Level 4 | C2(Server 编译器) | 编译慢、深度优化(内联、逃逸分析、循环优化),性能最高 |
流程:解释执行 → C1 快速编译(先让代码跑快点)→ 收集运行数据 → 特别热的再由 C2 深度编译。
⚠️ JIT 编译是「投机优化」——它会根据运行时观察到的信息做激进假设(如某分支从没走过、某方法从没被重写)。一旦假设被打破(新加载的类重写了方法、之前没走的分支走了),JIT 会去优化(Deoptimization),退回解释执行再重新编译。所以 JIT 的性能是「预热后」才达到峰值。
完整版教学
一、Java「半编译半解释」的由来
Java 号称「一次编译,到处运行」,靠的是字节码这个中间层:
源码 .java ──javac 编译──▶ 字节码 .class ──JVM 执行──▶ 机器码
(编译期,一次) (运行期,每次)
字节码是平台无关的「中间语言」,但 CPU 只认机器码,所以字节码要在运行时被「翻译」成机器码执行。翻译有两条路:解释器(逐条翻译,来一条译一条)和 JIT 编译器(整段翻译成机器码缓存复用)。纯解释太慢(每次执行都要重新翻译),纯编译启动太慢(程序一启动就把所有代码编译一遍,很多代码可能只跑一次,白编译)。所以 JVM 走「混合模式」——解释器保证快速启动,JIT 把反复执行的热代码编译成机器码提速。这是「启动速度」和「运行速度」的平衡艺术。
二、为什么只编译热点代码:二八定律
一个程序里,绝大部分运行时间消耗在极少数代码上(热点),而大量代码只执行几次(冷点)。编译是有成本的(占 CPU 和内存),所以 JVM 的策略是只把「投入产出比高」的热点代码编译:
典型程序:20% 的代码占用 80% 的执行时间(热点)
80% 的代码只跑几次(冷点)
编译冷点:编译成本 > 收益(跑几次就完了,机器码没机会复用)→ 不划算
编译热点:编译成本 << 收益(跑几百万次,机器码反复复用)→ 大赚
所以 JIT 不会一上来就编译所有代码,而是先解释执行 + 统计,等某段代码「热」到值得编译了再动手。这个「按需编译、把编译资源花在刀刃上」的思路,是 JIT 高效的根本。
三、热点探测:计数器怎么判断「热」
JVM 用「基于计数器的热点探测」找热点,每个方法两个计数器:
① 方法调用计数器:方法每被调用一次 +1
② 回边计数器:方法里的循环每回跳一次 +1("回边"= 循环末尾跳回开头)
任一计数器超过阈值(如 C2 默认方法计数器 10000)→ 判定为热点 → 提交 JIT 编译
两个计数器覆盖两种「热」:方法被频繁调用(方法计数器触发)、方法里有个执行很多次的循环(回边计数器触发)。后者带来一个特殊机制——OSR(On-Stack Replacement,栈上替换):如果一个方法只被调用一次,但里面有个循环跑了几百万次,方法计数器不会触发,但回边计数器会——这时不能等方法下次调用(可能没有下次),而要在循环执行过程中就把它替换成编译后的机器码,让正在跑的循环「换引擎不停车」。这就是 OSR。
四、分层编译:C1 和 C2 的接力
早期 JVM 让用户二选一:C1(Client,编译快优化浅,适合客户端/启动敏感)或 C2(Server,编译慢优化深,适合服务端/长跑)。但这是两难——启动快就峰值低,峰值高就启动慢。分层编译(JDK 8+ 默认) 让两者接力,兼得:
Level 0:解释执行(最开始,启动快)
↓ 代码变热
Level 1-3:C1 编译(快速编译出机器码,先让代码跑快,同时收集统计数据)
↓ 代码特别热
Level 4:C2 编译(用 C1 收集的数据做深度优化,达到峰值性能)
好处:启动阶段靠解释器和 C1 快速起步(不用等 C2 慢慢深度编译),长期运行靠 C2 提供峰值性能。C1 先顶上让代码尽快跑快,同时帮 C2 收集运行数据(哪些分支热、哪些类型常见),C2 再基于这些数据做激进优化。这是「既要启动快、又要峰值高」的最优解。
五、JIT 的深度优化:为什么机器码比字节码快得多
C2 不只是「翻译」,它做大量优化,让机器码远快于逐条解释字节码:
| 优化 | 说明 |
|---|---|
| 方法内联(Inlining) | 把被调用的小方法直接展开到调用处,省去方法调用开销,是最重要的优化 |
| 逃逸分析 | 未逃逸对象栈上分配/标量替换/锁消除 |
| 循环优化 | 循环展开、循环不变量外提 |
| 公共子表达式消除 | 重复计算只算一次 |
| 分支预测优化 | 根据运行统计,把热分支排前面 |
其中方法内联是基础——很多其他优化(逃逸分析、常量传播)都依赖内联先把方法边界打破。举例:一个 getter return this.x 若不内联,每次调用有方法调用开销;内联后直接变成字段访问,还能进一步和上下文一起优化。这些优化基于「运行时真实数据」,是静态编译(javac)做不到的——这也是为什么 JIT 编译的机器码有时比 C/C++ 静态编译还激进。
六、投机优化与去优化
JIT 的激进优化建立在「投机假设」上——它根据目前观察到的运行情况做假设,比如:
假设1:这个虚方法调用,运行至今都是同一个实现 → 直接内联那个实现(省去动态分派)
假设2:这个 if 分支运行至今从没走过 → 不为它生成优化代码
假设3:这个对象类型运行至今都是 String → 按 String 优化
一旦假设被打破(如新加载的类重写了方法、之前没走的分支走了):
→ 去优化(Deoptimization):丢弃编译后的机器码,退回解释执行,重新收集数据再编译
去优化是 JIT 敢激进的底气——它可以大胆假设,赌错了就回退,不影响正确性。这也解释了几个现象:① 为什么 Java 应用要「预热」(刚启动时还在解释/C1,没达到 C2 峰值,且投机优化还没稳定);② 为什么压测要先跑一会儿再看数据(等 JIT 编译和去优化稳定)。理解「投机 + 去优化」这对机制,就理解了 JIT 性能的动态特性。
记忆钩子:「字节码靠解释器 + JIT 混合执行;只编译热点(二八定律),靠方法计数器和回边计数器探测;分层编译 C1 快速起步 + C2 深度优化接力;JIT 靠内联等优化 + 投机假设提速,赌错了就去优化回退,所以要预热」。
七、常见误区与追问
- 误区:Java 是纯解释型语言。 是「解释 + JIT 编译」混合——启动用解释器,热点代码被 JIT 编译成机器码,峰值性能接近本地代码。
- 误区:所有代码都会被 JIT 编译。 只编译热点代码(反复执行的),冷代码继续解释执行,避免为只跑几次的代码白花编译成本。
- 误区:JIT 编译后性能立刻达到峰值。 需要「预热」——刚启动还在解释/C1 阶段,且投机优化未稳定,跑一段时间达到 C2 编译后才是峰值。
- 误区:C1 和 C2 只能二选一。 分层编译(JDK 8+ 默认)让两者接力——C1 快速起步、C2 深度优化,兼顾启动速度和峰值性能。
- 追问:什么是 OSR(栈上替换)? 方法只调用一次但内含大循环时,方法计数器不触发、回边计数器触发,JIT 在循环执行途中就把它替换成机器码,让正在跑的循环「不停车换引擎」。
- 追问:什么是去优化,为什么需要它? JIT 基于投机假设做激进优化,假设被打破(类被重写、冷分支被走)时丢弃机器码退回解释执行重新编译;它是 JIT 敢激进的安全网。
- 追问:JIT 最重要的优化是什么? 方法内联——把小方法展开到调用处,省调用开销,且打破方法边界让逃逸分析、常量传播等后续优化成为可能。
八、加强记忆
Java 是「解释 + JIT 编译混合执行」:源码先由 javac 编成平台无关的字节码,运行时字节码要翻译成机器码——解释器逐条翻译(启动快但慢),JIT 即时编译器把热点整段编成机器码缓存(编译慢但执行极快)。JVM 只编译热点代码(二八定律,冷代码编译不划算),靠方法调用计数器 + 回边计数器探测热点(后者触发 OSR 栈上替换,让途中的大循环不停车换机器码)。分层编译(JDK 8+ 默认)让 C1(编译快、优化浅、先让代码跑快并收集数据)和 C2(编译慢、深度优化)接力,兼顾启动速度和峰值性能。C2 的核心优化是方法内联(打破方法边界,使逃逸分析等后续优化可行),加上循环优化、公共子表达式消除等,基于运行时真实数据,比静态编译更激进。这些优化是投机的——赌某假设成立,赌错了就去优化退回解释执行重编。所以 Java 应用有「预热」特性,峰值性能要跑一段才达到。一句话「字节码解释起步、热点 JIT 编译、计数器探热、C1C2 分层接力、内联为王、投机优化靠去优化兜底、需要预热」。