这是「一文拿下 JVM」系列的第三篇,重点讲 JIT 阶段最核心的优化——逃逸分析。当年(2017)Java 8 默认开启,HotSpot 通过它能把部分对象直接消解在栈帧上,连 GC 都不需要。
一、什么是逃逸分析
逃逸分析(Escape Analysis)并不是直接的优化手段,而是一个代码分析。 它通过动态分析对象的作用域,为其他优化手段提供依据。
如果不存在逃逸行为,可对该对象进行:
- 栈上分配
- 标量替换
- 同步消除
发生逃逸有两种情况:
| 类型 | 说明 | 后果 |
|---|---|---|
| 方法逃逸 | 一个对象在方法内创建,被方法体之外引用(方法返回该对象 / 创建的对象赋值给成员变量) | 方法执行完毕后,该对象无法被 GC 回收(被其他变量引用) |
| 线程逃逸 | 如类变量或实例变量,可能被其他线程访问到 | 跨线程共享,性能下降 |

二、相关参数
| 参数 | 作用 |
|---|---|
-XX:+DoEscapeAnalysis | 开启逃逸分析 |
-XX:+PrintEscapeAnalysis | 开启逃逸分析后,可通过此参数查看分析结果 |
-XX:+EliminateAllocations | 开启标量替换 |
-XX:+PrintEliminateAllocations | 开启标量替换后,查看标量替换情况 |
-XX:+EliminateLocks | 开启同步消除 |
三、四大优化手段
3.1 同步消除
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
逃逸分析可以判断出某个对象是否始终只被一个线程访问。如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作——这样就能大大提高并发程度和性能。
// 锁对象的锁消除示例
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// 业务逻辑
}
}
上面的
lock对象永远不会逃逸出doSomething()方法(没被返回、没赋给成员变量),因此 JIT 会把synchronized完全消除。
3.2 标量替换
Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。
相对的,如果一个数据可以继续分解,那它称为聚合量。Java 中最典型的聚合量是对象。
如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分配在栈帧上。
class Point {
private int x;
private int y;
}
public void calc() {
Point p = new Point();
p.x = 1;
p.y = 2;
System.out.println(p.x + p.y);
}
// 经标量替换后,等价于:
public void calc() {
int x = 1;
int y = 2;
System.out.println(x + y);
}
标量替换后,对象根本没有被创建——连「对象」这个概念都不存在了。GC 没有任何工作要做。
3.3 栈上分配
严格说,HotSpot 并没有实现真正意义的栈上分配,实际上是标量替换。
把对象打散成标量后,直接分配在栈帧的局部变量表里。方法结束、栈帧出栈,标量自动消亡——零 GC 压力。
3.4 缺点
栈上分配受限于栈的空间大小。一般自我迭代类的需求以及大的对象空间需求操作,将导致栈的内存溢出——故只适用于一定范围之内的内存范围请求。
四、实测对比
摘自当年笔记。JDK 1.8 默认开启了逃逸分析。
4.1 标量替换测试

通过上例:
- 当开启逃逸分析时,使用
jmap -histo <pid>查看堆中User对象数,少于 10 个 - 而关闭逃逸分析时,堆中
User对象为 10 万个
注意:JDK 1.8 默认打开分层编译(Tiered Compilation),使用参数
-XX:-TieredCompilation关闭分层编译后,堆中User数量进一步减少。
性能差异:
- 开启逃逸分析:执行约 5 ms
- 关闭逃逸分析:执行约 4000 ms,且 GC 会频繁工作
800 倍的性能差距——这就是逃逸分析的力量。
4.2 同步消除测试
- JDK 1.8 默认开启同步消除
- 开启同步与关闭同步分别耗时 1865 ms / 2267 ms
同步消除的收益相对小(约 17%),因为大部分代码本来就很少锁。但如果用了
Vector/Hashtable这类历史包袱类,收益会非常明显。
五、:= 与 = 的区别
当年笔记里一个不起眼的小点,但很多新手栽过。
=为初始值:=为修改后的值
典型场景:JVM 工具(如
jhat/VisualVM)展示对象字段时,=是构造时的初始值,:=是当前实际值。两者不一致说明对象字段被修改过。
六、实战建议
6.1 不要刻意为逃逸分析写代码
逃逸分析是 JIT 自动做的优化,绝大多数情况下你不需要关心它。如果你发现某段代码性能差,先从算法和数据结构层面优化,再考虑 JVM 层。
6.2 但要避免「逃逸陷阱」
// 反例 1:方法返回内部对象
public Object getFoo() {
Foo foo = new Foo(); // foo 逃逸到方法外
foo.init();
return foo;
}
// 反例 2:赋值给成员变量
private List<Cache> caches = new ArrayList<>();
public void addCache() {
Cache c = new Cache(); // c 逃逸到成员变量
caches.add(c);
}
// 反例 3:传入到其他线程
new Thread(() -> {
use(obj); // obj 逃逸到另一个线程
}).start();
这三种写法都让对象无法享受逃逸分析的红利——但它们都是业务上必须的。所以逃逸分析是一种自动优化而不是强约束。
6.3 别相信「栈上分配一定快」
栈上分配是手段,目的是减轻 GC 压力。如果你的应用 GC 压力本来就不大(Young GC 几十毫秒、Full GC 几乎不发生),开不开逃逸分析体验差异不大。
当年(2017)有人用「关闭逃逸分析性能反而变好」的例子——这是因为逃逸分析本身也要消耗 CPU 去做分析,对短生命周期应用是负优化。
七、小结
| 优化手段 | 触发条件 | 收益 | 限制 |
|---|---|---|---|
| 同步消除 | 锁对象不逃逸 | 消除锁开销 | 仅针对单线程访问的锁 |
| 标量替换 | 对象不逃逸 + 可分解 | 对象不进入堆 | 对象不能跨方法使用 |
| 栈上分配 | 同标量替换 | 同标量替换 | 同标量替换 |
| 方法内联 | 调用足够频繁 | 减少方法调用开销 | 大方法无法内联 |
下一篇「一文拿下 JVM(番外):OOM 排查与 MAT / JVisualVM 实战」讲所有 JVM 问题中最让开发者头疼的那一类——线上 OOM 怎么救、dump 文件怎么读、MAT 怎么看。