Java 技术
#JVM#HotSpot#Code Cache#JIT#nmethod

JVM 代码缓存分段:为何 C1 与 C2 编译产物分区域存放

长时间运行的服务里,代码缓存耗尽会让 JIT 编译线程停摆,性能悄悄退化。文章从 HotSpot 代码缓存的分段布局出发,解释 CodeBlob、nmethod 与各段边界的关系,说明 JEP 197 引入分段后清扫策略和编译线程行为的变化,并给出按编译层级分布调整各段容量的判断方法。

一个订单服务平稳跑了三天,QPS 没有变化,GC 日志也没有异常,但 P99 延迟在某个时刻开始缓慢爬升,随后稳定在一个比峰值期更差的水平。线程栈里看不到锁竞争,堆内存也正常。真正发生变化的是 JIT 编译线程:它们已经停止把热点方法编译成机器码,解释器重新接管了部分调用路径。触发点通常是代码缓存写满。

代码缓存是 HotSpot 存放已生成机器码的区域,里面不只有 Java 方法编译后的产物,还有编译器缓冲区、字节码解释器、运行时桩代码等非方法代码。它的容量有限,写满之后新编译请求只能被拒绝或排队。理解为什么 C1 和 C2 的产物被放进不同段,是排查这类“没有报错但变慢”问题的起点。

CodeBlob、nmethod 与代码缓存的存放单位

代码缓存里的每一项都是一个 CodeBlob。CodeBlob 是 HotSpot 对“一段带元数据的机器码”的统一抽象,它把机器指令和描述这些指令的信息放在一起,例如这段代码的入口、大小、所属方法、栈帧布局。垃圾回收、栈回溯、异常处理和性能分析工具都依赖这些元数据来理解一段机器码。

nmethod 是 CodeBlob 的一个子类,专门表示编译后的 Java 方法。它比普通 CodeBlob 多出与方法相关的信息,例如对应的 Method 对象、内联了哪些方法、依赖了哪些假设。C1 和 C2 编译同一个方法会得到不同的 nmethod:C1 产物带性能采集代码,C2 产物经过更激进的优化、通常不带采集逻辑。

非方法代码同样以 CodeBlob 形式存在,但它不绑定任何 Java 方法。编译器缓冲区、适配器、运行时桩属于这一类,它们的生命周期与 JVM 进程一致,不会被回收。

分段布局要解决的核心矛盾就在这里:nmethod 有生命周期,非方法代码没有;C1 产物会被替换,C2 产物可能长期驻留。把它们混在一个堆里,清扫器无法只针对可回收的部分工作。

单一代码堆为什么会在长时间运行中失效

分段之前的代码缓存是一个建立在连续内存上的堆数据结构。所有 CodeBlob 按分配顺序混在一起,清扫器扫描时只能遍历整个区域。

问题的根源是生命周期不一致。C1 编译的 profiled nmethod 带有性能采集代码,当方法被判定为热点、升级到 C2 编译后,旧的 C1 产物就变成可回收对象。C2 编译的 non-profiled nmethod 则可能一直留在缓存里。非方法代码更是永久驻留。

当这三类对象交错分布时,清扫器每轮都要扫描整个代码缓存,即使其中大部分是不可回收的。扫描本身消耗时间,而且扫描期间对代码缓存的访问需要同步,这会干扰编译线程和正在执行已编译代码的线程。

另一个后果是碎片化。C2 产物通常比 C1 产物大,反复分配和回收小块空间之后,缓存里会出现许多零散空洞,单个空洞装不下新的 C2 编译结果。即使总空闲字节数看起来足够,分配仍然可能失败。

JEP 197 的动机描述里提到,分层编译引入后,编译代码量相比非分层编译增加数倍,profiled 代码与 non-profiled 代码混合存放带来的性能和设计问题更加突出。分段就是针对这个混合状态的直接回应。

三个代码堆的划分与边界

JEP 197 把代码缓存拆成三个顶层代码堆,每个堆只放特定类型的编译产物:

  • 非方法代码堆,存放编译器缓冲区、字节码解释器、运行时桩等,这部分代码永久驻留。
  • profiled 代码堆,存放轻量优化、带性能采集的 nmethod,生命周期较短。
  • non-profiled 代码堆,存放完全优化、不带采集的 nmethod,生命周期可能很长。

