首页 >> 大全

【笔记】深入理解 Java 虚拟机:晚期(运行期)优化

2023-06-23 大全 39 作者:考证青年

文章目录 编译过程 编译优化技术

概述

在部分商用虚拟机(Sun 、IBM J9)中,Java 程序最初是通过解释器解释执行的,当虚拟机发现有个方法或代码块运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器被称为即时编译器(Just In Time ,简称 JIT 编译器)。

虚拟机内的即时编译器

本节介绍 虚拟机内的即时编译器的运作过程,同时还要解决以下几个问题:

为何 虚拟机要使用解释器和编译器并存的架构?为何 虚拟机要实现两个不同的即时编译器?程序何时使用解释器执行?何时使用编译器执行?哪些程序代码会被编译为本地代码?如何编译为本地代码?如何从外部观察即时编译器的编译过程和编译结果? 解释器与编译器

尽管不是所有的 Java 虚拟机都采样解释器与编译器并存的价格,但是许多主流的虚拟机,比如 Sun 、IBM J9,都同时包含解释器与编译器。解释器与编译器有各自的优势:当程序需要快速启动时,解释器可以发挥作用,省去编译时间立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码后,可以获取更高的执行效率。

当程序运行环境的内存资源限制较大时,使用解释器执行节省内存,反之可以使用编译执行提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一个大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时,比如加载了新类后类型继承结构出现变化,出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。因此,在虚拟机中解释器和编译器经常配合工作,如下图所示:

虚拟机内置了两个即时编译器: 和 ,简称 C1 编译器和 C2 编译器。默认采用解释器和其中一个编译器直接配合的方式工作,具体使用哪个编译器,取决于虚拟机工作的模式,用户可以使用 - 参数或 - 参数指定虚拟机的工作模式,还可以使用 -Xint 强制虚拟机运行于“解释模式”。

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所需时间会更长。同时,解释器还要替编译器收集性能监控信息,这对解释执行速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡, 虚拟机又引入了分层编译的策略。分层编译根据编译器编译、优化的规模与耗时,划分为不同的编译层次,包括:

第 0 层,程序解释执行,不开启性能监控功能,可触发第 1 层编译。第 1 层,称为 C1 编译,将字节码编译为本地代码,并进行简单可靠的优化,如有必要将加入性能监控逻辑。第 2 层,称为 C2 编译,也是将字节码编译为本地代码,但是会进行耗时较长的优化,甚至会根据性能监控信息进行一些不完全可靠的激进优化。

实施分层编译后, 和 会同时工作,许多代码可能会被编译多次,用 获得更快的编译速度,用 获取更好的编译质量,在解释执行的时候也无需再承担收集性能监控信息的任务。

编译对象与触发条件

在运行过程中,会被即时编译器编译的热点代码有两类:

被多次调用的方法。被多次执行的循环体。

这两种情况,编译器都会编译整个方法。因为编译发生在方法执行过程中,因此形象地称之为栈上替换(On Stack ,简称 OSR,即方法栈帧还在栈上,方法就被替换了)。

判断一段代码是不是热点代码,是否需要触发即时编译,这样的行为称为热点探测(Hot Spot ),热点探测方式主要有两种:

基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果某个方法经常出现在栈顶,那它就是热点方法。其优点是简单、高效,还可以获取方法调用关系;缺点是不够精确,容易受到线程阻塞或其他外接因素的影响。基于计数的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法执行次数,次数超过一定阈值就认为是热点方法。这种方法实现起来麻烦,但是其统计结果相对来说更加精确和严谨。

在 虚拟机里使用的是第二种方法,因此它为每个方法准备了两类计数器:方法调用计数器( )和回边计数器(Back edge )。在确定虚拟机运行参数的情况下,这两个计数器都有一定的阈值,超过阈值就会触发 JIT 编译。

方法调用计数器

方法调用计数器用于统计方法被调用的次数,其默认阈值在 模式下是 1500,在 模式下是 10000,该阈值可以通过虚拟机参数 -XX: 来设置。方法调用计数器与 JIT 编译的交互如下:

