2.1 四种执行形态:为什么必须预热
上一节:无 | 下一节:2.2 JIT 的三大优化与去优化 配套代码:02-jvm-performance-basics/01-execution-tiers
一句话结论
JVM 上的同一段代码,随着被调用的次数增加,会经历「解释执行 → C1 编译 → C2 编译」三种形态,性能台阶式提升。 所以你测前 100 次调用,测的其实是最慢的那种形态,它和线上稳态毫无关系。
一、用餐厅厨房理解分层编译
假设你开了一家餐厅,来了一个新厨师。看他怎么做菜:
| 阶段 | 厨师状态 | 类比 JVM |
|---|---|---|
| 第 1 天 | 照着菜谱一步一步做,看一行做一步,很慢但立刻能上手 | 解释执行:逐条读取字节码并执行 |
| 做了几天后 | 熟练了,动作连贯,不用再看菜谱 | C1 编译:编译成机器码,快,但只做简单优化 |
| 做了几百次之后 | 老师傅开始重新设计整个流程:提前备料、合并步骤、把常用的调料放在手边 | C2 编译:做激进优化(内联、逃逸分析、循环展开) |
| 正在做菜时被老师傅接手 | 这道菜做到一半,老师傅直接接手继续 | OSR(栈上替换):循环体被热点编译 |
关键点:
- 老师傅不会第一天就来。 他需要先观察你做了几百次,确认「这道菜真的是最常点的」,才值得花时间重新设计流程。
- 重新设计流程很费时间(编译耗时),所以只有足够热的代码才配得上 C2。
- 所以:前几十次调用的性能,和你线上跑了一小时之后的性能,完全是两回事。
二、三种形态的实际差异
| 形态 | 触发条件 | 相对速度 | 说明 |
|---|---|---|---|
| 解释执行 | 一开始 | 最慢(可能慢 5–20 倍) | 逐条字节码执行,还要做类型检查 |
| C1(客户端编译器) | 调用几百次后 | 中等 | 编译快、优化少,目标是「尽快摆脱慢速解释」 |
| C2(服务端编译器) | 调用数千到上万次后 | 最快 | 编译慢、优化激进,会做基于运行时 profile 的推测性优化 |
具体阈值随 JDK 版本和运行情况变化(大致在数千到上万次调用 / 循环回边这个量级)。不要记具体数字,要记住「有台阶」这个事实。 需要精确数字时用
-XX:+PrintCompilation观察自己的程序。
三、一个具体的观察
用同一段代码,在不同调用次数下测量,你会看到:
第 1 次调用 : 1180 ns/op ← 解释执行
第 100 次调用 : 612 ns/op ← 已经进了 C1
第 1 万次调用 : 342 ns/op ← C2 开始介入
第 10 万次调用 : 232 ns/op ← 稳态(C2 完成)
第 100 万次调用 : 230 ns/op ← 稳态
这不是「越来越熟练」,而是「换了一套执行引擎」。 中间那些台阶,就是形态切换的瞬间。
四、怎么亲眼看到这些切换
JVM 提供了一个开关,把每次编译事件打印出来:
java -XX:+PrintCompilation -jar your-app.jar
输出形如:
120 45 3 java.lang.String::hashCode (55 bytes)
135 52 % 4 com.example.Service::process @ 12 (240 bytes) made not entrant
150 61 4 com.example.Service::process (240 bytes)
怎么读这几列:
| 列 | 含义 |
|---|---|
| 第 1 列 | 从 JVM 启动到编译发生的毫秒数 |
| 第 2 列 | 编译任务 ID |
% |
OSR(栈上替换)——循环体被热点编译,不是整个方法 |
3 / 4 |
编译层级:3 = C1 带 profiling,4 = C2 |
made not entrant |
去优化!之前的编译结果作废了(第 2 节详述) |
末尾 (240 bytes) |
编译的字节码大小 |
重点看两类行:
- 带
%的:说明是循环热点被编译(不是方法整体)。 - 带
made not entrant的:说明发生了去优化——这是性能抖动的常见来源。
五、这对你的三个直接影响
❶ 基准测试必须有预热阶段
否则你测的是解释执行或 C1 的速度。这不是「测不准」,而是「测的不是同一个东西」。
❷ 压测必须区分「冷启动」和「稳态」
服务刚启动的 30 秒内,大量代码还在解释执行/C1 阶段。如果压测只有 30 秒,你测的主要是冷启动。
❸ 部署方式会受此影响
- 滚动发布:新实例刚起来时性能差,如果此时把大量流量切过去,会拖垮整个集群。
- 自动扩容:新扩容的实例需要几分钟才能达到稳态性能。
- Serverless:函数冷启动时几乎没有预热机会,性能完全由冷启动决定。
这就是为什么「预热」在云原生环境里重新变成了一个重要话题。
六、本节小结
- JVM 代码有四种执行形态:解释执行 → C1 → C2,加上循环热点的 OSR。
- 形态切换由调用次数驱动,所以性能随时间变化——这是 JVM 与 C++ 最根本的区别之一。
- 不预热的测量测的是「最慢的形态」,与稳态无关。
- 用
-XX:+PrintCompilation可以亲眼看到编译事件和去优化事件。 - 冷启动、滚动发布、自动扩容、Serverless 都受这条机制直接影响。
七、自测
- 一个服务在压测的前 20 秒 P99 是 800 ms,之后稳定在 150 ms。请解释这个现象,并说明这对压测设计有什么要求。
-XX:+PrintCompilation输出里的%和made not entrant分别代表什么?它们各自提示了什么风险?- 一个团队做 Serverless 函数,发现「每次冷启动的第一个请求要 2 秒」。用本节的机制解释原因,并说出至少两种缓解方式。
- 前 20 秒大量代码还在解释执行或 C1 阶段(加上类加载、连接池初始化、缓存未预热),所以延迟高;随着热点方法升到 C2,性能进入稳态。对压测的要求:① 必须有独立的预热阶段,且预热数据不计入统计;② 稳态测量时长要足够(至少 3–5 分钟),否则测出的主要是过渡态;③ 报告里要写明预热时长。另外,如果 SLO 覆盖发布期,冷启动性能必须单独测,不能只看稳态。
%表示这是一次 OSR(栈上替换)编译——针对循环体而不是整个方法。它提示:你的热点在循环里,如果循环内部有类型不稳定的调用,会影响优化效果。made not entrant表示去优化——之前编译好的代码作废了(通常因为 JIT 的假设被打破)。它提示:性能会出现抖动甚至短暂回落,也说明这段代码的类型分布不稳定(见第 2 节)。- 原因:函数实例是新启动的 JVM,第一个请求要经历类加载 + 解释执行 + C1 编译,还没有任何 C2 编译成果;同时连接池、缓存都是空的。缓解方式:① 预留实例(provisioned concurrency)——保持一定数量的已预热实例;② 启动时主动预热——在初始化阶段用合成的请求调用热点路径(也叫「暖机」),让关键方法提前升到 C2;③ 用 AppCDS / CRaC 之类技术减少类加载与启动开销;④ 把重型初始化改成懒加载或异步初始化,先让函数能快速响应。