Java 基础体系 · 第 73/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JVM 逃逸分析:标量替换、锁消除、分配观测和误区

逃逸分析(Escape Analysis,EA)是 JIT 编译器用来判断“一个对象的引用是否会离开某个受分析范围”的优化技术。它本身不是一种新的 Java 语法,也不是 Java 虚拟机规范要求所有 JVM 都必须实现的算法,而是 HotSpot 等具体 JVM 实现可能采用的运行时优化。

逃逸分析最常被提到的结果有三个:

  1. 标量替换:对象不再以完整对象的形式存在,字段被拆成独立的标量值,甚至整个分配被消除。
  2. 锁消除:如果锁对象不可能被其他线程看到,相关的加锁和解锁操作可以被移除。
  3. 分配观测:通过 JFR、GC 日志、基准测试和编译日志观察优化后的真实分配行为。

这三个概念相互关联,但不能混为一谈:

  • 逃逸分析回答的是“对象的引用能否被外部观察”;
  • 标量替换和锁消除是基于该判断进行的优化;
  • 分配观测回答的是“程序运行时实际发生了什么”,而不是“源代码里写了多少个 new”。

一、先区分规范保证与 JVM 实现

Java 虚拟机规范定义了字节码的语义,例如:

  • new 指令创建对象;
  • getfieldputfield 访问实例字段;
  • monitorentermonitorexit 实现同步块;
  • 方法调用、异常、线程同步和对象引用具有规定的可观察行为。

这些语义可以在 JVMS 25 中找到。

但是,规范并没有要求 JVM 必须:

  • 执行每一个源代码层面的 new
  • 为每个对象分配独立的堆内存;
  • 为每个 synchronized 都执行真实的锁操作;
  • 提供或开启逃逸分析;
  • 采用标量替换;
  • 采用某一种具体的逃逸分析算法。

JIT 编译器可以在不改变 Java 程序可观察语义的前提下,把多个字节码操作合并、删除或重排。因此,下面两句话必须严格区分:

代码中存在对象分配语句。

和:

程序运行时发生了一个可观测的堆对象分配。

前者是源代码或字节码层面的事实,后者是某次机器码执行时的运行时事实。

Java 25 LTS 中,HotSpot 通常由分层编译器先收集运行信息,再由更高阶的 JIT 编译器进行包括逃逸分析在内的优化。但这属于 HotSpot 的实现行为,不是 Java 25 平台对所有 JVM 的统一保证。


二、什么是“逃逸”

设方法中创建了对象 o

Point o = new Point(x, y);

如果 o 的引用只在当前方法或当前线程的受控范围内使用,并且没有通过某种路径被外部代码获得,那么可以说它没有逃逸出这个分析范围。

如果引用可能被外部代码观察,则称为逃逸。

2.1 直接返回导致方法级逃逸

static Point create(int x, int y) {
    return new Point(x, y);
}

调用者获得了 Point 的引用:

Point p = create(1, 2);

因此,对 create 方法单独分析时,创建的对象已经通过返回值逃逸出了该方法。

这里要注意“方法级逃逸”不是“必然堆分配”的同义词。调用者如果被内联进来,JIT 可能把 create 和调用者放在同一个优化图中重新分析。最终是否能消除分配,要看更大的上下文。

2.2 写入共享字段导致全局逃逸

static Point published;

static void publish(int x, int y) {
    published = new Point(x, y);
}

对象引用被写入静态字段。其他线程、其他方法或反射代码理论上都可能读取它,因此对象不能被当作当前方法的私有临时值处理。

以下路径也具有类似效果:

static void store(Point p, List<Point> list) {
    list.add(p);
}

如果 list 本身可能被外部代码访问,那么 p 的引用也通过容器逃逸了。

2.3 传给未知方法可能导致逃逸

static void process(Point p) {
    unknown(p);
}

如果编译器无法证明 unknown 不会保存 p、返回 p、写入静态字段或传给其他线程,那么传参本身就可能构成逃逸。

如果 unknown 被内联,并且其内部行为完全可见,JIT 可能继续分析它,而不是机械地把所有方法调用都视为逃逸。

2.4 仅在方法内部使用的对象

static int sum(int x, int y) {
    Point p = new Point(x, y);
    return p.x + p.y;
}