默认情况下,方法调用计数器统计的不是方法被调用的绝对次数,而是一段时间内的方法被调用的次数。当超过一段的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半。这个过程称为热度衰减( Decay),这段时间称为方法统计的半衰周期( Half Life Time)。进行热度衰减的动作是虚拟机在垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:- 来关闭热度衰减。另外,还可以使用 -XX: 来设置半衰周期的时间,单位是秒。

回边计数器

回边计数器的作用是统计方法体中循环体代码执行次数,在字节码中遇到遇到控制流向后调整的指令称为回边。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。在 虚拟机里,通过参数 -XX:tage 来间接调整回边计数器的阈值,其计算公式如下:

模式下,回边计数器阈值计算公式为:方法调用计数器阈值 * tage / 模式下,回边计数器阈值计算公式为:方法调用计数器阈值 * (tage - 解释器监控比率) / 100

回边计数器触发即时编译的过程如下所示:

与方法计数器不同,回边计数器没有衰减过程,因此统计的就是绝对次数。当计数器溢出的时候,它会把方法计数器也调整到溢出状态,它还会把方法计数器也调整到溢出状态,这样下次再进入该方法时就会触发即时编译。

在 虚拟机的源码里,.hpp 文件定义了虚拟机中的内存布局,如下所示:

在这个内存布局中,一行长度为 32bit,从中可以清楚地看到方法调用计数器和回边计数器的位置和长度,还有 和 ry 这两个方法的入口。

编译过程

默认情况下,即时编译是在后台进行的,编译完成之前还是按照解释方式执行,用户可以通过参数 -XX:-n 来禁止后台编译。

那么在后台编译过程中,做了什么事情呢? 和 两个编译器的编译过程是不一样的。 是一个简单快速的三段式编译器,主要关注点在于局部优化,放弃了许多耗时的全局优化手段。

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码(High Level ,HIR)表示。HIR 使用静态单分配的形式来代表代码值,这使得一些在 HIR 之后和之中进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内敛、常量传播等。在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low Level ,LIR)表示。在此之前,会在 HIR 上完成另一些优化,比如空值检查消除、范围检查消除,以便让 HIR 达到更高效的代码表示形式。最后阶段,是在平台相关的后端,使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码。

大致执行过程如下图所示:

是面向服务端的,并且为服务端性能配置进行了特别调整,是一个充分优化过的高级编译器,几乎能达到 GNU 编译器使用 -O2 参数时的优化强度。它会执行所有经典的优化动作,比如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与 Java 语言特征密切相关的技术,比如范围检查消除、空值检查消除。另外,还可能根据解释器或 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。后面会挑选部分优化手段进行详细的讲解。

的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合。以即时编译的标准来看, 无疑是比较缓慢的,但它的编译速度依然超过传统的静态优化编译器,而且相对于 来说代码质量有所提高,可以减少本地代码执行时间,从而抵消了额外的编译时间开销。

编译优化技术

Java 虚拟机设计团队几乎对代码的所有优化措施都集中在了即时编译器中,因此一般来说,即时编译器产生的本地代码会比 javac 产生的字节码更加优秀。下面,我们介绍一些 虚拟机即时编译器生成代码时采用的代码优化技术。

优化技术概览

下面列出了 虚拟机即时编译器采用的一些优化技术,既有经典编译器的优化技术,也有针对 Java 语言进行的优化技术。后面我们挑几个重要而且典型的优化进行讲解。

上面的优化技术看起来都有点“高深莫测”,虽然实现上有些难度,但是大部分理解起来都不难。下面通过一段 Java 代码的变化过程来展示几种优化技术是如何发挥作用的:

static classs B {int value;final int get() {return value;}
}public void foo() {y = b.get();// do somethingz = b.get();sum = y + z;
}

上面的代码已经非常简单了,但是仍有许多优化余地。第一步进行方法内联,方法内联重要性高于其他优化措施,其目的有两个,一是去除方法调用的成本(建立栈帧)等,二是为其他优化建立基础,方法内联膨胀后便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。内联后的代码如下所示:

public void foo() {y = b.value;// do somethingz = b.value;sum = y + z;
}

第二步进行冗余访问消除,假设代码中间注释掉的 do 部分不会改变 b.value 的值,那么就可以把 z=b.value 替换成 z=y,这样就不用再去访问对象 b 的局部变量。如果把 b.value 当成一个表达式,也可以把这项优化看成公共子表达式消除。优化后代码如下所示:

