本节目标:分清
made not entrant、uncommon trap和 OSR,知道什么时候该把它们当成正常现象、什么时候该改设计。
适用版本:JDK 25(Temurin 25.0.5);JFR 25;-XX:+PrintCompilation为 product 参数。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。
承接的问题
libraryd 支持插件提供 IsbnValidator。内置校验器只有一种实现,调用点稳定了五分钟,C2 已经把它内联成直接调用。运维在运行中装了一个新插件,validation.service.reload 之后,接口调用的 p99 从 0.2 ms 跳到 8 ms,几十秒后又恢复。
日志里没有异常,GC 也正常。真正发生的是:新实现类被加载,类层次变了;已经编译的代码建立在「只有一种实现」的假设上,假设失效,JVM 把代码标记为不可用,下一次调用退回解释器,再重新编译。这个过程叫反优化(deoptimization)。
反优化不是 bug,它是推测优化的代价。理解了触发条件,才能判断「这次抖动是正常的编译过渡」还是「设计让热路径反复反优化」。
uncommon trap 与 made not entrant
先构造一个最小复现:一个接口,两个实现,第一阶段只调用第一个实现:
package lab;
public final class DeoptLabLate {
interface IsbnValidator {
boolean valid(String isbn);
}
static final class Isbn10Validator implements IsbnValidator {
public boolean valid(String isbn) {
int sum = 0;
int digits = 0;
for (int i = 0; i < isbn.length(); i++) {
char c = isbn.charAt(i);
if (c == '-') continue;
sum += (c - '0') * (10 - digits);
digits++;
}
return digits == 10 && sum % 11 == 0;
}
}
static final class Isbn13Validator implements IsbnValidator {
public boolean valid(String isbn) {
int sum = 0;
int digits = 0;
for (int i = 0; i < isbn.length(); i++) {
char c = isbn.charAt(i);
if (c == '-') continue;
sum += (digits % 2 == 0) ? (c - '0') : (c - '0') * 3;
digits++;
}
return digits == 13 && sum % 10 == 0;
}
}
static volatile long blackhole;
private static boolean check(IsbnValidator v, String isbn) {
return v.valid(isbn);
}
public static void main(String[] args) {
long iterations = args.length > 0 ? Long.parseLong(args[0]) : 5_000_000L;
IsbnValidator v = new Isbn10Validator();
long hits = 0;
for (long i = 0; i < iterations; i++) {
if (check(v, "0-306-40615-2")) hits++;
}
System.out.println("phase1 hits=" + hits + " validator=" + v.getClass().getSimpleName());
// 第二个实现类此刻才第一次被解析/加载
v = new Isbn13Validator();
for (long i = 0; i < iterations; i++) {
if (check(v, "978-7-111-54743-5")) hits++;
}
System.out.println("phase2 hits=" + hits + " validator=" + v.getClass().getSimpleName());
blackhole = hits;
}
}
运行并抓编译日志:
javac --release 25 -d out src/lab/DeoptLabLate.java
java -XX:+PrintCompilation -Xmx512m -cp out lab.DeoptLabLate 5000000
本机输出中第二阶段开始的行:
111 22 3 lab.DeoptLabLate::main (155 bytes) made not entrant: marked for deoptimization
111 20 4 lab.DeoptLabLate::check (8 bytes) made not entrant: uncommon trap
111 73 4 lab.DeoptLabLate::check (8 bytes)
111 74 3 lab.DeoptLabLate$Isbn13Validator::valid (86 bytes)
三行日志对应三个动作:
main ... marked for deoptimization:Isbn13Validator加载后,类层次发生变化,已经编译的main版本被标记为待反优化。marked表示标记,不是立刻丢弃;正在执行的栈帧会在安全点回退。check ... made not entrant: uncommon trap:check的 C2 版本里有一个「不常见陷阱」。它原本假设接收者总是Isbn10Validator,第一次遇到Isbn13Validator时触发陷阱,跳到解释器继续执行。- 新的
check编译任务(编号 73)和Isbn13Validator::valid(编号 74)随即产生,说明 JVM 在重新编译。
made not entrant 和 uncommon trap 都是正常机制。区别在于:made not entrant 是「这个版本不再作为入口」,可能因为被更好的版本替代(not used),也可能因为假设失效;uncommon trap 是具体的陷阱点,通常带字节码偏移(JFR 里能看到 bci)。
类层次变化与接收者 profile
反优化有两个常见触发源,容易混在一起:
- 类层次变化:接口只有一个实现时,C2 可以假设「以后也只有这个」。新实现类加载后,这个假设失效,所有依赖它的编译代码被标记。日志里是
marked for deoptimization。 - 接收者类型不匹配:调用点编译时只见过类型 A,运行时来了类型 B。日志里是
uncommon trap,JFR 里是reason = "class_check"。
JFR 把每次反优化记成 jdk.Deoptimization 事件:
java -XX:StartFlightRecording=settings=profile,filename=deopt.jfr \
-Xmx512m -cp out lab.DeoptLabLate 5000000
jfr print --events jdk.Deoptimization deopt.jfr
本机输出(节选我们自己的方法):
jdk.Deoptimization {
startTime = 11:39:42.546 (2026-10-28)
compileId = 1406
compiler = "c2"
method = lab.DeoptLabLate.check(DeoptLabLate$IsbnValidator, String)
lineNumber = 41
bci = 2
instruction = "invokeinterface"
reason = "class_check"
action = "maybe_recompile"
eventThread = "main" (javaThreadId = 3)
stackTrace = [
lab.DeoptLabLate.check(DeoptLabLate$IsbnValidator, String) line: 41
lab.DeoptLabLate.main(String[]) line: 58
]
}
reason = "class_check" 是接收者类型检查失败;instruction = "invokeinterface" 是触发陷阱的指令;bci = 2 是指令在方法里的字节码偏移;action = "maybe_recompile" 表示 JVM 可能重新编译,不是永久退回解释器。
同一次记录里还有 reason = "unstable_if" 的事件,落在 main 的循环条件上。那是循环退出导致的:C2 根据 profile 认为循环会一直走下去,第一次退出时触发陷阱。循环退出型反优化非常常见,不代表代码有问题。
jfr summary 可以先看事件总量:
jfr summary deopt.jfr | grep Deoptimization
jdk.Deoptimization 16 265
16 次事件里大部分来自 JDK 内部(Collections$UnmodifiableCollection 的 class_check),我们自己的方法只占几条。读 JFR 时先按 method 过滤,不要被总量吓到。
OSR:循环跑到一半换代码
OSR(On-Stack Replacement)解决的是「方法还没退出,但循环已经很热」的问题。普通编译要等方法调用次数到阈值;一个只调用一次、内部跑一亿次循环的方法,可能永远等不到。OSR 允许 JVM 在循环回边上直接把解释器帧换成编译后的帧。
PrintCompilation 里带 % 的行就是 OSR:
67 21 % 3 lab.DeoptLabLate::main @ 32 (155 bytes)
69 23 % 4 lab.DeoptLabLate::main @ 32 (155 bytes)
71 21 % 3 lab.DeoptLabLate::main @ 32 (155 bytes) made not entrant: OSR invalidation of lower level
94 23 % 4 lab.DeoptLabLate::main @ 32 (155 bytes) made not entrant: uncommon trap
@ 32是循环回边的字节码偏移,不是行号。- 先出现 tier 3 的 OSR 版本(21),再出现 tier 4 的 OSR 版本(23)。
- tier 4 出现后,tier 3 的 OSR 版本
made not entrant: OSR invalidation of lower level——低层级被高层级替代。 - 最后 tier 4 版本
uncommon trap,因为循环退出了,编译代码里的假设(继续循环)不成立,退回解释器执行方法剩余部分。
OSR 的代价是编译时机不受控:它发生在循环中途,编译线程要暂停一点时间做替换。对 libraryd 的请求处理循环来说,这意味着「服务刚启动时先慢几毫秒,然后突然变快」,而不是平滑过渡。压测报告里的前几秒数据要单独标注,不能和稳定期混在一起算分位。
在 libraryd 里落地
插件校验器的接口保持不变,问题不在接口,而在「什么时候换实现」。下面把接口、反例和正例放在同一个文件里(实际项目里接口单独成文件),先看反例,再看正例:
package com.plume.library.app;
import java.util.Objects;
interface IsbnValidator {
boolean valid(String isbn);
static IsbnValidator builtin() {
return isbn -> isbn != null && isbn.length() == 13;
}
}
// 反例:在请求路径上按配置动态切换实现
final class DynamicValidation {
interface Registry {
IsbnValidator current();
}
private final Registry registry;
DynamicValidation(Registry registry) {
this.registry = registry;
}
public boolean valid(String isbn) {
IsbnValidator current = registry.current(); // 可能刚被替换
return current.valid(isbn); // 调用点接收者类型不断变化
}
}
// 正例:选择与调用分离
public final class ValidationService {
private volatile IsbnValidator validator = IsbnValidator.builtin();
public boolean valid(String isbn) {
return validator.valid(isbn);
}
/** 只在启动阶段或维护窗口调用。 */
public void reload(IsbnValidator next) {
this.validator = Objects.requireNonNull(next);
}
}
反例里 registry.current() 每次返回的对象可能不同,调用点永远处于 megamorphic 或反复 class_check 的状态。正例把「选择」和「调用」分开:validator 是 volatile,替换后所有线程立刻看到新值;代价是调用点的接收者类型变了,第一次调用会触发一次 class_check 反优化,然后重新编译。这个代价只在 reload 时付一次,不应该在每个请求上付。
如果插件必须在运行中加载,按下面的顺序做:
- 在维护窗口或启动阶段完成
ServiceLoader加载,不要在请求线程里Class.forName。 - 加载后主动预热:用新实现跑几千次校验,让 C2 重新编译,而不是让第一个真实请求承担反优化。
- 监控
jdk.Deoptimization的事件速率;如果reload后持续出现class_check,说明调用点仍在多态状态,要检查是否把「选择」放进了热路径。
内置校验器用 sealed 接口或枚举,给 JIT 一个封闭的类型集合;插件校验器走单独的、冷的边界。两者不要混在同一个调用点。
常见错误与边界
「-XX:+PrintDeoptimizationDetails 能打印细节。」 product JDK 上会直接拒绝启动:
Error: VM option 'PrintDeoptimizationDetails' is develop and is available only in debug version of VM.
Improperly specified VM option 'PrintDeoptimizationDetails'
Error: Could not create the Java Virtual Machine.
要看细节用 JFR 的 jdk.Deoptimization,它给出 reason、action、bci、instruction 和调用栈,信息比 PrintCompilation 多,而且是 product 版本可用的。
「把反优化当成故障。」 uncommon trap 在循环退出、类型变化、分支翻转时都会出现,是推测优化的正常代价。真正需要关注的是「反优化频率」:每个请求都触发一次,说明调用点设计有问题;启动后几十次,属于编译过渡。判断依据是 JFR 事件的时间分布和 method 字段,不是单看某一行日志。
「基准测试不预热就下结论。」 反优化会让某一次测量突然变慢,平均值被拉高。JMH 的 @Warmup 不只是「让 JIT 编译」,也是「让反优化发生完」。自定义基准至少要跑够编译阈值(默认 10000 次调用),并丢弃前几轮;否则测到的是解释器加反优化的混合状态。
小结
- 反优化是推测优化的代价:类层次变化触发
marked for deoptimization,接收者类型不匹配触发uncommon trap。 made not entrant: not used是正常替换,uncommon trap是陷阱回退,两者都不是错误。- JFR 的
jdk.Deoptimization给出reason(如class_check、unstable_if)、action、bci和调用栈,是 product JDK 上最直接的证据。 - OSR 用
%标记,@后面是循环回边的字节码偏移;低层级 OSR 版本会被高层级版本替代。 - 循环退出也会触发
unstable_if反优化,这类事件常见且通常无害。 - libraryd 的插件切换要放在启动或维护窗口,切换后主动预热;不要把「选择实现」放进每个请求的热路径。
到这里,执行引擎的观测告一段落。下一节进入内存管理,从可达性分析和四种引用类型开始,看一个对象什么时候才算真正「死了」。
阅读导航:上一节:4.2 即时编译优化:内联、逃逸分析与去虚拟化 · 下一节:5.1 可达性分析与引用类型:从 GC Root 到 Cleaner 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。