Skip to content

JIT 与逃逸分析:new 出来的对象不一定真的分配 ​

Java 代码里写了 new,不代表运行时一定在堆上分配了一个对象。热点代码被 JIT 编译后,只在方法内部使用的对象可以被拆成几个局部变量、完全消除分配。但这是编译器的优化,不是语言的承诺,一个看似无关的改动就能让它失效。

同一个方法:new Point(x, y),再算一次距离平方。JIT 编译之后,每次调用分配 0 字节;把这个点顺手写进一个静态字段,变成每次 24 字节;把它传给一个接口方法,调用点只见过 2 种实现时仍是 0 字节,见过 4 种实现后又变回 24 字节。

本文用 ThreadMXBean.getThreadAllocatedBytes 直接测量每次调用分配的字节数,看逃逸分析和标量替换在什么条件下成立、什么条件下失效。结果来自 JDK 21 的 HotSpot C2 编译器。

一、先说结论 ​

  • 不逃逸的对象可以不分配:点只在方法内使用时,编译后每次调用 0 字节;关闭逃逸分析(-XX:-DoEscapeAnalysis)或只解释执行(-Xint)时,每次 24 字节。
  • 逃逸分析依赖内联:对象传给另一个方法时,只有那个方法被内联,编译器才能看清对象的去向;禁止内联后每次 24 字节。
  • 调用点的多态程度会改变结果:接口调用点只见过 2 种实现时能内联,分配被消除;见过 4 种实现后不再内联,对象逃逸。
  • 优化需要预热:第一批调用里还有解释执行和低层级编译的代码,平均每次分配几个到十几个字节。
  • 不要为猜测中的优化改写代码:先用 JFR、分配剖析确认热点,再决定要不要调整;微基准用 JMH。

二、从解释执行到编译 ​

HotSpot 先解释执行字节码,同时收集运行数据:哪些方法调用得多、每个调用点实际见过哪些类型、分支走向如何。热点方法先由 C1 快速编译,更热的再由 C2 基于这些数据做激进优化。数据描述的情况变了(例如出现了新的实现类),编译后的代码就要去优化,回退到解释执行,重新收集、重新编译。

几项和本文有关的优化:

优化做什么前提
内联把被调用方法的代码复制到调用点方法足够小、足够热,调用点的目标类型可预测
逃逸分析判断一个对象是否会被方法之外看到对象的所有使用都在编译器能看到的代码里
标量替换不创建对象,把它的字段变成局部变量对象不逃逸,且不需要作为整体存在

三者是连在一起的:内联把更多代码放进同一个编译单元,逃逸分析才能看清对象的去向,标量替换才能消除分配。

三、实测:同一个方法在八种条件下 ​

java
record Point(int x, int y) {}

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

每种条件单独启动一个 JVM,跑 40 批、每批 20 万次调用,看最后 10 批每次调用的分配字节数:

条件第 1 批稳定后
点只在方法内使用4.20
点传给另一个静态方法(能被内联)18.10
点传给接口方法,调用点见过 2 种实现2.60
点写进静态字段24.024
点传给接口方法,调用点见过 4 种实现24.024
点只在方法内使用,-XX:-DoEscapeAnalysis24.024
点传给另一个方法,-XX:CompileCommand=dontinline24.024
点只在方法内使用,-Xint24.024

一个 Point 在这台机器上占 24 字节:12 字节对象头、两个 4 字节的 int,再按 8 字节对齐。

热点代码被 C2 编译?对象用到的方法都被内联?对象没有逃出方法?标量替换0 字节 / 次是是是-Xint24 字节dontinline · 超多态24 字节写进静态字段24 字节否否否双态调用点仍可内联
图 1 · 每一步都满足时,对象被标量替换,每次调用分配 0 字节;任何一步不满足(只解释执行、禁止内联或超多态调用点、写进字段),都回到每次 24 字节

3.1 逃逸:被方法之外看到 ​

写进静态字段之后,别的线程随时可能读到这个对象,它必须以完整的对象存在,分配无法消除。返回对象、写进另一个对象的字段、放进集合、传给编译器看不见的方法,都属于逃逸。

