Java 技术
#JVM#HotSpot#C2#逃逸分析#标量替换

HotSpot C2 逃逸分析与标量替换:对象生命周期与栈上分配的边界

高并发服务中短生命周期对象常被当作 GC 压力的来源,但 C2 的逃逸分析与标量替换能在编译期消除一部分分配。本文解释 NoEscape、ArgEscape、GlobalEscape 的判定过程,标量替换的成立条件,与 TLAB 的关系,以及哪些代码写法会让对象无法被拆解。

一个看似矛盾的观测

一个高并发订单服务在压测时出现周期性的 GC 停顿:每秒钟创建数十万个请求上下文对象,每个对象只在一个方法调用链里存活几微秒。直觉上,这类对象应该由 TLAB 快速分配、很快变成垃圾,Young GC 处理起来代价很低。但实际观测到的是分配速率与晋升速率同时偏高,老年代增长比预期快,Full GC 偶尔被触发。

更奇怪的是,把其中一段热点循环从 foreach 改成基于下标访问,分配速率下降了一截,GC 停顿也随之缓解。这段循环本身没有创建任何业务对象,只是遍历一个 ArrayList。它创建的只有 Iterator。如果 JIT 真能消除这种临时对象,为什么改写循环会有效果?

答案在于 C2 的逃逸分析(Escape Analysis,EA)和标量替换(Scalar Replacement of Aggregates,SRA)并不是无条件生效的。它们依赖方法内联、控制流形状、被调方法的可见性等一系列前提。前提不成立时,对象照常分配在堆上,TLAB 只是让分配变快,并不能让对象消失。本文围绕“为什么有的对象能被拆成标量、有的不能”展开,用一个贯穿的请求上下文场景说明判定过程、失败模式和可观测信号。

逃逸分析到底在回答什么问题

逃逸分析不是一种优化,而是一种静态分析。它针对某个对象 O、方法 M 和线程 T,回答两个问题:

  • Escapes(O, M):O 的生命周期是否会超过 M 的执行?例如把 O 的引用赋给一个全局静态字段,方法返回后这个引用仍然可达。
  • Escapes(O, T):O 是否会脱离当前线程?例如把 O 的引用复制到另一个线程可见的位置。

HotSpot 的 EA 基于 Choi 等人提出的流不敏感(flow-insensitive)算法。流不敏感意味着:只要对象在方法的任意一条控制流路径上逃逸,算法就保守地认为它在整个方法中都逃逸。

分析结果被归纳为三种逃逸状态:

  • NoEscape:对象既没有逃出方法,也没有逃出线程,是最理想的候选。
  • ArgEscape:对象作为参数传给了其他方法,但被调方法没有让它逃出调用线程。
  • GlobalEscape:对象逃出了方法或线程,例如被存入静态字段、被其他线程引用,或传给无法分析的方法。

这三种状态决定了后续能做什么优化。NoEscape 的对象有机会被标量替换;ArgEscape 通常只能做锁消除;GlobalEscape 基本无法优化。需要强调的是,HotSpot 并不做真正意义上的栈上分配(Stack Allocation)。资料明确指出 HotSpot 不执行 SA。社区里常说的“栈上分配”在 HotSpot 语境下实际指的就是标量替换:对象被拆成若干标量,原本的对象分配被消除,字段访问变成局部变量访问。

标量替换把对象拆成了什么

标量(scalar)指不可再分的基本量,例如 intlong、引用本身。聚合量(aggregate)是可以继续分解的量,一个对象就是聚合量。标量替换的做法是把对象的实例字段提升为独立的局部变量,把对 obj.field 的读写替换为对这些局部变量的读写,从而不再需要分配这个对象。

以贯穿场景中的请求上下文为例。假设热点路径上有一个只承载两个字段的临时对象:

final class RequestCtx {
    final int tenantId;
    final long deadlineNanos;
    RequestCtx(int tenantId, long deadlineNanos) {
        this.tenantId = tenantId;
        this.deadlineNanos = deadlineNanos;
    }
    int tenantId() { return tenantId; }
    long deadlineNanos() { return deadlineNanos; }
}

如果 tenantId()deadlineNanos() 被内联,且 RequestCtx 实例没有逃出当前方法,C2 可以把这两个字段提升为局部变量,消除 new RequestCtx(...)。此时对象从未在堆上出现,TLAB 也不参与。

