跳到内容

第 9 卷 / 第 5 章 / 第 3 课

5.3 JIT、去优化与观测:从字节码到机器码 ​

同一个 Java 方法在启动时和预热后,可能走不同的执行路径。JVM 可以收集运行画像,把热点编译成本机代码,也可以在假设失效时撤销优化。javap 能交代编译产物,但运行时后来做了什么,得另找证据。光看菜单,算不出厨房今晚有多忙。

规范保证语义,实现选择策略 ​

JVMS 描述抽象机器、class 文件和指令语义,但不要求 JVM 必须使用解释器或 JIT。HotSpot、OpenJ9 等实现可以采用不同编译层级、垃圾收集器和对象布局,只要可观察行为符合规范。

讨论性能时,应把结论标成两类:

  • 规范结论:例如方法调用、异常处理和内存模型语义;
  • 实现观察:例如某版 HotSpot 是否内联、是否消除分配。

后者必须带运行时版本、参数、负载和证据,不能写成 Java 永久保证。

从冷代码到热点代码 ​

一种常见执行过程是:

text
加载并验证 class
      ↓
解释执行 / 低层编译
      ↓ 收集调用次数、分支、接收者类型等画像
识别热点
      ↓
更高层优化编译
      ↓
运行本机代码

具体实现可能进行分层编译,并在循环运行途中使用 OSR(On-Stack Replacement)切换到已编译版本。阈值和层级是实现细节,不应依赖某个固定调用次数。

常见优化背后的条件 ​

内联 ​

JIT 把被调用方法的代码放进调用点,减少调用开销,更重要的是暴露跨方法优化机会:

java
record Order(int subtotal) {}

int taxFor(Order order) {
    return tax(order.subtotal());
}

int tax(int subtotal) {
    return Math.toIntExact(subtotal * 13L / 100);
}

上面是放在同一个类中的方法与记录声明,金额仅按整数单位演示,并采用整数除法截断。是否内联取决于方法大小、调用热度、接收者类型画像和编译预算等。final、private 或小方法可能有利于推理,但不是“必定内联”的承诺。

逃逸分析与标量替换 ​

java
record Point(int x, int y) {}

int distance() {
    Point p = new Point(3, 4);
    return p.x() + p.y();
}

若实现证明对象不逃逸,可能拆成标量或消除实际分配。源码中的 new 表达对象创建语义,不保证必须在堆上形成一个按固定布局存在到 GC 的对象。

去虚拟化 ​

若画像显示某调用点长期只出现一种接收者类型,JIT 可能按该假设优化并保留类型守卫。该调用点后来收到别的类型时,守卫可能转入慢路径或触发去优化;若优化依赖类层次假设,加载新子类也可能使依赖失效。它们不是所有新子类出现时都会统一触发的固定流程。

去优化是自适应系统的正常行为 ​

推测优化依赖运行时事实:

text
假设:这个调用点只有 FastParser
      ↓
生成针对 FastParser 的优化代码 + 守卫
      ↓
后来出现 SafeParser
      ↓
守卫失败,转回较通用执行状态

这个过程称为去优化。它不必意味着 JVM 出错,而是自适应优化纠正假设。反复去优化、代码缓存压力或画像持续变化都值得检查;即使只有一次去优化,也可能影响对延迟很敏感的请求。是否构成问题要看负载和时间证据。

因此,微基准若没有预热、结果未被消费、分支画像与生产不同,测到的数字很容易失真。Java 微基准通常应使用 JMH 等专门工具,并理解它只能改善实验方法,不能自动代表真实业务。

从源码问题选择观测工具 ​

问题首选证据能回答什么
编译器生成了什么javap -c -v -p指令、常量池、属性、异常表
类从哪里加载类加载日志、JFR加载器与代码来源
CPU 时间花在哪里JFR 或采样分析器热栈、方法与线程
分配或保留来自哪里JFR 分配事件;堆转储分析前者定位分配采样,后者查看存活对象与引用链
方法是否被编译JVM 编译日志、JFR编译活动与代码缓存
线程为何等待线程转储、JFR 锁事件阻塞点与持有者

