Java 技术
#JVM#分层编译#C1#C2#预热优化#编译策略

JVM 分层编译的层级选择:C1/C2 编译器协作与预热期性能优化

分析 HotSpot 分层编译的五个执行层级,解释为何方法在 C1 编译后可能被 C2 重新编译,以及如何通过 -XX:TieredStopAtLevel 控制编译策略。以微服务启动后预热期的性能抖动为贯穿场景,讨论编译阈值、Profiling 数据收集、去优化机制,并与 Graal JIT 进行对比。读者将掌握根据延迟和吞吐需求调整编译层级的参数,并理解为何短生命周期方法不适合 C2 编译。

微服务在启动后的最初几分钟,响应延迟经常出现剧烈抖动:一个原本只需要几毫秒的接口,偶尔会突然飙升到上百毫秒。这种抖动往往不是由请求量或业务逻辑变化引起的,而是 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 编译事件集中在这个时间段。

预热期的性能抖动主要来自三个方面:

  1. 解释执行的开销:服务刚启动时,大部分代码还在 Level 0 解释执行,执行速度慢。
  2. 编译线程的 CPU 争用:C1 和 C2 编译器线程会消耗大量 CPU,与应用线程竞争资源,导致请求处理变慢。
  3. 去优化风暴:随着流量特征变化,一些 C2 编译的假设失效,触发去优化,导致短暂的回退执行。

为了平滑预热期,可以采用以下策略:

  • 降低 C2 的触发门槛:通过 -XX:Tier3CompileThreshold-XX:Tier4CompileThreshold 等参数,让方法更快进入 C2 编译,缩短在低层级停留的时间。但这样做会增加编译线程的负载,可能适得其反。
  • 限制最高层级:如果服务对启动延迟要求极高,可以设置 -XX:TieredStopAtLevel=23,完全避免 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 在几个关键维度上的差异:

维度C1C2Graal 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 的作用以及去优化的触发条件,是做出合理决策的基础。

资料来源

  1. Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide, Release 21
  2. JEP 165: Compiler Control
  3. src/hotspot/share/compiler/compilationPolicy.hpp
  4. src/hotspot/share/runtime/tieredThresholdPolicy.hpp