13. 后端编译与优化

一、即时编译器

1.1 解释器和编译器

在目前主流的商用Java虚拟机中,内部都同时包含解释器和编译器,解释器与编译器两者各有优势:

  1. 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行;当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码翻译成本地代码,这样可以减少解释器的中间消耗,获得更高的执行效率
  2. 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提高效率;
  3. 解释器可以充当编译器作为激进优化时后备的“逃生门”。

在Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作,其交互关系如图所示

在这里插入图片描述

1.2 分层编译

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中两个编译器存在已久,分别称为“客户端编译器” 和 “服务端编译器”,建成C1编译器和C2编译器,第三个是JDK10时才出现的,长期目标是代替C2的Graal编译器。

为什么引入分层编译?

由于即时编译器编译本地代码需要占用程序的时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也会有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机才在编译子系统中加入了分层编译的功能。

实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控。
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部统计信息。
  • 第4层。使用服务端编译器将字节码翻译成本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

以上层次不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。更层次的交互、转换关系如图所示

在这里插入图片描述

1.3 编译对象与触发条件

即时编译器编译的目标是“热点代码”,这里所指的热点代码主要有两类,包括:

  1. 被多次调用的方法体
  2. 被多次执行的循环体

对于这两种情况,编译的目标都是整个方法体,而不是单独的循环体。这种编译方式因为编译发生在方法执行的过程中,因此很形象地被称为 “栈上替换”,即方法的栈帧还在栈上,方法就被替换了。

要知道某段代码是不是热点代码,是不是需要出发即时编译,这个行为称为“热点探测”,其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种,分别是:

  • 基于采样的热点探测。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易因为收到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方式实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但它的统计结果相对来说更加精确严谨。

这两种探测手段在商用Java虚拟机中都用使用到,如J9用过第一种采样热点探测,而在Hotspot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会出触发即时编译。

**方法调用计数器:**在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器,那该方法的调用计数器就会减少一半,这个过程被称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。

**回边计数器:**回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流后向后跳转的指令就称为“回边”。与方法计数器不同,回边计数器没有热度衰减的过程,因此这个计数器统计的就是该方法循环体的绝对次数。

二、提前编译器

提前编译器在1996年发布,在1998年曾广泛使用GNU Complier for Java。但是提前编译很快又在Java世界里沉寂下来了,因为这与Java平台中立性的理念是直接冲突的。

2013年Android的ART使用提前编译技术横空出世,有了广泛应用,这震惊到了Java世界,有些人为了更好的执行性能,可以舍弃平台中立性。所以提前编译器又在Java中重新开始被主流JDK支持。

2.1 提前编译器干的事情

提前编译器的实现有两条分支,一条是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另一条是把原本把原本即时编译器在运行时要做的翻译工作提前做好保存下来,下次运行到这些代码时直接把它加载进来使用。

**静态翻译:**它在Java中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。

**编译缓存:**本质上是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。这种提前编译的方式目前实际用起来并不容易,原因是这种提前编译方式不仅要和目标机器相关,甚至还要和虚拟机的运行时参数绑定。尽管还有很多困难,但提前编译无疑已经成为一种极限榨取性能的手段,且被官方JDK关注,相信日后会更加灵活,更容易使用。

2.2 和即时编译器的对比

提前编译器一定就比即时编译器好吗?答案是否定的,下面介绍三种即时编译器相对于提前编译器的天然优势。

  1. **性能分析制导优化。**即时编译器会在运行中不断收集性能监控信息,譬如某个程序点抽象类通常会是什么类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解,最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好行。如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源给它。
  2. **激进预测优化。**这是很多即时编译优化措施的基础。静态优化无论如何都必须保证优化后所有的程序外部可见影响与优化前是等效的,不然优化之后会导致程序报错或结果不对。但是,即时编译的优化策略可以不必这样保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,就退回低级编译器甚至解释器上去执行,并不会出现无法挽回的后果。只要出错概率足够低,这样的优化往往能够大幅降低目标程序的复杂度,输出运行速度非常高的代码。
  3. **链接时优化。**Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的代码,这类事情在Java里毫无违和感。但是类似的场景如果出现提前编译中,例如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。这是因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译,优化自己的代码。当出现跨链接库便捷的调用时,那些理论上应该要做的优化——譬如对调用方法的内联,就会执行起来相当困难。