p 没有返回、没有写入字段、没有传给未知调用,也没有用于身份相关操作。它的两个字段最终只参与整数加法,因此是标量替换的典型候选。

从抽象角度看,可以把对象的使用分成两类:

  • 值使用:读取字段、修改字段、参与数值计算;
  • 身份使用:比较对象身份、计算身份哈希、同步、把引用保存到外部可见位置等。

标量替换主要依赖对象只被当作“值的集合”使用,而不是被当作具有独立身份的对象使用。


三、从逃逸分析到标量替换

3.1 标量是什么

标量(scalar)是不能再按对象或聚合结构整体拆分的值,例如:

  • int
  • long
  • double
  • 引用本身
  • 机器寄存器中的中间值

与之相对的是对象、数组、记录等聚合结构,它们包含多个字段或元素。

考虑如下类型:

final class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

源代码中:

static int distanceLikeValue(int a, int b) {
    Point p = new Point(a, b);
    return p.x * p.x + p.y * p.y;
}

可以抽象成:

p.x = a
p.y = b
result = a * a + b * b

如果 p 的对象身份没有被观察,JIT 可以将其转换为两个独立的标量值:

x = a
y = b
result = x * x + y * y

此时没有必要:

  1. 在堆上分配 Point
  2. 初始化对象头;
  3. 写入 xy 字段;
  4. 再从对象中读取 xy
  5. 让垃圾回收器以后处理这个对象。

这就是标量替换的核心。

3.2 形式化条件

把对象 o 的字段集合记为:

F(o)={f1,f2,,fn}F(o) = \{f_1, f_2, \ldots, f_n\}

若满足以下条件:

  1. o 不会被返回给分析范围之外的代码;
  2. o 不会被存入可能逃逸的对象、数组或字段;
  3. 没有通过 ==System.identityHashCode(o) 等方式观察其身份;
  4. 没有对 o 执行需要真实对象身份的同步操作;
  5. 所有字段访问都能被 JIT 识别并替换为局部值;
  6. 不存在无法分析的调用、反射或底层操作破坏这些假设;

则可以将:

o = new T(...)
o.f1 = v1
o.f2 = v2
...
use(o.f1, o.f2)

转换为:

s1 = v1
s2 = v2
...
use(s1, s2)

其中 s1s2 是代表字段值的标量。

推导过程通常可以理解为:

  1. 识别对象分配节点;
  2. 跟踪对象引用流向;
  3. 判断引用是否进入返回值、共享字段、未知调用或其他可观察位置;
  4. 收集每个字段的赋值和读取;
  5. 用字段对应的 SSA 值或寄存器值替代对象字段访问;
  6. 删除不再有用的对象分配和初始化操作。

这是一个编译器内部的抽象过程。实际 HotSpot 使用的是基于编译中间表示和连接关系的分析,不应把上面的步骤理解为一个公开 API 或规范算法。

3.3 构造函数不一定意味着必须分配对象

构造函数调用经常被误解为“调用了构造函数,所以对象一定存在”。

例如:

static int f(int x) {
    Point p = new Point(x, x + 1);
    return p.x + p.y;
}

如果 Point 的构造函数只是给字段赋值,且对象不逃逸,JIT 可能把整个过程看成:

p.x = x
p.y = x + 1
return x + (x + 1)

构造函数中的代码仍然必须保留其可观察行为。例如构造函数可能:

  • 修改静态变量;
  • 抛出异常;
  • 调用无法分析的方法;
  • 发布 this
  • 执行依赖对象身份的操作。

因此,“对象不逃逸”并不代表构造函数中的所有代码都可以删除,而是说明对象本身可能不需要以完整对象形式存在。

3.4 完整反例:返回对象

static Point create(int x) {
    return new Point(x, x + 1);
}

调用者这样使用:

static int consume(int x) {
    Point p = create(x);
    return p.x + p.y;
}

如果 create 被内联,整个调用链可能重新变成:

x1 = x
x2 = x + 1
return x1 + x2

但是如果返回值被保存:

static Point saved;

static void consumeAndSave(int x) {
    saved = create(x);
}

对象引用进入了静态字段,标量替换通常就不能再把它拆成仅存在于当前计算中的字段值。

