← 返回博客
2026-09-03 10:05:00

JVM 逃逸分析:你以为 new 的对象都住堆里?C2 可能根本没让它出生

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 万次迭代的循环,代码几乎一样,命运截然不同:

为什么能消掉:new 不一定是「真的 new」

Java 是先编译成字节码、跑起来再由 JIT 编译成本地码。干活的是 HotSpot 里的两个即时编译器(都是 C++ 写的,「C1」「C2」只是历史名字,跟 C 语言没关系):

早年要拿 -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 时的推理只有三步:

  1. p 由这一句 new Pt(i, i + 1) 产生,构造器把 a = i、b = i + 1;
  2. 之后只读过 p.a 和 p.b 两个字段;
  3. 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= 那几个数字的判读):

-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」都解释得通:

上面这份 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 失灵的不是「出了方法」,而是引用被存进了活得比方法久的地方。下一步可以在自己项目里挑一个高频创建对象的热路径,用本文的对照组方法测一测它到底逃逸没有,再决定要不要动手优化。