跳到主要内容
约 7 分钟3 张卡

方法内联与去优化

方法内联把被调用方法的代码纳入调用点,让即时编译器(JIT)能跨方法优化。本文先说明内联的收益和限制,再解释类型检查与去优化。去优化会在优化所依赖的假设失效时,恢复解释执行,保证程序继续按 Java 语义运行。

1. 面试核心​

先记住这几条,再读正文
  • 内联收益。 内联扩大优化范围,使常量传播和分支消除跨越方法边界。
  • 内联条件。 HotSpot 根据调用点热度、方法字节码大小和内联深度决定是否内联,以控制机器码膨胀。卡 · 内联条件
  • 类型画像。 虚方法调用点的接收者类型稳定时,JIT 可根据类型画像生成带类型检查的内联代码。
  • 执行状态恢复。 去优化用编译元数据重建解释器栈帧,以继续执行 Java 语义。卡 · 去优化
  • 对象重建。 去优化能重新创建被标量替换消除的对象。卡 · 栈帧与对象恢复

2. 内联的收益​

内联既能省去方法调用,也能让编译器看到调用前后的计算。例如,下面的调用把折扣比例固定为 10:

int price(int subtotal) {
return discount(subtotal, 10);
}

int discount(int subtotal, int rate) {
return subtotal - subtotal * rate / 100;
}

内联后,编译器在调用点能看到 rate 恒为 10。它可以把这个常量代入计算,这称为常量传播。这样一来,后续计算就有机会进一步简化。

当编译器能同时分析调用者和被调用方法时,还能尝试以下跨方法优化:

  • 消除条件已确定为假的分支,省去永远不会执行的代码。
  • 分析临时对象是否会被外部使用,从而消除完整对象分配。
  • 在能证明下标安全时,消除重复的数组边界检查。
  • 找出循环中始终不变的计算,将它移到循环之外。
  • 继续内联下层调用,让更多代码一起参与优化。

因此,小型 getter 内联后能省去独立调用。具体收益取决于调用位置和最终机器码,Java 语言本身不保证方法一定会被内联。

3. 内联条件​

内联需要在优化收益与代码体积之间取舍。把方法体复制到多个调用点,会增加机器码大小。机器码越多,编译耗时和指令缓存压力也越大。指令缓存是 CPU 用来暂存待执行指令的高速存储。

HotSpot 会综合考虑以下内联因素:

  • 调用点的热度决定了优化后能节省多少重复执行成本。
  • 方法字节码大小影响内联后的代码体积。
  • 内联深度和递归深度决定还能展开多少层调用。
  • 异常路径和历史画像帮助编译器判断哪些路径值得展开。
  • 总代码预算限制一次编译可以扩大的范围。

static、private、final 有助于确定调用目标,普通虚方法也能依赖画像内联。因此,仅为追求内联而把公共 API 全部改成 final,缺少充分依据。即使调用目标明确,方法仍需满足内联预算。

4. 投机内联​

虚方法的实际实现由接收者对象决定。接收者就是接收这次方法调用的对象,例如 obj.encode() 中的 obj。类型画像记录了调用点实际遇到过哪些类型。

例如,Codec 接口的 encode 方法接收 Message,返回编码后的字节数组。某个调用点长期只接收 JsonCodec 类型的对象。JIT 可以据此假设这个类型会继续占多数,生成带类型检查的内联代码。匹配时执行内联代码,否则进入回退路径:

if (receiver 的类型是 JsonCodec) {
执行内联后的 encode
} else {
执行通用调用或触发去优化
}

这段伪代码中的检查称为类型守卫,它保证内联代码只用于匹配的类型。调用点只有少量稳定类型时,也可以为不同类型生成多个内联分支。类型继续增多后,检查分支和代码体积都会增长,这时保留间接调用更合适。

另一种依据是类层级分析:编译器根据当前已加载的类,判断有哪些方法实现可被调用。如果新加载的类破坏了原有判断,JVM 就需要使依赖该判断的编译代码失效。

5. 去优化​

投机优化让常见情况运行得更快,去优化则负责在假设失效后继续正确执行。编译器依赖的运行时假设包括:

  • 调用点长期只有一种接收者类型。
  • 某个分支几乎不会执行。
  • 当前类层级中只有一个可用的方法实现。
  • 某个值在当前优化依赖的条件下可以视为常量。

类型守卫失败、进入罕见分支或编译依赖失效,都可触发去优化。JVM 根据编译时保存的信息,恢复解释器能够继续执行的栈帧。随后,它继续收集类型和分支画像,再按策略生成新的编译版本。

出现新类型不一定触发去优化。如果编译代码已保留通用回退路径,就可以直接调用对应方法。新类加载也只会影响依赖失效假设的代码。

