《深入理解JAVA虚拟机(第2版)》- 第11章 - 学习笔记

第11章 晚期(运行期)优化

11.1 概述

  1. 频繁执行的方法或代码块,被认定为“热点代码”(Hot Spot Code)。
  2. 为了提高热点代码的执行效率,在运行期,将热点代码编译成本地机器码并进行优化,完成这个功能的编译器叫做即时编译器
  3. 对于虚拟机来说即时编译器并不是必须的,Java虚拟机规范当中并没有规定必须有即时编译器,更没有规定如何去实现它。

11.2 HotSpot虚拟机内的编译器

11.2.1 解释器与编译器
  1. 主流虚拟机(例如:HotSpot、J9等)都是采用解析器和编译器并存的架构。
    • 解释器:优点是能够快速启动和执行,不需要编译,不需要很大的内存空间。缺点是执行效率不高。
    • 编译器:缺点是需要将代码编译成本地代码,因此对内存空间有一定要求。优点是执行效率高。
    • 解释器通常作为编译器激进优化失败后的“逃生门”,即退回到解释器执行。
    • 解析器与编译器的交互,如下图: 在这里插入图片描述
  2. HotSpot虚拟机内置了两个编译器:Client Complier(即C1编译器)和Server Complier(即C2编译器)。默认情况下,HotSpot采用的是解释器和其中一个编译器来配合工作的。至于采用的是哪个编译器,是由虚拟机的运行模式(Client模式或Server模式)来决定的。
  3. 为了使程序启动速度和执行效率达到一个平衡,HotSpot虚拟机还启用了分层编译的策略,基于编译器编译、优化的规模和耗时,划分了以下三个层次的编译。
    • 第0层:解释执行,不开启性能监控功能,可触发第1层编译。
    • 第1层:也就是C1编译,将字节码编译成本地代码,并进行简单、可靠的优化,根据具体的情况去判断是否开启性能监控功能。
    • 第2层:也就是C2编译,和第一层一样也是将字节码编译成本地代码,不同的是会开启编译耗时较长的优化,甚至会根据性能监控信息进行一些不那么可靠的激进优化。
11.2.2 编译的对象与触发条件
  1. 编译的对象就是我们所说的“热点代码”,热点代码又分为两类:

    • 多次调用的方法:以方法为单位进行编译,这种编译是虚拟机中的标准JIT编译

    • 多次执行的代码块:还是以方法为单位进行编译,这种编译是发生在代码执行的过程中,所以称为栈上替换(On Stack Replacement,OSR,即栈帧还在栈上,方法就被替换了)

  2. 编译的触发条件
    上边提到的多次调用、多次执行,这里的“多次”具体到底是多少次?虚拟机是如何统计出来方法或代码块执行次数的?回答了这两个问题,也就说明了编译的触发条件。

    判断一段代码是不是热点代码,是否能触发即时编译,这样的行为称为热点探测(Hot Spot Detection)。

    目前,主要的热点探测判定方式有两种,如下:

    • 基于采样的热点探测:在一个周期内观察所有线程的栈顶,某个方法在栈顶出现的频次较多则为热点代码。这种方式实现简单,但是由于线程阻塞等原因,导致结果不准确

    • 基于计数器的热点探测:每个方法(或代码块)分配一个计数器,用来记录方法调用的次数,当次数达到指定的阈值则认为该方法为热点代码。

    HotSpot虚拟机采用的是基于计数器的热点探测,它为每个方法准备了两类计数器:方法调用计数器回边计数器

    • 方法调用计数器:

      方法调用计数器的作用是:用于统计方法被调用的次数

      方法调用计数器触发即时编译的过程,如下图: 在这里插入图片描述
      过程描述:方法调用的时候,首先检查是否存在JIT编译的版本,如果存在,则直接该版本的本地代码执行。如果不存在,则方法调用计数器+1,然后判断此时方法调用计数器+回边计数器的总和是否超过了阈值,如果没超过则会继续解释执行。如果超过了,则会向编译器发起一次编译请求,默认情况下,此时的虚拟机不会等待编译器返回编译结果,会继续使用解析器去执行。当编译完成后,会自动修改方法的入口地址。

      在这个过程当中其实还涉及到两个概念,这里也简单介绍下,那就是:热度衰减半衰周期

      方法调用计数器统计的总数并不是方法被调用的绝对次数,而是在一段时间内方法被调用的次数。当在一个时间段内方法被调用的次数无法达到可触发即时编译的阈值时,则会将计数减半,这个过程就是热度衰减,而这个时间段则称为半衰周期

    • 回边计数器

      回边计数器的作用是:用于统计方法中循环体执行的次数

      什么是回边指令呢?字节码中那些控制流向后跳转的指令称为回边指令

      回边计数器触发OSR编译的过程,如下图: 在这里插入图片描述
      过程描述:碰到回边指令的时候,首先检查是否存在已编译的版本,如果存在则直接执行本地代码。如果没有,则将回边计数器+1,然后判断此时回边计数器+程序调用计数器的总数是否超过了阈值,如果没超过则继续解释执行。如果超过了,则向编译器提交OSR编译器请求,并将回边计数器的值降低些,以此来保证后续循环可以解释执行(即编译器返回编译结果之前,循环继续,如果回边计数器的值不降低,则会触发又一次的OSR编译请求的提交)。

      与程序调用计数器不同,回边计数器触发OSR编译的过程中,没有热度衰减的过程,所以回边计数器中的值是循环执行的绝对次数。当回边计数器溢出的时候,会同时将方法调用计数器也调整为溢出状态,这样在下次方法被调用的时候就会触发标准的JIT编译过程了。