非方法代码堆有固定大小,JEP 197 给出的值是 3 MB,用于容纳 VM 内部代码,并在此基础上按 C1/C2 编译器线程数量追加编译器缓冲区所需空间。剩余代码缓存空间在 profiled 和 non-profiled 两个堆之间平均分配。

三个堆的大小可以分别通过启动参数控制:-XX:NonMethodCodeHeapSize-XX:ProfiledCodeHeapSize-XX:NonProfiledCodeHeapSize。这些参数在启用分段布局时才有意义。

边界不是绝对的。OpenJDK 源码注释里说明,在非方法代码堆被填满的罕见情况下,非方法代码会退而存放到 non-profiled 代码堆。这是分段布局里的一个回退路径,意味着非方法代码堆的容量估算偏小不会立即导致编译失败,但会挤占 C2 产物的空间。

分段如何改变清扫与编译线程行为

清扫器是分段布局最直接的受益者。分段后,清扫器只遍历方法代码堆,跳过非方法代码。非方法代码永久驻留,本来就不需要被清扫,把它排除在扫描范围之外,每轮清扫的工作量就降下来了。

profiled 和 non-profiled 分开之后,清扫可以针对各自堆内的可回收对象进行。C1 产物被 C2 产物替换后,profiled 堆里的空间可以较快回收;non-profiled 堆里的对象回收频率低,清扫压力小。

编译线程的行为也受分段影响。分层编译策略会根据各代码堆的空闲空间调整编译阈值。当某个堆接近满时,策略可以降低对应层级的编译活动,而不是等到整个代码缓存耗尽才停摆。JEP 197 把“根据代码堆空闲空间设置编译阈值”列为受影响的组件之一。

JFR 的代码缓存相关事件在分段后需要区分堆类型。诊断时看到的不再是单一的缓存占用率,而是各段的独立占用情况。这改变了排查路径:需要先确定是哪一段先满。

一个贯穿场景:代码缓存耗尽后的 JIT 停摆

假设一个持续运行的风控服务,启动后流量平稳,方法调用分布长期稳定。运行几天后,延迟开始上升。用 jcmd <pid> Compiler.codecache 或 JFR 的代码缓存事件观察,可以看到 non-profiled 段接近满,profiled 段还有余量。

这个分布说明什么?服务里的热点方法已经完成从 C1 到 C2 的升级,C2 产物在 non-profiled 堆里持续累积。如果方法数量多、每个方法编译后体积大,这个堆会先被填满。填满之后,新的 C2 编译请求被拒绝,方法停留在 C1 版本或解释执行,延迟上升。

下面的流程图展示从方法被调用到代码缓存各段被占用的路径,以及 non-profiled 段满后的退化分支。

flowchart TD
    A[方法被频繁调用] --> B{达到 C1 编译阈值}
    B -->|是| C[生成 profiled nmethod]
    C --> D[存入 profiled 代码堆]
    D --> E{方法成为热点}
    E -->|是| F[生成 non-profiled nmethod]
    F --> G[存入 non-profiled 代码堆]
    G --> H{non-profiled 堆是否已满}
    H -->|否| I[替换旧 C1 产物]
    H -->|是| J[拒绝 C2 编译请求]
    J --> K[方法停留在 C1 或解释执行]
    K --> L[延迟上升,吞吐下降]
    I --> M[profiled 堆空间被清扫回收]

图中关键转折在 non-profiled 堆满的分支。此时 profiled 堆可能仍有空闲,但 C2 产物无法写入,清扫回收 profiled 堆空间并不能缓解 non-profiled 堆的压力。这正是分段布局下容量分配需要按编译层级分布来调整的原因。

分段与非分段布局的对比

分段布局不是在所有场景下都更优。JEP 197 的风险说明里明确指出,固定大小的代码堆会导致内存浪费:一个堆满了而另一个堆还有空间时,这部分空间无法被利用。对于很小的代码缓存,可能出现编译器被关闭但仍有空闲空间的情况。为此 JEP 提供了关闭分段布局的选项,用于小代码缓存场景。

