JIT 与字节码深入:Java 到底是怎么变快的
第 9 章讲了 JVM 的内存和 GC,这一章往下再钻一层:你的 .java 文件到底变成了什么,又是怎么从"慢吞吞的解释执行"变成"接近 C 的速度"的。学完这章你会拥有两项很值钱的能力:用 javap 看懂任何一段代码背后的字节码,以及——不再写出"测出来比 C 还快"的假基准测试。
从 .java 到机器码:一条路走三段
是什么:Java 代码的执行链路是:javac 把 .java 编译成字节码(.class,JVM 的"机器语言")→ JVM 启动后,解释器逐条执行字节码,同时对"热点代码"做统计 → JIT(Just-In-Time)编译器把热点方法编译成本机的机器码,之后就走机器码了。
为什么这么设计(而不是像 C 那样一次编译到底):因为这里有一个根本性的权衡:提前编译(AOT)能立刻全速运行,但编译期看不到运行时的信息——比如"这个接口调用到底有几种实现""这个分支实际走哪边"。JVM 选择"先跑起来,边跑边收集信息,等信息够了再做针对性优化",所以 Java 程序的典型曲线是:刚启动慢,跑热了很快,甚至可能比某些静态编译的代码更快(因为它能基于真实数据做投机优化)。
衡解释器 + JIT 的分工,其实是在买时间
解释器:不用编译,立刻能跑,但每条字节码都要"查表 + 分发",慢 10 倍以上。它的价值是让程序在编译还没完成时就能提供服务,同时充当"采集器"。
C1 编译器:编译快、优化保守,大约 1~3 倍于解释执行的性能。它的价值是让程序快速进入"不太慢"的状态。
C2 编译器:编译慢(占用额外 CPU)、优化激进(内联、逃逸分析、向量化、循环展开),能到接近本机的性能。它的价值是峰值性能。
三者不是替代关系,而是流水线上的三个阶段。这也解释了为什么短命进程(比如一个只跑 2 秒的命令行工具)几乎享受不到 JIT 的好处,却要付 C2 编译线程的 CPU 成本。
javap:把字节码摊开看
是什么:javap 是 JDK 自带的反汇编器——它读 .class 文件,把里面的字节码指令打印成人能看的列表。
为什么值得学:三个很实际的用途:① 验证"这行 Java 代码到底生成了什么"(比如字符串拼接、自动装箱、try-with-resources 的真实开销);② 确认编译目标版本(-v 里的 major version 直接告诉你这个 class 是用哪个 JDK 编的);③ 看懂语法糖的真相(增强 for、lambda、变长参数,全是编译器帮你生成的字节码)。
| 选项 | 作用 |
|---|---|
javap -c Calc | 反汇编:显示每个方法的字节码指令。 |
javap -p Calc | 显示私有成员。不加 -p 时私有方法和字段是不显示的。 |
javap -v Calc | verbose:附带常量池、版本号、各种属性。看 major version 就用它。 |
javap -l Calc | 显示行号表和局部变量表——有它才能把字节码和源码行对应起来(需要编译时带 -g)。 |
javap -cp build/classes/java/main com.example.Calc | 指定类路径来反汇编。反汇编 jar 里的类也靠 -cp。 |
javap -s Calc | 显示类型签名(描述符),排查反射、JNI 问题时有用。 |
第一个例子:加法方法,四条指令就能看懂字节码的"读栈"模型
// 源码 Adder.java
public class Adder {
public static int add(int a, int b) {
return a + b;
}
}
// $ javac Adder.java && javap -c Adder
// 输出(省略了类头信息):
// public static int add(int, int);
// Code:
// 0: iload_0 <-- 把第 0 个局部变量(即参数 a)压入操作数栈
// 1: iload_1 <-- 把第 1 个局部变量(即参数 b)压入栈
// 2: iadd <-- 弹出栈顶两个 int,相加,结果压回栈
// 3: ireturn <-- 弹出栈顶 int 作为返回值,方法结束
// 记住这个模型就够了:字节码是"栈式虚拟机"的指令集,
// 所有运算都是"把操作数压栈 -> 执行 -> 结果压回栈",没有寄存器。
// 命名规律:i=int, l=long, f=float, d=double, a=引用, b/ c/s = byte/char/short
第二个例子:分支与字段访问(offset 会随 javac 版本略有差异,看指令本身即可)
public class Calc {
private int total = 0;
public int addIfPositive(int x) {
if (x > 0) {
total += x;
}
return total;
}
}
// $ javap -c -p Calc (-p 才能看到 private 字段相关的东西)
// public int addIfPositive(int);
// Code:
// 0: iload_1 <-- 压入参数 x
// 1: ifle 11 <-- 若 x <= 0 就跳到 11(注意:是"跳转条件"而不是"跳转去执行 if")
// 4: aload_0 <-- 压入 this(实例方法的第 0 个局部变量永远是 this)
// 5: dup <-- 复制栈顶(因为后面 putfield 会消耗掉一个 this)
// 6: getfield #7 <-- 取 this.total
// 9: iload_1 <-- 压入 x
// 10: iadd <-- total + x
// 11: putfield #7 <-- 写回 this.total
// 14: aload_0 <-- 压入 this
// 15: getfield #7 <-- 取 this.total 作为返回值
// 18: ireturn
// 两条值得注意的事:
// ① if 语句在字节码里是"条件不成立就向前跳",即"ifle = if less or equal"
// ② 实例方法里,局部变量槽 0 是 this,参数从槽 1 开始
用 -v 确认编译目标版本:这是排查"Unsupported class file major version"的标准动作
$ javap -v Adder | head -12
// 输出里会有:
// Classfile /path/to/Adder.class
// Last modified 2026-...; size 400 bytes
// Major version: 65 <-- 65 = JDK 21
// Minor version: 0
# 记住这几个对照关系(major version = JDK 版本 + 44):
# 52 = JDK 8 55 = JDK 11 61 = JDK 17
# 65 = JDK 21 69 = JDK 25
# "Unsupported class file major version 65" = 你在用老 JDK 跑新 JDK 编译的类
三个"奇怪现象"其实都是正常行为:
① 私有方法看不见 → 忘了加 -p(默认只显示 public/protected)。
② 字节码里没有 i、name 这些变量名 → 局部变量表默认不写进 class(要用 javac -g 编译,再用 javap -l 才看得到)。这也是"反编译工具能还原出源码"的前提——有了行号和变量表,还原质量才会好。
③ 提示"class not found" → 类在包里或 jar 里,必须用 -cp 指定路径,并且用全限定名(com.example.Calc)而不是文件名。顺带一句:javap 只是把 class 里的信息打印出来,它不会做"反编译成 Java 源码"这种智能还原——那是 CFR、JD-GUI 的活。
分层编译:解释器与 C1 / C2 是怎么分工的
是什么:HotSpot 默认开启分层编译(Tiered Compilation),把执行状态分成 5 个层级,方法会随着"变热"逐级上升。
| 层级 | 由谁执行 | 特点 |
|---|---|---|
| Level 0 | 解释器 | 逐条执行字节码,同时收集调用次数、循环次数等"热度"信息。新方法都从这开始。 |
| Level 1 | C1 编译 | 纯 C1、无 profiling。用于"不需要 C2 优化"的简单方法(比如本身就很小的方法)。 |
| Level 2 | C1 编译 | C1 + 少量 profiling(只统计调用次数和循环回边)。 |
| Level 3 | C1 编译 | C1 + 完整 profiling。绝大多数热点方法先到这里——先用 C1 跑起来,同时把类型、分支等 profile 数据攒够。 |
| Level 4 | C2 编译 | 拿着 Level 3 攒好的 profile 做激进优化:内联、逃逸分析、循环展开、向量化。这是性能的最终形态。 |
怎么用:观察 JIT 到底在干什么,靠这个开关。
# 打印每次 JIT 编译(最常用的观察手段)
java -XX:+PrintCompilation Main
# 输出形如(每列的含义见下方注释):
# 123 45 3 java.lang.String::hashCode (55 bytes)
# 145 46 % 4 com.example.Hot::loop @ 2 (30 bytes) made not entrant
# ↑ ↑ ↑ ↑ ↑ ↑
# │ │ │ │ │ └─ 被去优化(见后文)
# │ │ │ │ └─ 方法名 + 字节码大小
# │ │ │ └─ 编译层级(1~4,见上表)
# │ │ └─ % = OSR(栈上替换,在循环里就被编译)
# │ └─ 编译任务序号
# └─ JVM 启动后经过的毫秒数
# 看本机的编译阈值真实值(不同 JDK 小版本会有差异,以实测为准)
java -XX:+PrintFlagsFinal -version | grep -i -E 'Tier[34]'
# 只升到 C1(牺牲峰值性能换启动速度,适合短命进程)
java -XX:TieredStopAtLevel=1 Main
# 完全关掉分层编译(只用 C2)-- 一般不这么干,启动会明显变慢
java -XX:-TieredCompilation Main
一个跑 3 秒就退出的批处理 / CLI 工具,可能 90% 的时间都花在解释执行和 JIT 编译上,还没来得及享受 C2 的成果。这类程序的正确做法是"别让它编译那么多":
① -XX:TieredStopAtLevel=1:只让 C1 干活,省下 C2 编译线程的 CPU(对短命进程通常能快 10%~30%)。② 别用 -Xint 来"提速"——-Xint 是纯解释执行,是诊断用的(排查"是不是 JIT 优化导致的 bug"),性能会掉 10 倍以上。③ 想要真正的快启动,方向是 CDS/AppCDS(-XX:SharedArchiveFile,把类加载和部分编译结果缓存下来)或者 GraalVM Native Image(提前编译成可执行文件,代价是构建慢、反射需要额外配置)。
逃逸分析与标量替换:为什么"新建对象"可能不花钱
是什么:逃逸分析(Escape Analysis)是 C2 的一项优化:判断一个对象会不会"逃出"当前方法。如果没有逃逸,JIT 就可以做三件很有价值的事:① 标量替换(Scalar Replacement)——把这个对象的字段拆成几个局部变量放在栈上,根本不分配对象;② 锁消除(Lock Elision)——对象只被当前线程访问,那 synchronized 就没必要;③ 消除部分内存屏障。
为什么重要:它直接影响你写的代码"是不是真的在产生垃圾"。很多人以为"循环里 new 对象 = 每次循环都产生一个垃圾对象",实际在 C2 优化之后可能一个对象都没分配,全在栈上完成了。理解了这一点,你就不会去为了"避免 GC"写出难以维护的代码。
public class EscapeDemo {
static int noEscape(int a, int b) {
var box = new Point(a, b); // 只在方法内部使用
return box.x() + box.y(); // C2 可把它标量替换掉,不产生对象,也不产生垃圾
}
record Point(int x, int y) {}
static Point escapes(int a, int b) {
return new Point(a, b); // 被返回了 -> 逃逸 -> 老老实实在堆上分配
}
}
# 观察逃逸分析是否发生(需要诊断开关)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis EscapeDemo
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations EscapeDemo
# 输出里 "allocated" / "not escaped" 之类的信息会告诉你它把谁优化掉了
| 写法 | 为什么逃逸 |
|---|---|
return obj; | 对象被方法返回,调用方可能长期持有。 |
this.field = obj; | 存进了字段,生命周期超出当前方法。 |
把对象传给不确定的方法(如 list.add(obj)、unknownMethod(obj)) | JIT 无法保证对方不会把它存起来,只能保守地认为"逃逸了"。 |
| 放进数组 / 集合(哪怕只是暂时) | 同上,容器是天然的"逃生通道"。 |
| 对象被另一个线程访问到 | 必须走堆 + 内存屏障,绝不可能栈上化。 |
两件事要分清:① 逃逸分析不是保证——它是 C2 的优化,只有当方法被 JIT 编译到 Level 4、且分析得出"不逃逸"时才会生效;冷代码(只跑几次的方法)走的是解释器,照样老老实实分配对象。② 它优化的是"对象分配",不是"算法复杂度"——你写了一个 O(n²) 的循环拼字符串,逃逸分析帮不了你任何忙,该用 StringBuilder 还是得用。正确的态度:把逃逸分析当成"知道有这回事、不必为它做特殊设计",而不是"反正 JIT 会优化,所以随便写"。顺带说一句:Optional、Stream 这些抽象确实会带来额外对象,但在绝大多数业务代码里它们不是瓶颈——先测量,再优化(见本章最后一节)。
方法内联:为什么说它是"优化之母"
是什么:内联(Inlining)就是把被调用的方法的字节码"搬进"调用点,消掉这次方法调用。它看起来只是个"省掉调用开销"的小优化,实际上是所有其他优化的前提。
为什么它是一切的入口:因为只有内联之后,C2 才能"看见"被调用方法内部的逻辑,才有机会做后续这些事:常量传播(把参数替换成实际常量)、死代码消除(把永远走不到的分支删掉)、逃逸分析(发现对象没跑出方法)、循环优化、向量化。一个方法一旦没被内联,这些优化基本都做不了。
怎么用:JIT 决定要不要内联,主要看方法的字节码大小和它有多热。你可以直接观察它的决定。
# 打印内联决策(必须加 UnlockDiagnosticVMOptions)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining Main
# 输出里会看到这类说明(这些才是你要关心的):
# too large -> 方法太大,超了内联阈值
# hot method too large -> 热点方法但依然超阈值
# already compiled into a big method -> 已经被内联进别的大方法了
# recursive call -> 递归调用,默认内联层数有限
# virtual call, no type profile -> 没有类型数据,无法去虚化
# 正常内联成功时,会显示 "inline (hot)" 之类的标记
# 看/调内联阈值(默认值以 PrintFlagsFinal 为准,不同版本会有微调)
java -XX:+PrintFlagsFinal -version | grep -i -E 'InlineSize|InlineSmallCode|MaxRecursiveInlineLevel'
# -XX:MaxInlineSize=35 非热点方法的字节码大小上限(字节)
# -XX:FreqInlineSize=325 热点方法的尺寸上限(宽松得多)
# -XX:MaxRecursiveInlineLevel 递归内联的最大层数(默认 1)
| 常见说法 | 实际情况 |
|---|---|
"加 final 才能被内联" | 错。现代 JIT 不看 final,它看运行时类型 profile:如果某个调用点历史上只出现过一种实现(单态 monomorphic),就可以直接内联那一种,并在类型变化时去优化回退。 |
| "接口/虚方法调用一定慢" | 不一定。单态(1 种实现)和双态(2 种)调用都能被很好地优化;只有超多态(megamorphic,比如超过阈值种实现)时才会退化成查表调用,性能明显下降。 |
| "getter/setter 是多余的方法调用,不如直接访问字段" | 错。getter/setter 的体积小得可怜,几乎总会被内联掉,开销接近 0。为了"性能"放弃封装是不划算的——真正值得关注的是那些几百行的大方法,它们才是内联不进去的。 |
"看起来慢其实被优化了":微基准测试的五个陷阱
是什么:JIT 的优化能力强大到会把你想测量的代码整段删掉。这一节讲的就是这些"测不准"的现象。
为什么必须知道:因为"自己写个循环 + 卡表测时间"是所有人的第一反应,而它几乎总是得出错误结论。用错误的基准测试做技术决策,比不做基准测试更糟。
| 现象 | 发生了什么 / 怎么识别 |
|---|---|
| 死代码消除(DCE) | 算出来的结果没人用,C2 判断这段代码没有副作用,直接整段删掉。你会测出 0 毫秒。识别:结果"快得不合理",甚至快到 0。 |
| 常量折叠 | for (int i = 0; i < 100; i++) s += i; 里的常量循环可能被编译期直接算成 4950。识别:无论循环多大,耗时几乎不变。 |
| 预热效应 | 第一次跑是解释执行,跑几万次后才进 C2。识别:同一段代码第一次和第十次的结果差好几倍。 |
| 空循环被消除 | 没有副作用的空循环会被整个删掉——用它来"制造延迟"或在里面做计时基准都不可靠。 |
| 测量工具本身的开销 | System.currentTimeMillis() 精度只有毫秒级,而你要测的可能只有几百纳秒;循环里再调用它一次,测量成本比被测代码还高。识别:时间数字跳得很粗(0 或 1 毫秒)。 |
反面教材 vs 正确做法(注意差别只在"结果有没有被使用")
// ✗ 反面教材:结果没人用,整段被 DCE 删掉,测出 0ms 也不是真的快
long t0 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
someExpensiveComputation(i);
}
System.out.println(System.currentTimeMillis() - t0);
// ✓ 正确做法:交给 JMH。它替你处理预热、分叉、死代码消除(靠返回值/Blackhole)、统计
// build.gradle.kts
// plugins { id("me.champeau.jmh") version "0.7.x" } // JMH 的 Gradle 插件
// dependencies {
// jmh("org.openjdk.jmh:jmh-core:1.37")
// jmhAnnotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:1.37")
// }
// 运行:./gradlew jmh
@State(Scope.Thread)
public class SumBench {
private int[] arr = new int[1024];
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1) // 先热身 5 轮,让代码跑到 C2
@Measurement(iterations = 5, time = 1) // 正式测量 5 轮
@Fork(2) // 独立进程跑 2 次,排除偶发波动
public long sum() {
long s = 0;
for (int v : arr) s += v;
return s; // 必须把结果返回(或用 Blackhole.consume),否则会被 DCE 掉
}
}
当你看到某个手写基准测出"Java 比 C 快 3 倍"时,先默认它是 DCE,而不是先相信 Java 逆天——九成情况下,被测代码被优化掉了。同样,以下这些做法也都会给出无意义的数字:① 用 -Xint 跑基准(那是解释执行,慢 10 倍以上,代表不了真实运行);② 在同一次进程里先测 A 再测 B(B 会受 A 留下的 JIT 状态、堆状态影响,JMH 用 @Fork 正是为了隔离);③ 在 JMH 里用 @Setup(Level.Invocation) 之外的地方做 IO 或打日志(测量被污染)。记住一句话:没有 JMH 的微基准结论,不值得拿去做技术决策。
去优化:优化之后的"后悔药"
是什么:JIT 的激进优化建立在"基于目前观察到的运行时数据做假设"之上。一旦假设被打破,JVM 会把这些已经编译好的机器码废弃掉,让方法退回解释执行(或低层级)重新升温——这个过程叫去优化(deoptimization)。
为什么它很重要:因为它是"看起来快、偶尔卡一下"这类问题的常见来源,也是理解"为什么 Java 程序性能会阶段性波动"的关键。典型的触发场景:某个接口调用点原本只有一种实现被内联了,运行中突然加载了第二种实现;或者某个分支的判空假设失效。
# 观察去优化(注意:这个开关非常啰嗦,只在你明确怀疑时才开)
java -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization Main
# 更轻量的做法:在 PrintCompilation 输出里找这两个词
java -XX:+PrintCompilation Main
# ... 46 % 4 com.example.Hot::loop @ 2 (30 bytes) made not entrant
# ↑ 这段编译产物已废弃
# ... 47 4 com.example.Hot::loop (30 bytes) made zombie
# ↑ 已被回收(连替换版本都没有了)
| 可能原因 | 说明与对策 |
|---|---|
| 类型 profile 失效 | 某个调用点从单态变成多态(比如运行时加载了新的实现类)。对策:检查是否存在"用 setter 动态切换实现"这类设计,它会持续破坏 JIT 假设。 |
| 类被重新定义 | 热部署、Java Agent 改写字节码、某些 APM 的探针都会触发。排查手段:关掉可疑 agent 再对比。 |
| 激进优化假设被推翻 | 比如"数组访问不越界"的假设失效、某个分支第一次走到。这类是正常的,一两次去优化不算问题。 |
| 频繁去优化本身就是问题 | 如果同一个方法反复被编译又废弃,性能会剧烈波动。用 -XX:+PrintCompilation 加时间戳观察频率,必要时用 -XX:-TieredCompilation 对比实验来定位。 |
定三句话总结这一章
① 字节码是"栈式虚拟机"的指令集,看懂 iload/istore/iadd/invoke*/getfield/putfield/ireturn 这十几个指令,你就能读懂绝大多数 Java 代码背后的字节码。
② JIT 的核心是"基于运行时数据做投机优化,错了就回退":分层编译负责"先快起来再跑更快",内联是所有优化的入口,逃逸分析决定对象能不能不上堆,去优化是这一切的安全网。
③ 一切性能结论必须以经过预热的测量为准:想测量就用 JMH,别手写循环卡表——你测到的很可能不是你的代码,而是 JIT 优化之后剩下的那部分。
① javap -c -p 看字节码,javap -v 看 major version(65=JDK 21、61=JDK 17、52=JDK 8);实例方法的局部变量槽 0 永远是 this。
② 分层编译 = 解释器(0)→ C1(1/2/3,边跑边采集)→ C2(4,激进优化)。短命进程用 -XX:TieredStopAtLevel=1,长跑服务靠 C2 拿峰值性能;-XX:+PrintCompilation 是观察 JIT 的第一工具。
③ 内联是"优化之母"、逃逸分析决定对象上不上堆、去优化是后悔药;而所有性能结论都必须以 JMH + 预热 为前提——手写循环测出来的数字,多数时候只是 JIT 优化后的残影。