标量替换能成立需要满足几个条件:

  1. 对象被判定为 NoEscape,或者至少不逃出当前编译单元。
  2. 访问该对象字段的方法被内联,使字段访问暴露在同一个编译单元内。
  3. 对象没有以无法追踪的方式被使用,例如没有被传入无法分析的方法、没有被存入未知位置。
  4. 对象没有参与控制流合并导致引用来源不确定的情况。

条件 2 常被忽视。SRA 高度依赖方法内联。如果访问字段的方法没有被内联,编译器看不到字段访问点,就无法把字段替换为局部变量。资料中给出的例子说明:当 otherMethod 被内联且方法体较大时,getP1 可能因为方法体过大而不再被内联,结果对象被认为逃逸,SRA 无法进行。内联和 EA/SRA 在 HotSpot 中并不互相通信,内联决策不会为了配合 SRA 而调整。

对象在 JVM 中的完整生命周期

把上面的机制放进一个请求处理的完整流程,可以看到对象从创建到被消除或被回收的路径。

flowchart TD
    A[方法进入: new RequestCtx] --> B{C2 是否已编译该方法}
    B -- 否 --> C[解释执行/分层编译中: 堆上分配, 走 TLAB]
    B -- 是 --> D[逃逸分析: 计算 Escapes O M 与 Escapes O T]
    D --> E{逃逸状态}
    E -- NoEscape --> F{字段访问方法是否内联}
    F -- 是 --> G[标量替换: 字段提升为局部变量, 分配消除]
    F -- 否 --> H[对象仍分配在堆上]
    E -- ArgEscape --> I[可能做锁消除, 不做 SRA]
    E -- GlobalEscape --> J[正常堆分配]
    C --> K[TLAB 快速分配]
    H --> K
    J --> K
    K --> L[对象变为垃圾, Young GC 回收]
    G --> M[无对象产生, 无 GC 压力]

关键转折点在 EF。逃逸状态决定对象是否有资格被拆解,内联决定编译器能否看到字段访问。两者都满足时,分配在编译期就被消除;任一不满足,对象回到堆上,走 TLAB 分配路径。

TLAB(Thread Local Allocation Buffer)是每个线程在 Eden 区预留的一小块私有缓冲区。线程分配对象时先在自己的 TLAB 内做指针碰撞,不需要和其他线程争抢。TLAB 让分配变快,但它不改变对象是否产生。标量替换消除的是分配这个动作本身,TLAB 优化的是分配发生时的开销。两者作用在不同层面:一个减少对象数量,一个降低单次分配成本。把两者混为一谈,就会误以为“有 TLAB 就不需要关心逃逸分析”。

哪些写法会让对象无法被拆解

资料列出了 HotSpot 当前 EA/SRA 实现的若干局限,这些局限直接对应到工程中常见的代码形状。

条件逃逸。流不敏感算法在对象于任意一条路径上逃逸时就认定它逃逸。例如:

public int conditionalEscape(boolean cond1) {
    RequestCtx o = new RequestCtx(1, 2L);
    if (cond1) {
        EscapeAnalysis.global_escape = o; // 逃逸到静态变量
    }
    return o.tenantId();
}

即使 cond1 在运行时几乎总是 false,算法仍认为 o 逃逸。Graal 编译器能做的部分逃逸分析(Partial Escape Analysis)可以把分配推迟到 if 分支内部,HotSpot 目前不具备这个能力。

控制流合并。当对象引用在合并点来源不确定时,SRA 不会进行:

public int escapeAnalysisFails(boolean cond, int x, int y) {
    RequestCtx o = new RequestCtx(0, 0L);
    if (cond) {
        o = new RequestCtx(x, y);
    }
    return o.tenantId();
}

合并点之后 o 可能指向前一个对象或 if 内新建的对象,编译器无法确定字段来源,于是放弃标量替换。

循环携带对象。循环中把临时对象赋值给跨迭代存活的引用时,SRA 往往不生效。资料指出,编译器不会对跨迭代携带的对象做标量替换。

跨过程分析限制。C2 的跨过程 EA 基于 Kotzmann 和 Mossenbock 的论文,在字节码层面进行,只分析静态方法调用。虚方法不被分析,其消费或返回的引用被保守认为逃逸。此外,被分析方法体有大小阈值:超过 150 字节的方法不被分析,其消费或返回的对象被视为 GlobalEscape。

