JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线
title: “JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线”
date: 2026-11-01
tags: [JVM, Java, 性能优化]
categories: [03-JVM]
JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线
同一个方法,第一次调用要 3 ms,跑够一万次之后只要 0.3 ms。
这不是玄学,是 JIT 在你看不见的地方改写了字节码。理解它,才知道为什么"代码写得小巧"在 Java 里真的更快。
结论先说
| 问题 | 答案 |
|---|---|
| 为什么 Java 启动慢、跑起来快 | 解释执行 → C1(带 profiling)→ C2(激进优化),前几万次调用都在"交学费" |
| JIT 最关键的优化是什么 | 方法内联。没有内联,后面的常量折叠、逃逸分析、锁消除全都做不了 |
| 对象真的分配在栈上吗 | 不是。HotSpot 用标量替换把对象拆成局部变量,效果等价于栈分配 |
| 为什么一个"没改过"的服务某天变慢 | 类型 profile 被新流量污染,去虚化失效 + 去优化(deoptimize) |
| 想看优化结果看什么 | -XX:+PrintCompilation、-XX:+PrintInlining,或 JITWatch |
一、执行引擎的三级火箭
HotSpot 的执行引擎有三条路径,靠**分层编译(Tiered Compilation)**串起来:
| 层级 | 编译器 | 特点 | 触发 |
|---|---|---|---|
| 0 | 解释器 | 极慢(10~100 倍差距),但启动零成本 | 首次执行 |
| 1 | C1(无 profiling) | 快,简单优化 | 方法简单、调用频繁 |
| 2 | C1(部分 profiling) | 快,带计数 | 过渡 |
| 3 | C1(完整 profiling) | 较慢,但收集类型/分支信息 | 默认入口 |
| 4 | C2 | 最慢的编译,最优的代码 | 方法"够热" |
方法被调用
↓ 解释执行,计数器 +1
↓ 达到阈值(CompileThreshold,默认 10000,分层下 C1 约 2000)
C1 编译,插入 profiling 代码(记录"这个虚调用实际是哪个类型")
↓ 继续累积 profile
↓ 达到 C2 阈值
C2 编译,基于 profile 做激进优化(去虚化、内联、逃逸分析)
这个设计解释了两件事:
- 短命进程(命令行工具、定时任务)永远是解释执行的慢速版——它活不到 JIT 生效。
- 压测前几分钟的数据没有意义,必须预热。压测脚本要跑够"至少 1 万次/方法 × 每个方法",或者用 JMH 的
@Warmup。
OSR:循环体内的即时替换
一个跑 10 亿次的 for 循环,方法本身只被调用一次——按"方法调用计数"永远到不了阈值。
JVM 用回边计数器(backedge counter)解决:每绕一圈 +1,达到阈值就在循环体内直接触发编译并替换栈帧,这叫 OSR(On-Stack Replacement)。
-XX:+PrintCompilation 里带 % 标记的就是 OSR 编译:
12345 87 % 4 com.example.Loop::run @ 12 (156 bytes)
二、第一层:方法内联——所有优化的前提
JIT 的优化是"跨方法"的,但跨方法优化的前提是先把方法体摊平。
2.1 为什么内联这么关键
public int add(int a, int b) { return a + b; }
public int calc(int x) {
int s = 0;
for (int i = 0; i < 100; i++) s = add(s, x);
return s;
}
不内联:100 次方法调用,每次都有参数压栈、栈帧创建、返回跳转。
内联后:编译器看到的是 s = s + x,直接优化成 s += 100 * x。
没有内联,C2 的绝大多数优化(常量折叠、死代码消除、逃逸分析、锁消除)都无从谈起,因为它们都需要"看到足够大的代码范围"。
2.2 内联的门槛
| 参数 | 默认值 | 含义 |
|---|---|---|
-XX:MaxInlineSize |
35 | 字节码 ≤ 35 字节的方法无条件内联 |
-XX:FreqInlineSize |
325 | 热点方法(被频繁调用)的字节码上限 |
-XX:MaxRecursiveInlineLevel |
1 | 递归最多内联几层 |
-XX:InlineSmallCode |
1000(C2) | 已编译代码的大小上限 |
注意单位是 字节码字节数,不是源码行数。一个 getter 通常 5~10 字节,随便内联;一个几十行的业务方法,往往超过 325 字节,内联直接失败。
这就是"把大方法拆小"在性能上确实有意义的原因——不是因为方法小更好读,而是因为大了就进不了内联。
2.3 内联的连锁反应
C2 的内联是递归的:A 内联 B,B 内联 C,最终形成一棵巨大的"内联树"。这带来一个副作用:
内联树太大 → 编译时间变长 → 编译线程抢 CPU → 应用可能出现"编译期抖动"。
所以 -XX:FreqInlineSize 不是越大越好。有人调大到 1000 想"多内联点",结果启动阶段 CPU 被编译线程吃满,反而更慢。
2.4 看内联结果
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+PrintCompilation MyApp
输出里读关键词:
@ 12 com.example.Foo::bar (18 bytes) inline (hot)
@ 27 com.example.Foo::baz (412 bytes) too big
@ 33 com.example.Foo::qux (22 bytes) virtual call, no type profile
too big 是最常见的失败原因,其次是 no type profile(见下一节)。
三、第二层:去虚化——让虚调用变成直调用
Java 的方法调用默认是虚调用(invokevirtual),要靠 vtable 查表。查表本身不慢,慢的是它挡住了内联——编译器不知道到底调哪个实现。
C2 用两种手段解决:
3.1 类层次分析(CHA)
如果 Animal 只有一个实现 Dog,那 animal.speak() 必然是 Dog.speak()。这在类加载时就能确定(前提是没有新子类被加载;一旦加载了新子类,已编译代码要去优化)。
3.2 类型 profile(更常用)
C1 在编译时插桩,记录每个虚调用点实际接收者的类型:
| 记录情况 | 优化方式 |
|---|---|
| 只有 1 种类型(monomorphic) | 直接内联该实现,加类型守卫(instanceof 失败则跳去优化) |
| 2 种类型(bimorphic) | 内联两个分支 |
| ≥ 3 种(megamorphic) | 放弃优化,走 vtable 查表 |
@ 33 com.example.Foo::qux (22 bytes) virtual call, no type profile ← 内联失败
这就是**"服务跑久了变慢"的一个真实成因**:某个接口原来只有 1 个实现,JIT 安心内联;后来加了个新实现,且新实现走了同一条调用路径 → profile 变成 megamorphic → 内联失效 → 热点代码性能掉一截,而且没有任何报错。
排查手段:-XX:+PrintInlining 看 no type profile,或者用 JFR 的 jdk.CompilerInlining 事件。
四、第三层:逃逸分析与标量替换
4.1 逃逸的三种状态
C2 会分析每个 new 出来的对象"跑没跑出去":
| 状态 | 含义 | 能否优化 |
|---|---|---|
| NoEscape | 只在方法内部用,没被返回、没存到字段、没传给其他线程 | ✅ 可以彻底拆掉 |
| ArgEscape | 作为参数传出去了,但被调方没让它继续逃逸 | ⚠️ 部分优化 |
| GlobalEscape | 赋给静态字段、作为返回值、被其他线程看到 | ❌ 只能正常分配 |
相关参数:
-XX:+DoEscapeAnalysis # 默认开启
-XX:+EliminateAllocations # 标量替换,默认开启
-XX:+EliminateLocks # 锁消除,默认开启
4.2 "栈上分配"其实不存在
经常有人问"对象能不能分配在栈上"。准确的说法是:
HotSpot 没有真正的栈上分配。它做的是标量替换(scalar replacement):如果对象不逃逸,且字段都能拆成局部变量,就不分配对象了,直接用寄存器/栈上的标量代替。
public int calc() {
Point p = new Point(1, 2); // 不逃逸
return p.x + p.y;
}
优化后等价于:
public int calc() {
int x = 1;
int y = 2;
return x + y;
}
对象根本没被分配,连 GC 压力都没有。这比"分配在栈上"更彻底。
4.3 逃逸分析失效的常见写法
// ❌ 返回了对象 → GlobalEscape
public Point make() { return new Point(1, 2); }
// ❌ 存进字段 → GlobalEscape
private Point p;
public void init() { this.p = new Point(1, 2); }
// ❌ 传给未知方法 → ArgEscape,且被调方可能存起来
public void run() { register(new Point(1, 2)); }
// ❌ lambda 捕获 → 生成的对象可能逃逸
list.forEach(i -> cache.put(i, new Point(i, i)));
循环里创建临时对象是最典型的可优化场景:
// ✅ 每次迭代的对象都不逃逸,可以被完全消除
long sum = 0;
for (int i = 0; i < 1_000_000; i++) {
var box = new IntBox(i);
sum += box.value;
}
在 JMH 里测这段代码,优化前后能差 5~10 倍,而且 GC 次数从几百次降到 0。
4.4 锁消除
同理,如果锁对象不逃逸,synchronized 会被直接删掉:
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 局部变量,不逃逸
sb.append(a).append(b); // 内部 synchronized 被消除
return sb.toString();
}
这也解释了"StringBuffer 一定比 StringBuilder 慢吗"——单线程局部使用时,JIT 把锁消掉了,两者几乎一样快。 但别因此就用 StringBuffer,StringBuilder 语义更清晰,也不依赖 JIT。
五、去优化:为什么"跑着跑着又慢了"
JIT 的优化是基于假设的,假设一旦被打破,就要撤销已编译的代码,退回解释执行——这叫 deoptimization。
| 触发原因 | 场景 |
|---|---|
| 类型假设失效 | 新子类被加载,CHA 的内联不再成立 |
| 分支假设失效 | profile 里"这个 if 从没走过",某天走了 → uncommon trap |
| 代码被替换 | 新版本编译完成,旧的标记 made not entrant |
-XX:+PrintCompilation 里的标记:
12345 87 4 com.example.Foo::bar (156 bytes)
12400 87 4 com.example.Foo::bar (156 bytes) made not entrant
made not entrant 就是旧版本被废弃。如果它在日志里高频出现,说明代码在"编译—去优化—再编译"之间反复横跳,性能会很差。
常见成因:热点方法里有一个极少走到的分支,一旦走到就 uncommon trap → 整个方法退回解释 → 重新攒热度 → 再编译。解决办法是把冷分支抽成单独方法,让热点方法保持"路径单一"。
六、JIT 友好的代码长什么样
| 写法 | 原因 |
|---|---|
| 方法体控制在小体积(尤其 getter/工具方法) | 超过 325 字节字节码就进不了内联 |
| 接口实现尽量少(同一条调用路径上 ≤ 2 个) | 超过 2 个变 megamorphic,去虚化失效 |
热点路径不要有大 try-catch 包裹 |
异常表会影响内联与优化 |
| 循环内不创建会逃逸的对象 | 否则标量替换失败,GC 压力上来 |
final / private 方法 |
静态绑定,内联更确定(现代 JIT 已不太依赖,但仍有益) |
| 避免超长 lambda(> 325 字节) | lambda 编成合成方法,同样受内联阈值限制 |
别在热路径用反射 Method.invoke |
早期版本无法内联;JDK 18+ 有方法句柄优化,但仍不如直调 |
一个反面案例——用 lambda 写了个巨型业务逻辑:
// ❌ 这个 lambda 编译出的合成方法轻松超过 325 字节,内联失败
list.stream().map(x -> {
// 30 行校验 + 转换 + 日志
return result;
}).collect(toList());
抽成独立的小方法后,stream 的整条链路反而能被内联成一整块,性能明显改善。这也是"Stream 慢"的常见真实原因——不是 Stream 本身慢,是 lambda 太大导致内联失败。
七、动手观察一次
# 看编译事件与去优化
java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp
# 只看 C2 编译
java -XX:+PrintCompilation -XX:-TieredCompilation
# 编译线程数(默认约 CPU 核数的 1/4)
java -XX:CICompilerCount=4 MyApp
# 关闭分层编译(只用于对比实验,别上生产)
java -XX:-TieredCompilation
更直观的方式是 JITWatch:把 -XX:+LogCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 产出的 hotspot_pid*.log 丢进去,可以直接看到内联树、哪些方法被去虚化、哪些对象被标量替换。
八、小结
JIT 的优化是一条严格的链条:
先内联(把代码摊平)
↓
才能去虚化(把调用变成直调)
↓
才能做逃逸分析(看到对象的完整生命周期)
↓
才能标量替换 / 锁消除(把对象和锁彻底删掉)
链条上任何一环断掉,后面的优化全部归零。而断链的最常见原因是:方法太大、实现类太多、对象逃逸了。
所以"Java 性能优化"在代码层面其实只有三条:方法写小、实现收敛、别让临时对象跑出去。
系列内链
- 前置:03-JVM/05-类加载机制——CHA 依赖类加载时机
- 关联:03-JVM/08-JVM调优参数与实战——
CICompilerCount与编译线程争抢 - 关联:03-JVM/03-垃圾回收算法与分代收集——逃逸分析成功时,GC 压力直接消失
- 关联:02-集合与并发/07-Stream API实战——流式写法与内联的关系
- 点赞
- 收藏
- 关注作者
评论(0)