JVM 逃逸分析:你以为 new 的对象都住堆里?C2 可能根本没让它出生
new 出来的对象一定分配在堆上吗?HotSpot 用逃逸分析说不一定——只要对象没逃出当前方法,C2 编译器会在编译期把它拆成几个字段、连堆都不上。这篇用三个对照实验证明这件事,再讲清标量替换、锁消除,以及什么会挡住逃逸分析、让你白白多付一次 GC。
复现:同一个循环,三种命运
上一单元讲了对象在堆里怎么住(对象头、TLAB、晋升),那篇有一个默认前提:new 出来的对象最后都进了堆,区别只在住哪块。这个前提只对了一半。下面这份代码同一个循环写了两个版本:sumNon 里 new 一个 Pt、读它两个字段求和就返回,对象从来没离开这个方法;sumEsc 多一行,把 new 出来的 Pt 塞进一个静态数组——对象被方法外的代码「看见」了。代码在文末,本机 JDK 26.0.1、堆固定 -Xms64m -Xmx64m -Xmn16m(年轻代故意给得很小,好让分配压力无处可藏),三种跑法各跑三遍:
A 不逃逸 + EA 开(默认) Young GC 0 / 0 / 0 次 墙钟 21 / 23 / 20 ms
B 不逃逸 + EA 关(-XX:-DoEscapeAnalysis) Young GC 72 / 72 / 72 次 墙钟 167 / 167 / 169 ms
C 逃逸 + EA 开(默认) Young GC 76 / 76 / 76 次 墙钟 266 / 281 / 264 ms
三个 5000 万次迭代的循环,代码几乎一样,命运截然不同:
- A 和 B 跑的是同一份
sumNon源码,唯一差别是关没关逃逸分析。 A 一次 Young GC 都没有,说明 5000 万次 new 的Pt一个都没真正落进堆——真有 5000 万个 16 字节对象进堆,16MB 的年轻代早被撑爆几十轮了(对照组 B 的 72 次就是证据)。对象被编译器消掉了。 - C 证明了逃逸分析的边界:同样是 EA 开,但对象一被静态数组收走(逃逸了),EA 就无能为力,76 次 Young GC 比 B 还多——因为逃逸不仅让分配照旧,ring 数组的读写还添了额外内存压力。
- 墙钟 A 比 B 快约 8 倍,还不止是省了分配——省掉的是分配、对象头初始化、写屏障,以及它们引发的整轮 GC。
为什么能消掉:new 不一定是「真的 new」
Java 是先编译成字节码、跑起来再由 JIT 编译成本地码。C2(服务端编译器,跑在分层编译的最顶层)对热点方法做编译时,会顺手做一遍逃逸分析(escape analysis):分析这个方法里 new 出来的对象,它的引用有没有「逃」出这个方法——被 return 还给调用方、被当参数传给会把它存起来的方法、被塞进 static 字段/别的对象的字段/数组、被抛进异常、或者被别的线程拿到。只要有一个出口,就算逃逸。
对象没逃逸,C2 就有资格动它,具体是三件事:
- 标量替换(scalar replacement)——HotSpot 的逃逸分析最常落的刀子。既然没人能在方法外看到这个对象,那对象本身就没有存在的必要,C2 直接把
Pt拆成两个独立的 int 局部变量a、b,放进寄存器或栈槽里用,就像你手写了两个 int 一样。对象头不要了、内存分配不要了、之后的回收更不要了——所以 A 里 5000 万个「对象」其实是 5000 万次纯算术,连垃圾都不产生,0 次 Young GC 就是这么来的。注意说法:概念里常把「栈上分配」「标量替换」「锁消除」并列为 EA 的三件武器,但 HotSpot 的主流实现是标量替换——对象整个不建,不是「建在栈上」。很多老文章说的「栈上分配」,在 HotSpot 里绝大多数场景其实是标量替换在起作用,面试别把两者混成一个保证。 - 同步消除(lock elimination)——如果被
synchronized的锁对象也没逃逸(典型是方法里的局部StringBuffer,方法内自己append自己用,没人能拿到这个 buffer 的引用去竞争),C2 判断这把锁根本不可能有第二个线程碰到,就把加解锁去掉了。-XX:+EliminateLocks默认开。注意这和 JDK 15 移除偏向锁是两回事:偏向锁是「把无竞争的锁变便宜」,同步消除是「直接把锁删了」,后者更彻底。(本机没单独测锁消除的收益,这条讲的是机制,不是实验结论。) - 消除读的副作用——对象没逃逸时,对这个对象做的操作只要不影响可观察结果,都可以重新安排。这一条通常和前两条配合,不单独拿出来说。
这一切的前提是 C2 真的看到了对象完整的一生:sumNon 能被内联进循环,C2 才能看清 Pt 只在循环里用、从构造函数出来就没人再引用。这也是为什么逃逸分析是「编译期优化」而不是「运行时魔法」——它依赖 JIT 把相关方法摊开看。
什么会挡住它:对象一「露头」就得真分配
逃逸分析是保守的:只要有一条路可能逃逸,就当它逃逸,老老实实分配。常见挡路牌,面试顺着念就行:
- return 给调用方、或传进一个会把引用存下来的方法(存成员、存 static、存别的对象)——C 实验里
RING[idx] = p就是这个,存进静态数组后 EA 立刻失效。 - 装进数组再返回/存走。
- 抛进异常:
throw new MyException()的对象会被上层 catch 到,算逃逸。 - 身份被用:
synchronized(p)要用对象身份当锁、System.identityHashCode(p)或没被覆写的默认hashCode要看对象地址——这些「身份相关」操作一旦出现,对象就不能被标量替换(拆成字段后身份没了)。 - 跨线程:对象被丢给另一个线程(new Thread 里用、提交给线程池),逃逸分析管不到别的线程,直接放弃。
- 反射访问。
判断方法本身还有前提:只有被 C2 编译且方法被成功内联的代码才谈得上 EA;方法太大、太复杂没被内联,或者 C1 阶段、解释执行阶段,都按「对象会逃逸」的保守策略处理——这也是为什么压测必须先预热再计时:跑不够热,C2 还没接管,你测的是解释执行/ C1 的分配量,不是生产形态的分配量。
对象池悖论:别为了「省分配」去手动复用对象
逃逸分析带来的一个反直觉实践建议:短命的小对象,直接 new 通常比手动复用便宜。 理由就在上面的实验里——只要对象不逃逸,new 的成本接近零(A 的 20ms 里几乎全是算术本身),而对象池要维护并发安全、要处理「借了忘还」「池子被清空后泄漏」一类问题,这些成本远高于一次被消除的分配。现实中真正值得做对象池的,是对象必然逃逸且分配确实成为瓶颈的地方(典型是网络缓冲区、大对象),而且要先用压测证实瓶颈在分配,别凭感觉上池子。
这也能解释一个 3-2《GC 调参入门》里留的伏笔:那篇的压测探针 GcProbe 特意用一个静态 INFLIGHT 列表把对象收住,注释里写着「否则 JIT 的逃逸分析会把分配优化掉,测不到 GC」——就是这个道理。反过来,线上用 GC/分配量看压力时要想一层:profiling 工具报的 allocation 里有相当一部分被 EA 消掉了,真正让 GC 忙碌的是那些逃逸出去、活下来的对象。想直接数「被消除了几个分配」也行,但 -XX:+PrintEliminateAllocations 是 develop 级别的 flag,release 版 JVM 用不了——所以生产上验证 EA 最实用的办法就是本文这招:关掉它跑一遍对照组,用 GC 次数的落差反推。
面试速答
逃逸分析是 C2 编译热点方法时做的一次分析:看方法内 new 的对象引用有没有逃出方法(return、存入外部容器/字段、抛异常、跨线程、身份被用都算逃逸)。没逃逸就能优化,主力是标量替换——把对象拆成独立字段当局部变量用,对象不真建、不上堆、不产生垃圾,所以无逃逸的热循环里 new 几乎免费;其次是锁消除,对不可能被竞争的局部锁去掉加解锁。对象一旦露头(比如存进静态数组或作为返回值)分析立即失效,照常堆分配。实践含义:短命小对象别手搓对象池,直接 new;验证 EA 用「关掉对照跑一轮,看 GC 次数落差」最直观。面试被问「new 一定在堆上吗」,答案是语义上按在堆上设计、实际可能被编译器消掉不上堆——这正是 JIT 优化「观察不到、但快」的典型。
本文关键词:JVM、逃逸分析、标量替换、JIT、性能优化
核心收获一句话:new 的对象不一定住堆,C2 对不逃逸对象做标量替换把它整个消掉,0 次 GC 就是证据;对象一逃逸 EA 就失灵,所以优化思路是「别让短命对象逃逸」,而不是「别 new」。下一步可以在自己项目里挑一个高频创建对象的热路径,用本文的对照组方法测一测它到底逃逸没有,再决定要不要动手优化。
附:实验代码
import java.io.*;
import java.nio.charset.StandardCharsets;
/** 逃逸分析三连:对象到底上没上堆。JDK 26。
* 用法:java EscapeLab <iterations> <mode> <outfile>
* mode=0 sumNon:对象不逃逸,字段求和即返回
* mode=1 sumEsc:对象存进静态数组(逃逸),EA 救不了
*/
public class EscapeLab {
static long sink;
static final Pt[] RING = new Pt[4096];
static int idx;
static final class Pt { final int a, b; Pt(int a, int b) { this.a = a; this.b = b; } }
static int sumNon(int i) { Pt p = new Pt(i, i + 1); return p.a + p.b; } // 不逃逸
static int sumEsc(int i) { Pt p = new Pt(i, i + 1); RING[idx++ & 4095] = p; return p.a + p.b; } // 逃逸
public static void main(String[] x) throws Exception {
int n = Integer.parseInt(x[0]);
int mode = Integer.parseInt(x[1]);
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) {
sink += mode == 0 ? sumNon(i) : sumEsc(i);
}
long ms = (System.nanoTime() - t0) / 1_000_000;
long chk = sink; // 防止整个循环被判定无副作用
try (PrintStream out = new PrintStream(new FileOutputStream(x[2]), true, StandardCharsets.UTF_8)) {
out.println("mode=" + mode + " iterations=" + n + " wall=" + ms + "ms");
}
}
}
对照跑法,三档分开跑、各自留 GC 日志:
javac -encoding UTF-8 EscapeLab.java
java -Xms64m -Xmx64m -Xmn16m -Xlog:gc:file=gc-A.log EscapeLab 50000000 0 outA.txt # 不逃逸 + EA 开
java -Xms64m -Xmx64m -Xmn16m -XX:-DoEscapeAnalysis -Xlog:gc:file=gc-B.log EscapeLab 50000000 0 outB.txt # 不逃逸 + EA 关
java -Xms64m -Xmx64m -Xmn16m -Xlog:gc:file=gc-C.log EscapeLab 50000000 1 outC.txt # 逃逸 + EA 开
对比三份日志里 Pause Young 的行数(grep -c 'Pause Young' gc-X.log)就是正文表格的三组数字。想让对照更有说服力,把 50000000 换成更大数时,B、C 的 GC 次数会同比上涨,A 依然接近 0——差异是量级的,不是巧合。