本节目标:知道 C2 的三类核心优化在什么条件下生效,以及怎么用日志和基准测试证明它们生效或失效。
适用版本:JDK 25(Temurin 25.0.5);JMH 1.37;-XX:+PrintInlining需要-XX:+UnlockDiagnosticVMOptions。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。
承接的问题
libraryd 的罚金规则从一条变成四条:标准、豁免、封顶、插件自定义。实现方式从「直接调用 FinePolicy.fineCents」改成「注入一个 FineRule 接口,运行时选实现」。功能没问题,压测吞吐却掉了三分之一。
代码审查看不出问题:四种实现的逻辑都只有几行。差异不在算法,而在调用点。原来 C2 看到的是单一接收者,可以把方法内联、把对象标量化、把接口调用变成直接调用;改成四实现后,调用点变成 megamorphic,内联和去虚拟化都失效,每次调用都要做类型检查,甚至要走 vtable。
这一节用三个实验把这条链路拆开:内联日志、逃逸分析开关、JMH 分派基准。所有数字和日志都来自本机。
内联:先看拒绝原因
内联是所有优化的前提。方法不内联,逃逸分析看不到对象的使用方式,去虚拟化也没有意义。打开内联日志需要两个参数,顺序不能反:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining \
-Xmx512m -cp out lab.IsbnLab 300000 300000
本机输出(节选,同一个 validIsbn13 在不同编译任务里的两种结论):
@ 66 lab.IsbnLab::validIsbn13 (100 bytes) failed to inline: inlining prohibited by policy
@ 66 lab.IsbnLab::validIsbn13 (100 bytes) inline (hot)
@ 8 java.lang.String::length (11 bytes) inline (hot)
@ 16 java.lang.String::charAt (25 bytes) inline (hot)
@ 1 java.lang.String::isLatin1 (19 bytes) inline (hot)
@ 12 java.lang.StringLatin1::charAt (15 bytes) inline (hot)
@ 5 jdk.internal.util.Preconditions::checkIndex (18 bytes) (intrinsic)
@ 21 java.lang.StringUTF16::charAt (11 bytes) failed to inline: low call site frequency
@ 8 java.lang.Long::parseLong (7 bytes) inline
@ 3 java.lang.Long::parseLong (295 bytes) failed to inline: callee is too large
@ 40 java.lang.System::nanoTime (0 bytes) intrinsic
几个结论直接写在日志里:
inline (hot):调用点足够热,C2 允许内联。validIsbn13100 字节,远小于热调用点的FreqInlineSize=325。failed to inline: callee is too large:Long.parseLong的 295 字节版本被拒绝。295 小于 325,但那个调用点不热,只能套用冷调用阈值MaxInlineSize=35,所以太大。failed to inline: low call site frequency:StringUTF16.charAt是String.charAt的另一个分支,当前输入全是 Latin-1,UTF-16 分支几乎不走,调用点频率不够。(intrinsic):System.nanoTime、String.checkIndex由 JVM 直接替换成机器指令或内建序列,不经过普通内联。
两个阈值可以在启动时确认:
java -XX:+PrintFlagsFinal -version 2>/dev/null \
| grep -E " (MaxInlineSize|FreqInlineSize|InlineSmallCode) "
intx FreqInlineSize = 325 {C2 pd product} {default}
intx InlineSmallCode = 2500 {C2 pd product} {default}
intx MaxInlineSize = 35 {C2 product} {default}
MaxInlineSize 是冷调用点的字节码上限,FreqInlineSize 是热调用点的上限。InlineSmallCode 还有一条规则:已经编译过且机器码小于 2500 字节的方法,可以直接内联机器码,不再看字节码大小。
同一个方法在不同编译任务里结论不同,这不是矛盾。C1 和 C2 的内联策略不同,同一个 C2 编译在不同阶段看到的 profile 也不同。读日志时要连编译任务一起看,不能只看一行就下结论。
逃逸分析与标量替换
对象如果只在方法内部使用、不逃逸到堆或其他线程,C2 可以把它拆成若干标量放在寄存器里,连分配都省掉。这叫标量替换(scalar replacement),由逃逸分析(escape analysis)驱动。
写一个只读字段的分配回路:
package lab;
public final class EALab {
public record LoanEvent(long loanId, String isbn, long atMillis) {}
static volatile long blackhole;
public static void main(String[] args) {
long iterations = args.length > 0 ? Long.parseLong(args[0]) : 20_000_000L;
long t0 = System.nanoTime();
long sum = 0;
for (long i = 0; i < iterations; i++) {
LoanEvent e = new LoanEvent(i, "978-7-111-54743-5", i * 13);
sum += e.loanId() % 7 + e.isbn().length() + (e.atMillis() & 1);
}
long t1 = System.nanoTime();
blackhole = sum;
System.out.printf("sum=%d ms=%.1f nsPerOp=%.3f%n",
sum, (t1 - t0) / 1e6, (t1 - t0) / (double) iterations);
}
}
LoanEvent 没有写出方法,也没有传给别的线程,C2 可以把它拆成三个标量。用 -XX:-DoEscapeAnalysis 关掉这条优化对照:
java -Xmx2g -cp out lab.EALab 20000000
java -XX:-DoEscapeAnalysis -Xmx2g -cp out lab.EALab 20000000
本机输出(单次运行,未做统计):
sum=409999997 ms=31.5 nsPerOp=1.574
sum=409999997 ms=90.0 nsPerOp=4.501
关闭逃逸分析后慢 2.9 倍。分配量更直观:把堆压到 256 MB,20M 次分配约 640 MB,开启逃逸分析时一次 Young GC 都没有,关闭后出现 4 次:
java -Xmx256m "-Xlog:gc:file=gc-ea.log" -cp out lab.EALab 20000000
java -XX:-DoEscapeAnalysis -Xmx256m "-Xlog:gc:file=gc-noea.log" -cp out lab.EALab 20000000
grep -c "Pause Young" gc-ea.log gc-noea.log
gc-ea.log:0
gc-noea.log:4
-XX:-DoEscapeAnalysis 只用于实验,不要放进生产参数。它关闭的是「优化」,不是「功能」;关闭后程序结果不变,只是慢。反过来,也不能假设所有临时对象都会被标量化:只要对象被放进集合、赋给字段、传给未知方法或跨线程,它就逃逸,照常分配。
去虚拟化与类型 profile
接口调用在字节码里是 invokeinterface,运行时要把接收者类型解析到具体实现。如果 C2 发现某个调用点只见过一种接收者,它可以推测「以后也只有这一种」,把接口调用换成直接调用并内联。这叫去虚拟化(devirtualization)。见过两种接收者时,可以生成两次类型检查;超过两种就退化成 megamorphic,只能查表或走 vtable。
用 JMH 1.37 比较三种情况。四个实现类做同样的小动作,只有接收者数量不同:
package lab;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Thread)
public class DispatchBenchmark {
interface IsbnValidator {
boolean valid(String isbn);
}
static final class V1 implements IsbnValidator {
public boolean valid(String isbn) { return isbn.length() == 17; }
}
static final class V2 implements IsbnValidator {
public boolean valid(String isbn) { return isbn.length() == 18; }
}
static final class V3 implements IsbnValidator {
public boolean valid(String isbn) { return isbn.length() == 19; }
}
static final class V4 implements IsbnValidator {
public boolean valid(String isbn) { return isbn.length() == 20; }
}
private final IsbnValidator one = new V1();
private final IsbnValidator two = new V2();
private final IsbnValidator[] many = { new V1(), new V2(), new V3(), new V4() };
private final String isbn = "978-7-111-54743-5";
private int index;
@Benchmark
public boolean monomorphic() {
return one.valid(isbn);
}
@Benchmark
public boolean bimorphic() {
return one.valid(isbn) ^ two.valid(isbn);
}
@Benchmark
public boolean megamorphic() {
return many[index++ & 3].valid(isbn);
}
}
构建与运行:
mvn -q -DskipTests package
java -jar target/benchmarks.jar -wi 3 -i 5 -f 2 -w 1s -r 1s
本机结果:
Benchmark Mode Cnt Score Error Units
DispatchBenchmark.bimorphic avgt 10 0.743 ± 0.026 ns/op
DispatchBenchmark.megamorphic avgt 10 3.441 ± 0.116 ns/op
DispatchBenchmark.monomorphic avgt 10 0.742 ± 0.118 ns/op
单态和双态都是 0.74 ns/op,几乎没有差别:两种接收者都能被内联,比较和分支被折叠掉了。巨态是 3.44 ns/op,慢 4.6 倍。这个差距不是「接口调用天生慢」,而是「调用点见过的类型太多,C2 放弃推测」。
代码里 index++ & 3 是在四个实现之间轮换。如果只轮换两个,调用点仍然是 bimorphic,成本会回到 0.74 附近。这也解释了 libraryd 的现象:从一种罚金实现变成四种,调用点的接收者数量跨过了 2 这个门槛。
在 libraryd 里落地
FinePolicy 是 record,直接调用时是单态的:
package com.plume.library.domain;
public final class FineExample {
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;
}
}
public static long fineCents(long overdueDays) {
return FinePolicy.DEFAULT.fineCents(overdueDays);
}
}
一旦引入「运行时选择实现」的接口,调用点就可能变成多态。设计上有两条路:
第一条是把内置规则做成枚举加 switch,让分派发生在同一个方法里:
package com.plume.library.domain;
public final class FineRules {
public enum FineRule { STANDARD, WAIVED, CAPPED }
public record FinePolicy(int centsPerDay, int graceDays) {
public long fineCents(long overdueDays) {
return Math.max(0, overdueDays - graceDays) * centsPerDay;
}
}
public static long fineCents(FineRule rule, FinePolicy policy, long overdueDays) {
return switch (rule) {
case STANDARD -> policy.fineCents(overdueDays);
case WAIVED -> 0L;
case CAPPED -> Math.min(policy.fineCents(overdueDays), 5_000L);
};
}
}
枚举 switch 编译成 tableswitch,分支目标在编译期已知,不依赖接收者类型 profile。内置规则数量固定,这是最稳的写法。
第二条是插件策略。插件实现数量不可控,调用点注定是 megamorphic,这时不要假装它会被去虚拟化:
- 把插件调用放在冷路径或批量路径上,不要放在每个请求的核心计算里。
- 用
FineRule先把请求分流,只有真的命中插件规则时才走接口。 - 如果插件调用频率高,考虑把结果缓存成
long或预算好的策略对象,而不是每次请求重新分派。
第二个落点是 LoanState。sealed 接口加模式匹配的 switch 在编译期知道所有子类型,C2 可以生成类型判断链,不需要 vtable:
package com.plume.library.domain;
public sealed interface LoanState
permits LoanState.Active, LoanState.Overdue, LoanState.Returned {
record Active(java.time.LocalDate dueOn) implements LoanState {}
record Overdue(java.time.LocalDate dueOn, long days) implements LoanState {}
record Returned(java.time.LocalDate returnedOn, long fineCents) implements LoanState {}
}
描述器在应用层,和领域类型分开:
package com.plume.library.app;
import com.plume.library.domain.LoanState;
public final class LoanDescriber {
public static String describe(LoanState state) {
return switch (state) {
case LoanState.Active a -> "active until " + a.dueOn();
case LoanState.Overdue o -> "overdue " + o.days() + " days";
case LoanState.Returned r -> "returned, fine " + r.fineCents();
};
}
}
sealed 的价值不只是「穷尽检查」,它还给了 JIT 一个封闭的类型集合。开放接口做不到这一点。
常见错误与边界
「-XX:+PrintEscapeAnalysis 没有输出。」 这个参数在 product JDK 上直接拒绝启动:
Error: VM option 'PrintEscapeAnalysis' is develop and is available only in debug version of VM.
Improperly specified VM option 'PrintEscapeAnalysis'
Error: Could not create the Java Virtual Machine.
它只在 develop 版本(debug build)里存在。product JDK 上观察逃逸分析只能间接来:对比 -XX:-DoEscapeAnalysis 前后的分配量、GC 次数或 jdk.ObjectAllocationSample。不要伪造 PrintEscapeAnalysis 的输出。
「-XX:+PrintInlining 单独用。」 它是 diagnostic 参数,必须先解锁:
Error: VM option 'PrintInlining' is diagnostic and must be enabled via -XX:+UnlockDiagnosticVMOptions.
Error: The unlock option must precede 'PrintInlining'.
Improperly specified VM option 'PrintInlining'
顺序也重要:-XX:+UnlockDiagnosticVMOptions 必须在 -XX:+PrintInlining 之前。写成一行时从左到右生效,写成两行时顺序同样保留。
「用一次手写循环证明内联生效。」 没有预热、没有消费返回值、没有独立进程的测量,测到的可能是解释器、可能是被删掉的计算。本节的内联结论来自日志(inline (hot) 是编译器的决定),吞吐结论来自 JMH(预热、fork、Blackhole 都受控)。两者不能互相替代:日志证明「编译成了什么」,基准证明「快了多少」。
小结
- 内联是逃逸分析和去虚拟化的前提;
MaxInlineSize=35管冷调用点,FreqInlineSize=325管热调用点。 PrintInlining的拒绝原因直接可读:callee is too large、low call site frequency、inlining prohibited by policy。- 逃逸分析把不逃逸的对象拆成标量;关闭后同一回路慢 2.9 倍,20M 次分配多出 4 次 Young GC。
- 单态和双态调用都能被内联,JMH 都是 0.74 ns/op;四实现的巨态调用是 3.44 ns/op,慢 4.6 倍。
- 内置规则用枚举
switch,插件策略放在冷路径;sealed 接口给 JIT 一个封闭类型集合。 PrintEscapeAnalysis、PrintDeoptimizationDetails是 develop-only 参数,product JDK 上会启动失败。
优化建立在「编译器对代码形状的假设」上。假设一旦被推翻,代码要退回解释器重来。下一节讲反优化、uncommon trap 和 OSR。
阅读导航:上一节:4.1 解释执行与分层编译:C1、C2 与阈值 · 下一节:4.3 反优化与 OSR:代码什么时候退回解释器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。