JVM 逃逸分析:你以为 new 的对象都住堆里?C2 可能根本没让它出生
new 出来的对象一定分配在堆上吗?HotSpot 用逃逸分析(escape analysis,下文简称 EA)说不一定——只要对象没逃出当前方法,C2 编译器(服务端编译器,第二节细说 C1/C2)会在编译期把它拆成几个字段、连堆都不上。这篇用四组可独立运行的对照实验证明这件事,再讲清标量替换、锁消除,以及什么会挡住 EA、让你白白多付一次 GC。
复现:同一个循环,三种命运
上一单元讲了对象在堆里怎么住(对象头、TLAB、晋升),那篇有一个默认前提:new 出来的对象最后都进了堆,区别只在住哪块。这个前提只对了一半,反例就是下面这份代码——两个方法只差一行:
import java.lang.management.*;
/** 逃逸分析三连:对象到底上没上堆。JDK 26。
* 用法:java EscapeLab <iterations> <mode>
* 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; } // 逃逸
/** 直接问 JVM 要 GC 次数,省得去数 GC 日志 */
static long gcCount() {
long n = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) n += b.getCollectionCount();
return n;
}
public static void main(String[] x) {
int n = Integer.parseInt(x[0]);
int mode = Integer.parseInt(x[1]);
long gc0 = gcCount();
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;
System.out.println("mode=" + mode + " iterations=" + n + " wall=" + ms + "ms"
+ " gc=" + (gcCount() - gc0) + " sink=" + sink);
}
}
sumNon 里 new 一个 Pt、读它两个字段求和就返回,对象从来没离开这个方法;sumEsc 多一行,把 new 出来的 Pt 塞进一个静态数组——对象被方法外的代码「看见」了。
两个细节值得先交代。一是 gcCount():GC 次数直接从 JVM 的 GarbageCollectorMXBean 读,不用开 -Xlog、不用去数日志,程序跑完自己报数——后面四个实验都靠它出数,表里的数字你自己跑一遍就能复现。二是末尾那句 System.out.println 把 sink 一起打出来:数组/静态字段这类累加结果必须被用掉,否则整个循环会被判定为无副作用的死代码直接删掉,测出来的「快」就假了。
堆固定 -Xms64m -Xmx64m -Xmn16m(年轻代故意给得很小,好让分配压力无处可藏)。三种跑法各跑三遍——为了让你看见「哪一列稳、哪一列会飘」,下面每条命令原样重复写了三次(不用 shell 的 for 循环,Windows 的 PowerShell 里直接整段粘贴就能跑):
javac -encoding UTF-8 EscapeLab.java
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 0 # A 不逃逸 + EA 开(默认)第 1 遍
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 0 # A 第 2 遍
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 0 # A 第 3 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-DoEscapeAnalysis EscapeLab 50000000 0 # B 不逃逸 + EA 关 第 1 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-DoEscapeAnalysis EscapeLab 50000000 0 # B 第 2 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-DoEscapeAnalysis EscapeLab 50000000 0 # B 第 3 遍
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 1 # C 逃逸 + EA 开 第 1 遍
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 1 # C 第 2 遍
java -Xms64m -Xmx64m -Xmn16m EscapeLab 50000000 1 # C 第 3 遍
mode=0 iterations=50000000 wall=25ms gc=0 sink=2500000000000000
mode=0 iterations=50000000 wall=20ms gc=0 sink=2500000000000000
mode=0 iterations=50000000 wall=20ms gc=0 sink=2500000000000000
mode=0 iterations=50000000 wall=211ms gc=72 sink=2500000000000000
mode=0 iterations=50000000 wall=206ms gc=72 sink=2500000000000000
mode=0 iterations=50000000 wall=213ms gc=72 sink=2500000000000000
mode=1 iterations=50000000 wall=323ms gc=76 sink=2500000000000000
mode=1 iterations=50000000 wall=349ms gc=76 sink=2500000000000000
mode=1 iterations=50000000 wall=318ms gc=76 sink=2500000000000000
九行归拢成一张表,就是三种命运:
A 不逃逸 + EA 开(默认) Young GC 0 / 0 / 0 次 墙钟 25 / 20 / 20 ms
B 不逃逸 + EA 关(-XX:-DoEscapeAnalysis) Young GC 72 / 72 / 72 次 墙钟 211 / 206 / 213 ms
C 逃逸 + EA 开(默认) Young GC 76 / 76 / 76 次 墙钟 323 / 349 / 318 ms
墙钟每次会有几十毫秒的浮动(机器负载、JIT 编译时机、GC 抖动,你自己重跑的数字不会和上面完全一样),真正稳定复现的是 GC 次数:A 恒为 0、B 恒为 72、C 恒为 76,反复跑都不变。所以下面所有结论只认 GC 次数这一列,墙钟仅用来对照量级。
(这里出现的 JIT 指 just-in-time compilation,即时编译——把跑热了的字节码编译成本地机器码,就是下一节 C1/C2 要讲的事;它和 GC 一样都会给墙钟引入抖动,这也是只看 GC 次数那一列的原因。)
三个 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 快约 10 倍(21ms 对 211ms),还不止是省了分配——省掉的是分配、对象头初始化、写屏障,以及它们引发的整轮 GC。
为什么能消掉:new 不一定是「真的 new」
Java 是先编译成字节码、跑起来再由 JIT 编译成本地码。干活的是 HotSpot 里的两个即时编译器(都是 C++ 写的,「C1」「C2」只是历史名字,跟 C 语言没关系):
- C1,客户端编译器:编译快、优化保守,负责让代码「尽快跑起来」;
- C2,服务端编译器:编译慢、优化激进,负责让最热的那部分代码「跑得最快」。
早年要拿 -client / -server 二选一,JDK 7 起改成分层编译(tiered compilation,JDK 8 起默认开启),两者变成接力:代码先解释执行、只收集运行信息(profile:调用次数、循环次数、类型分布),攒够之后交给 C1 快速编一版跑着,同时继续收集 profile,最热的那批最终交给 C2 做深度优化。伪代码式的分层是:
第 0 层 解释执行,只收集 profile
第 3 层 C1 快速编译一版跑着,同时收集完整 profile
第 4 层 攒够 profile,交给 C2 做深度优化
本文讲的三件武器全是 C2 的——逃逸分析、标量替换、锁消除都只在第 4 层才有;在解释执行(第 0 层)和 C1 编译的代码(第 1-3 层)里,new 就是老老实实分配。记住这条,末尾「压测必须先预热再计时」就顺理成章了。
对热点方法做编译时,C2 会顺手做一遍逃逸分析(escape analysis):分析这个方法里 new 出来的对象,它的引用有没有「逃」出这个方法——被 return 还给调用方、被当参数传给会把它存起来的方法、被塞进 static 字段/别的对象的字段/数组、被抛进异常、或者被别的线程拿到。只要有一个出口,就算逃逸。
对象没逃逸,C2 就有资格动它,具体是三件事:
1. 标量替换:对象整个不建
标量替换(scalar replacement)——HotSpot 的逃逸分析最常落的刀子。既然没人能在方法外看到这个对象,那对象本身就没有存在的必要。验证方式很直接:同一件事,一处 new 一个 Pt 来算,一处手写成两个 int 来算,看两者是不是一样快、一样不产生垃圾:
import java.lang.management.*;
/** 标量替换验证:new 一个 Pt 求和 vs 手写两个 int 求和。JDK 26。
* 用法:java ScalarLab <iterations> <mode> mode=0 Pt 版本,mode=1 int 版本 */
public class ScalarLab {
static long sink;
static final class Pt { final int a, b; Pt(int a, int b) { this.a = a; this.b = b; } }
static int sumPt(int i) { Pt p = new Pt(i, i + 1); return p.a + p.b; } // new 出来的对象
static int sumInt(int i) { int a = i, b = i + 1; return a + b; } // 手写成两个 int
static long gcCount() {
long n = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) n += b.getCollectionCount();
return n;
}
public static void main(String[] x) {
int n = Integer.parseInt(x[0]), mode = Integer.parseInt(x[1]);
long gc0 = gcCount();
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) sink += mode == 0 ? sumPt(i) : sumInt(i);
System.out.println("mode=" + (mode == 0 ? "Pt" : "int") + " wall=" + (System.nanoTime() - t0) / 1_000_000
+ "ms gc=" + (gcCount() - gc0) + " sink=" + sink);
}
}
javac -encoding UTF-8 ScalarLab.java
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 0 # Pt 版本 第 1 遍
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 0 # Pt 第 2 遍
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 0 # Pt 第 3 遍
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 1 # 手写 int 版本 第 1 遍
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 1 # 手写 int 第 2 遍
java -Xms64m -Xmx64m -Xmn16m ScalarLab 50000000 1 # 手写 int 第 3 遍
mode=Pt wall=22ms gc=0 sink=2500000000000000
mode=Pt wall=22ms gc=0 sink=2500000000000000
mode=Pt wall=22ms gc=0 sink=2500000000000000
mode=int wall=19ms gc=0 sink=2500000000000000
mode=int wall=20ms gc=0 sink=2500000000000000
mode=int wall=20ms gc=0 sink=2500000000000000
两边墙钟在同一水平线上、GC 都是 0——new 出来的 Pt 和手写的两个 int 完全等价。这就是标量替换:C2 把 Pt 拆成 a、b 两个独立的局部变量,放进寄存器或栈槽里用,就像你手写了两个 int 一样。对象头不要了、内存分配不要了、之后的回收更不要了——所以正文 A 里 5000 万个「对象」其实是 5000 万次纯算术,连垃圾都不产生,0 次 Young GC 就是这么来的。
C2 具体是怎么下的手?它看 sumPt 时的推理只有三步:
p由这一句new Pt(i, i + 1)产生,构造器把a = i、b = i + 1;- 之后只读过
p.a和p.b两个字段; p这个引用没被任何地方拿走——没进数组/字段、没返回、没当锁用。
既然它从头到尾只被用来装那两个 int,「对象」这层壳就没有存在的必要,于是被重写成:
// C2 眼里的 sumPt(等价重写,不是源码)
static int sumPt(int i) {
int a = i, b = i + 1; // 原来是 p.a、p.b 两个字段
return a + b; // 还能进一步折成 2*i + 1
}
接下来是最容易卡住的一个问题:拆出来的字段「存哪儿」? 答案是——不存在「存」这个动作。a、b 变成了两个普通的 int 局部变量,和你在方法里手写的局部变量待遇完全一样:优先待在 CPU 寄存器里,寄存器不够就溢出到当前栈帧的栈槽。不是一个对象整体搬到了栈上,而是字段散成了几个局部变量,各去各的寄存器/栈槽。
所以「不逃逸 → 不产生垃圾」的完整链条是四环,缺一环都不成立:
不逃逸(前提) → 允许标量替换 → 没有 new → 没有分配 → 没有垃圾
垃圾是「分配」的产物,不是「用对象」的产物。 GC 只负责回收已经分配过的内存;让「分配」这一步整个消失,垃圾自然无从产生。A 组 0 次和 C 组 76 次的全部差别就在这里——C 组多写的那一行 RING[idx++ & 4095] = p 把对象塞进了静态数组,new 就只能真刀真枪执行 5000 万次。
面试口径补一句:概念里常把「栈上分配」「标量替换」「锁消除」并列为 EA 的三件武器,但「栈上分配」是个以讹传讹的说法——HotSpot 并没有把整个对象搬上栈,做的是标量替换(字段散成局部变量)。老文章讲的「栈上分配」,绝大多数场景其实就是标量替换在起作用,别把两者混成一个保证。
2. 同步消除:锁直接被删掉
同步消除(lock elimination)——如果被 synchronized 的锁对象也没逃逸,C2 判断这把锁根本不可能有第二个线程碰到,就把加解锁去掉了。典型是方法里的局部锁:没人能拿到它,也就没人能跟当前线程抢它。同一行 synchronized 代码,只换锁对象是局部还是共享,结果完全不同:
import java.lang.management.*;
/** 同步消除验证:锁对象是方法内局部对象 vs 共享静态对象。JDK 26。
* 用法:java LockLab <iterations> <mode> mode=0 局部锁,mode=1 共享锁 */
public class LockLab {
static long sink;
static final Object SHARED = new Object();
static int syncLocal(int i) { // 锁对象没人拿得到 -> 锁消除
Object lock = new Object();
int s;
synchronized (lock) { s = i + 1; }
return s;
}
static int syncShared(int i) { // 同一行代码,锁对象共享 -> 消不掉
int s;
synchronized (SHARED) { s = i + 1; }
return s;
}
static long gcCount() {
long n = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) n += b.getCollectionCount();
return n;
}
public static void main(String[] x) {
int n = Integer.parseInt(x[0]), mode = Integer.parseInt(x[1]);
long gc0 = gcCount();
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) sink += mode == 0 ? syncLocal(i) : syncShared(i);
System.out.println("mode=" + (mode == 0 ? "局部锁" : "共享锁") + " wall=" + (System.nanoTime() - t0) / 1_000_000
+ "ms gc=" + (gcCount() - gc0) + " sink=" + sink);
}
}
javac -encoding UTF-8 LockLab.java
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 0 # 局部锁:消除生效 第 1 遍
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 0 # 局部锁 第 2 遍
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 0 # 局部锁 第 3 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-EliminateLocks LockLab 50000000 0 # 局部锁:关掉消除 第 1 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-EliminateLocks LockLab 50000000 0 # 关掉消除 第 2 遍
java -Xms64m -Xmx64m -Xmn16m -XX:-EliminateLocks LockLab 50000000 0 # 关掉消除 第 3 遍
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 1 # 共享锁:想消也消不掉 第 1 遍
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 1 # 共享锁 第 2 遍
java -Xms64m -Xmx64m -Xmn16m LockLab 50000000 1 # 共享锁 第 3 遍
mode=局部锁 wall=74ms gc=0 sink=1250000025000000
mode=局部锁 wall=72ms gc=0 sink=1250000025000000
mode=局部锁 wall=72ms gc=0 sink=1250000025000000
mode=局部锁 wall=918ms gc=48 sink=1250000025000000
mode=局部锁 wall=907ms gc=48 sink=1250000025000000
mode=局部锁 wall=920ms gc=48 sink=1250000025000000
mode=共享锁 wall=853ms gc=0 sink=1250000025000000
mode=共享锁 wall=857ms gc=0 sink=1250000025000000
mode=共享锁 wall=844ms gc=0 sink=1250000025000000
归拢成三行:
局部锁 + 锁消除开(默认) Young GC 0 次 墙钟 74 / 72 / 72 ms
局部锁 + -XX:-EliminateLocks Young GC 48 次 墙钟 918 / 907 / 920 ms
共享静态锁(对照组) Young GC 0 次 墙钟 853 / 857 / 844 ms
三行读出来的信息比「锁消除省时间」多(Windows 的中文控制台若把 局部锁 显示成乱码,敲一句 chcp 65001 换成 UTF-8 代码页即可,不影响 gc= 那几个数字的判读):
- 局部锁那一行 0 次 GC,说明锁不只被消除了,连
new Object()这个锁对象都被一起消掉了——它自己也不逃逸。 - 把锁消除关掉,墙钟掉 13 倍,而且还多出 48 次 GC。多出来的 GC 不是因为加解锁,而是因为锁要留着就必须有个真身份当监视器,锁对象跟着回到了堆上,标量替换也一起失效——两件武器是绑在一起的。
- 第三行是关键的对照:共享锁 0 次 GC(没有额外分配),墙钟却和「关掉消除」那一行一样慢。说明局部锁省下的不是分配,是加解锁这套动作本身;而共享锁谁都消不掉,它是真的会被别的线程抢。
-XX:+EliminateLocks 默认开。注意这和 JDK 15 移除偏向锁是两回事:偏向锁是「把无竞争的锁变便宜」,同步消除是「直接把锁删了」,后者更彻底。
3. 消除读的副作用
对象没逃逸时,对这个对象做的操作只要不影响可观察结果,都可以重新安排。这一条通常和前两条配合,不单独拿出来说。
这一切的前提是 C2 真的看到了对象完整的一生:sumNon 能被内联进循环,C2 才能看清 Pt 只在循环里用、从构造函数出来就没人再引用。所以「有没有逃出这个方法」里的「方法」,指的是 JIT 内联之后的那个编译单元,不是源码里那个方法——这一点在下面 mode=6 会看到活生生的例子。这也是为什么逃逸分析是「编译期优化」而不是「运行时魔法」——它依赖 JIT 把相关方法摊开看。
什么会挡住它:对象一「露头」就得真分配
逃逸分析是保守的:只要有一条路可能逃逸,就当它逃逸,老老实实分配。与其背一份清单,不如把几种典型写法塞进同一个循环、看谁的 GC 次数炸掉——除了 m0 那一行基线,其余每行只在 m0 基础上改一个地方:
import java.lang.management.*;
/** 七种写法对照,看 Young GC 次数。JDK 26。
* 用法:java TriggerLab <iterations> <mode> */
public class TriggerLab {
static long sink;
static final Pt[] RING = new Pt[4096];
static Pt FIELD;
static int idx;
static final class Pt { final int a, b; Pt(int a, int b) { this.a = a; this.b = b; } }
static int m0(int i) { Pt p = new Pt(i, i + 1); return p.a + p.b; } // 基线:不逃逸
static int m1(int i) { Pt p = new Pt(i, i + 1); RING[idx++ & 4095] = p; return p.a + p.b; } // 装进数组
static int m2(int i) { Pt p = new Pt(i, i + 1); FIELD = p; return p.a + p.b; } // 存进 static 字段
static int m3(int i) { Pt p = new Pt(i, i + 1); synchronized (p) { sink += p.a; } return p.b; } // 当锁用
static int m4(int i) { Pt p = new Pt(i, i + 1); sink += System.identityHashCode(p); return p.a + p.b; }
static int m5(int i) { Pt p = new Pt(i, i + 1); sink += p.hashCode(); return p.a + p.b; } // 默认 hashCode
static int m6(int i) { Pt p = mk(i); return p.a + p.b; } // 从别的方法返回
static Pt mk(int i) { return new Pt(i, i + 1); }
static long gcCount() {
long n = 0;
for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) n += b.getCollectionCount();
return n;
}
public static void main(String[] x) {
int n = Integer.parseInt(x[0]), mode = Integer.parseInt(x[1]);
long gc0 = gcCount();
for (int i = 0; i < n; i++) {
switch (mode) {
case 0: sink += m0(i); break;
case 1: sink += m1(i); break;
case 2: sink += m2(i); break;
case 3: sink += m3(i); break;
case 4: sink += m4(i); break;
case 5: sink += m5(i); break;
case 6: sink += m6(i); break;
}
}
System.out.println("mode=" + mode + " gc=" + (gcCount() - gc0));
}
}
mode 是命令行参数,七种写法依次跑一遍就行:
javac -encoding UTF-8 TriggerLab.java
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 0 # 基线
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 1 # 装进数组
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 2 # 存进 static 字段
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 3 # 当锁用
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 4 # identityHashCode
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 5 # 默认 hashCode
java -Xms64m -Xmx64m -Xmn16m TriggerLab 50000000 6 # 从别的方法返回
mode=0 gc=0
mode=1 gc=76
mode=2 gc=76
mode=3 gc=0
mode=4 gc=72
mode=5 gc=72
mode=6 gc=0
对上号就是:
mode=0 基线:对象只在方法内用 Young GC 0 次
mode=1 装进数组 RING[idx]=p Young GC 76 次
mode=2 存进 static 字段 FIELD=p Young GC 76 次
mode=3 当锁用 synchronized(p){...} Young GC 0 次
mode=4 System.identityHashCode(p) Young GC 72 次
mode=5 p.hashCode()(默认实现) Young GC 72 次
mode=6 从别的方法 return 出来 Young GC 0 次
先花十秒把「身份」是什么说清,mode=3/4/5 三行的差别全落在这个词上。对象身份(identity)指的是:它在内存里有一个唯一地址,别人能拿它做下面这些事——
| 你写的代码 | 依赖对象的什么 |
|---|---|
p == q 判断是不是同一个对象 | 地址 |
synchronized (p) | monitor 挂在对象头的 mark word 上 |
System.identityHashCode(p) | 默认 hash 存在对象头里 |
p.hashCode()(没被覆写的默认实现) | 同上,默认实现就是 identityHashCode |
| 存进数组 / 字段 / 传给别人 | 别人得「拿得到它」 |
前四行要的是一个稳定地址,第五行要的是对象能被外部持有。标量替换把对象拆成散装字段之后,这两样同时消失:没有地址可比较、没有对象头挂锁和 hash、别人也无从「拿到那个对象」。所以表里任何一项出现,C2 就必须放弃标量替换、老实分配真对象——区别只在于有的项有别的优化能把它救回来,有的救不回来。
对照这张表再看七行结果,每个「反直觉的 0」都解释得通:
- 存进活得比代码久的地方,才是真的逃逸。 mode=1 的静态数组
RING[idx++] = p和 mode=2 的静态字段FIELD = p都炸到 76 次 GC——这才是 C 实验失灵的真正原因。判断标准不是「引用有没有离开这个方法」,而是这个引用有没有被留在一个方法返回后依然活着的地方:静态字段、别的对象的成员字段、数组,都算。这一条踩的是表里最后一行(对象被外部持有)。 return本身不是判决。 mode=6 一路return new Pt(...)还给调用方,GC 依然是 0——因为调用方被内联进了同一个编译单元,拿到引用用完就丢,C2 全程看得见。只有当返回值被调用方存起来(mode=1、mode=2 那种),才真的逃逸。「方法」指的是内联后的编译单元,这里就是活证据。synchronized(p)也不必然挡路。 mode=3 拿对象当锁用,GC 还是 0:只要锁对象自己没逃逸、没有第二个线程能拿到它,同步消除会先把这把锁删掉,对象跟着一起被消掉(上一节 LockLab 就是这个机制)——表里第二行的「要对象头」这个需求,连同锁一起没了。- 真正救不回来的是身份 hash。 mode=4 的
System.identityHashCode(p)和 mode=5 的默认hashCode()都炸到 72 次 GC:它们直接观察对象地址,C2 没法替你把「一个稳定地址」这件事糊弄过去,只能老实分配。拆成散装字段,这个 hash 就没有地方可存了。Object.toString()这类默认实现踩的是同一根线,而且它自己还要再分配一个 String,GC 次数会比 72 高出一大截。 - 抛进异常:
throw new MyException()的对象会被上层 catch 到,算逃逸。 - 跨线程:对象被丢给另一个线程(new Thread 里用、提交给线程池),逃逸分析管不到别的线程,直接放弃。
- 反射访问。
上面这份 demo 装得下的就这七行,剩下几种(异常、跨线程、反射)的机制一样:出口是否真实存在不重要,编译器能不能证明它不存在才重要——证不了就按逃逸处理。
判断方法本身还有前提:只有被 C2 编译且方法被成功内联的代码才谈得上 EA;方法太大、太复杂没被内联,或者 C1 阶段、解释执行阶段,都按「对象会逃逸」的保守策略处理——这也是为什么压测必须先预热再计时:跑不够热,C2 还没接管,你测的是解释执行/ C1 的分配量,不是生产形态的分配量。
对象池悖论:别为了「省分配」去手动复用对象
逃逸分析带来的一个反直觉实践建议:短命的小对象,直接 new 通常比手动复用便宜。 理由就在上面的 ScalarLab 里——只要对象不逃逸,new 的成本接近零(Pt 版本和手写 int 版本墙钟一样),而对象池要维护并发安全、要处理「借了忘还」「池子被清空后泄漏」一类问题,这些成本远高于一次被消除的分配。现实中真正值得做对象池的,是对象必然逃逸且分配确实成为瓶颈的地方(典型是网络缓冲区、大对象),而且要先用压测证实瓶颈在分配,别凭感觉上池子。
这也能解释一个 3-2《GC 调参入门》里留的伏笔:那篇的压测探针 GcProbe 特意用一个静态 INFLIGHT 列表把对象收住,注释里写着「否则 JIT 的逃逸分析会把分配优化掉,测不到 GC」——就是这个道理(对照 mode=2,静态字段一存,分配立刻回来)。反过来,线上用 GC/分配量看压力时要想一层:profiling 工具报的 allocation 里有相当一部分被 EA 消掉了,真正让 GC 忙碌的是那些逃逸出去、活下来的对象。想直接数「被消除了几个分配」也行,但 -XX:+PrintEliminateAllocations 是 develop 级别的 flag,release 版 JVM 用不了——所以生产上验证 EA 最实用的办法就是本文这招:关掉它跑一遍对照组,用 GC 次数的落差反推。
面试速答
逃逸分析是 C2 编译热点方法时做的一次分析:看方法内 new 的对象引用有没有逃出这个方法(存进静态字段/数组/别的对象、被别的线程或异常带走都算逃逸),注意这里的「方法」实际是 JIT 内联后的编译单元。没逃逸就能优化,主力是标量替换——把对象拆成独立字段当局部变量用,对象不真建、不上堆、不产生垃圾,所以无逃逸的热循环里 new 几乎免费(实测和手写两个 int 完全等价);其次是锁消除,对不可能被竞争的局部锁去掉加解锁,连锁对象本身都会被一起消掉。对象一旦被存在活得比方法久的地方(静态数组、成员字段)分析立即失效,照常堆分配。实践含义:短命小对象别手搓对象池,直接 new;验证 EA 用「关掉对照跑一轮,看 GC 次数落差」最直观。面试被问「new 一定在堆上吗」,答案是语义上按在堆上设计、实际可能被编译器消掉不上堆——这正是 JIT 优化「观察不到、但快」的典型。
本文关键词:JVM、逃逸分析、标量替换、JIT、性能优化
核心收获一句话:new 的对象不一定住堆,C2 对不逃逸对象做标量替换把它整个消掉,0 次 GC 就是证据;真正让 EA 失灵的不是「出了方法」,而是引用被存进了活得比方法久的地方。下一步可以在自己项目里挑一个高频创建对象的热路径,用本文的对照组方法测一测它到底逃逸没有,再决定要不要动手优化。