public void foo() {y = b.value;// do somethingz = y;sum = y + z;
}

第三步我们进行复写传播,因为在这段程序中并没有必要使用变量 z,它与变量 y 是完全相等的。复写传播优化后代码如下所示:

public void foo() {y = b.value;// do somethingy = y;sum = y + y;
}

第四步进行无用代码消除,无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此被形象地称为 Dead Code。这里 y=y 是无意义的代码,消除之后代码如下所示:

public void foo() {y = b.value;// do somethingy = y;sum = y + y;
}

经过四次优化之后,代码比原来精简了很多,执行效率也更高。接下来,继续学习几种最具有代表性的优化技术,看看它们是如何运作的。

公共子表达式消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的原理是:如果一个表达式 E 已经计算过了,并且从先前计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次计算就称为公共子表达。对于这种表达式,就没有必要再对其进行计算了,使用之前计算过的值即可。下面举例说明其优化过程,对于下面的代码:

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

当这段代码进入即时编译器后,编译器检测到 c * b 和 b * c 是一样的表达式,并且在计算期间 b、c 的值不变,因此这段表达式可以被视为:

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

这时,编译器还能进行另一种简化:代数化简,把表达式简化为:

int d = E * 12 + 2 * a

表达式经过优化后,计算效率就提高了。

数组边界检查消除

数组边界检查消除是即时编译器中语言相关的经典优化技术。如果有一个数组 foo[],Java 语言在访问数组元素 foo[i] 时,系统会自动进行上下界的范围检查,即 i 的取值范围是 0 ~ foo.-1,否则将抛出运行时异常 java.lang.。这对开发者来说是好事情,即时程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是,对于虚拟机的执行子系统来说,每次数组元素的读写操作都带有一次隐含的条件判定操作,对于拥有大量数组访问的系统,无疑是一种性能上的负担。

编译器会对代码进行分析,如果确定某次数组访问一定不会越界,就可以去掉数组的上下界检查。比如在循环访问数组时,编译器只要通过数据流分析确定循环变量的取值范围一定在 [0, foo.) 之间,就可以在整个循环中把数组上下界检查消除。

与数组边界检查消除类似的优化,还有隐式异常处理,Java 中空指针检查和除数为零检查都采用了这种思路。举个例子,Java 中访问一个对象 foo 的 value 属性的代码如下:

if (foo != null) {return foo.value;
} else {throw new NullPointerException();
}

在使用隐式异常优化后,伪代码如下:

try {return foo.value;
} catch (segment_fault) {uncommon_trap();
}

虚拟机会注册一个 falut 信号的异常处理器(伪代码中的 ()),这样当 foo 不为空时,对 value 的访问不会额外消耗一次空值检查的开销。代价是,当 foo 为空时,必须转入到异常处理器中恢复并抛出 异常,这个过程必须从用户态转到内核态处理,结束后再回到用户态,速度远比一次判空检查慢。当 foo 极少为空的时候,隐式异常优化是值得的, 虚拟机会根据运行期收集到的信息自动选择最优方案。

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用成本之外,它更重要的意义是为其他优化手段建立良好的基础。比如下面的代码,事实上 里都是无用代码,如果不做内联,是无法发现任何 dead code 的,因为分开来看,foo 和 两个方法里的操作都可能是有意义的。

public static void foo(Object obj) {if (obj != null) {System.out.println("hello");}
}public static void testInline() {Object obj = null;foo(obj);
}

方法内联看起来简单,但实际中很多方法都无法直接进行内联。原因是除了使用 指令调用的私有方法、实例构造器、父类方法以及使用 指令调用的静态方法,还有部分 final 方法能够在编译时唯一确定执行的方法版本,其他都可能存在多于一个版本的方法接收者,需要在运行时才能确定,这一类方法称为非虚方法。

为了解决虚方法的内联问题,Java 虚拟机引入了一种称为“类型继承关系分析”(Class ,CHA)的技术,这是一种基于整个查询的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种实现,某个类是否存在子类、子类是否为抽象类等信息。