频繁去优化和重新编译会消耗 CPU,并带来延迟波动。因此,排查时要把流量分布、类加载和编译事件放到同一时间线上比较。

5.1 栈帧与对象恢复​

去优化需要恢复的不只是执行位置,还包括每个方法当时的局部变量和对象。一次机器码执行经过内联后,可以包含多个逻辑方法。HotSpot 会保存字节码位置、局部变量值和锁状态等元数据。去优化时,它据此展开多个解释器栈帧,让各个方法恢复到对应位置。

如果对象分配已被标量替换消除,JVM 可以重新分配对象,再把保存的字段值填回去。恢复后从对应字节码位置继续执行,所以已经完成的写入等副作用无需从头重做。

标量替换将对象拆成独立字段参与计算,具体机制见 逃逸分析与标量替换。

6. uncommon trap​

uncommon trap 用于处理编译器认为几乎不会执行的路径。JIT 可以把这部分逻辑移出主要机器码,实际遇到时再触发去优化,交给解释器执行。这样能缩小常见路径的代码体积。

比如,压测只覆盖正常输入,校验失败分支就会显得很冷。生产中若突然出现大量无效输入,原来的罕见路径会频繁触发陷阱,增加恢复和重新编译的成本。HotSpot 会根据新画像调整编译结果,所以测试数据应包含真实的类型和分支比例。

7. 编译日志​

方法是否内联,需要查看实际编译结果。在诊断环境中,可以启用以下日志:

java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintCompilation -XX:+PrintInlining -jar app.jar

PrintInlining 输出内联结果和拒绝原因,PrintCompilation 显示编译层级与版本状态。需要分析完整编译日志时,可额外启用 -XX:+LogCompilation,再配合 JITWatch 等工具阅读。Java Flight Recorder(JFR)是 JVM 的运行事件记录工具。它的编译、去优化和 Code Cache 事件可帮助建立时间线。

分析时,围绕热点与编译结果依次确认:

  1. 用性能采样确认目标调用点确实消耗了较多 CPU。
  2. 查看目标方法是否编译到了预期层级。
  3. 检查内联拒绝原因,判断限制来自类型还是代码预算。
  4. 将去优化事件与流量、类加载和输入变化对应起来。
  5. 修改后用 JMH 检查局部收益,再用业务负载验证整体效果。

这些日志选项属于 HotSpot 诊断接口,输出格式会随 JDK 变化。升级后应重新核对日志和工具的解析结果。

8. 常见问题​

8.1 小方法的内联条件​

小方法也需要满足热度、代码预算和调用类型等条件,才会被内联。方法体小能降低代码膨胀的代价,但递归深度和编译层级仍会影响决策。

8.2 内联后的逻辑栈​

8.3 去优化与性能波动​

9 · 面试题卡片​

9.1 JVM 在什么情况下会进行方法内联​

出现公司:京东数科

考察重点:解释调用热度、代码预算和类型画像怎样影响内联,以及内联为何能扩大优化范围。

相关内容:内联条件。

参考回答

HotSpot 综合调用点热度、方法大小、内联深度和代码预算,决定是否内联。

静态、私有或 final 方法的目标更容易确定。普通虚方法若长期只遇到少量类型,也能根据类型画像,生成带类型守卫的内联代码。

内联让编译器同时分析调用前后的计算,从而进行常量传播、分支消除和逃逸分析。省去调用只是其中一项收益,具体结果可用 PrintInlining 核对。

9.2 什么是去优化,为什么优化后的代码还要退回去​

出现公司:京东数科

考察重点:解释优化依赖哪些假设、哪些变化会让假设失效,以及 JVM 怎样恢复解释执行。

相关内容:去优化。

参考回答

去优化是在投机假设失效时,将编译代码的执行状态恢复为解释器状态的过程。

比如,调用点出现了原先未覆盖的类型,或者新类让类层级依赖失效。继续沿用原优化路径会破坏语义,因此 HotSpot 用元数据重建栈帧,再从对应位置执行。

如果编译代码已保留通用回退路径,新类型也能直接走回退调用。之后,JVM 会根据更新后的画像重新编译。频繁恢复和编译会消耗 CPU,并造成延迟波动。

9.3 方法已内联、对象已被消除,去优化怎样继续执行?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明内联方法的栈帧和被消除的对象怎样重建,以及为何无需重做已经完成的副作用。

相关内容:栈帧与对象恢复。

参考回答

HotSpot 根据编译时保存的元数据,重建解释器栈帧和被消除的对象。

一个机器码栈帧中内联了多个方法时,需要分别恢复这些方法的执行位置、局部变量和锁状态。

如果后续执行需要完整对象,JVM 会重新分配对象并回填字段。程序随后从对应字节码位置继续,所以已经发生的业务副作用无需从方法开头重做。

10. 参考资料​