Java 的逃逸分析?
解释 Java 逃逸分析如何判断对象不逃逸、方法逃逸或线程逃逸,并说明标量替换、分配消除、锁消除等优化,以及通过 JVM 参数和示例验证其默认开启与开关效果。
学完这篇你能搞清楚 Java 逃逸分析到底在分析什么、它换来了哪些实际优化,以及怎样用 JVM 参数亲手验证——不用背结论,自己跑一遍就知道。
第一步:先弄明白「逃逸」指的是什么
逃逸分析(Escape Analysis)是 JIT 编译器在把字节码编译成本地机器码时做的一项分析:判断一个 new 出来的对象,会不会「跑出」它被创建的方法或线程。判断结果有三种状态:
- 不逃逸:对象只在方法内部使用,方法返回后就没人能引用它。
- 方法逃逸:对象被作为返回值返回、赋给静态字段/实例字段、存进数组或集合、传给其他方法。
- 线程逃逸:对象被别的线程拿到,比如赋给静态变量、放进共享队列。
只有「不逃逸」的对象,后面那些优化才有机会生效。
第二步:看懂它能带来哪四项优化
逃逸分析的产出不是「把对象放栈上」这一句话,而是四个具体动作:
- 标量替换:把对象的字段拆成几个局部变量(标量),直接放进栈帧或寄存器,对象本身根本不创建。这才是大家口中「栈上分配」的真实实现方式。
- 分配消除:连字段都不需要保留时(比如创建后只读一个字段),整条
new指令被删掉。 - 锁消除:如果
synchronized锁住的对象不逃逸,别的线程根本拿不到它,monitor 的加锁解锁指令直接删掉。 - 同步消除的连带的读消除:锁没了,锁保护的字段读取也可能被优化掉。
第三步:判断你的代码会不会逃逸
写循环时最容易踩的坑,就是明明想「临时对象」,却无意间让它逃逸了。下面几种写法都会让对象逃逸:
static List<Point> cache = new ArrayList<>();
Point p = new Point(1, 2);
cache.add(p); // 逃逸:进入共享集合
this.point = new Point(1, 2); // 逃逸:赋给实例字段
return new Point(1, 2); // 逃逸:作为返回值
arr[0] = new Point(1, 2); // 逃逸:写入数组元素
不逃逸的典型形态是:对象在方法内创建、只调用它的方法或读取字段、不出方法边界。
注意:赋给
static字段、实例字段,写成数组元素,或者被 lambda/匿名内部类捕获,都会被判定为逃逸,哪怕你觉得「实际上只有一个线程在用」。
第四步:动手验证,用参数开关对比
先确认参数状态。JDK 8 及以上版本里,逃逸分析默认是开的,用下面这条命令查(Linux/macOS 用 grep,Windows 用 findstr):
java -XX:+PrintFlagsFinal -version | grep -E "DoEscapeAnalysis|EliminateAllocations|EliminateLocks"
三个值应该都是 true。相关参数是:
-XX:+DoEscapeAnalysis:总开关,JDK 6u23 之后默认开启-XX:+EliminateAllocations:标量替换-XX:+EliminateLocks:锁消除
再写个最小验证程序:
public class EscapeDemo {
static class Point { int x, y; Point(int x, int y){ this.x = x; this.y = y; } }
static int sum() {
int s = 0;
for (int i = 0; i < 1_000_000; i++) {
Point p = new Point(i, i); // 不逃逸
s += p.x + p.y;
}
return s;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) System.out.println(sum());
}
}
用 -Xmx64m 跑,加 -Xlog:gc(JDK 9+)或 -XX:+PrintGCDetails(JDK 8)看 GC 次数;再追加 -XX:-DoEscapeAnalysis 跑一次对比。开启时如果没有发生 GC,说明这一百万个 Point 压根没被分配出来。
想看更底层的信息,可以加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 观察方法内联情况。注意 -XX:+PrintEscapeAnalysis 这个参数只在 fastdebug 版本的 JDK 上可用,正式版传进去会报 Unrecognized VM option,这属于正常现象,不是你的问题。
注意:
sum()要被执行足够多次(默认-XX:CompileThreshold=10000)才会触发 C2 编译,优化才生效。只跑一两次的代码,逃逸分析基本帮不上忙。另外,逃逸分析只在 C2(Server 编译器)里实现,用-Xint纯解释执行时完全不生效。
第五步:记住几条边界,别为它改代码风格
- 对象一旦逃逸,四项优化全部失效,所以优化效果在「对象小、生命周期短、循环内创建」的场景最明显。
- 方法内联会改变逃逸范围:内联后创建点和使用点出现在同一个编译单元里,原本逃逸的写法也可能被优化。
- 大对象、字段很多的对象,标量替换要付出拆解成本,不一定划算,JIT 会自己权衡。
- 别为了迎合逃逸分析把代码写成反人类的形态,可读性优先;真实瓶颈请用 JMH 或 async-profiler 测,别靠猜。
小结:
- 逃逸分析判断对象是否逃出方法/线程,分不逃逸、方法逃逸、线程逃逸三种。
- 它带来的实际优化是标量替换、分配消除、锁消除,「栈上分配」只是这些优化的俗称。
- JDK 8 起默认开启,用
-XX:+PrintFlagsFinal确认DoEscapeAnalysis为 true。 - 验证靠循环 + 小堆 + GC 日志对比,而且必须跑到 JIT 编译阈值以上。
- 对象赋给字段、数组、返回值、被内部类捕获都会逃逸,此时优化全部失效。
- 别为优化牺牲可读性,先用工具定位真实热点再说。
转载请注明出处,版权归原作者所有。