5.3 JIT、去优化与观测:从字节码到机器码
同一个方法在预热前后表现不同,语言工坊的性能报告却只保存了一次采样结果。
同一个 Java 方法刚启动时和运行几分钟后,执行路径可能不同。JVM 可以先解释字节码、收集运行画像,再把热点编译成本机代码;当先前假设失效时,又撤回优化继续执行。理解这条链路,能避免用一次 javap 输出解释所有运行时性能。
规范保证语义,实现选择策略
JVMS 描述抽象机器、class 文件和指令语义,但不要求 JVM 必须使用解释器或 JIT。HotSpot、OpenJ9 等实现可以采用不同编译层级、垃圾收集器和对象布局,只要可观察行为符合规范。
讨论性能时,应把结论标成两类:
- 规范结论:例如方法调用、异常处理和内存模型语义;
- 实现观察:例如某版 HotSpot 是否内联、是否消除分配。
后者必须带运行时版本、参数、负载和证据,不能写成 Java 永久保证。
从冷代码到热点代码
一种常见执行过程是:
加载并验证 class
↓
解释执行 / 低层编译
↓ 收集调用次数、分支、接收者类型等画像
识别热点
↓
更高层优化编译
↓
运行本机代码具体实现可能进行分层编译,并在循环运行途中使用 OSR(On-Stack Replacement)切换到已编译版本。阈值和层级是实现细节,不应依赖某个固定调用次数。
常见优化背后的条件
内联
JIT 把被调用方法的代码放进调用点,减少调用开销,更重要的是暴露跨方法优化机会:
int total(Order order) {
return tax(order.subtotal());
}
int tax(int subtotal) {
return subtotal * 13 / 100;
}是否内联取决于方法大小、调用热度、接收者类型画像和编译预算等。final、private 或小方法可能有利于推理,但不是“必定内联”的承诺。
逃逸分析与标量替换
int distance() {
Point p = new Point(3, 4);
return p.x() + p.y();
}若实现证明对象不逃逸,可能拆成标量或消除实际分配。源码中的 new 表达对象创建语义,不保证必须在堆上形成一个按固定布局存在到 GC 的对象。
去虚拟化
若画像显示某调用点长期只出现一种接收者,JIT 可能按该假设直接优化并保留守卫。后来出现新子类时,守卫失败,运行时可以撤销已编译代码的相关假设。
去优化是自适应系统的正常行为
推测优化依赖运行时事实:
假设:这个调用点只有 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 没优化”往往只是无法复现的猜测。
用字节码解释常见源码现象
装箱
Integer sum = 1;
sum += 2;字节码可暴露 Integer.valueOf、拆箱和重新装箱调用。JIT 之后可能消除一部分开销,也可能因为逃逸等原因保留;静态字节码只能证明编译器生成了哪些语义步骤。
桥接方法
泛型覆盖在擦除后可能需要编译器生成 ACC_BRIDGE、ACC_SYNTHETIC 方法,以保持多态分派:
class StringBox implements Box<String> {
public String get() { return "ok"; }
}用 javap -v -p 可以看到源码中没有直接声明的桥接方法。这不是重复业务代码,而是编译器维护二进制层方法签名的手段。
switch 与字符串拼接
switch 可能生成 tableswitch 或 lookupswitch,选择取决于 case 分布和编译器策略。非常量字符串拼接可能经由 invokedynamic,常量表达式则可能折叠。任何“Java 永远生成某条指令”的结论都应先在目标工具链验证。
一条可靠的诊断路径
- 先描述可观察症状:吞吐下降、尾延迟上升还是分配增加;
- 固定运行环境和复现负载;
- 用 JFR 或采样数据定位热点,而不是先猜 JIT;
- 必要时查看字节码、编译事件和类加载来源;
- 提出一个可证伪的假设,只改一个变量复测;
- 确认优化没有破坏正确性和其他负载场景。
完成检查
选择一个包含泛型、Lambda 和装箱的方法:
- 用
javap找出桥接方法、invokedynamic和装箱调用; - 写下你预测 JIT 可能采取的优化;
- 明确哪些预测不是规范保证;
- 设计 JFR 或基准实验验证;
- 记录 JDK 版本和完整参数,使别人可以复现。