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 基于这些数据做激进优化。数据描述的情况变了(例如出现了新的实现类),编译后的代码就要去优化,回退到解释执行,重新收集、重新编译。
几项和本文有关的优化:
| 优化 | 做什么 | 前提 |
|---|---|---|
| 内联 | 把被调用方法的代码复制到调用点 | 方法足够小、足够热,调用点的目标类型可预测 |
| 逃逸分析 | 判断一个对象是否会被方法之外看到 | 对象的所有使用都在编译器能看到的代码里 |
| 标量替换 | 不创建对象,把它的字段变成局部变量 | 对象不逃逸,且不需要作为整体存在 |
三者是连在一起的:内联把更多代码放进同一个编译单元,逃逸分析才能看清对象的去向,标量替换才能消除分配。
三、实测:同一个方法在八种条件下
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.2 | 0 |
| 点传给另一个静态方法(能被内联) | 18.1 | 0 |
| 点传给接口方法,调用点见过 2 种实现 | 2.6 | 0 |
| 点写进静态字段 | 24.0 | 24 |
| 点传给接口方法,调用点见过 4 种实现 | 24.0 | 24 |
点只在方法内使用,-XX:-DoEscapeAnalysis | 24.0 | 24 |
点传给另一个方法,-XX:CompileCommand=dontinline | 24.0 | 24 |
点只在方法内使用,-Xint | 24.0 | 24 |
一个 Point 在这台机器上占 24 字节:12 字节对象头、两个 4 字节的 int,再按 8 字节对齐。
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 字节。把它当作解释现象的工具,而不是写代码时的前提;需要优化分配时,先测量,再改代码。
配套实验
- codesphere-labs/java/jit-and-escape-analysis:同一个方法在默认、逃逸、调用、双态与超多态、关闭逃逸分析、禁止内联、解释执行下每次调用的分配字节数(验证记录)
参考资料