文档目录

2.1 四种执行形态:为什么必须预热

上一节:无 | 下一节:2.2 JIT 的三大优化与去优化 配套代码:02-jvm-performance-basics/01-execution-tiers


一句话结论

JVM 上的同一段代码,随着被调用的次数增加,会经历「解释执行 → C1 编译 → C2 编译」三种形态,性能台阶式提升。 所以你测前 100 次调用,测的其实是最慢的那种形态,它和线上稳态毫无关系。


一、用餐厅厨房理解分层编译

假设你开了一家餐厅,来了一个新厨师。看他怎么做菜:

阶段 厨师状态 类比 JVM
第 1 天 照着菜谱一步一步做,看一行做一步,很慢但立刻能上手 解释执行:逐条读取字节码并执行
做了几天后 熟练了,动作连贯,不用再看菜谱 C1 编译:编译成机器码,快,但只做简单优化
做了几百次之后 老师傅开始重新设计整个流程:提前备料、合并步骤、把常用的调料放在手边 C2 编译:做激进优化(内联、逃逸分析、循环展开)
正在做菜时被老师傅接手 这道菜做到一半,老师傅直接接手继续 OSR(栈上替换):循环体被热点编译

关键点:

  1. 老师傅不会第一天就来。 他需要先观察你做了几百次,确认「这道菜真的是最常点的」,才值得花时间重新设计流程。
  2. 重新设计流程很费时间(编译耗时),所以只有足够热的代码才配得上 C2。
  3. 所以:前几十次调用的性能,和你线上跑了一小时之后的性能,完全是两回事。

二、三种形态的实际差异

形态 触发条件 相对速度 说明
解释执行 一开始 最慢(可能慢 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) 编译的字节码大小

重点看两类行:

  1. 带 % 的:说明是循环热点被编译(不是方法整体)。
  2. 带 made not entrant 的:说明发生了去优化——这是性能抖动的常见来源。

五、这对你的三个直接影响

❶ 基准测试必须有预热阶段

否则你测的是解释执行或 C1 的速度。这不是「测不准」,而是「测的不是同一个东西」。

❷ 压测必须区分「冷启动」和「稳态」

服务刚启动的 30 秒内,大量代码还在解释执行/C1 阶段。如果压测只有 30 秒,你测的主要是冷启动。

❸ 部署方式会受此影响

  • 滚动发布:新实例刚起来时性能差,如果此时把大量流量切过去,会拖垮整个集群。
  • 自动扩容:新扩容的实例需要几分钟才能达到稳态性能。
  • Serverless:函数冷启动时几乎没有预热机会,性能完全由冷启动决定。

这就是为什么「预热」在云原生环境里重新变成了一个重要话题。


六、本节小结

  1. JVM 代码有四种执行形态:解释执行 → C1 → C2,加上循环热点的 OSR。
  2. 形态切换由调用次数驱动,所以性能随时间变化——这是 JVM 与 C++ 最根本的区别之一。
  3. 不预热的测量测的是「最慢的形态」,与稳态无关。
  4. 用 -XX:+PrintCompilation 可以亲眼看到编译事件和去优化事件。
  5. 冷启动、滚动发布、自动扩容、Serverless 都受这条机制直接影响。

七、自测

  1. 一个服务在压测的前 20 秒 P99 是 800 ms,之后稳定在 150 ms。请解释这个现象,并说明这对压测设计有什么要求。
  2. -XX:+PrintCompilation 输出里的 % 和 made not entrant 分别代表什么?它们各自提示了什么风险?
  3. 一个团队做 Serverless 函数,发现「每次冷启动的第一个请求要 2 秒」。用本节的机制解释原因,并说出至少两种缓解方式。
  1. 前 20 秒大量代码还在解释执行或 C1 阶段(加上类加载、连接池初始化、缓存未预热),所以延迟高;随着热点方法升到 C2,性能进入稳态。对压测的要求:① 必须有独立的预热阶段,且预热数据不计入统计;② 稳态测量时长要足够(至少 3–5 分钟),否则测出的主要是过渡态;③ 报告里要写明预热时长。另外,如果 SLO 覆盖发布期,冷启动性能必须单独测,不能只看稳态。
  2. % 表示这是一次 OSR(栈上替换)编译——针对循环体而不是整个方法。它提示:你的热点在循环里,如果循环内部有类型不稳定的调用,会影响优化效果。made not entrant 表示去优化——之前编译好的代码作废了(通常因为 JIT 的假设被打破)。它提示:性能会出现抖动甚至短暂回落,也说明这段代码的类型分布不稳定(见第 2 节)。
  3. 原因:函数实例是新启动的 JVM,第一个请求要经历类加载 + 解释执行 + C1 编译,还没有任何 C2 编译成果;同时连接池、缓存都是空的。缓解方式:① 预留实例(provisioned concurrency)——保持一定数量的已预热实例;② 启动时主动预热——在初始化阶段用合成的请求调用热点路径(也叫「暖机」),让关键方法提前升到 C2;③ 用 AppCDS / CRaC 之类技术减少类加载与启动开销;④ 把重型初始化改成懒加载或异步初始化,先让函数能快速响应。