javap 是静态工具,看不到是否内联和最终机器码。线程转储是一个时刻的快照,也不能单独证明长期 CPU 热点。工具要与问题匹配。

Java Flight Recorder(JFR)可用于持续或定时采集事件,开销取决于配置、事件量和栈采集,仍需在目标负载下测量。开始诊断前先记录:

  • JDK 发行版和完整版本;
  • JVM 参数、容器 CPU/内存限制;
  • 负载形状和采集时间窗口;
  • 是否处于启动、预热还是稳态;
  • 同时发生的 GC、部署和流量变化。

没有这些上下文,所谓“JIT 没优化”往往只是无法复现的猜测。

先跑通一次 JFR 采集 ​

在包含 JFR 和 jfr 命令的 HotSpot JDK 17 或更高版本中,把下面的代码保存为 JitDemo.java:

java
public final class JitDemo {
    static long mix(int value) {
        return (value * 31L) ^ (value >>> 3);
    }

    public static void main(String[] args) {
        long checksum = 0;
        for (int i = 0; i < 100_000_000; i++) {
            checksum += mix(i);
        }
        System.out.println(checksum);
    }
}
bash
javac --release 17 JitDemo.java
java -version
java -XX:StartFlightRecording=filename=jit-demo.jfr,settings=profile,dumponexit=true JitDemo
jfr summary jit-demo.jfr
jfr print --events jdk.Compilation,jdk.Deoptimization jit-demo.jfr

程序打印校验和,JVM 的录制提示会另外输出。检查录制文件能否被 jfr summary 读取,再查看当前配置实际记录了哪些编译或去优化事件。事件数、编译层级以及 mix 是否单独出现都可能不同;内联后它未必保留独立条目,零条去优化事件也不证明它永远不会发生。

这个循环用于跑通采集流程,没有测量耗时,也不是性能基准。要比较实现速度,还需要 JMH 的预热、独立 fork、结果消费和有代表性的输入。

用字节码解释常见源码现象 ​

装箱 ​

java
Integer sum = 1;
sum += 2;

字节码可暴露 Integer.valueOf、拆箱和重新装箱调用。JIT 之后可能消除一部分开销,也可能因为逃逸等原因保留;静态字节码只能证明编译器生成了哪些语义步骤。

桥接方法 ​

泛型覆盖在擦除后可能需要编译器生成 ACC_BRIDGE、ACC_SYNTHETIC 方法,以保持多态分派:

java
interface Box<T> {
    T get();
}

class StringBox implements Box<String> {
    public String get() { return "ok"; }
}

用 javap -v -p 可以看到源码中没有直接声明的桥接方法。这不是重复业务代码,而是编译器维护二进制层方法签名的手段。

switch 与字符串拼接 ​

switch 可能生成 tableswitch 或 lookupswitch,选择取决于 case 分布和编译器策略。非常量字符串拼接可能经由 invokedynamic,常量表达式则可能折叠。任何“Java 永远生成某条指令”的结论都应先在目标工具链验证。

一条可靠的诊断路径 ​

  1. 先描述可观察症状:吞吐下降、尾延迟上升还是分配增加;
  2. 固定运行环境和复现负载;
  3. 用 JFR 或采样数据定位热点,而不是先猜 JIT;
  4. 必要时查看字节码、编译事件和类加载来源;
  5. 提出一个可证伪的假设,只改一个变量复测;
  6. 确认优化没有破坏正确性和其他负载场景。

完成检查 ​

选择一个实现泛型接口的类,并在调用它的代码中加入 Lambda 和装箱;仅使用泛型并不必然生成桥接方法:

  1. 用 javap 找出桥接方法、invokedynamic 和装箱调用;
  2. 写下你预测 JIT 可能采取的优化;
  3. 明确哪些预测不是规范保证;
  4. 设计 JFR 或基准实验验证;
  5. 记录 JDK 版本和完整参数,使别人可以复现。

参考资料 ​

Built with VitePress | Software Systems Atlas