如果通过 CHA 分析得知某个方法只有一个版本,就可以进行内联,不过这种内联属于“激进优化”,需要预留一个“逃生门”,称为守护内联。如果程序在执行过程中,虚拟机一直没有加载到令这个类继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,返回到解释状态执行,或者重新进行编译。

如果 CHA 查出来某方法有多个版本,则编译器还会进行最后一次努力,使用内联缓存 Cache 来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,其工作原理是:在未发生方法调用之前,内联缓存为空,当第一次调用发生后,缓存记录下方法接收者的版本信息。后续每次执行都检查版本,一致则使用内联缓存,否则查找虚方法表进行方法分派。

逃逸分析

逃逸分析是目前 Java 虚拟机中比较前言的优化技术,它与类型继承关系分析一样,并不是直接优化代码的技术,而是为其他优化手段提供依据的分析技术。逃逸分析的基本行为是分析对象动态作用域:当一个对象在方法里定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部方法访问到,譬如赋值给类变量或其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个对象不会逃逸到方法或线程之外,就可以为这个变量进行一些高效优化:

栈上分配:Java 一般是在堆上分配对象的,对象的回收依赖虚拟机的垃圾收集系统,垃圾收集系统回收和整理内存都需要耗费时间。如果一个对象不会逃逸出方法之外,那让这个对象在栈上分配会是一个不错的主意,对象所占用的内存空间可以随着栈帧出栈而销毁,减轻了垃圾收集系统的压力。同步消除:线程同步本身是一个相对耗时的过程,如果逃逸线程分析确定一个对象不会逃逸出线程,不会被其他线程访问,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉。标量替换:标量是指一个数据已经无法再分解成更小的数据了,Java 中的原始数据类型都不能再进一步分解,就可以称为标量。相对的,如果一个数据可以继续分解,就可以称作聚合量,Java 中的对象就是典型的聚合量。如果把一个 Java 对象拆散,根据程序访问情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候就可能不创建这个对象,改为直接创建它的若干个成员变量来代替。将对象拆分后,除了可以让成员变量在栈上分配和读写外,还可以为后续进一步优化创建条件。

下面给出一个演示逃逸分析的代码示例:

public class Test {public static void main(String[] args) throws Exception {long sum = 0;long count = 1000000;//warm upfor (long i = 0; i < count ; i++) {sum += fn(i);}Thread.sleep(500);for (long i = 0; i < count ; i++) {sum += fn(i);}System.out.prlongln(sum);System.in.read();}private static long fn(long age) {User user = new User(age);long i = user.getAge();return i;}
}class User {private final long age;public User(long age) {this.age = age;}public long getAge() {return age;}
}

在上面的代码里执行了 200 万次 fn 函数,创建了 200 万次 User 对象。jdk8 里默认就开启了逃逸分析,通过 java -cp . -Xmx3G -Xmn2G - -XX:- Test 命令运行代码,这里特意使用 -XX:- 参数来关闭逃逸分析。通过 jps 查看 java 进程的 PID,接着通过 jmap -histo [pid] 查看 java 堆上的对象分布情况,结果如下:

albon$ jmap -histo 50590num     #instances         #bytes  class name
----------------------------------------------1:           433       64179744  [I2:       2000000       32000000  User3:          1764         205872  [C4:           219          65736  [B5:           482          54960  java.lang.Class6:          1353          32472  java.lang.String

可以看到堆里有 个 User 对象。

然后,再通过 java -cp . -Xmx3G -Xmn2G - Test 运行代码,结果如下:

albon$ jmap -histo 50603num     #instances         #bytes  class name
----------------------------------------------1:           432       58962184  [I2:        312831        5005296  User3:          1764         205872  [C4:           219          65736  [B5:           482          54960  java.lang.Class6:          1353          32472  java.lang.String

在堆里,我们只看到了 31 万个 User 对象。由此可以验证,开启逃逸分析之后,只有 31 万左右的 User 对象在 Java 堆上分配,其余的对象已经通过标量替换优化了。

关于我们

最火推荐

小编推荐

联系我们


版权声明:本站内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 88@qq.com 举报,一经查实,本站将立刻删除。备案号:桂ICP备2021009421号
Powered By Z-BlogPHP.
复制成功
微信号:
我知道了