5.3 JIT、去优化与观测:从字节码到机器码
同一个 Java 方法在启动时和预热后,可能走不同的执行路径。JVM 可以收集运行画像,把热点编译成本机代码,也可以在假设失效时撤销优化。javap 能交代编译产物,但运行时后来做了什么,得另找证据。光看菜单,算不出厨房今晚有多忙。
规范保证语义,实现选择策略
JVMS 描述抽象机器、class 文件和指令语义,但不要求 JVM 必须使用解释器或 JIT。HotSpot、OpenJ9 等实现可以采用不同编译层级、垃圾收集器和对象布局,只要可观察行为符合规范。
讨论性能时,应把结论标成两类:
- 规范结论:例如方法调用、异常处理和内存模型语义;
- 实现观察:例如某版 HotSpot 是否内联、是否消除分配。
后者必须带运行时版本、参数、负载和证据,不能写成 Java 永久保证。
从冷代码到热点代码
一种常见执行过程是:
加载并验证 class
↓
解释执行 / 低层编译
↓ 收集调用次数、分支、接收者类型等画像
识别热点
↓
更高层优化编译
↓
运行本机代码具体实现可能进行分层编译,并在循环运行途中使用 OSR(On-Stack Replacement)切换到已编译版本。阈值和层级是实现细节,不应依赖某个固定调用次数。
常见优化背后的条件
内联
JIT 把被调用方法的代码放进调用点,减少调用开销,更重要的是暴露跨方法优化机会:
record Order(int subtotal) {}
int taxFor(Order order) {
return tax(order.subtotal());
}
int tax(int subtotal) {
return Math.toIntExact(subtotal * 13L / 100);
}上面是放在同一个类中的方法与记录声明,金额仅按整数单位演示,并采用整数除法截断。是否内联取决于方法大小、调用热度、接收者类型画像和编译预算等。final、private 或小方法可能有利于推理,但不是“必定内联”的承诺。
逃逸分析与标量替换
record Point(int x, int y) {}
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 没优化”往往只是无法复现的猜测。
先跑通一次 JFR 采集
在包含 JFR 和 jfr 命令的 HotSpot JDK 17 或更高版本中,把下面的代码保存为 JitDemo.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);
}
}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、结果消费和有代表性的输入。
用字节码解释常见源码现象
装箱
Integer sum = 1;
sum += 2;字节码可暴露 Integer.valueOf、拆箱和重新装箱调用。JIT 之后可能消除一部分开销,也可能因为逃逸等原因保留;静态字节码只能证明编译器生成了哪些语义步骤。
桥接方法
泛型覆盖在擦除后可能需要编译器生成 ACC_BRIDGE、ACC_SYNTHETIC 方法,以保持多态分派:
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 永远生成某条指令”的结论都应先在目标工具链验证。
一条可靠的诊断路径
- 先描述可观察症状:吞吐下降、尾延迟上升还是分配增加;
- 固定运行环境和复现负载;
- 用 JFR 或采样数据定位热点,而不是先猜 JIT;
- 必要时查看字节码、编译事件和类加载来源;
- 提出一个可证伪的假设,只改一个变量复测;
- 确认优化没有破坏正确性和其他负载场景。
完成检查
选择一个实现泛型接口的类,并在调用它的代码中加入 Lambda 和装箱;仅使用泛型并不必然生成桥接方法:
- 用
javap找出桥接方法、invokedynamic和装箱调用; - 写下你预测 JIT 可能采取的优化;
- 明确哪些预测不是规范保证;
- 设计 JFR 或基准实验验证;
- 记录 JDK 版本和完整参数,使别人可以复现。