跳到内容

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

同一个方法在预热前后表现不同,语言工坊的性能报告却只保存了一次采样结果。

同一个 Java 方法刚启动时和运行几分钟后,执行路径可能不同。JVM 可以先解释字节码、收集运行画像,再把热点编译成本机代码;当先前假设失效时,又撤回优化继续执行。理解这条链路,能避免用一次 javap 输出解释所有运行时性能。

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

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

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

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

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

从冷代码到热点代码

一种常见执行过程是:

text
加载并验证 class

解释执行 / 低层编译
      ↓ 收集调用次数、分支、接收者类型等画像
识别热点

更高层优化编译

运行本机代码

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

常见优化背后的条件

内联

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

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

int tax(int subtotal) {
    return subtotal * 13 / 100;
}

是否内联取决于方法大小、调用热度、接收者类型画像和编译预算等。finalprivate 或小方法可能有利于推理,但不是“必定内联”的承诺。

逃逸分析与标量替换

java
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 没优化”往往只是无法复现的猜测。

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

装箱

java
Integer sum = 1;
sum += 2;

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

桥接方法

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

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

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

switch 与字符串拼接

switch 可能生成 tableswitchlookupswitch,选择取决于 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