37-编译优化-方法内联
引言
在上一篇介绍JIT编译器分工后,本篇开始深入具体的编译优化。**方法内联(Method Inlining)**被誉为JIT"优化之母"——它是几乎所有其他优化的前提:没有内联,逃逸分析的边界就被方法调用割裂,循环展开无法跨方法,常量传播也会在调用边界处中断。
方法内联的收益不只是"省去一次方法调用的开销"(压栈、跳转、出栈本身很便宜),更在于为后续优化打开视野。一个被内联的小方法,可能让整个调用链路上的常量传播、死代码消除、逃逸分析一次性生效,带来数量级的性能提升。理解内联的条件与限制,是诊断"我的代码为什么慢"的关键能力。
方法内联的本质
方法内联就是把被调用方法的代码"复制"到调用点,消除方法调用本身。看一个最简单的例子:
// 内联前
static int add(int a, int b) {
return a + b;
}
static int compute(int x) {
return add(x, 1) * 2;
}
// 内联后(等价于)
static int compute(int x) {
return (x + 1) * 2;
}
表面收益是省掉了一次call指令和返回。但真正的收益在于:内联后编译器能看到add的完整逻辑,可以进一步做常量折叠、死代码消除等。如果add内部还有分支,内联后这些分支也可能被调用点的上下文消除掉。
内联的收益为何远超"省一次调用"
一个被频繁引用的例子:
// 内联前
static boolean isZero(int v) { return v == 0; }
static void process(int[] data) {
for (int i = 0; i < data.length; i++) {
if (isZero(data[i])) {
// 处理零值分支
} else {
// 处理非零分支
}
}
}
如果不内联isZero,每次循环迭代都是一次方法调用,且编译器无法判断分支走向。内联后:
static void process(int[] data) {
for (int i = 0; i < data.length; i++) {
if (data[i] == 0) { // 内联展开
// 编译器可基于profiling消除某个分支
}
}
}
进一步,如果profiling显示这个数组从未出现过0,C2甚至会把整个if优化掉。这种跨方法的优化,只有内联后才能发生。
内联的条件
JIT不会无脑内联所有方法。内联是有代价的——CodeCache膨胀、编译时间增加。HotSpot通过一组规则判断是否内联:
方法大小
最直接的限制是方法的字节码大小。相关参数:
- -XX:MaxInlineSize:默认35字节。小于这个大小的"小方法"无论调用频率如何,都会尝试内联
- -XX:FreqInlineSize:默认325字节(JDK 11/17,64位)。小于这个大小且调用频繁的方法才会被内联
# 查看默认值
java -XX:+PrintFlagsFinal -version | grep -i inlinesize
# intx MaxInlineSize = 35
# intx FreqInlineSize = 325
需要注意的是,这里说的是字节码大小,不是源码行数。一个看似简短的方法如果包含复杂表达式,字节码可能很长。
调用频率
大方法(超过MaxInlineSize但小于FreqInlineSize)只有在被频繁调用时才内联。判断依据是profiling数据——方法的调用次数和回边次数。
是否final/static/private
final、static、private方法都是静态绑定的,编译器能确定唯一的调用目标,内联没有障碍。但实例方法(invokevirtual)就涉及虚方法分派,需要额外处理(下一节详述)。
异常处理与本地方法
- 包含try-catch的方法内联受限(JDK 8的C2基本不内联带异常处理的方法,JDK 11+有所放宽)
- native方法不能内联(它们是JNI调用,无字节码可内联)
- 构造方法内联有限制(涉及对象初始化语义)
虚方法内联:最棘手的部分
invokevirtual调用的方法在运行时才能确定具体实现(多态)。JIT如何内联一个"不知道调谁"的方法?答案是去虚化(Devirtualization)。
CHA:类层次分析
Class Hierarchy Analysis(CHA)是C1使用的去虚化手段。编译时扫描已加载的类,如果某个接口/虚方法在当前类层次中只有一个实现,就把它当作final方法直接内联。
interface Worker { void work(); }
class SingleWorker implements Worker {
public void work() { /* 唯一实现 */ }
}
void run(Worker w) {
w.work(); // CHA发现只有一个实现,直接内联
}
但CHA是静态分析,它不知道运行时是否会加载新的实现类。所以C1会在内联代码旁插入"守卫":一旦新类被加载导致多实现,触发逆优化,回退到解释执行并重新编译。
Inline Cache与多态性
C2不依赖CHA,而是基于profiling数据做更精确的去虚化。每个虚方法调用点维护一个内联缓存(Inline Cache),记录历史上接收者的实际类型。根据类型分布,分为三种状态:
Monomorphic(单态)
调用点历史接收者只有一种类型。这是最理想的情况,C2直接内联该类型的方法,并在入口插入类型检查:
if (recv.getClass() == ExpectedClass) {
// 内联 ExpectedClass.method() 的代码
} else {
// uncommon trap,回退
}
绝大多数虚方法调用点在运行时都是单态的(即使方法本身是多态的,特定调用点往往只看到一种类型)。
Bimorphic(双态)
调用点历史接收者有两种类型。C2仍能内联,生成"二选一"的快速路径:
if (recv.getClass() == ClassA) {
// 内联 ClassA.method()
} else if (recv.getClass() == ClassB) {
// 内联 ClassB.method()
} else {
// uncommon trap
}
双态内联是HotSpot的一个特色优化——很多JVM只处理单态,而HotSpot额外覆盖了双态。
Megamorphic(多态)
调用点接收者超过两种类型。此时C2无法内联,退化为标准的虚方法表(vtable)查找。这就是"多态杀死内联"的根源。
多态性的工程启示
理解了内联缓存的三态,就能解释一些性能反直觉的现象:
// 场景一:单态,可内联
List<String> list = new ArrayList<>();
for (int i = 0; i < n; i++) {
list.add("x"); // 调用点只见过ArrayList,单态
}
// 场景二:双态,仍可内联
List<String> list = (flag ? new ArrayList<>() : new LinkedList<>());
for (int i = 0; i < n; i++) {
list.add("x"); // 调用点见过ArrayList和LinkedList,双态
}
// 场景三:多态,无法内联
List<String>[] lists = { new ArrayList<>(), new LinkedList<>(), new CopyOnWriteArrayList<>() };
for (List<String> list : lists) {
for (int i = 0; i < n; i++) {
list.add("x"); // 调用点见过3种类型,多态
}
}
场景三的性能可能比场景一低数倍,原因就是虚方法调用无法内联,且后续优化链断裂。
内联缓存的工作机制
内联缓存本质是调用点(CallSite)级别的状态记录。HotSpot的实现在方法的**MethodData(MDO)**中为每个调用点维护一个类型profile槽位。
Profile的收集
层级3的C1编译代码会在每次虚方法调用时记录接收者类型:
调用 s.work()
│
▼
查MDO中该调用点的profile槽位
│
▼
记录类型:是已记录的类型?是新类型?
│
▼
更新计数:单态→双态→多态
Profile的容量限制
每个调用点的profile槽位有限(默认记录2个类型,对应双态阈值)。一旦超过,标记为多态,不再更新。相关参数:
-XX:TypeProfileWidth=2 # 默认2,可调大但收益有限
调大TypeProfileWidth看似能覆盖更多多态场景,但会增加MDO内存占用和profiling开销,且双态以上的内联收益本就急剧下降,实际中很少调整。
代码示例:内联前后性能对比
下面通过一个完整示例直观感受内联的性能影响。
// 适用 JDK 11/17
public class InliningDemo {
// 小方法,会被内联
static int square(int x) {
return x * x;
}
// 模拟大方法,超过 MaxInlineSize 但小于 FreqInlineSize
static int heavyCompute(int x) {
int a = x * 2 + 1;
int b = a * a – x;
int c = b % 3 + a;
int d = c * c – b;
int e = d + a – c;
return e * 2;
}
static long testInline(int n) {
long sum = 0;
for (int i = 0; i < n; i++) {
sum += square(i); // 小方法,必然内联
}
return sum;
}
static long testNoInline(int n) {
long sum = 0;
for (int i = 0; i < n; i++) {
sum += heavyCompute(i); // 大方法,可能不内联
}
return sum;
}
public static void main(String[] args) {
// 预热
for (int i = 0; i < 50_000; i++) {
testInline(100);
testNoInline(100);
}
long start = System.nanoTime();
long r1 = testInline(10_000_000);
long t1 = System.nanoTime() – start;
start = System.nanoTime();
long r2 = testNoInline(10_000_000);
long t2 = System.nanoTime() – start;
System.out.printf("testInline: %d, %d ms%n", r1, t1 / 1_000_000);
System.out.printf("testNoInline: %d, %d ms%n", r2, t2 / 1_000_000);
}
}
用不同参数运行对比:
# 正常分层编译(内联开启)
java InliningDemo
# 关闭内联,观察退化
java -XX:-Inline InliningDemo
# 打印内联决策
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining InliningDemo
PrintInlining输出示例:
@ 27 InliningDemo::square (4 bytes) inline (hot)
@ 31 InliningDemo::heavyCompute (68 bytes) too big
square被内联,heavyCompute因超过MaxInlineSize且调用频率未达FreqInlineSize门槛而被拒绝。典型运行结果中,testInline比testNoInline快2-5倍——这个差距在关闭内联后会显著缩小,证明性能差异主要来自内联及其后续优化。
实践要点
方法别太大也别太小:把热点路径上的小方法写成简短的、单一职责的方法,让JIT能内联。一个方法几十行字节码是健康的,上百行就需要警惕。
减少虚方法的多态性:在热点循环里,尽量让调用点只见到一种或两种类型。如果业务上必须多态,考虑把多态调用移出循环,循环内用具体类型。
final关键字有用但非万能:final方法保证静态绑定,帮助CHA去虚化。但C2基于profiling的去虚化对非final方法也有效——只要运行时是单态。盲目加final未必带来收益,代码可读性更重要。
慎用-XX:-Inline:关闭内联会让性能下降一个数量级,只在调试"是不是内联导致的副作用"时使用。
-XX:MaxInlineSize调大要谨慎:有些人为了让某个方法被内联把它调到100,结果CodeCache迅速膨胀、编译时间暴涨。正确做法是重构方法,把热点部分拆小。
关注PrintInlining的too big:如果关键路径上的方法频繁出现too big,说明方法过大,考虑拆分。注意输出中的hot/not hot标记——频率不够也是内联失败原因。
字段访问器的影响:IDE自动生成的getter/setter通常会被内联,性能无忧。但若getter内有复杂逻辑(如懒加载、校验),可能超出MaxInlineSize,热点路径上要留意。
异常处理会阻断内联:JDK 8的C2对含try-catch的方法内联支持有限。热点路径上避免在循环体内频繁try-catch,把try-catch提到循环外或方法外。
小结
- 方法内联是JIT"优化之母",其真正价值是为后续优化打开跨方法的视野
- 内联条件由方法大小(MaxInlineSize/FreqInlineSize)、调用频率、是否静态绑定共同决定
- 虚方法内联依赖去虚化:C1用CHA做静态分析,C2用基于profiling的内联缓存
- 内联缓存有单态/双态/多态三种状态,双态以上无法内联,这是"多态杀性能"的根源
- 减少热点路径上的多态性、控制方法大小,是让JIT充分内联的工程要点
下一篇我们将深入另一项C2的看家优化——逃逸分析与标量替换,看JVM如何让"本该堆分配的对象"彻底消失。
更多内容:JVM调优实战
