learn-tech/专栏/编译原理实战课/15JavaJIT编译器(三):探究内联和逃逸分析的算法原理.md
2024-10-16 10:05:23 +08:00

278 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
15 Java JIT编译器探究内联和逃逸分析的算法原理
你好,我是宫文学。
基于Graal IR进行的优化处理有很多。但有些优化针对Java语言的特点会显得更为重要。
今天这一讲我就带你来认识两个对Java来说很重要的优化算法。如果没有这两个优化算法你的程序执行效率会大大下降。而如果你了解了这两个算法的机理则有可能写出更方便编译器做优化的程序从而让你在实际工作中受益。这两个算法分别是内联和逃逸分析。
另外我还会给你介绍一种JIT编译所特有的优化模式基于推理的优化。这种优化模式会让某些程序比AOT编译的性能更高。这个知识点可能会改变你对JIT和AOT的认知因为通常来说你可能会认为AOT生成的机器码速度更快所以通过这一讲的学习你也会对“全生命周期优化”的概念有所体会。
好,首先,我们来看看内联优化。
内联Inlining
内联优化是Java JIT编译器非常重要的一种优化策略。简单地说内联就是把被调用的方法的方法体在调用的地方展开。这样做最大的好处就是省去了函数调用的开销。对于频繁调用的函数内联优化能大大提高程序的性能。
执行内联优化是有一定条件的。第一,被内联的方法要是热点方法;第二,被内联的方法不能太大,否则编译后的目标代码量就会膨胀得比较厉害。
在Java程序里你经常会发现很多短方法特别是访问类成员变量的getter和setter方法。你可以看看自己写的程序是否也充斥着很多对这种短方法的调用这些调用如果不做优化的话性能损失是很厉害的。你可以做一个性能对比测试通过“-XX:-Inlining”参数来阻止JVM做内联优化看看性能降低得会有多大。
但是这些方法有一个好处:它们往往都特别短,内联之后,实际上并不会显著增加目标代码长度。
比如针对add2示例方法我们采用内联选项优化后方法调用被替换成了LoadField加载成员变量
public int add2(){
return getX() + getY();
}
图1将getter方法内联
在做了Lower处理以后LoadField会被展开成更底层的操作根据x和y的地址相对于对象地址的偏移量获取x和y的值。
图2计算字段x和y的地址
而要想正确地计算字段的偏移量我们还需要了解Java对象的内存布局。
在64位平台下每个Java对象头部都有8字节的标记字里面有对象ID的哈希值、与内存收集有关的标记位、与锁有关的标记位标记字后面是一个指向类定义的指针在64位平台下也是8位不过如果堆不是很大我们可以采用压缩指针只占4个字节在这后面才是x和y字段。因此x和y的偏移量分别是12和16。
图3内存中Java对象头占据的空间
在Low Tier编译完毕以后图2还会进一步被Lower形成AMD64架构下的地址。这样的话编译器再进一步翻译成汇编代码就很容易了。
图4生成AMD64架构的地址计算节点
内联优化除了会优化getter、setter这样的短方法它实际上还起到了另一个重要的作用即跨过程的优化。一般的优化算法只会局限在一个方法内部。而启动内联优化后多个方法会合并成一个方法所以就带来了更多的优化的可能性。
我们来看看下面这个inlining示例方法。它调用了一个atLeastTen方法这个方法对于
public int inliningTest(int a){
return atLeastTen(3); //应该返回10
}
//至少返回10
public int atLeastTen(int a){
if (a < 10)
return 10;
else
return a;
}
如果不启用编译器的内联选项那么inliningTest方法对应的IR图就是常规的方法调用而已
图5不启用内联时调用atLeastTen()方法
而一旦启用了内联选项就可以触发一系列的优化在把字节码解析生成IR的时候编译器就启动了内联分析过程从而会发现this参数和常量3对于inliningTest方法根本是无用的在图里表现成了一些孤岛在Mid Tier处理完毕之后inliningTest方法就直接返回常量10了
图6启用内联后调用atLeastTen()方法
另外方法的类型也会影响inlining如果方法是final的或者是private的那么它就不会被子类重载所以可以大胆地内联
但如果存在着多重继承的类体系方法就有可能被重载这就会导致多态在运行时JVM会根据对象的类型来确定到底采用哪个子类的具体实现这种运行时确定具体方法的过程叫做虚分派Virtual Dispatch
在存在多态的情况下JIT编译器做内联就会遇到困难了因为它不知道把哪个版本的实现内联进来不过编译器仍然没有放弃这时候所采用的技术就叫做多态内联Polymorphic inlining
它的具体做法是在运行时编译器会统计在调用多态方法的时候到底用了哪几个实现然后针对这几个实现同时实现几个分支的内联并在一开头根据对象类型判断应该走哪个分支这个方法的缺陷是生成的代码量会比较大但它毕竟可以获得内联的好处最后如果实际运行中遇到的对象与提前生成的几个分支都不匹配那么编译器还可以继续用缺省的虚分派模式来做函数调用保证程序不出错
这个案例也表明了JIT编译器是如何充分利用运行时收集的信息来做优化的对于AOT模式的编译来说由于无法收集到这些信息因此反倒无法做这种优化
如果你想对多态内联做更深入的研究还可以参考这一篇经典论文Inlining of Virtual Methods
总结起来内联优化不仅能降低由于函数调用带来的开销还能制造出新的优化机会因此带来的优化效果非常显著接下来我们看看另一个能带来显著优化效果的算法逃逸分析
逃逸分析Escape Analysis, EA
逃逸分析是JVM的另一个重要的优化算法它同样可以起到巨大的性能提升作用
逃逸分析能够让编译器判断出一个对象是否能够在创建它的方法或线程之外访问如果只能在创建它的方法内部访问我们就说这个对象不是方法逃逸的如果仅仅逃逸出了方法但对这个对象的访问肯定都是在同一个线程中那么我们就说这个对象不是线程逃逸的
判断是否逃逸有什么用呢用处很大只要我们判断出了该对象没有逃逸出方法或线程就可以采用更加优化的方法来管理该对象
以下面的示例代码为例我们有一个escapeTest()方法这个方法可以根据输入的年龄返回年龄段小于20岁的返回1否则返回2
在示例程序里我们创建了一个Person对象并调用它的ageSegment方法来返回年龄段
public int escapeTest(int age){
Person p = new Person(age);
return p.ageSegment();
}
public class Person{
private int age;
private float weight;
public Person(int age){
this.age = age;
}
//返回年龄段
final public int ageSegment(){
if (age < 20)
return 1;
else
return 2;
}
public void setWeight(float weight){
this.weight = weight;
}
public float getWeidht(){
return weight;
}
}
你可以分析一下针对这段程序我们可以做哪些优化工作
首先是栈上分配内存
在Java语言里对象的内存通常都是在堆中申请的对象不再被访问以后会由垃圾收集器回收但对于这个例子来说Person对象的生命周期跟escapeTest()方法的生命周期是一样的在退出方法后就不再会有别的程序来访问该对象
换句话说这个对象跟一个int类型的本地变量没啥区别那么也就意味着我们其实可以在栈里给这个对象申请内存就行了
你已经知道在栈里申请内存会有很多好处可以自动回收不需要浪费GC的计算量去回收内存可以避免由于大量生成小对象而造成的内存碎片数据的局部性也更好因为在堆上申请内存它们的物理地址有可能是不相邻的从而降低高速缓存的命中率再有在并发计算的场景下在栈上分配内存的效率更高因为栈是线程独享的而在堆中申请内存可能需要多线程之间同步所以我们做这个优化是非常有价值的
再进一步还可以做标量替换Scalar Replacement
这是什么意思呢你会发现示例程序仅仅用到了Person对象的age成员变量而weight根本没有涉及所以我们在栈上申请内存的时候根本没有必要为weight保留内存同时在一个Java对象的标准内存布局中还要有一块固定的对象头的内存开销在64位平台对象头可能占据了16字节这下倒好示例程序本来只需要4个字节的一个整型最后却要申请24个字节是原需求的6倍这也太浪费了
通过标量替换的技术我们可以根本不生成对象实例而是把要用到的对象的成员变量作为普通的本地变量也就是标量来管理
这么做还有一个好处就是编译器可以尽量把标量放到寄存器里去连栈都不用这样就避免了内存访问所带来的性能消耗
Graal编译器也确实是这么做的在Mid Tier层处理完毕以后你查看IR图会发现它变成了下面的这个样子
图7对Person对象做标量替换
你会看到编译器连Person的ageSegement方法也内联进来了最后优化后的函数相当于
public int escapeTest(int age){
return age<20 ? 1 : 2;
}
图7中的Conditional是一个条件计算的三元表达式你看到这个优化结果的时候有没有感到震惊是的善用编译器的优化算法就是会达到如此程度的优化优化前后的代码的功能是一样的但优化后的代码变得如此简洁直奔最终计算目标忽略中间我们自己编程所带来的冗余动作
上面讲的都是没有逃逸出方法的情况这种情况被叫做NoEscape还有一种情况是虽然逃逸出了方法但没有逃逸出当前线程也就是说不可能被其他线程所访问这种逃逸叫做ArgEscape也就是它仅仅是通过方法的参数逃逸的最后一种情况就是GlobalEscape也就是能够被其他线程所访问因此没有办法优化
对于ArgEscape的情况虽然编译器不能做内存的栈上分配但也可以做一定的优化这就是锁消除或者同步消除
我们知道在并发场景下锁对性能的影响非常之大而很多线程安全的对象比如一些集合对象它们的内部都采用了锁来做线程的同步如果我们可以确定这些对象没有逃逸出线程那么就可以把这些同步逻辑优化掉从而提高代码的性能
好了现在你已经理解了逃逸分析的用途那么逃逸分析的算法是怎么实现的呢这方面你可以去参考这篇经典论文Escape Analysis for Java论文里的算法利用了一种数据结构叫做连接图Connection Graph简单地说就是分析出了程序中对象之间的引用关系
整个分析算法是建立在这样一种直觉认知上的基于一个连接图也就是对象之间的引用关系图如果A引用了B而A能够被线程外的程序所访问线程逃逸那么也就意味着B也是线程逃逸的也就是说逃逸性是有传染能力的通过这样的分析那些完全没被传染的对象就是NoEscape的只被ArgEscape对象传染的那就也是ArgEscape的原理说起来就是这么简单
另外我们前面所做的分析都是静态分析也就是基于对代码所做的分析对于一个对象来说只要存在任何一个控制流使得它逃逸了那么编译器就会无情地把它标注为是逃逸对象也就不能做优化了但是还会出现一种情况就是有可能这个分支的执行频率特别少大部分情况下该对象都是不逃逸的
所以Java的JIT编译器实际上又向前迈进了一步实现了部分逃逸分析Partial Escape Analysis它会根据运行时的统计信息对不同的控制流分支做不同的处理对于没有逃逸的分支仍然去做优化在这里你能体会到编译器为了一点点的性能提升简直无所不用其极呀
如果你还想对部分逃逸分析做进一步的研究那你可以参考这篇论文Partial Escape Analysis and Scalar Replacement for Java
总结起来逃逸分析能够让对象在栈上申请内存做标量替换从而大大减少对象处理的开销这个算法对于对象生命周期比较短的场景优化效果是非常明显的
在讲内联和逃逸算法的时候我们都发现编译器会根据运行时的统计信息通过推断来做一些优化比如多态内联部分逃逸分析而这种优化模式就叫做基于推理的优化
基于推理的优化Speculative Optimization
我刚刚说过一般情况下编译器的优化工作是基于对代码所做的分析也就是静态分析而JIT编译还有一个优势就是会根据运行时收集的统计信息来做优化
我还是以Foo.atLeastTen()方法举例在正常情况下它生成的HIR是下面的样子根据条件表达式的值a
图8基于静态分析编译atLeastTen()方法
而如果我们在主程序里调用atLeastTen方法是采用下面示例代码中的逻辑在启动JIT编译器时已经循环了上万次而在这上万次当中只有9次i的值是小于10的那么编译器就会根据运行时的统计信息判断i的值大概率是大于10的所以它会仅针对大于10的分支做编译
而如果遇到小于10的情况则会导致逆优化你会看到IR中有一个绿色的FrameState节点这个节点保存了栈帧的状态在逆优化时会被用到
int i = 0;
while(true){
i++;
foo.atLeastTen(i);
...
}
图9基于推理优化只编译if语句的第1个分支返回10
我们把主程序修改一下再做一次实验这次我们传给Foo.atLeastTen方法的参数是i%10也就是参数a的取值范围永远是在0到9之间这一次JIT编译器会反过来仅针对a小于10的分支做编译而对a大于10的情况做逆优化处理
int i = 0;
while(true){
i++;
foo.atLeastTen(i%10);
...
}
图10基于推理优化只编译if语句的第2个分支返回a
通过这个简单的例子你对JIT编译器基于推理的优化情况就有了一个直观的了解对于atLeastTen这个简单的方法这样的优化似乎并不会带来太多的好处但对于比较复杂的程序那既可以节省编译时间也能减少编译后的代码大小收益是很高的比如对于程序中仅用于输出Debug信息的分支就根本不需要生成代码
另外这种基于推理的优化还会带来其他额外的优化机会比如对于逃逸分析而言去掉了一些导致逃逸的分支以后在剩下的分支中对象并没有逃逸所以也就可以做优化了
总结起来基于运行时的统计信息进行推理的优化有时会比基于静态分析的AOT产生出性能更高的目标代码所以现代编译技术的实践会强调全生命周期优化的概念甚至即使是AOT产生的目标代码仍然可以在运行期通过JIT做进一步优化LLVM项目的发起人之一Chris Lattner就曾写了一篇论文来提倡这个理念这也是LLVM的设计目标之一
课程小结
今天我带你了解了Java JIT编译器中两个重要的优化算法这两个优化算法都会大大提升程序的执行效率另外你还了解了在JIT编译中基于运行时的信息做推理优化的技术
在课程中我不可能带你去分析所有的优化算法但你可以根据课程的内容去举一反三研究一下里面的其他算法如果你对这些算法掌握得比较清晰那你就可以胜任编译器的开发工作了因为编译器开发的真正的工作量都在中后端
另外熟悉这些重要的优化算法的原理还有助于你写出性能更高的程序比如说你要让高频使用的代码易于内联在使用对象的时候让它的作用范围清晰一些不要做无用的关联尽量不要逃逸出方法和线程之外等等
本讲的思维导图我也放在下面了供你参考
一课一思
今天的思考题还是想请你设计一个场景测试内联 vs 无内联或者做逃逸优化 vs 无逃逸优化的性能差异借此你也可以熟悉一下如何控制JVM的优化选项欢迎在留言区分享你在测试中的发现
关闭内联优化: -XX:-InliningJDK8缺省是打开的-
显示内联优化详情-XX:+PrintInlining-
关闭逃逸分析-XX:-DoEscapeAnalysisJDK8缺省是打开的-
显示逃逸分析详情-XX:+PrintEscapeAnalysis-
关闭标量替换-XX:-EliminateAllocationsJDK8缺省是打开的-
显示标量替换详情-XX:+PrintEliminateAllocations
参考资料
多态内联Inlining of Virtual Methods
逃逸分析Escape Analysis for Java
部分逃逸分析Partial Escape Analysis and Scalar Replacement for Java