11.3 编译优化技术

11.3.1 公共子表达式消除

如果一个表达式E已经计算过了,再出现的时候什么变化都没有,则直接使用之前的结果,不需要再进行计算了。

11.3.2 数组边界检查消除

在一个循环体中,当编译器进行数据流分析后确定循环变量是在0~数组长度之间,则会消除掉数据边界检查。

11.3.3 方法内联

方法内联可以减少方法调用,同时也是其他优化手段的基础。

方法内联,简单来说就是将目标方法的代码“复制”到调用该方法的方法中,以此减少方法调用。还记得之前之前提到的解析和分派吗?只有那些非虚方法(静态方法、私有方法、父类方法、实例构造器、被final修饰的实例方法)能够确定只有唯一的版本,这样“复制”的时候也就只有唯一的选择,不会造成不知道“复制”哪个版本的情况出现。

而那些虚方法呢?它们只有在运行期才能确定调用哪个版本,所以编译期进行方法内联是无法确定内联哪个方法版本的

为了要解决上边这个问题,Java虚拟机团队引入了一个新技术 —— 类型继承关系分析(Class Hierarchy Analysis,CHA),它用于确定目前已加载的类中,某个接口是否多于一个实现,某个类是否有子类,子类是否为抽象类等信息。

下面对方法内联的过程进行如下总结:

  1. 判断方法是否为非虚方法,如果是非虚方法,则直接进行内联。
  2. 如果是虚方法,通过CHA来判断方法是否只有唯一一个版本,如果是只有一个唯一的版本,则进行内联(这种内联是激进优化,必须留有逃生门,称为守护内联(Guarded Inlining)),在之后的程序运行过程中如果虚拟机没有加载到能改变该方法该接收者继承关系的类,则方法内联会一直有效。否则,抛弃已编译的代码,退回到解释执行,或重新编译。
  3. 如果通过CHA查出来虚方法有多个版本,编译器还是会进行最后的尝试,采用内联缓存(Inline Cache)来进行方法内联。内联缓存是建立在方法正常入口之前的缓存。在方法调用之前,内联缓存中是空,当发生第一次方法调用的时候,内联缓存中会记录当前方法的版本,之后再调用该方法的时候会先比较之前的版本,如果版本一致,则方法内联一直有效,如果版本不一致,则方法内联撤销,通过虚方发表进行分派。
11.3.4 逃逸分析

逃逸分析是一种分析对象动态作用域的行为,它不是具体的优化手段,而是作为优化手段的辅助技术(与CHA类似)。逃逸分析分为:方法逃逸和线程逃逸。

  • 方法逃逸:对象在方法中定义后,被另外一个方法所引用,例如:对象作为另外一个方法的参数被传递。
  • 线程逃逸:方法中定义的对象被另外一个线程所引用,例如:将对象赋值给类变量或者可以被其他线程引用的实例变量。

通过对对象进行逃逸分析,如果我们能确定该对象不会被其他方法或线程访问到(即不会出现方法逃逸或线程逃逸的情况),则可以对对象进行如下优化:

  • 栈上分配

    如果对象没有发生方法逃逸,那么该对象所需的内存就可以在栈上分配,这样的好处是,可以随着栈帧出栈(即方法结束),对象所占用的内存就被释放掉了,同时也减少了垃圾收集器的压力。

    HotSpot虚拟机要想实现栈上分配很复杂,所以目前还没有实现这项优化

    Java语言中对象的内存只能在堆上分配,只有方法中的局部变量才有可能在栈

  • 同步消除

    如果对象没有发生线程逃逸,也就是说不会有其他线程访问该对象,不会出现竞争的情况,那么同步也就没有了意义了,所以同步指令可以消除。

  • 标量替换

    标量(Scalar)是不能再分解的最小数据,Java中的原始数据类型(例如:int、long … reference类型等)都不能再进一分解,它们就是标量。与之相反的,即可以继续分解的数据,称为聚合量(Aggregate),在Java中对象就是典型的聚合量。

    如果对象没有发生方法逃逸,且该对象可以被进一步分解,那么程序在真正执行的时候可能不会创建这个对象,而是直接创建该对象中的被方法使用到的成员变量,这个过程就是标量替换。

    标量替换可以看做是一种高度优化的后栈上分配,它相当于将对象分解为局部变量后再进行栈上分配

上一篇:《深入理解JAVA虚拟机(第2版)》- 第10章 - 学习笔记
下一篇:《深入理解JAVA虚拟机(第2版)》- 第12章 - 学习笔记

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cab5

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值