这说明逃逸分析的范围不是固定的“一个方法”。内联可以扩大分析范围,而发布引用会立即限制优化空间。


四、标量替换不等于“对象被分配到栈上”

这是逃逸分析最常见的误解之一。

很多资料会把“没有逃逸”简化为“对象分配到栈上”。这种说法容易造成错误预期,因为:

  1. Java 虚拟机规范没有规定普通对象必须进行栈分配;
  2. HotSpot 的常见优化路径是直接消除对象分配,而不是把对象完整地移动到栈上;
  3. JIT 可能让对象字段存在于寄存器、栈槽或编译器中间值中,但这不等于存在一个可以被 Java 代码观察的栈对象;
  4. 一旦发生去优化(deoptimization),JVM 可能需要根据标量值重新构造对象状态,以便恢复解释器语义。

因此,更准确的表述是:

对象可能被标量替换并完全不产生运行时堆对象;这不是对“栈上分配对象”的保证。

“栈上分配”与“标量替换”在某些讨论中被混用,但在实现层面并不是同一个概念。


五、锁消除的原理

5.1 synchronized 的语义

如下代码:

static int synchronizedLocal(int x) {
    Object lock = new Object();

    synchronized (lock) {
        return x + 1;
    }
}

从字节码语义看,synchronized (lock) 需要执行与 monitorentermonitorexit 对应的监视器操作。正常路径、异常路径都必须正确释放监视器。

同步操作具有多重语义:

  • 同一监视器上的互斥;
  • 可重入;
  • 解锁与后续加锁之间的内存可见性和排序关系;
  • 异常路径上的释放;
  • 在合法场景中与 waitnotifynotifyAll 的交互。

但这里的 lock 是当前方法新建的局部对象,且没有被保存、返回或传递给其他线程。其他线程没有办法获得同一个监视器引用,因此不存在真正的竞争者。

在这种情况下,锁的互斥和同步效果没有外部可观察对象。HotSpot 可能同时进行:

  1. 标量替换,消除 new Object()
  2. 锁消除,删除 monitorentermonitorexit

5.2 锁消除的形式化直觉

设监视器对象为 m,同步块为:

lock(m)
    body
unlock(m)

若满足:

NoOtherThreadCanAcquire(m)\text{NoOtherThreadCanAcquire}(m)

并且:

NoIdentityObservation(m)\text{NoIdentityObservation}(m)

那么执行锁操作不会改变其他线程可观察到的行为,因而可以转换为:

body

这里的关键不是“同步块很短”,也不是“锁对象是局部变量”这一语法表象,而是编译器是否能够证明该监视器引用不会被其他执行流获得。

5.3 共享锁不能被这样消除

private static final Object LOCK = new Object();

static int synchronizedShared(int x) {
    synchronized (LOCK) {
        return x + 1;
    }
}

LOCK 是静态共享对象。其他线程可能同时执行:

synchronized (LOCK) {
    // 临界区
}

删除同步操作会改变互斥关系以及内存模型允许的行为,因此不能因为当前方法中没有明显的竞争代码,就认为这把锁可以消除。

5.4 锁消除不等于锁粗化

两个概念应当分开:

  • 锁消除:证明锁没有实际必要,因此删除锁;
  • 锁粗化:把多个相邻的锁操作合并成更大的同步区域,以减少加解锁次数。

例如:

for (int i = 0; i < n; i++) {
    synchronized (lock) {
        work(i);
    }
}

某些实现可能考虑将多次加锁合并,但这会扩大临界区,必须重新验证异常、线程交错和内存可见性语义。锁粗化并不意味着锁对象没有竞争,也不等于锁消除。

5.5 waitnotify 和锁身份会改变条件

下面的代码不能按“局部锁无竞争”简单处理:

static void waitOnLocalLock() throws InterruptedException {
    Object lock = new Object();

    synchronized (lock) {
        lock.wait();
    }
}

wait 与特定监视器身份相关,并且会释放和重新获取该监视器。即使这个示例本身最终会因为没有通知而等待,也不能把它当作普通空同步块处理。

同样,以下操作会使对象身份变得重要:

int hash = System.identityHashCode(lock);
boolean same = lock == another;