三、编译器优化技术

编译器的目标虽然是做由程序代码翻译成本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

3.1 方法内联

方法内联是编译器最重要的优化手段,甚至都可以不加“之一”。内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,没有内联,多数其他优化手段都无法有效进行。如下代码,testInline()方法的内部全部都是无用的代码,但如果不做内联,后续即时进行了无用代码消除的优化,也无法发现任何“Dead Code”的存在。如果分开来看,foo()和testInline()两个方法里面的操作有可能是有意义的。
在这里插入图片描述

3.2 逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。在JDK8中,逃逸分析是默认开启的。

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(值逃逸出方法而不会逃逸出线程),则可能为这个对象采取不同程度的优化,如:

  • 栈上分配

    如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾回收子系统的压力就会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

    举例:

    该代码是关闭逃逸分析

    //-server -Xmx1g -Xms1g -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations -XX:-UseTLAB
    public class OnStackTest {
        // User类
        public static class User{
            public int id=0;
            public String name="";
        }
        // 创建User类对象
        public static void alloc(){
            User u=new User();
            u.id=5;
            u.name="geym";
        }
        // 程序入口
        public static void main(String[] args) throws InterruptedException {
            long b=System.currentTimeMillis();
            // 创建大量的对象
            for(int i=0;i<9999999;i++){
                alloc();
            }
            long e=System.currentTimeMillis();
            // 打印执行时间
            System.out.println(e-b);
            Thread.sleep(100000L);
        }
    }
    //执行时间:197ms
    

在这里插入图片描述

开启逃逸分析后,执行时间为5ms,且user个数急剧减少

在这里插入图片描述

**注意:开启逃逸分析后,User实例的个数依然不是0。**这个原因我怀疑是因为程序在启动的时候,在进行逃逸分析的过程中,也会创建对象,这个时候创建了对象在堆内存中。

  • 标量替换

    若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以称为标量。如果把一个Java对象拆散,**如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问,这个过程就称为标量替换。**将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可 以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考 虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

    举例:

    package dailyTest;
    
    /**
     * @Author: ZhaoLei
     * @Create date: 2022/8/7
     * @Description:
     */
    
    class Point {
        int x;
        int y;
    }
    public class InsteadTest {
        public static void main(String[] args) throws InterruptedException {
            long b = System.currentTimeMillis();
            for (int i = 0; i < 9999999; i++) {
                Point p = new Point();
                p.x = i;
                p.y = i + 1;
            }
            long e = System.currentTimeMillis();
    
            System.out.println(e - b);
    
            Thread.sleep(10000000L);
        }
    
    }
    

    未开启EliminateAllocations,耗时240ms

在这里插入图片描述

在这里插入图片描述

开启EliminateAllocations,耗时6ms

在这里插入图片描述

在这里插入图片描述

可以看到,开启EliminateAllocations后,堆空间和栈空间占用都显著降低。但是栈空间里具体存了什么现在还不会分析。

  • 同步消除

    线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。

从上述实验中可以看到,逃逸分析可以取得不错的成绩,但是在实际的应用程序中,尤其是大型应用程序中反而实施逃逸分析可能出现效果不稳定的情况,或分析过程耗时但无法有效判别出非逃逸对象导致性能下降。

3.3 公共子表达式擦除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式代替E。假设存在如下代码:

int d = (c * b) * 12 + a + (a + b * c);

那么这段代码交给Javac编译器则不会进行任何优化,那生成的代码将如下所示,是完全按照Java源码的写法直译而成的

iload_2     // b
imul        // 计算b*c
bipush 12   // 推入12
imul        // 计算(c*b)*12
iload_1     // a 
iadd        // 计算(c*b)*12+a 
iload_1     // a
iload_2     // b
iload_3     // c
imul        // 计算b*c
iadd        // 计算a+b*c
iadd        // 计算(c*b)*12+a+a+b*c
istore 4    

当这段代码进入虚拟机即时编译后,它将进行如下优化:编译器监测到c * b与 b * c是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此这条表达式就可能被视为:

int d = E * 12 + a + (a + E);

这时候,编译器还可能进行另一种优化——代数化简,在E本来就有乘法运算的前提下,把表达式变为:

int d = E * 13 + a + a;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值