这些限制合在一起解释了一个常见现象:把大方法拆小、把虚调用改成可内联的调用、避免在条件分支里把对象存到外部,往往比微调 GC 参数更能降低分配速率。

与替代方案的对比

在“减少短生命周期对象对 GC 的压力”这个问题上,工程上有几种可选路径。它们的收益、成本和适用边界不同。

方案作用层面典型收益主要成本适用边界
逃逸分析 + 标量替换C2 编译期消除分配,对象不进入堆依赖内联与代码形状,编译时间增加对象 NoEscape 且字段访问可内联
增大 TLAB运行时分配降低分配时的同步与慢路径概率内存占用上升,可能浪费分配速率高但对象确实需要存在
对象池 / 复用应用层减少分配次数需处理并发与生命周期,易引入状态残留对象创建昂贵、可安全复用
换用 Graal编译器支持部分逃逸分析等更多场景部署与兼容性成本愿意更换编译器的项目
调 GC 参数回收层缓解停顿表现不减少对象产生,治标分配本身无法避免时

这张表的核心判断是:标量替换减少的是对象数量,其他方案多数是在对象已经产生之后降低其代价。当分配速率是瓶颈时,优先检查代码是否给逃逸分析制造了障碍,通常比调 GC 参数更直接。

生产环境如何观测与定位

标量替换是否发生,不能靠猜。HotSpot 提供了诊断开关。资料和常见工程实践中,-XX:+PrintEliminateAllocations 可以打印标量替换的情况,-XX:+EliminateAllocations 控制标量替换开关,-XX:+EliminateLocks 控制锁消除。这些参数属于诊断性质,输出量大,不建议在生产长期开启,通常在预发或压测环境使用。

需要观察的信号包括:

  • 分配速率与晋升速率。如果分配速率高但晋升速率低,说明对象大多在 Young 区被回收,压力可控;如果晋升速率也高,说明对象存活时间超出预期,逃逸分析可能没生效。
  • Young GC 频率与停顿。分配速率直接决定 Young GC 频率。
  • 编译日志中的内联决策。用 -XX:+PrintInlining 可以看到哪些方法被内联、哪些因为方法体过大被拒绝。SRA 失败常常能在这里找到原因。
  • 去优化事件。如果标量替换后的代码因为假设失效被去优化,会重新分配对象,需要关注去优化计数。

定位路径通常是:先确认分配热点在哪个方法,再检查该方法是否被 C2 编译,然后检查字段访问方法是否被内联,最后检查对象是否在某个分支或调用中被存入外部位置。任何一步失败,标量替换都不会发生。

边界与不适用场景

逃逸分析和标量替换不是万能的,以下情况需要明确预期:

  • 对象确实需要跨方法或跨线程存活时,标量替换不适用,也不应该强行改写代码去迎合编译器。
  • 虚方法调用、反射、JNI 边界会让对象被视为逃逸。把这些调用放在热点路径上,会持续阻止优化。
  • 对象被存入集合、数组或静态字段后,逃逸状态变为 GlobalEscape,无法消除。
  • 流不敏感算法对条件逃逸的保守处理意味着:只要代码里存在一条逃逸路径,即使运行时几乎不走,优化也可能不生效。改写代码把分配移入分支内部,是可行的应对方式。
  • 不同 JVM 实现的行为不同。Graal 支持部分逃逸分析,HotSpot C2 不支持。把某个实现的细节当作所有 JVM 的规范行为,会导致错误的性能预期。

回到开头的订单服务。把热点循环从 foreach 改回下标访问之所以有效,是因为 ArrayList.Itr 的创建与使用需要 iteratorhasNextnext 全部内联后 SRA 才能生效。当其中某个方法因为方法体大小或调用形状没有被内联时,Itr 实例就留在堆上,每个循环都产生一个新对象。这不是 foreach 本身的问题,而是内联与逃逸分析协作失败的表现。理解这一点,就能把优化方向从“避免语法糖”转向“让热点路径上的对象满足 NoEscape 且字段访问可内联”。

资料来源

  1. HotSpot Escape Analysis and Scalar Replacement Status
  2. 23 | 逃逸分析-深入拆解Java虚拟机 - 极客时间