微服务在启动后的最初几分钟,响应延迟经常出现剧烈抖动:一个原本只需要几毫秒的接口,偶尔会突然飙升到上百毫秒。这种抖动往往不是由请求量或业务逻辑变化引起的,而是 JVM 正在后台疯狂编译热点方法。HotSpot 的分层编译机制试图在启动速度和峰值性能之间取得平衡,但默认策略并不总是适合所有场景——特别是那些启动后需要立即稳定响应的在线服务。
JVM 在执行 Java 方法时,并不是一开始就直接编译为机器码,而是先通过解释器执行,同时收集方法的调用次数和循环回边次数。当某个方法被判定为“热点”时,编译器才会介入。HotSpot 内置了两个编译器:C1(Client Compiler)和 C2(Server Compiler)。C1 编译速度快,生成的代码质量尚可;C2 编译速度慢,但能生成高度优化的代码。分层编译将这两个编译器组合成五个执行层级,让方法在解释执行和不同优化程度的编译结果之间逐步升级,从而在启动阶段快速获得可接受的性能,同时为关键方法保留深度优化的机会。
五个执行层级与编译策略
HotSpot 的分层编译定义了五个明确的执行层级,每个层级对应不同的编译方式和信息收集强度。根据 OpenJDK 源码中的 tieredThresholdPolicy.hpp,这五个层级分别是:
- Level 0:解释执行。方法在字节码解释器中运行,同时维护调用计数器和回边计数器。这是所有方法开始执行的地方。
- Level 1:C1 编译,不进行任何 Profiling。C1 编译器快速生成机器码,但不插入任何收集运行时数据的代码。这个层级主要用于快速提升启动性能,但因为没有 Profiling 信息,无法为后续 C2 优化提供依据。
- Level 2:C1 编译,仅收集调用次数和回边次数。相比 Level 1,这个层级会在编译后的代码中插入简单的计数器。这些计数器可以触发进一步的编译决策,但收集的信息不足以支持 C2 的激进优化。
- Level 3:C1 编译,进行完整的 Profiling。除了计数器,还会收集方法内部的分支跳转概率、类型分布等详细数据(通过 Method Data Object,MDO)。这些信息是 C2 进行深度优化的关键输入。
- Level 4:C2 编译。C2 利用 Level 3 收集的 Profiling 数据,应用内联、逃逸分析、循环展开等大量优化,生成高质量的机器码。
方法在这些层级之间的升级路径并不是固定的。默认情况下,策略会优先考虑 C2 队列的长度:如果 C2 编译器负载不高,方法可能从 Level 0 直接升到 Level 3,在收集足够的 Profiling 后立即提交 C2 编译请求;如果 C2 队列已经堆积了很多任务,策略则可能先让方法进入 Level 2,快速获得比解释执行更好的性能,同时等待 C2 队列压力减轻后再重新提交 Level 3 编译,以便收集 Profiling。这种动态调整避免了方法长时间卡在 Level 3 等待 C2 编译,从而减少预热期的性能凹陷。
编译阈值与 Profiling 驱动的升级
方法从一个层级升级到下一个层级,需要满足特定的阈值条件。这些阈值主要基于两个计数器:调用计数器(Invocation Counter)和回边计数器(Backedge Counter)。调用计数器记录方法被调用的次数,回边计数器记录方法内部循环执行的次数。
在解释执行阶段(Level 0),JVM 会定期检查这两个计数器的值。当调用次数加上回边次数超过某个阈值时,编译策略被触发。这个阈值由 -XX:CompileThreshold 控制,默认值通常是 10000 次(具体值可能因平台和版本而异)。但分层编译引入后,实际使用的阈值更加复杂:不同层级之间的升级阈值不同,而且还会受到 C2 队列长度、方法大小等因素的动态调整。
例如,从 Level 3 升级到 Level 4 的阈值通常比从 Level 0 升级到 Level 3 的阈值更高,因为 C2 编译成本高昂,只有确认方法确实非常热时才值得进行。此外,Level 3 编译后的代码在运行时会持续收集 Profiling 数据,当 MDO 中的信息足够成熟(例如类型分布已经稳定),即使计数器未达到阈值,也可能提前触发 C2 编译。这种机制保证了 C2 编译总是在有充分 Profiling 的前提下进行,避免基于不完整信息做出错误优化。
Profiling 数据收集本身也有开销。Level 3 编译的代码中插入了许多额外的指令,用于记录类型、分支走向等。这些指令会轻微拖慢执行速度。因此,方法在 Level 3 停留的时间越长,累积的 Profiling 开销越大。这就是为什么策略会尽量缩短 Level 3 的停留时间——要么尽快升到 Level 4,要么在 C2 繁忙时先降到 Level 2 以消除 Profiling 开销。
去优化:当 C2 的假设失效时
C2 编译器在优化时会做出许多激进假设,例如某个虚方法调用的具体实现类总是同一个,或者某个分支几乎从不执行。这些假设基于 Level 3 收集的 Profiling 数据。然而,程序运行时的行为可能发生变化,导致这些假设不再成立。此时,C2 编译的机器码必须被废弃,执行回退到解释器或低层级编译代码,这个过程称为去优化(Deoptimization)。
去优化是分层编译中一个关键的反馈机制。当发生去优化时,JVM 会丢弃当前 C2 编译的代码,并将方法状态恢复到解释执行。如果该方法继续成为热点,它可能会再次经历 Level 3 Profiling 和 C2 编译,但这次 Profiling 数据会反映新的运行时行为,从而生成更合适的优化代码。去优化保证了即使优化假设错误,程序也能继续正确执行,但代价是突然的性能下降——这正是预热期性能抖动的另一个来源。
例如,一个微服务在启动初期可能只有少量请求,C2 根据这些请求的 Profiling 将某个接口的实现类内联了。当流量上来后,另一个实现类也开始被频繁调用,导致之前的内联假设失效,触发去优化。去优化瞬间,该方法的执行会回退到解释器,延迟突然升高,直到重新编译完成。
控制编译层级的 JVM 参数
对于希望精细控制编译行为的场景,HotSpot 提供了一系列参数。最直接的是 -XX:TieredStopAtLevel,它可以限制方法能够到达的最高编译层级。例如,-XX:TieredStopAtLevel=1 会让所有方法最多只编译到 Level 1(C1 无 Profiling),完全避免 C2 编译。这在启动时间敏感、且峰值吞吐不那么重要的场景下非常有用。
另一个常用参数是 -XX:-TieredCompilation,它直接关闭分层编译,使 JVM 只使用 C2(或只使用 C1,取决于其他参数)。不过,完全关闭分层编译通常会导致启动时间显著增加,因为所有热点方法都必须等待 C2 编译。
对于更细粒度的控制,JEP 165 引入了编译器指令(Compiler Directives),允许以方法为单位指定编译选项。通过一个 JSON 格式的指令文件,可以强制某些方法只使用 C1 编译,或禁止 C2 编译,甚至完全排除编译。例如,以下指令文件禁止对 com.example.service.HeavyService 类中的所有方法进行 C2 编译:
[
{
match: ["com/example/service/HeavyService::*"],
c2: {
Exclude: true
}
}
]
通过 -XX:CompilerDirectivesFile 参数加载该文件,即可在运行时生效。这种机制非常适合处理那些已知会被频繁调用但生命周期很短、不值得 C2 编译的方法,或者用于规避某些 C2 编译器的 Bug。
贯穿场景:微服务预热期的性能抖动
考虑一个典型的 Spring Boot 微服务,它使用嵌入式 Tomcat,在 Kubernetes 中运行。服务启动后,健康检查通过,开始接收生产流量。最初几分钟,接口的 P99 延迟可能高达 200ms,而稳定运行后只有 10ms。通过 JFR(Java Flight Recorder)或 -XX:+PrintCompilation 日志,可以观察到大量 C1 和 C2 编译事件集中在这个时间段。
预热期的性能抖动主要来自三个方面:
- 解释执行的开销:服务刚启动时,大部分代码还在 Level 0 解释执行,执行速度慢。
- 编译线程的 CPU 争用:C1 和 C2 编译器线程会消耗大量 CPU,与应用线程竞争资源,导致请求处理变慢。
- 去优化风暴:随着流量特征变化,一些 C2 编译的假设失效,触发去优化,导致短暂的回退执行。
为了平滑预热期,可以采用以下策略:
- 降低 C2 的触发门槛:通过
-XX:Tier3CompileThreshold和-XX:Tier4CompileThreshold等参数,让方法更快进入 C2 编译,缩短在低层级停留的时间。但这样做会增加编译线程的负载,可能适得其反。 - 限制最高层级:如果服务对启动延迟要求极高,可以设置
-XX:TieredStopAtLevel=2或3,完全避免 C2 编译。这样虽然牺牲了长期运行的峰值吞吐,但换来了更平稳的启动曲线。 - 使用编译器指令:针对已知的热点方法,在启动后立即通过指令强制 C2 编译,或者禁止那些短生命周期方法的 C2 编译。
- AOT 编译:利用 GraalVM Native Image 或 Jaotc 提前编译,消除运行时编译开销。但这会牺牲一些平台无关性和动态优化能力。
C1/C2 与 Graal JIT 的对比
Graal JIT 是 HotSpot 的一个替代 JIT 编译器,它本身也是用 Java 编写的。与 C2 相比,Graal JIT 在优化策略上更加激进,支持部分逃逸分析、更复杂的内联决策等。在分层编译模式下,Graal JIT 可以替代 C2 作为最高层级的编译器(Level 4)。
从预热期性能的角度看,Graal JIT 的编译速度通常比 C2 慢,但生成的代码质量在某些场景下更高。这意味着使用 Graal JIT 时,预热期可能会更长,抖动更剧烈。不过,Graal JIT 也提供了更好的可控性:通过编译器指令,可以更精细地调整哪些方法应该被编译,甚至可以在运行时替换已编译的代码(称为“热替换”),这为平滑预热期提供了新的可能。
下面的表格对比了 C1、C2 和 Graal JIT 在几个关键维度上的差异:
| 维度 | C1 | C2 | Graal JIT |
|---|---|---|---|
| 编译速度 | 快(毫秒级) | 慢(秒级) | 更慢(秒级) |
| 代码质量 | 中等 | 高 | 高,部分场景更优 |
| Profiling 依赖 | 低(Level 1 无 Profiling) | 高(依赖 Level 3 的 MDO) | 高(同样依赖 Profiling) |
| 启动影响 | 小,快速提升性能 | 大,编译线程消耗 CPU | 更大,编译更慢 |
| 可控性 | 有限 | 通过分层和指令控制 | 更灵活的指令和热替换 |
| 适用场景 | 启动敏感、短生命周期应用 | 长期运行、吞吐优先的服务 | 追求极致性能、可接受较长预热 |
短生命周期方法为何不适合 C2
在微服务环境中,许多方法只在请求处理期间短暂存在,例如控制器方法、数据库访问方法等。这些方法虽然可能被频繁调用,但它们的执行上下文经常变化(例如不同的参数类型、不同的分支路径)。C2 编译需要稳定的 Profiling 数据才能做出有效优化,而短生命周期方法往往来不及收集足够的 Profiling,或者 Profiling 数据在编译完成时已经过时。
更糟糕的是,C2 编译本身的开销可能超过优化带来的收益。编译一个方法可能需要几百毫秒甚至几秒的 CPU 时间,如果这个方法只被调用几十次,那么编译成本永远无法回收。此外,C2 编译的代码占用更多的 Code Cache 空间,如果大量短生命周期方法被 C2 编译,可能导致 Code Cache 满,触发紧急清理,进一步影响性能。
因此,对于这类方法,通常建议通过 -XX:TieredStopAtLevel=2 或编译器指令将其限制在 C1 层级。C1 编译速度快,代码体积小,虽然优化程度不如 C2,但对于短生命周期方法来说,总收益往往更高。
编译层级选择的决策流程
在实际生产环境中,选择编译层级需要综合考虑应用的特征和性能目标。以下流程图展示了一个典型的决策路径:
flowchart TD
A[应用启动] --> B{启动时间敏感?}
B -- 是 --> C{可接受峰值吞吐下降?}
C -- 是 --> D[使用 TieredStopAtLevel=2 或 3]
C -- 否 --> E[使用 AOT 编译或预热脚本]
B -- 否 --> F{存在短生命周期热点?}
F -- 是 --> G[通过编译器指令排除 C2]
F -- 否 --> H[使用默认分层编译]
H --> I[监控 Code Cache 和编译线程]
I --> J{出现性能抖动?}
J -- 是 --> K[分析编译日志,调整阈值或指令]
J -- 否 --> L[保持当前配置]
这个流程从应用启动开始,首先判断启动时间是否敏感。如果是,并且可以接受峰值吞吐的下降,那么直接限制最高编译层级是最简单的方案。如果启动时间敏感但不能接受吞吐下降,则需要考虑 AOT 编译或通过预热脚本在流量进入前触发关键方法的编译。
如果启动时间不敏感,下一步是识别是否存在短生命周期的热点方法。这些方法可以通过 JFR 的方法采样或编译日志发现。一旦确认,就可以用编译器指令将它们排除在 C2 之外,避免不必要的编译开销。
最后,即使使用默认配置,也需要持续监控 Code Cache 使用率、编译线程活跃度和去优化事件。这些指标可以通过 JMX 或 JFR 获取。当观察到性能抖动时,分析编译日志(-XX:+PrintCompilation)可以定位是哪些方法的编译或去优化导致了问题,从而针对性地调整阈值或指令。
分层编译的层级选择不是一个“一次性配置”的任务,而是需要根据应用的实际运行数据不断调整的过程。理解 C1 和 C2 的协作机制、Profiling 的作用以及去优化的触发条件,是做出合理决策的基础。