这类操作至少会限制通常意义上的标量替换和锁消除。具体优化结果仍取决于 JIT 的实现,但业务代码不能依赖“JIT 一定会继续消除”。


六、一个可运行的观察示例

下面的程序同时包含:

  • 只在方法内部使用的 Point
  • 通过 volatile 静态字段发布的 Point
  • 一个可能被消除的局部锁;
  • 一个共享锁。
public class EscapeDemo {
    static final class Point {
        int x;
        int y;

        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    private static volatile Point published;
    private static final Object SHARED_LOCK = new Object();

    static long nonEscaping(int x) {
        Point p = new Point(x, x + 1);
        return (long) p.x * p.x + (long) p.y * p.y;
    }

    static long escaping(int x) {
        Point p = new Point(x, x + 1);
        published = p;
        return (long) p.x * p.x + (long) p.y * p.y;
    }

    static int localLock(int x) {
        Object lock = new Object();

        synchronized (lock) {
            return x + 1;
        }
    }

    static int sharedLock(int x) {
        synchronized (SHARED_LOCK) {
            return x + 1;
        }
    }

    public static void main(String[] args) throws Exception {
        long sum = 0;

        // 先运行足够多次,让 JIT 有机会编译热点方法。
        for (int i = 0; i < 50_000_000; i++) {
            sum += nonEscaping(i);
            sum += escaping(i);
            sum += localLock(i);
            sum += sharedLock(i);
        }

        System.out.println("sum = " + sum);
        System.out.println("published.x = " + published.x);

        // 给外部观测工具留出时间。
        Thread.sleep(2_000);
    }
}

编译:

javac EscapeDemo.java

直接运行:

java EscapeDemo

程序会输出一个确定的 sum,以及最后一次发布对象的 x 值。volatile 的作用不是“帮助逃逸分析”,而是让 published 的读写具有明确的跨线程可见性语义,并且明确展示一个对象引用已经被发布到共享位置。

可以使用 HotSpot 的开关做对照实验:

java -XX:-DoEscapeAnalysis EscapeDemo

在支持这些 HotSpot 选项的构建中,也可以显式打开相关优化:

java -XX:+DoEscapeAnalysis \
     -XX:+EliminateAllocations \
     -XX:+EliminateLocks \
     EscapeDemo

这些选项是 HotSpot 实现细节,不是跨 JVM、跨供应商的 Java 标准接口。某些诊断选项只在特定构建中可用,不能把某个版本或某个发行版上的选项当作 Java 25 的规范保证。

这个示例的逻辑预期如下:

方法 引用是否发布 标量替换候选 锁消除候选
nonEscaping
escaping 是,写入 volatile 静态字段 通常不是
localLock 锁对象未发布 对象本身可能被消除
sharedLock 共享静态对象 不适用 通常不是

“候选”不是“必然发生”。还要考虑方法是否被编译、是否内联、代码路径是否执行、编译器是否因为其他原因放弃优化。


七、如何观察实际分配

7.1 JFR 观察对象分配事件

JDK Flight Recorder(JFR)可以记录对象分配相关事件,随后使用 JDK Mission Control(JMC)进行分析。JDK 25 的 JFR/JMC 文档位于:

JDK Mission Control Documentation

可以这样启动程序:

java \
  -XX:StartFlightRecording=filename=ea.jfr,settings=profile,duration=10s \
  EscapeDemo

录制结束后查看对象分配事件:

jfr print \
  --events jdk.ObjectAllocationInNewTLAB,jdk.ObjectAllocationOutsideTLAB \
  ea.jfr

两个事件名称表达的是对象分配发生时所处的路径:

  • jdk.ObjectAllocationInNewTLAB:对象分配在新建或使用中的 TLAB 路径上;
  • jdk.ObjectAllocationOutsideTLAB:对象分配未通过 TLAB 路径完成。

TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲区)是 HotSpot 为线程预留的一小段堆内存。线程可以在其中通过移动指针快速分配对象,通常不需要每次都执行全局同步。

JFR 结果可能受到以下因素影响:

  • 相关事件没有启用;
  • 录制配置使用了阈值或采样策略;
  • 程序在录制开始前已经结束;
  • 方法没有执行到;
  • JIT 已经消除了某些源代码层面的分配;
  • 只观察到了某条执行路径,而不是所有路径。

