【JVM原理详解】37-编译优化-方法内联
37-编译优化-方法内联引言在上一篇介绍JIT编译器分工后本篇开始深入具体的编译优化。**方法内联Method Inlining**被誉为JIT优化之母——它是几乎所有其他优化的前提没有内联逃逸分析的边界就被方法调用割裂循环展开无法跨方法常量传播也会在调用边界处中断。方法内联的收益不只是省去一次方法调用的开销压栈、跳转、出栈本身很便宜更在于为后续优化打开视野。一个被内联的小方法可能让整个调用链路上的常量传播、死代码消除、逃逸分析一次性生效带来数量级的性能提升。理解内联的条件与限制是诊断我的代码为什么慢的关键能力。方法内联的本质方法内联就是把被调用方法的代码复制到调用点消除方法调用本身。看一个最简单的例子// 内联前staticintadd(inta,intb){returnab;}staticintcompute(intx){returnadd(x,1)*2;}// 内联后等价于staticintcompute(intx){return(x1)*2;}表面收益是省掉了一次call指令和返回。但真正的收益在于内联后编译器能看到add的完整逻辑可以进一步做常量折叠、死代码消除等。如果add内部还有分支内联后这些分支也可能被调用点的上下文消除掉。内联的收益为何远超省一次调用一个被频繁引用的例子// 内联前staticbooleanisZero(intv){returnv0;}staticvoidprocess(int[]data){for(inti0;idata.length;i){if(isZero(data[i])){// 处理零值分支}else{// 处理非零分支}}}如果不内联isZero每次循环迭代都是一次方法调用且编译器无法判断分支走向。内联后staticvoidprocess(int[]data){for(inti0;idata.length;i){if(data[i]0){// 内联展开// 编译器可基于profiling消除某个分支}}}进一步如果profiling显示这个数组从未出现过0C2甚至会把整个if优化掉。这种跨方法的优化只有内联后才能发生。内联的条件JIT不会无脑内联所有方法。内联是有代价的——CodeCache膨胀、编译时间增加。HotSpot通过一组规则判断是否内联方法大小最直接的限制是方法的字节码大小。相关参数-XX:MaxInlineSize默认35字节。小于这个大小的小方法无论调用频率如何都会尝试内联-XX:FreqInlineSize默认325字节JDK 11/1764位。小于这个大小且调用频繁的方法才会被内联# 查看默认值java-XX:PrintFlagsFinal-version|grep-iinlinesize# intx MaxInlineSize 35# intx FreqInlineSize 325需要注意的是这里说的是字节码大小不是源码行数。一个看似简短的方法如果包含复杂表达式字节码可能很长。调用频率大方法超过MaxInlineSize但小于FreqInlineSize只有在被频繁调用时才内联。判断依据是profiling数据——方法的调用次数和回边次数。是否final/static/privatefinal、static、private方法都是静态绑定的编译器能确定唯一的调用目标内联没有障碍。但实例方法invokevirtual就涉及虚方法分派需要额外处理下一节详述。异常处理与本地方法包含try-catch的方法内联受限JDK 8的C2基本不内联带异常处理的方法JDK 11有所放宽native方法不能内联它们是JNI调用无字节码可内联构造方法内联有限制涉及对象初始化语义虚方法内联最棘手的部分invokevirtual调用的方法在运行时才能确定具体实现多态。JIT如何内联一个不知道调谁的方法答案是去虚化Devirtualization。CHA类层次分析Class Hierarchy AnalysisCHA是C1使用的去虚化手段。编译时扫描已加载的类如果某个接口/虚方法在当前类层次中只有一个实现就把它当作final方法直接内联。interfaceWorker{voidwork();}classSingleWorkerimplementsWorker{publicvoidwork(){/* 唯一实现 */}}voidrun(Workerw){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查找。这就是多态杀死内联的根源。多态性的工程启示理解了内联缓存的三态就能解释一些性能反直觉的现象// 场景一单态可内联ListStringlistnewArrayList();for(inti0;in;i){list.add(x);// 调用点只见过ArrayList单态}// 场景二双态仍可内联ListStringlist(flag?newArrayList():newLinkedList());for(inti0;in;i){list.add(x);// 调用点见过ArrayList和LinkedList双态}// 场景三多态无法内联ListString[]lists{newArrayList(),newLinkedList(),newCopyOnWriteArrayList()};for(ListStringlist:lists){for(inti0;in;i){list.add(x);// 调用点见过3种类型多态}}场景三的性能可能比场景一低数倍原因就是虚方法调用无法内联且后续优化链断裂。内联缓存的工作机制内联缓存本质是调用点CallSite级别的状态记录。HotSpot的实现在方法的**MethodDataMDO**中为每个调用点维护一个类型profile槽位。Profile的收集层级3的C1编译代码会在每次虚方法调用时记录接收者类型调用 s.work() │ ▼ 查MDO中该调用点的profile槽位 │ ▼ 记录类型是已记录的类型是新类型 │ ▼ 更新计数单态→双态→多态Profile的容量限制每个调用点的profile槽位有限默认记录2个类型对应双态阈值。一旦超过标记为多态不再更新。相关参数-XX:TypeProfileWidth2# 默认2可调大但收益有限调大TypeProfileWidth看似能覆盖更多多态场景但会增加MDO内存占用和profiling开销且双态以上的内联收益本就急剧下降实际中很少调整。代码示例内联前后性能对比下面通过一个完整示例直观感受内联的性能影响。// 适用 JDK 11/17publicclassInliningDemo{// 小方法会被内联staticintsquare(intx){returnx*x;}// 模拟大方法超过 MaxInlineSize 但小于 FreqInlineSizestaticintheavyCompute(intx){intax*21;intba*a-x;intcb%3a;intdc*c-b;inteda-c;returne*2;}staticlongtestInline(intn){longsum0;for(inti0;in;i){sumsquare(i);// 小方法必然内联}returnsum;}staticlongtestNoInline(intn){longsum0;for(inti0;in;i){sumheavyCompute(i);// 大方法可能不内联}returnsum;}publicstaticvoidmain(String[]args){// 预热for(inti0;i50_000;i){testInline(100);testNoInline(100);}longstartSystem.nanoTime();longr1testInline(10_000_000);longt1System.nanoTime()-start;startSystem.nanoTime();longr2testNoInline(10_000_000);longt2System.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);}}用不同参数运行对比# 正常分层编译内联开启javaInliningDemo# 关闭内联观察退化java-XX:-InlineInliningDemo# 打印内联决策java-XX:UnlockDiagnosticVMOptions-XX:PrintInliningInliningDemoPrintInlining输出示例 27 InliningDemo::square (4 bytes) inline (hot) 31 InliningDemo::heavyCompute (68 bytes) too bigsquare被内联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调优实战

相关新闻

最新新闻

日新闻

周新闻

月新闻