逃逸分析与标量替换
逃逸分析判断新对象会被哪些方法或线程访问。本文以 Java 17、21 的 HotSpot C2 为例,介绍分析结果怎样用于优化。C2 是 HotSpot 中负责高层级优化的即时编译器。下文先说明怎样判断逃逸,再讲标量替换与锁消除,最后用分配数据验证效果。
1. 面试核心
先记住这几条,再读正文- 逃逸判断。 逃逸分析依据内联后的调用关系,判断对象是否会被外部方法或其他线程访问。
- 分配消除。 C2 对满足优化条件的不逃逸对象做标量替换,将字段拆成独立值并消除分配。卡 · 对象逃逸的判断
- 锁消除。 C2 证明对象只由当前线程使用时,可以消除该对象上的同步锁。卡 · 标量替换
- 分支合并。 JDK 21 C2 的流不敏感分析会合并不同分支的逃逸结果。卡 · 条件逃逸
2. 对象逃逸的判断
判断逃逸,要看对象创建后被交给了谁。返回对象、写入外部可达的字段,都会扩大它的可见范围。把对象交给无法分析的调用时,编译器也需要保守地保留分配。下面的方法只返回坐标计算结果:
record Point(int x, int y) {}
static long distance(int x1, int y1, int x2, int y2) {
Point left = new Point(x1, y1);
Point right = new Point(x2, y2);
return Math.abs((long) left.x() - right.x())
+ Math.abs((long) left.y() - right.y());
}
两个 Point 都只用于当前计算,方法返回的是距离。内联构造方法和坐标访问方法后,C2 就能看到这两次分配的完整用途。减法前转为 long,是为了避免两个 int 坐标相减时溢出。
可以用以下三种写法检查对象被交给了谁:
| 写法 | 对分析的影响 |
|---|---|
return point; | 对象被交给调用方,需要结合内联后的调用关系判断。 |
this.latest = point; | 若 this 已被外部持有,point 也会通过字段被访问。 |
consumer.accept(point); | 调用目标无法分析时,编译器需要保守判断对象用途。 |
作为参数传递只是线索。若调用目标已内联,编译器仍可跨方法证明对象没有逃逸。
3. 标量替换
对象只用来临时组合几个字段时,计算未必需要完整的对象。标量替换就是把这些字段拆成独立值,让编译器直接使用。前例中的 Point 可以拆成坐标值,再用坐标计算距离:
分配 Point → 读取坐标 → 计算
↓
直接使用坐标计算
对象身份无需保留,且标量替换成功时,C2 就可以消除完整对象的分配。运行时只需用寄存器或栈槽保存独立值,省去对象头和完整字段布局。这也减少了堆分配量与后续 GC 压力。
优化后的代码若退回解释执行,执行过程又需要这个对象,JVM 可以将它重新创建出来。这称为重新物化,所需字段值由去优化元数据记录。
4. 锁消除
锁用于协调多个线程访问同一个对象。如果编译器证明对象只由当前线程使用,这些同步操作就没有线程间竞争。C2 因而可以消除对应的 monitor,也就是对象同步锁操作:
String format(int id, int status) {
StringBuffer buffer = new StringBuffer();
buffer.append(id).append(':').append(status);
return buffer.toString();
}
buffer 只在当前调用中使用,StringBuffer 的方法本身带有同步。C2 能证明它是线程私有对象时,可以省去这些锁操作。编写单线程字符串拼接代码时,仍应直接选择语义匹配的 StringBuilder。
锁粗化处理的是另一类成本:代码相邻多次锁定同一对象时,编译器可以把同步区间合并。这样会扩大临界区,但能减少重复进入和退出同步区间的开销。
5. 优化条件
对象没有逃逸,说明它具备进一步优化的条件。能否消除完整分配,还取决于编译器能看到多少代码,以及对象的使用方式。分析时需要关注以下五类限制:
- 调用没有内联,且编译器无法确定被调用方法怎样使用对象。
- 对象身份需要保留,或反射、native 调用让对象用途难以分析。native 调用指执行本地代码的方法调用。
- 控制流复杂,编译器难以把各条路径上的字段值拆开处理。
- 方法仍在解释执行,或尚未进入 C2 优化层级。
- 编译时间和优化预算有限,且不同 JDK、编译器的实现能力有差异。
所以,源码中只看到局部变量,只能说明对象的写法,优化结果仍需测量。
5.1 条件逃逸
条件分支会影响同一个分配点的优化。JDK 21 C2 使用流不敏感逃逸分析,也就是把保留分支上的逃逸结果合并判断。某个分支将对象写入全局字段,就会影响整个分配点:
static Point saved;
static int compute(boolean publish, int x) {
Point p = new Point(x, 1);
if (publish) {
saved = p;
}
return p.x();
}
如果发布分支仍在编译器分析的代码图中,p 就会被判为全局逃逸。即使大多数调用传入 false,这次分配仍受到该分支的限制。部分逃逸分析可以按路径区分逃逸情况,其他编译器是否支持它,需要看具体实现。
6. 栈上分配
理解分配消除后,就能区分完整对象和独立字段值。JDK 21 HotSpot C2 主要通过标量替换省去完整对象。即使某些字段值放在栈槽里,也不能据此说对象整体被搬到了线程栈。
保留的对象分配仍属于堆分配。TLAB 是堆内线程私有分配区,见Java 对象布局。
7. 分配数据验证
验证优化是否发生,要比较实际分配量。JMH 的 GC profiler 是用于观察分配与回收的分析器,可以测量每次操作分配了多少字节:
java -jar target/benchmarks.jar EscapeBenchmark -prof gc
java -jar target/benchmarks.jar EscapeBenchmark -prof gc -jvmArgsAppend -XX:-DoEscapeAnalysis
第二组关闭逃逸分析,并在独立 fork 中运行。fork 指 JMH 启动的独立 JVM 进程,用于隔离两组实验。两组都充分预热后,比较 gc.alloc.rate.norm,它表示每次操作的分配字节数。关闭分析只用于实验对照,生产配置仍需按真实负载评估。
基准应返回计算出的标量结果,或将该结果交给 JMH 的 Blackhole 消费。这样能避免结果无人使用而被整段删除。还要使用不同输入参数,避免计算被常量折叠。把整个待测对象交给外部会改变逃逸条件,因此要区分对象和计算结果。
基准设计见JMH。
JFR 的 New TLAB、Outside TLAB 分配事件可以帮助定位对象创建位置。async-profiler 的 allocation 模式也能查找分配热点。事件和样本各有记录范围,具体见GC 停顿分析。
线上验证还要比较每请求分配字节数、每秒分配率、GC CPU 和端到端吞吐。单次操作变快,只有转化为这些指标的改善,才能说明服务获得了收益。
8. 常见问题
8.1 对象没有逃出方法就一定不在堆上吗?
局部对象仍可保留堆分配。C2 要先成功分析对象用途,再完成标量替换。方法尚未编译、调用无法分析或对象身份需要保留时,源码中的局部作用域就不足以保证分配消除。
8.2 逃逸分析能消除所有临时集合吗?
临时集合比只含几个字段的小对象更难优化。集合包含数组,还会动态扩容并经过复杂调用,这些都增加了标量替换的难度。所以,减少中间集合仍应优先从算法和 API 使用方式入手。
8.3 分配很快,为什么还要关注对象数量?
TLAB 让分配路径更短,但每个对象仍需要初始化,并占用内存带宽。分配产生的垃圾还需要后续 GC 处理。只有分析结果表明分配率限制性能时,才值得为减少对象而增加代码复杂度。
9 · 面试题卡片
9.1 Java 对象一定分配在堆上吗
出现公司:字节跳动、美团、高德
考察重点:区分规范中的对象存储与 C2 分配消除。
参考回答
JVM 规范将对象存储归入堆,但 JIT 可以通过等价优化消除对象分配。
C2 证明对象满足优化条件后,会把字段拆成独立值。这时计算仍能完成,运行时却无需存在一个完整的堆对象。
完整对象放在线程栈上是另一种实现方式。C2 是否消除分配,取决于逃逸结果、内联、编译层级和控制流。不同 JDK 实现也有差异,最终应通过实际分配数据验证。
9.2 逃逸分析可能带来哪些优化
出现公司:满帮
考察重点:理解分析结果对标量替换和锁消除的作用。
参考回答
逃逸分析为标量替换、分配消除和锁消除提供依据。
C2 把符合条件的对象拆成独立字段值后,就可以省去完整对象的分配。如果对象只由当前线程使用,对应的 monitor 操作也可消除。
内联能让分析跨越方法边界,但分析结果仍需满足具体优化的条件。对象身份需要保留、控制流过于复杂或代码尚未进入 C2 时,分配仍可保留。
9.3 对象只在极少数分支发布,其他调用就能消除分配吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释 C2 为什么合并分支的逃逸结果,以及低频发布如何影响同一分配点。
相关内容:条件逃逸。
参考回答
仅凭发布分支执行得少,无法保证其他调用消除分配。
JDK 21 C2 会合并仍保留在编译图中的分支结果。只要其中一个分支把对象写入全局字段,同一分配点就会被判为全局逃逸。所以,需要确认发布分支是否仍被保留,再检查实际分配量。