因此,JFR 中没有某类对象分配事件,只能说明“在这次录制条件下没有观测到对应事件”,不能单独证明源代码中的 new 一定被标量替换。

7.2 用 JMC 查看分配热点

ea.jfr 打开后,可以在 JMC 中查看:

  • 录制期间的对象分配;
  • 分配线程;
  • 分配类型;
  • 分配所在方法;
  • TLAB 与 TLAB 外分配;
  • 垃圾回收活动和停顿;
  • 编译、CPU 和线程事件。

观察时应把“分配类型”与“调用栈”结合起来看。调用栈中出现某个 new Point 的源代码位置,并不等于每次执行都发生了真实堆分配,因为调用栈可能来自解释执行、未优化编译版本或某条未被优化的路径。

7.3 用 GC 日志观察总量,但不要把它当作对象级证据

可以追加 GC 日志:

java \
  -Xlog:gc*,safepoint \
  -XX:StartFlightRecording=filename=ea-gc.jfr,settings=profile,duration=10s \
  EscapeDemo

GC 日志适合观察:

  • 堆使用量变化;
  • 垃圾回收次数;
  • TLAB 或分配速率的整体变化;
  • 是否发生频繁年轻代回收;
  • 优化前后暂停时间的变化。

但 GC 日志通常不能直接回答“某个 Point 是否被标量替换”。它记录的是堆和回收层面的结果,不是每个 JIT 优化决策的证明。


八、为什么基准测试很容易得出错误结论

8.1 解释器执行阶段没有优化

程序启动时可能经历:

解释执行
  ↓
收集调用次数和类型信息
  ↓
低阶编译
  ↓
高阶编译与逃逸分析
  ↓
可能发生去优化并重新编译

如果只测程序启动后的很短时间,测到的可能主要是解释执行或低阶编译结果,此时不能据此判断最终稳态是否发生了标量替换。

8.2 JIT 可能删除整个计算

如下代码:

static void useless() {
    new Point(1, 2);
}

如果对象构造没有副作用,方法结果也没有被使用,JIT 可能删除整个方法体中的分配和计算。

这时测量到的“执行很快”可能是因为代码根本没有按源代码形式执行,而不是因为对象分配特别高效。

8.3 结果使用不足会造成死代码消除

static long calculate() {
    Point p = new Point(1, 2);
    return p.x + p.y;
}

for (int i = 0; i < n; i++) {
    calculate();
}

如果返回值没有被使用,JIT 可能进一步删除调用和计算。

即使返回值参与了一个普通局部变量,也要确认最终结果没有被完全证明为无用。生产级基准测试通常应使用 JMH,通过黑洞、状态管理、预热和测量迭代降低这些误差,而不是手写一个 System.nanoTime() 循环就下结论。

8.4 对照实验必须只改变一个因素

例如比较逃逸和不逃逸时,应尽量保持:

  • 方法逻辑一致;
  • 返回值使用一致;
  • 循环次数一致;
  • 编译和运行参数一致;
  • 预热时间一致;
  • 线程数一致;
  • JFR 配置一致。

否则,差异可能来自方法内联、分支预测、GC 压力或代码布局,而不是逃逸分析。


九、逃逸分析的常见误区

9.1 “局部变量一定不逃逸”

错误。

static Point localButPublished() {
    Point p = new Point(1, 2);
    global = p;
    return p;
}

变量 p 是局部变量,但它引用的对象通过 global 和返回值逃逸了。判断对象是否逃逸,观察的是引用传播路径,而不是变量声明位置。

9.2 “返回值一定不能标量替换”

也不绝对。

static int consume() {
    Point p = create();
    return p.x + p.y;
}

如果 create() 被内联,调用者可能直接使用字段值,最终不需要创建对象。

但以下情况会阻止这种优化:

Point p = create();
return p;

因为对象引用确实要交给调用者。

9.3 “没有逃逸就一定能标量替换”

错误。

对象虽然没有逃逸,但仍可能因为以下原因无法被拆分:

  • 对象结构复杂;
  • 数组访问模式无法被证明;
  • 字段值存在复杂别名关系;
  • 调用了无法内联或无法分析的方法;
  • 使用了反射、Unsafe 或其他底层机制;
  • 编译器的实现限制;
  • 代码没有成为足够热的编译目标。