3.2 内联:编译器能不能看到对方的代码 ​

call 场景把点传给 consume(Point p)。默认情况下 consume 很小,被内联进调用方,编译器看到它只读取了两个字段,于是整个对象被消除。用 -XX:CompileCommand=dontinline,Escape::consume 禁止内联之后,编译器只知道「这个对象被传给了一个方法」,只能保守地认为它逃逸了。

3.3 调用点的多态程度 ​

接口方法 Metric.apply(Point) 有 4 个实现。调用点在运行中只见过 2 种实现时,C2 会生成「如果是 A 就执行 A 的代码,如果是 B 就执行 B 的代码」的双态内联,点依然不逃逸。见过 4 种实现后,调用点变成超多态,只能走虚方法调用,对象被传给一个看不见的方法,每次分配 24 字节。

这意味着:给一个接口新增实现类,可能让某个热点路径的分配悄悄增加。它不改变程序的正确性,只改变性能特征,所以很难在代码评审里发现。

四、「栈上分配」这个说法 ​

常听到「逃逸分析之后对象在栈上分配」。HotSpot 的做法更准确的描述是标量替换:对象根本不存在,它的字段变成了寄存器或栈上的局部变量。结果是同样的「没有堆分配」,但机制不同:只要对象需要作为一个整体存在,例如被存进字段、返回给调用方、传给没有被内联的方法,标量替换就做不成。

更重要的是,这是 HotSpot 在当前版本的实现行为。Java 语言规范只规定程序的可观察结果,不承诺对象在哪里分配;换一个 JIT 编译器、换一个版本、换一组参数,结果都可能不同。

五、工程上怎么用这些知识 ​

写代码时不必为它让路。 为了「让对象不逃逸」而把一个清晰的值对象拆成几个参数,是用可读性去换一个没有证据的收益。JDK 21 的 record、不可变值对象在热点路径上常常能被消除;不能消除时,年轻代分配本身也很便宜。

排查分配问题时可以用它解释现象:

  • 同一段代码压测时分配率很低,上线后变高:看看线上是否有更多实现类参与了某个接口调用;
  • 调试参数、Agent 改变了内联决策,性能随之变化;
  • 刚启动时分配率高、之后下降:预热还没完成。

测量要用对工具:

  • 看分配:JFR 的 jdk.ObjectAllocationSample,或者像本文这样在测试里用 ThreadMXBean.getThreadAllocatedBytes;
  • 看编译与去优化:JFR 的编译事件,或 -XX:+PrintCompilation;
  • 做微基准:用 JMH,它负责预热、防止死代码消除、隔离多次运行;手写的计时循环很容易测到的是别的东西。
  • 看单次操作的尾部:JMH 给的是平均或分位数,要找「哪一次特别慢」需要逐次计时,做法与注意事项见 平均 86 纳秒的 put,为什么有一次要 21 毫秒。

GC 层面的分配压力与停顿,见 G1 与 ZGC;线上排查的整体思路,见 JVM 调优与线上排查。

六、常见误区 ​

  • 「Java 对象都分配在堆上」:语言层面不承诺分配位置,JIT 可以完全消除分配。
  • 「逃逸分析后对象在栈上」:HotSpot 做的是标量替换,对象被拆成局部变量,而不是整体搬到栈上。
  • 「方法越小越好,JIT 会全部内联」:内联受大小、热度和调用点多态程度限制,超多态调用点不内联。
  • 「手写一个循环计时就能验证优化」:没有预热、没有防止死代码消除,测到的可能是解释执行或被整个删除的代码。
  • 「为了性能尽量避免创建小对象」:先有证据;很多小对象在热点路径上根本不会分配。

小结 ​

逃逸分析和标量替换能让不逃逸的对象完全不分配,但它依赖编译、依赖内联,也依赖调用点的类型剖析数据。实测里,写进静态字段、禁止内联、调用点变成超多态,都让每次调用从 0 字节回到 24 字节。把它当作解释现象的工具,而不是写代码时的前提;需要优化分配时,先测量,再改代码。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权