《Java 虚拟机原理与性能调优》4.3 反优化与 OSR:代码什么时候退回解释器

加载一个插件类,热了五分钟的接口调用突然退回解释器。本节用 -XX:+PrintCompilation 抓 marked for deoptimization 与 uncommon trap,用 JFR 的 jdk.Deoptimization 事件看 class_check 的 bci、原因与动作,再解释 OSR 为什么在循环中途换代码,以及 libraryd 插件热加载该怎么避开反优化。

本节目标:分清 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

反优化有两个常见触发源,容易混在一起:

  1. 类层次变化:接口只有一个实现时,C2 可以假设「以后也只有这个」。新实现类加载后,这个假设失效,所有依赖它的编译代码被标记。日志里是 marked for deoptimization。
  2. 接收者类型不匹配:调用点编译时只见过类型 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 时付一次,不应该在每个请求上付。

如果插件必须在运行中加载,按下面的顺序做:

  1. 在维护窗口或启动阶段完成 ServiceLoader 加载,不要在请求线程里 Class.forName。
  2. 加载后主动预热:用新实现跑几千次校验,让 C2 重新编译,而不是让第一个真实请求承担反优化。
  3. 监控 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发