一个看似矛盾的观测
一个高并发订单服务在压测时出现周期性的 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)指不可再分的基本量,例如 int、long、引用本身。聚合量(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 也不参与。
标量替换能成立需要满足几个条件:
- 对象被判定为 NoEscape,或者至少不逃出当前编译单元。
- 访问该对象字段的方法被内联,使字段访问暴露在同一个编译单元内。
- 对象没有以无法追踪的方式被使用,例如没有被传入无法分析的方法、没有被存入未知位置。
- 对象没有参与控制流合并导致引用来源不确定的情况。
条件 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 压力]
关键转折点在 E 和 F。逃逸状态决定对象是否有资格被拆解,内联决定编译器能否看到字段访问。两者都满足时,分配在编译期就被消除;任一不满足,对象回到堆上,走 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 的创建与使用需要 iterator、hasNext、next 全部内联后 SRA 才能生效。当其中某个方法因为方法体大小或调用形状没有被内联时,Itr 实例就留在堆上,每个循环都产生一个新对象。这不是 foreach 本身的问题,而是内联与逃逸分析协作失败的表现。理解这一点,就能把优化方向从“避免语法糖”转向“让热点路径上的对象满足 NoEscape 且字段访问可内联”。