本节目标:看懂
-XX:+PrintCompilation的每一列,知道一个方法在什么条件下从解释器升到 C1、再到 C2。
适用版本:JDK 25(Temurin 25.0.5);默认分层编译;jcmd25。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。
承接的问题
libraryd 刚启动的 30 秒里,/loans 的 p99 是稳定期的三倍;30 秒后自己降下来,没有任何配置变更。监控上 CPU 不高、GC 正常,唯一的解释是执行引擎还没把热代码编译好。
JVM 不会一上来就把所有方法编译成机器码。它先解释执行,统计哪些方法热,再把热方法交给 C1 快速编译,最后把更热的方法交给 C2 做深度优化。这个流水线叫分层编译(tiered compilation)。理解它的价值不是「知道有 C1/C2」,而是能回答三个工程问题:
- 一个方法现在跑在哪个层级?
- 它为什么还没升级?
- 关闭高层编译能不能让启动更快?
这一节用 FinePolicy.fineCents 和 IsbnLab.validIsbn13 两个方法做观测对象,全部输出都能在本机复现。
从解释器到 C2:五层
HotSpot 用 level 表示执行状态,共 5 层:
| level | 执行方式 | 是否采集 profile | 典型用途 |
|---|---|---|---|
| 0 | 解释器 | 否 | 冷启动、一次性代码 |
| 1 | C1 无 profile | 否 | 简单方法,追求编译速度 |
| 2 | C1 轻量 profile | 是(少量) | 过渡层 |
| 3 | C1 完整 profile | 是 | 为 C2 收集类型与分支数据 |
| 4 | C2 | 否(使用已有 profile) | 峰值性能 |
默认参数下,方法调用次数达到 CompileThreshold=10000 后进入编译流程;循环回边(backedge)另有 Tier3BackEdgeThreshold=60000。这两个数可以在启动时确认:
java -XX:+PrintFlagsFinal -version 2>/dev/null \
| grep -E " (CompileThreshold|Tier3BackEdgeThreshold|TieredStopAtLevel|TieredCompilation) "
intx CompileThreshold = 10000 {pd product} {default}
intx Tier3BackEdgeThreshold = 60000 {pd product} {default}
intx TieredStopAtLevel = 4 {pd product} {default}
bool TieredCompilation = true {pd product} {default}
阈值是「计数」不是「时间」。一个每秒调用一百万次的方法几毫秒就到阈值,一个每分钟调用一次的方法可能永远停在解释器。
读 PrintCompilation
-XX:+PrintCompilation 每编译一个方法打印一行。用下面的程序制造热点:
package lab;
public final class JitLab {
public record FinePolicy(int centsPerDay, int graceDays) {
public long fineCents(long overdueDays) {
return Math.max(0, overdueDays - graceDays) * centsPerDay;
}
}
static volatile long blackhole;
public static void main(String[] args) {
long warmup = args.length > 0 ? Long.parseLong(args[0]) : 5_000_000L;
long measure = args.length > 1 ? Long.parseLong(args[1]) : 5_000_000L;
FinePolicy policy = new FinePolicy(50, 0);
long sum = 0;
for (long i = 0; i < warmup; i++) {
sum += policy.fineCents(i % 30);
}
for (long i = 0; i < measure; i++) {
sum += policy.fineCents(i % 30);
}
blackhole = sum;
System.out.println("sum=" + sum);
}
}
编译并运行:
javac --release 25 -d out src/lab/JitLab.java
java -XX:+PrintCompilation -Xmx512m -cp out lab.JitLab 5000000 5000000
本机输出中与 FinePolicy 和主循环相关的行:
49 12 3 lab.JitLab$FinePolicy::fineCents (18 bytes)
49 13 4 lab.JitLab$FinePolicy::fineCents (18 bytes)
49 12 3 lab.JitLab$FinePolicy::fineCents (18 bytes) made not entrant: not used
51 14 % 3 lab.JitLab::main @ 78 (229 bytes)
51 15 3 lab.JitLab::main (229 bytes)
52 16 % 4 lab.JitLab::main @ 78 (229 bytes)
53 14 % 3 lab.JitLab::main @ 78 (229 bytes) made not entrant: OSR invalidation of lower level
57 16 % 4 lab.JitLab::main @ 78 (229 bytes) made not entrant: uncommon trap
每一列的含义:
| 位置 | 示例 | 含义 |
|---|---|---|
| 第 1 列 | 49 | VM 启动后的毫秒数 |
| 第 2 列 | 12 | 编译任务编号,同一方法重编译会换号 |
| 标志位 | %、n、s、b | OSR、native、synchronized、blocking |
| 第 4 列 | 3、4 | 编译层级;1 到 3 是 C1,4 是 C2 |
| 方法 | lab.JitLab$FinePolicy::fineCents (18 bytes) | 方法签名和字节码大小 |
| 后缀 | made not entrant: not used | 旧版本失效的原因 |
fineCents 在 49 毫秒内先被 C1 编译成 tier 3,紧接着被 C2 编译成 tier 4。tier 3 的版本随即 made not entrant: not used——它不是出错,而是被更好的版本替代了。made not entrant 的字面意思是「不再作为入口」,已经进入这个方法的栈帧会继续跑完旧代码,新调用走新版本。
% 表示这是一次 OSR(On-Stack Replacement)编译:主循环已经在解释器里跑了很久,JVM 不等方法退出,直接在循环回边上换掉执行版本。main @ 78 里的 78 是循环回边的字节码偏移。4.3 会专门讲 OSR 和反优化。
编译阈值、队列与代码缓存
PrintCompilation 是事后日志,运行中的状态要用 jcmd:
jcmd <pid> Compiler.queue
Current compiles:
C1 compile queue:
Empty
C2 compile queue:
Empty
队列为空说明编译线程跟得上,没有积压。如果压测时看到队列持续非空,说明编译速度成了瓶颈,通常伴随 CPU 被编译线程占满。这时可以增加 -XX:CICompilerCount,或者减少一次性加载的类数量。
代码缓存用同一个命令组里的 Compiler.codecache 看:
jcmd <pid> Compiler.codecache
CodeHeap 'non-profiled nmethods': size=120032Kb used=32Kb max_used=32Kb free=119999Kb
CodeHeap 'profiled nmethods': size=120032Kb used=122Kb max_used=122Kb free=119909Kb
CodeHeap 'non-nmethods': size=5712Kb used=1190Kb max_used=1205Kb free=4521Kb
CodeCache: size=245776Kb, used=1344Kb, max_used=1359Kb, free=244429Kb
total_blobs=535, nmethods=157, adapters=287, full_count=0
profiled nmethods 放 C1 生成的带 profile 代码,non-profiled 放 C2 生成的代码,non-nmethods 放适配器和运行时桩。full_count=0 表示代码缓存没满过;如果它大于 0,JVM 会清理旧方法甚至停止编译,表现为「跑得越久越慢」。
Compiler.codelist 能看到每个 nmethod 的地址、层级和大小:
jcmd <pid> Compiler.codelist
19 4 0 java.lang.Object.<init>()V [0x000000011c3709c8, 0x000000011c370ac0 - 0x000000011c370b48]
20 1 0 java.lang.Enum.ordinal()I [0x000000011c370b88, 0x000000011c370c80 - 0x000000011c370d60]
31 1 0 jdk.internal.util.StrongReferenceKey.get()Ljava/lang/Object; [0x000000011c371988, 0x000000011c371a80 - 0x000000011c371b60]
三列数字是编译号、层级、是否为 OSR;后面的区间是代码在 CodeHeap 里的地址范围。
C1-only 的启动与峰值
-XX:TieredStopAtLevel=1 只允许 C1 编译,跳过 C2。常见的说法是「这样启动更快」,但需要分场景验证。
先看峰值。IsbnLab.validIsbn13 是一个 13 位 ISBN 校验循环,20M 次调用的结果:
| 配置 | 第 1 次 ns/op | 第 2 次 ns/op |
|---|---|---|
| 默认分层编译 | 31.014 | 27.282 |
-XX:TieredStopAtLevel=1 | 42.394 | 44.134 |
命令:
java -Xmx512m -cp out lab.IsbnLab 20000000 20000000
java -XX:TieredStopAtLevel=1 -Xmx512m -cp out lab.IsbnLab 20000000 20000000
C1-only 慢约 1.5 倍,因为 C1 不做 C2 的循环优化和内联深度。单次运行,未做统计,数字只用于量级对比。
再看启动。把同一个程序缩到 1000 次调用,测整个进程的墙钟时间,各跑 15 次取中位数:
| 配置 | 中位数 | p25 | p75 |
|---|---|---|---|
| 默认分层编译 | 98.1 ms | 90.8 ms | 113.1 ms |
-XX:TieredStopAtLevel=1 | 99.1 ms | 88.0 ms | 111.4 ms |
两者差异落在噪声里。原因是这个进程的启动时间主要花在 JVM 初始化、类加载和 CDS 映射上,C2 编译线程即使启动也还没编译几个方法。TieredStopAtLevel=1 不是通用的启动优化开关;它适合「方法很多、C2 编译很重、进程活得很短」的场景,例如命令行工具或短生命周期批处理。libraryd 是常驻服务,关掉 C2 只会损失峰值。
在 libraryd 里落地
FinePolicy.fineCents 是罚金计算的热点,每个借阅请求至少调用一次:
package com.plume.library.domain;
public record FinePolicy(int centsPerDay, int graceDays) {
public static final FinePolicy DEFAULT = new FinePolicy(50, 0);
public long fineCents(long overdueDays) {
return Math.max(0, overdueDays - graceDays) * centsPerDay;
}
}
它的字节码只有 18 字节,C2 会把它内联进调用者。设计上要配合这一点:
- 保持热方法短小。
fineCents只做一次减法和乘法,内联成本低;如果把日志、校验、数据库访问塞进同一个方法,内联会被拒绝。 - 不要在热路径上用反射或
MethodHandle。它们会阻止内联,还会让调用点退化成 megamorphic。 - 把「计算」和「副作用」分开。
FinePolicy只计算,审计事件由LoanEventSink处理,这样计算部分可以被稳定内联。
第二个落点是 Loan.state。sealed 接口加模式匹配的 switch 在 C2 下会编译成类型判断链或 tableswitch,比多态调用更容易优化:
package com.plume.library.domain;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public sealed interface LoanState
permits LoanState.Active, LoanState.Overdue, LoanState.Returned {
record Active(LocalDate dueOn) implements LoanState {}
record Overdue(LocalDate dueOn, long days) implements LoanState {}
record Returned(LocalDate returnedOn, long fineCents) implements LoanState {}
}
Loan 单独一个文件,state 方法用穷尽 switch 消费这个封闭层次:
package com.plume.library.domain;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public record Loan(long id, String isbn, String memberId,
LocalDate borrowedOn, LocalDate dueOn,
LocalDate returnedOn, int renewCount) {
public static final int LOAN_DAYS = 14;
public static final int MAX_RENEWALS = 1;
public boolean active() {
return returnedOn == null;
}
public long overdueDays(LocalDate today) {
LocalDate end = returnedOn != null ? returnedOn : today;
return Math.max(0, ChronoUnit.DAYS.between(dueOn, end));
}
public LoanState state(LocalDate today) {
if (returnedOn != null) {
return new LoanState.Returned(returnedOn, 0);
}
long overdue = overdueDays(today);
return overdue > 0
? new LoanState.Overdue(dueOn, overdue)
: new LoanState.Active(dueOn);
}
}
这段代码的分支在 today 固定时会高度可预测,C2 可以做分支裁剪。若把 state 写成接口的多实现,每次调用都要做接收者类型检查,4.2 会看到代价。
观测手段是启动脚本里加 -XX:+PrintCompilation(仅预发)或用 jcmd Compiler.queue 周期性采样。生产不要长期打开 PrintCompilation,它会输出大量日志并轻微影响编译线程。
常见错误与边界
「把 PrintCompilation 当成性能分析器。」 它只告诉你「编译了什么、什么时候编译的」,不告诉你「哪个方法最耗时」。一个方法被编译很多次不代表它慢,可能只是内联后反复失效。找热点用 JFR 的 jdk.ExecutionSample 或 jdk.Compilation 事件,PrintCompilation 用来验证「有没有编译、升到几层」。
「以为 TieredStopAtLevel=1 一定让启动更快。」 上面的 15 次中位数是 98.1 ms 对 99.1 ms,差异在噪声内。启动时间由 JVM 初始化、类加载、CDS 和首个请求共同决定;只关 C2 通常收益很小,却确定损失峰值吞吐。要先测再改,不要把它当成默认优化。
「把编译阈值当成时间阈值。」 CompileThreshold=10000 是调用计数加回边计数,不是毫秒。一个方法在 1 秒内被调用 100 次、每次循环 1000 次,回边计数很快到 60000,会先触发 OSR;另一个方法每分钟调用一次,可能跑一整天都不编译。调阈值之前先确认瓶颈是「编译太晚」还是「编译太频繁」。
小结
- 分层编译有 5 层:解释器、C1 的三层(含 profile)和 C2;默认
TieredStopAtLevel=4。 CompileThreshold=10000、Tier3BackEdgeThreshold=60000都是计数阈值,不是时间。PrintCompilation的列依次是启动毫秒、编译号、标志位、层级、方法和失效原因;%表示 OSR。made not entrant: not used是正常替换,不是错误;uncommon trap才是推测失败后的回退。jcmd Compiler.queue看积压,Compiler.codecache看容量,Compiler.codelist看已编译方法。- C1-only 在 20M 次 ISBN 校验上慢约 1.5 倍;在 1000 次短进程上启动时间差异落在噪声内。
方法编译之后,C2 还会做内联、逃逸分析和去虚拟化。下一节看这些优化在真实日志里长什么样,以及它们在什么情况下失效。
阅读导航:上一节:3.3 分配与 TLAB:指针碰撞、线程本地缓冲与大对象 · 下一节:4.2 即时编译优化:内联、逃逸分析与去虚拟化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。