逃逸分析提供的是优化依据,不是优化结果的承诺。

9.4 “所有 new 都会带来等量堆分配”

错误。

一个 new 可能:

  • 在解释器中产生真实分配;
  • 在未优化编译版本中产生真实分配;
  • 在高阶编译后被标量替换;
  • 因去优化路径再次需要恢复对象;
  • 只在某些分支中执行;
  • 因常量传播和死代码消除而完全删除。

因此,分配数量必须在指定运行阶段、指定配置和指定路径下测量。

9.5 “逃逸分析把对象移动到栈上”

前面已经说明,通常更准确的描述是“对象被消除,字段值以标量形式存在”。不要把“没有堆分配”解释为“栈上有一个完整对象”。

9.6 “关闭逃逸分析就能证明所有对象都分配了”

关闭 DoEscapeAnalysis 可以帮助做 HotSpot 对照实验,但它不是严格的实验隔离手段:

  • 其他优化仍可能消除计算;
  • 内联、常量传播、死代码消除仍可能发生;
  • 不同编译级别的行为可能不同;
  • 运行时间和 GC 变化会反过来影响编译时机;
  • 该选项只适用于支持它的 HotSpot 实现。

所以它适合用来寻找“优化可能产生了影响”的证据,不是证明某条规范结论的工具。

9.7 “锁消除会破坏线程安全”

如果 JVM 正确完成了优化,锁消除只能发生在它已经证明锁的同步效果不可被其他线程观察的情况下。

对于共享锁,删除锁会改变程序语义,因此不能合法地消除。真正危险的是程序本身存在数据竞争、错误发布或依赖未定义行为;这类问题不能通过“指望 JIT 不优化”来修复。


十、对象身份是标量替换的边界

Java 对象不只是字段集合,还具有身份。以下操作依赖身份:

static boolean same(Object a, Object b) {
    return a == b;
}
static int identity(Object o) {
    return System.identityHashCode(o);
}
static void synchronize(Object o) {
    synchronized (o) {
        // 依赖监视器身份
    }
}
static WeakReference<Point> reference(Point p) {
    return new WeakReference<>(p);
}

这些代码都在不同程度上要求对象作为一个独立实体存在,或者至少要求 JVM 保留其身份语义。

尤其是 System.identityHashCode 与普通的 Point.hashCode() 不同:

  • 普通 hashCode() 可能是类方法计算出的值;
  • identityHashCode 依赖对象身份,即使类覆盖了 hashCode() 也不改变这一点。

一旦对象身份或外部引用可见,编译器就不能仅凭“字段值相同”替代“对象相同”。两个分别创建的对象即使字段完全相同,也不一定满足:

a == b

因此,标量替换必须保持身份相关操作的语义,而不是只保留字段数值。


十一、分配成本不只由 new 决定

即使对象没有被标量替换,年轻代对象分配也未必等同于昂贵的全局锁竞争。

典型的 HotSpot 分配路径可能包括:

  1. 在线程自己的 TLAB 中移动分配指针;
  2. TLAB 不足时申请新的 TLAB;
  3. 对大对象或特殊对象走 TLAB 外路径;
  4. 后续由垃圾回收器处理对象存活与回收。

因此,性能问题不能简单归因于:

有 new → 一定很慢
没有 new → 一定很快

需要同时观察:

  • 分配速率;
  • 对象存活时间;
  • 年轻代回收频率;
  • 晋升到老年代的数量;
  • TLAB 外分配;
  • 线程数量;
  • CPU 使用率;
  • 锁竞争;
  • JIT 编译和去优化。

一个对象即使分配成本很低,如果每秒创建数十亿个、并且部分对象存活时间较长,仍然可能形成明显的 GC 压力。反过来,某些短命对象即使没有被消除,也可能因为 TLAB 分配和快速年轻代回收而成本可接受。


十二、诊断时应建立“源代码—字节码—机器码—运行数据”链路

可靠分析至少要把四个层次连接起来。

12.1 源代码层

确认对象是否:

  • 返回;
  • 写入字段;
  • 放入集合或数组;
  • 传给未知调用;
  • 用作锁;
  • 参与身份比较;
  • 进入 Thread、任务队列或其他并发结构。

