JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线

举报
yd_254019812 发表于 2026/11/02 23:13:48 2026/11/02
【摘要】 title: “JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线”date: 2026-11-01tags: [JVM, Java, 性能优化]categories: [03-JVM] JIT 到底优化了什么:内联、去虚化与逃逸分析的三层流水线同一个方法,第一次调用要 3 ms,跑够一万次之后只要 0.3 ms。这不是玄学,是 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 做激进优化(去虚化、内联、逃逸分析)

这个设计解释了两件事:

  1. 短命进程(命令行工具、定时任务)永远是解释执行的慢速版——它活不到 JIT 生效。
  2. 压测前几分钟的数据没有意义,必须预热。压测脚本要跑够"至少 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实战——流式写法与内联的关系
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。