维度非分段布局分段布局
存放方式所有 CodeBlob 混在一个堆按类型分入三个代码堆
清扫范围遍历整个代码缓存只遍历方法代码堆,跳过非方法代码
碎片化C1 与 C2 产物交错,空洞零散同类产物集中,碎片化降低
容量控制单一上限各段可分别设置上限
内存利用空闲空间全局可用段间不能互相借用,可能浪费
小缓存场景无额外开销可能出现编译器提前关闭
诊断粒度单一占用率各段独立占用率

表格里的权衡决定了选择方向。大代码缓存、分层编译、方法数量多的服务,分段带来的清扫效率和碎片控制收益更明显;代码缓存很小、编译活动少的场景,分段可能因为段间无法借用空间而提前触发编译停止。

根据编译层级分布调整各段容量

调整容量的前提是先观察各段的实际占用。JFR 的代码缓存事件和 jcmd 的代码缓存命令都能给出各段的大小与使用量。需要区分的是:profiled 段高占用通常意味着大量方法停留在 C1 层级,可能是 C2 编译跟不上或方法本身不适合 C2;non-profiled 段高占用意味着 C2 产物多,方法数量或单方法编译体积大。

如果 non-profiled 段先满,可以增大 -XX:NonProfiledCodeHeapSize。如果 profiled 段先满,说明 C1 产物堆积,需要检查是否有大量方法反复在 C1 和 C2 之间切换,或者 C2 编译队列积压。非方法段通常不需要调整,除非使用了大量编译器线程或特殊运行时特性导致编译器缓冲区需求超出默认值。

调整时要注意段间不能借用空间。把 non-profiled 段调大,意味着 profiled 段相应变小,需要确认 profiled 段在调整后仍能容纳正常运行时的 C1 产物。

一个实用的判断顺序是:先确认代码缓存是否真的耗尽,再确认是哪一段耗尽,最后根据该段对应的编译层级决定调整方向。跳过前两步直接调大总容量,可能只是把问题推迟,而没有解决段间分布不均。

失败模式与观测信号

代码缓存耗尽的表现不是崩溃,而是性能退化。编译线程停止后,JVM 不会报错,只会让方法停留在较低编译层级。观测信号包括:编译线程的编译活动下降、JFR 中代码缓存事件显示某段接近满、方法采样显示热点方法仍以解释或 C1 执行。

另一个失败模式是段间分布不均导致的提前停摆。总容量看起来充足,但 non-profiled 段已满,profiled 段还有大量空闲。这种情况下,调大总容量而不调整段比例,问题会重现。

非方法代码堆填满时会回退到 non-profiled 堆,这会挤占 C2 产物的空间,表现为 non-profiled 段比预期更早满。排查时需要确认非方法代码的实际占用是否超出默认估算。

分段布局的启用条件与代码缓存大小相关。JEP 197 为小代码缓存提供了关闭分段的选项,说明分段并非无条件启用。在容器化环境中,如果代码缓存被显式调小,需要确认分段是否仍然生效,以及各段容量是否合理。

边界与不适用场景

分段布局解决的是异构代码混合存放带来的清扫和碎片问题,它不改变代码缓存的总量上限。如果服务本身的方法数量或编译体积超出代码缓存能容纳的范围,分段只能延缓耗尽,不能避免。

对于编译活动很少的服务,分段带来的段间隔离可能反而造成空间浪费。JEP 197 的风险说明已经指出这一点,并提供了关闭分段的路径。

分段布局也不改变 C1 和 C2 的编译决策逻辑。它影响的是编译产物存放和清扫,编译阈值仍由分层编译策略根据代码堆空闲空间等因素决定。把延迟问题归因于分段布局之前,需要先确认编译活动本身是否正常。

代码缓存的分段是 HotSpot 在分层编译普及后对代码存放方式的一次调整。它把生命周期不同的代码分开管理,让清扫器不必扫描永久驻留的非方法代码,也让 C1 和 C2 产物的碎片问题得到控制。代价是段间空间不能互相借用,容量分配需要根据实际编译层级分布来判断。

资料来源

  1. JEP 197: Segmented Code Cache
  2. Java SE 21 HotSpot Virtual Machine Garbage Collection Tuning Guide: Code Cache
  3. Java HotSpot Virtual Machine Performance Enhancements
  4. src/hotspot/share/code/codeCache.hpp