一个运行了数周的 Java 服务,某天突然出现大量 CodeCache is full. Compiler has been disabled. 日志,随后 CPU 飙升、接口延迟陡增。重启后恢复,但数周后再次出现。这类问题在长生命周期应用中并不罕见,根因往往不在堆内存或 GC,而在 JVM 的代码缓存(CodeCache)——存储 JIT 编译器生成的本地代码的区域。当代码缓存耗尽,JIT 编译被禁用,热点方法只能回到解释执行,性能断崖式下跌。
代码缓存的管理远比堆内存复杂:它存储的是可执行代码,必须考虑代码的生成、失效、回收和碎片化。HotSpot 通过一个名为“清扫”(Sweeping)的机制回收不再使用的 nmethod,并配合分段代码缓存降低碎片化。理解这一机制,是诊断和预防代码缓存问题的前提。
nmethod 的生命周期
nmethod 是 HotSpot 中“本地方法”(native method)的缩写,指 JIT 编译器(C1 或 C2)从 Java 字节码生成的机器码块。每个 nmethod 对应一个被编译的 Java 方法,包含编译后的指令、元数据、调试信息,以及指向堆中对象的引用。
nmethod 的生成由分层编译驱动。HotSpot 默认启用分层编译(Tiered Compilation),将编译分为多个层级:解释执行(level 0)、C1 简单编译(level 1)、C1 带 profiling 的编译(level 2、level 3)、C2 完全优化编译(level 4)。方法先以解释方式运行,收集调用次数和分支信息,达到阈值后触发 C1 编译生成带 profiling 的 nmethod(level 2/3),这些 nmethod 会持续收集运行时信息,当方法足够热时,C2 会基于这些信息生成高度优化的 nmethod(level 4)。
nmethod 的状态变化是清扫的核心。一个 nmethod 从生成到回收,会经历以下几个状态:
- alive(存活):正在被使用,可以被调用。
- not_entrant(不再进入):不再接受新的调用,但已有调用者可能仍在执行。通常发生在方法被重新编译或去优化时。
- zombie(僵尸):不再被任何调用者引用,但内存尚未释放。
- unloaded(已卸载):对应的类被卸载,nmethod 已不可用,等待清扫。
状态转换的触发点包括:方法被重新编译(旧版本变为 not_entrant)、类卸载、以及去优化(deoptimization)——当 C2 的优化假设被打破时,例如某个类被继承导致方法内联失效,nmethod 会被标记为 not_entrant,并可能回退到较低层级的代码。
清扫机制:回收不再使用的 nmethod
清扫(Sweeping)是 HotSpot 回收 nmethod 的机制。JVM 中有一个专门的清扫线程(Sweeper Thread),它会周期性地遍历代码缓存,将状态为 zombie 或 unloaded 的 nmethod 标记为可回收,并释放其占用的内存。
清扫的触发并非基于代码缓存的使用率,而是由内部计数器控制。HotSpot 维护一个 _sweep_index,每次迭代扫描一定数量的 nmethod,然后让出 CPU,避免影响应用线程。清扫线程的调度频率与 JIT 编译活动相关:当编译发生频繁时,清扫也会更频繁,以尽快回收空间。
清扫的关键在于安全性。nmethod 可能正在被其他线程执行,直接释放内存会导致崩溃。因此,HotSpot 使用“安全点”(Safepoint)机制来协调:在安全点,所有 Java 线程都暂停,此时可以安全地修改 nmethod 状态。但清扫线程并非每次都在安全点工作,它通过一种无锁的协议来避免竞争:在将 nmethod 标记为 zombie 时,会先将其从活跃调用者中移除,并确保没有线程正在进入该 nmethod,然后才释放内存。
然而,清扫存在一个延迟问题:一个 not_entrant 的 nmethod 可能因为仍有调用者正在执行而无法立即回收,必须等待所有调用者退出。如果调用者长时间不退出(例如一个长循环),nmethod 就会一直占用内存。此外,如果代码缓存空间不足,而清扫又无法及时回收,就会触发更激进的措施。
代码缓存耗尽:编译被禁用
当代码缓存无法为新编译的 nmethod 分配空间时,HotSpot 会采取降级措施。默认情况下,如果代码缓存满,JVM 会打印类似 CodeCache is full. Compiler has been disabled. 的日志,并停止 JIT 编译。此时,所有未编译的方法只能由解释器执行,性能可能下降数倍甚至数十倍。
为什么不能简单地扩大代码缓存?因为代码缓存是 JVM 保留的本地内存区域,其大小由 -XX:ReservedCodeCacheSize 控制,默认值在 JDK 8 及以后为 240 MB(分层编译开启时)。扩大这个值可以推迟问题,但无法根治——只要应用持续生成新的 nmethod,代码缓存终会填满。
更关键的是,即使代码缓存未满,碎片化也可能导致分配失败。nmethod 的大小不一,大的 nmethod(如 C2 编译的复杂方法)可能达到几十 KB。如果代码缓存被大量小 nmethod 占据,剩余空间虽然总量足够,但可能没有连续区域容纳大 nmethod,导致分配失败。这正是分段代码缓存要解决的问题之一。
分段代码缓存:降低碎片化与清扫开销
JEP 197 在 JDK 9 中引入了分段代码缓存(Segmented Code Cache),将原本单一连续的代码缓存划分为三个不同用途的代码堆(code heap):
- 非方法代码堆(Non-method CodeHeap):存放 JVM 内部代码,如编译器缓冲区、字节码解释器。固定大小为 3 MB,加上根据编译器线程数调整的额外空间。
- profiled 代码堆(Profiled CodeHeap):存放 C1 编译的带 profiling 的 nmethod,这类代码生命周期短,因为 C2 编译完成后,profiled 版本就不再需要。
- 非 profiled 代码堆(Non-profiled CodeHeap):存放 C2 编译的完全优化 nmethod,可能长期存活。
三个代码堆的大小可通过 -XX:NonProfiledCodeHeapSize、-XX:ProfiledCodeHeapSize、-XX:NonMethodCodeHeapSize 分别设置。默认情况下,非方法代码堆固定为 3 MB,剩余空间在 profiled 和非 profiled 之间均分。
分段带来的直接好处是清扫效率提升。清扫线程不再需要扫描整个代码缓存,只需遍历方法代码堆(profiled 和非 profiled),且可以针对不同堆使用专门的迭代器。例如,profiled 代码堆中的 nmethod 生命周期短,清扫可以更频繁地回收;非 profiled 代码堆则较少需要清扫。这缩短了清扫时间,减少了 CPU 开销。
更重要的是,分段显著降低了碎片化。同类型的 nmethod 被分配在同一个代码堆中,大小分布更均匀,减少了跨类型分配造成的碎片。例如,C2 编译的大 nmethod 不会与 C1 编译的小 nmethod 混杂,从而降低了因碎片导致分配失败的概率。
与分层编译的交互
分层编译与分段代码缓存紧密配合。在分层编译下,每个方法可能经历多次编译:先 C1 生成 profiled nmethod,再 C2 生成非 profiled nmethod。C2 编译完成后,C1 版本通常会被标记为 not_entrant,并最终被清扫回收。
这种“先 C1 后 C2”的模式导致 profiled 代码堆中 nmethod 的生成和回收非常频繁。如果 profiled 代码堆空间不足,会触发什么?JEP 197 提到,分层编译策略会根据代码堆的剩余空间调整编译阈值。具体来说,当 profiled 代码堆空间紧张时,JVM 可能减少 C1 编译的触发,甚至直接使用 C2 编译(如果方法足够热),以减少 profiled nmethod 的数量。相反,如果非 profiled 代码堆空间紧张,JVM 可能推迟 C2 编译,让方法停留在 C1 版本。
这种动态调整避免了编译完全停止,但可能导致峰值性能下降。例如,一个方法原本应该由 C2 优化,但因为非 profiled 代码堆空间不足,只能以 C1 版本运行,性能不如预期。
此外,分段代码缓存的一个已知风险是:如果某个代码堆已满,而其他代码堆仍有空间,JVM 可能无法利用空闲空间,导致编译被禁用。JEP 197 指出,对于非常小的代码缓存,分段可能导致空间浪费,因此计划提供选项来关闭分段。在 JDK 9 中,可以通过 -XX:-UseSegmentedCodeCache 禁用分段,但默认开启。
监控与调整:避免性能下降
要避免代码缓存问题,首先需要监控其使用情况。JVM 提供了多种方式:
- JFR 事件:JDK 11 及以上,JFR 会记录
CodeCacheStatistics和CodeCacheConfiguration事件,包含各代码堆的使用率、清扫次数等。 - jcmd:
jcmd <pid> Compiler.codecache可以打印代码缓存的使用情况。 - JMX:
java.lang.management.RuntimeMXBean等不直接暴露代码缓存,但可以通过jstat的-compiler选项查看编译统计。
日志方面,-XX:+PrintCodeCache 会在 JVM 退出时打印代码缓存使用摘要,-XX:+PrintCodeCacheOnCompilation 则在每次编译时打印。这些日志有助于观察代码缓存的增长趋势。
调整参数时,需要权衡内存占用与性能。-XX:ReservedCodeCacheSize 是总大小,默认 240 MB(分层编译时)。对于大型应用,如果代码缓存频繁接近上限,可以适当增大,例如设置为 512 MB 或 1 GB。但要注意,代码缓存是本地内存,不占用堆空间,但过大会增加内存占用,且可能影响 iTLB 和 iCache 性能。
-XX:+UseCodeCacheFlushing 是一个重要的开关,在 JDK 8 中默认启用。它允许 JVM 在代码缓存满时主动清除过时的 nmethod,而不是直接禁用编译器。具体来说,当代码缓存使用率达到一定阈值(默认 92%),JVM 会启动“紧急清扫”,强制回收 zombie 和 not_entrant 的 nmethod,并可能降低编译阈值,甚至清空部分 profiling 数据。这可以避免编译完全停止,但可能带来性能波动。
如果代码缓存问题持续存在,且应用确实需要大量编译,可以考虑禁用分层编译(-XX:-TieredCompilation),减少 nmethod 数量,但会牺牲启动速度和峰值性能。或者使用 AOT 编译(如 GraalVM Native Image),将部分代码预先编译,减少运行时编译压力。
失败模式与适用边界
代码缓存清扫机制在大多数场景下工作良好,但在某些情况下会失效或产生副作用:
- pinning 问题:如果某个 nmethod 被长时间执行(如包含大循环),它会一直处于 not_entrant 状态,无法被回收,导致空间占用。这通常不是问题,但如果大量线程同时执行长方法,可能阻塞清扫。
- 清扫风暴:当代码缓存接近满时,清扫线程可能频繁触发,消耗 CPU,导致应用性能下降。JEP 197 的分段设计通过缩短清扫时间缓解了这一问题,但在极端情况下仍可能发生。
- 分段空间不均:如果应用生成的 profiled 代码远多于非 profiled 代码,profiled 代码堆可能先满,而其他堆仍有空间。此时 JVM 可能无法利用空闲空间,导致编译降级。可以通过调整各堆大小来缓解,但需要根据应用特性进行调优。
- 小代码缓存场景:对于嵌入式或内存受限环境,分段可能导致空间浪费,因为固定大小可能导致某个堆满而其他堆空。此时建议禁用分段(
-XX:-UseSegmentedCodeCache)或增大总大小。
此外,代码缓存问题可能与其他 JVM 机制交互。例如,类卸载会触发 nmethod 的 unloaded 状态,但类卸载本身由 GC 触发,如果 GC 不频繁,卸载的类对应的 nmethod 可能长时间占用空间。因此,频繁的类加载和卸载(如动态生成代理类)会加剧代码缓存压力。
诊断案例:从日志到参数调整
假设一个基于 Spring Boot 的微服务,运行一周后出现编译禁用日志。通过 jcmd <pid> Compiler.codecache 查看,发现 profiled 代码堆使用率 98%,而非 profiled 代码堆仅 60%。这说明应用生成了大量 C1 编译的临时 nmethod,可能是由于动态代理或反射调用频繁触发 C1 编译。
此时,可以采取以下步骤:
- 增大总代码缓存:
-XX:ReservedCodeCacheSize=512m。 - 调整分段比例:增加 profiled 代码堆大小,例如
-XX:ProfiledCodeHeapSize=256m(需同时调整其他堆)。 - 启用或确认
-XX:+UseCodeCacheFlushing,以便在接近满时主动清理。 - 检查应用是否存在过度动态编译,例如频繁创建新类或使用反射调用热点方法,考虑使用缓存或避免动态代理。
通过监控代码缓存使用趋势,可以预测问题发生的时间,提前调整参数。
替代方案比较
除了调整代码缓存参数,还有其他策略:
- 禁用分层编译:减少 nmethod 数量,但启动变慢,峰值性能可能下降。适用于对启动时间不敏感、但代码缓存压力大的应用。
- AOT 编译:使用 GraalVM 的 Native Image 或 JDK 的
jaotc工具预编译部分代码,减少运行时编译。但 AOT 编译的代码无法利用运行时 profiling,可能不如 C2 优化效果好,且需要额外构建步骤。 - 增大代码缓存:简单直接,但只是推迟问题,不能解决根本原因。适合代码缓存增长缓慢、但最终会满的场景。
下表总结了各方案的权衡:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 增大 ReservedCodeCacheSize | 简单,无需改动应用 | 内存占用增加,可能延迟问题而非解决 | 代码缓存增长缓慢,偶尔接近上限 |
| 调整分段堆大小 | 精准利用空间,避免单堆满 | 需要分析应用编译特征,调优复杂 | 代码类型分布不均的应用 |
| 启用 UseCodeCacheFlushing | 自动清理,避免编译禁用 | 可能引起性能波动,清扫开销 | 默认已启用,适合大多数场景 |
| 禁用分层编译 | 减少 nmethod 数量 | 启动慢,峰值性能下降 | 代码缓存压力大,且对启动时间不敏感 |
| AOT 编译 | 减少运行时编译,启动快 | 优化效果可能不如 C2,构建复杂 | 需要快速启动且代码缓存受限的环境 |
总结
代码缓存清扫是 HotSpot 维持长期稳定运行的关键机制。nmethod 的生命周期管理、清扫线程的调度、分段代码缓存的划分,共同决定了代码缓存的利用率和碎片化程度。当代码缓存耗尽时,JIT 编译被禁用,性能急剧下降,因此监控代码缓存使用情况、理解各参数的作用,是每个运行长生命周期 Java 应用的团队必备的技能。
通过合理设置 -XX:ReservedCodeCacheSize、-XX:+UseCodeCacheFlushing,以及必要时调整分段堆大小,可以显著降低编译禁用的风险。但也要认识到,这些参数只是缓解手段,根本的解决方案是减少不必要的编译压力,例如优化动态代理的使用、避免过度反射,或考虑 AOT 编译。
flowchart TD
A[方法被调用] --> B{调用次数达阈值?}
B -- 否 --> A
B -- 是 --> C[C1 编译生成 profiled nmethod]
C --> D{方法足够热?}
D -- 否 --> E[运行 C1 版本]
D -- 是 --> F[C2 编译生成非 profiled nmethod]
F --> G[旧 C1 nmethod 标记 not_entrant]
G --> H[清扫线程扫描代码堆]
H --> I{状态为 zombie/unloaded?}
I -- 是 --> J[释放内存]
I -- 否 --> K[保留]
J --> L[代码缓存空间回收]
K --> H
E --> H
上图展示了 nmethod 从生成到回收的流程。关键转折点在于 C2 编译完成后,C1 版本被标记为 not_entrant,随后由清扫线程回收。如果清扫不及时或空间不足,可能触发编译禁用。