12.2 字节码层

确认编译后的类中是否存在:

  • new
  • invokespecial 调用构造函数;
  • monitorenter
  • monitorexit
  • 相关字段写入。

字节码中存在这些指令,只能说明未经过 JIT 机器码优化前的结构,不能说明最终机器码一定执行了这些操作。

12.3 JIT 层

HotSpot 可以使用编译日志观察方法何时进入编译:

java -Xlog:jit+compilation=debug EscapeDemo

不同 JDK 构建对日志标签和详细程度可能存在差异。编译日志适合确认“方法是否被编译、何时编译”,但通常不能单独证明“某个对象一定被标量替换”。

某些 HotSpot 诊断构建还支持更详细的逃逸分析或消除日志,例如与 PrintEscapeAnalysisPrintEliminateAllocations 相关的选项。这些选项并非所有生产构建都提供,使用前应以当前 JDK 的:

java -XX:+PrintFlagsFinal -version

或发行版文档为准。不能把诊断输出格式当作 Java 25 的稳定接口。

12.4 运行时层

使用 JFR/JMC 观察:

  • 实际对象分配;
  • 分配调用栈;
  • GC;
  • 编译;
  • 线程和锁;
  • 去优化或异常运行路径。

四层数据必须相互印证。例如:

源码存在 new
  ↓
字节码存在 new
  ↓
方法被 JIT 编译
  ↓
JFR 中没有对应运行时分配

这可以支持“高阶编译版本中该路径可能消除了分配”的判断,但仍要排除事件未开启、程序未执行到、录制时机错误等因素。


十三、哪些情况下不应依赖逃逸分析

逃逸分析是性能优化,而不是程序正确性的基础。以下设计不能因为“当前 HotSpot 也许会优化”就被认为安全:

  • 依赖局部对象的锁来保护共享状态;
  • 依赖对象恰好不逃逸来避免数据竞争;
  • 依赖某个对象一定不会分配来满足内存预算;
  • 依赖某个字段一定存在于寄存器或栈上;
  • 依赖 new 一定触发或一定不触发 OutOfMemoryError
  • 依赖 JIT 的特定编译时机;
  • 依赖某个 JVM 参数在所有 Java 25 发行版中都存在。

正确的同步必须通过明确共享的锁、并发工具、volatile、原子类或其他符合 Java 内存模型的机制实现。正确的资源预算则应以生产配置下的 JFR、GC、吞吐量和延迟数据为依据。


十四、工程上的正确判断方式

当发现“创建了很多临时对象”时,应按以下因果链验证:

  1. 该方法是否真的进入了高阶 JIT 编译?
  2. 对象引用是否通过返回值、字段、容器或调用逃逸?
  3. 对象是否被用于身份比较、身份哈希、同步或引用队列?
  4. 字段访问是否足够简单,编译器是否能够内联相关调用?
  5. JFR 是否在正确时间窗口记录了分配事件?
  6. GC 是否显示出与分配速率相符的年轻代压力?
  7. 关闭或开启 HotSpot 相关优化后,结果是否在重复实验中稳定?
  8. 基准测试是否经过预热,并且结果没有被死代码消除?
  9. 对象是否因为存活时间长而晋升,真正成本是否来自 GC 而非分配指针移动?
  10. 这个优化是否依赖某个具体 JVM 实现,而不是 Java 规范保证?

最终应把结论写成带条件的判断,例如:

在 Java 25 的某个 HotSpot 构建中,经过预热并由高阶 JIT 编译后,nonEscapingPoint 没有在 JFR 分配事件中出现;结合编译日志和 GC 数据,可以认为该执行路径很可能发生了标量替换。该结论不适用于所有 JVM、所有运行阶段或所有输入路径。

而不应写成:

Java 25 会把所有局部对象放到栈上。

逃逸分析真正改变的是编译器对“对象是否必须以对象形式存在”的判断。标量替换利用这个判断消除对象表示,锁消除利用它消除不必要的监视器操作,JFR 和 GC 工具则帮助验证运行时是否真的产生了预期结果。理解这三者之间的边界,才能避免把源代码结构、字节码结构、JIT 机器码和运行时分配混成同一个层次。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。