java运行期优化_java虚拟机晚期优化-运行期优化方法

所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化发生,所有对象分配在堆上也逐渐变得不是那么“绝对”了。

然后有个标注指向了11章。其实我只是想先看看为什么会不那么“绝对”的,结果看到最后才找到为什么,那文章也看完了,就总结下来吧。

解释器和编译器

在主流虚拟机中(比如Hotspot)解释器和编译器是共存的,同时内置了两个编译器Client Compiler(C1编译器)和Server Compiler(C2编译器)

为了在程序启动速度和执行效率之间权衡,jvm将编译划分了三个层次

1.第0层:程序解释执行,没有性能监控

2.第1层:C1编译

字节码编译成本地代码

进行简单可靠的优化

必要时开启性能监控

3.第2层:C2编译

字节码编译成本地代码

优化过程复杂耗时

开启性能监控

进行不可靠的激进优化

编译触发条件

java程序在虚拟机中最初通过解释器执行,遇到“热点代码”则通过JIT即时编译器(Just In Time Compiler)编译执行。

编译过程是发生在执行过程中的,在编译过后栈帧上的方法会被替换,被称为栈上替换(On Stack Replacement,简称OSR)

热点代码有两类:

被多次调用的方法

被多次执行的循环体

热点探测方法主要有两种:

采样热点探测:周期检查各个线程栈顶,经常出现在栈顶的方法就是热点方法。

基于计数器的热点探测:统计方法调用次数,超过阈值就认为是热点方法。

Hotspot中使用的是第2种热点探测方法。

计数器有两种:

1.方法调用计数器

记录的是方法的调用次数,在方法调用时,如果方法没有被编译过则计数器加1,之后判断计数器是否超过阈值,超过就进行编译,否则就解释执行。

那如果编译之后,方法的入口地址讲被重写成被编译的本地代码地址,这就是JIT的作用。

2.回边计数器

记录的是循环体的调用次数,在循环调用时,如果代码片段没有被编译过则计数器加1,之后判断计数器是否超过阈值,超过就进行编译,否则就解释执行。

以上两种计数器在超过阈值之后都会通过OSR的方式进行编译。

总结

java虚拟机方法执行过程: 进入方法,判断是否存在这个方法的编译过的版本,如果存在,就执行编译的本地代码。如果不存在编译版本则更新计数器,根据计数器阈值判断是否需要进行编译,然后解释执行。

java循环执行过程: 遇到回边指令,判断是否存在编译后的本地代码,如果存在,执行本地代码。如果不存在则更新回边计数器,根据计数器阈值判断是否需要进行编译,然后解释执行。

那看到晚期优化对方法和循环的执行热度进行判断,如果“过热”就编译成本地代码进行OSR,这样运行效率必定提高。

编译过程

理解了优化的过程,也就是根据热度来编译成本地代码,那编译过程是怎样的呢?分开来看C1和C2编译器:

C1编译器是简单快捷的三段式编译器:

在字节码基础上完成部分基础优化:方法内联、常量传播等,优化完成之后构造成高级中间代码表现形式HIR(High-Level Intermediate Representaion)

在HIR基础完成另外一些优化:空值检查消除、范围检查消除等,之后产生低级中间代码表现形式LIR(Low-Level Intermediate Representaion)

最后在LIR上分配寄存器、完成窥孔优化,产生机器代码。

C2编译器一般用在服务器上,是充分优化过的编译器。它的优化过程比较缓慢,在C2中会执行很多经典优化动作。

优化技术

上边总结了一下晚期编译的整体过程,包括编译的层次、编译触发的条件、热点探测技术和编译过程。那上边只是流程性的东西,当然计数器还是很重要的东西,在server端使用C2的话,可以通过调整计数器来根据服务器资源调整编译触发条件,大家有兴趣可以去研读原著。

接下来总结下最具代表性的优化技术,分别是:

语言无关的经典优化技术之一:公共子表达式消除

语言相关的经典优化技术之一:数组范围检查消除

最重要的优化技术之一:方法内联

最前沿的优化技术之一:逃逸分析

公共子表达式消除

举例:

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

虚拟机发现c * b和b * c结果一样,并且 b和c的值在计算期间不会发生变化

那b * c的结果可能会被E替代,E就是公共子表达式

b * c的运算会被消除,如下

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

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

int d = E * 13 + a * 2

那这样计算的话,被编译成字节码后行数其实是减少了。

数组范围检查消除

在访问Java中的数组元素时,系统会进行边界检查,如果访问的下标超出边界,会抛出运行时异常:ArrayIndexOutOfBoundsException。

这样如果程序员出错会给出提示,可以避免大部分溢出攻击。

但是对于虚拟机来说,每次读写都包含一次条件判断,对于大量的访问来说,这无疑是一种性能负担。

那在java中如何做到性能和安全的平衡呢?

访问单个数组元素foo[3]的话,首先java在编译期间确定数组foo的长度,并且在编译期间根据下标3来判断是否越界,这样在运行期间就可以消除判断。

那如果在循环中访问数组元素,在编译期间根据数据流就可以确定循环变量的取值范围,如果取值范围没有越界,那整个循环在运行期就可以消除判断了。

方法内联

举例:

136be34314a57ba454d97dfa8ef5d707.png

在编译之后,foo方法就会被内联到testInline了,如果再次执行testInline,foo方法是不会再被调用了。

java面向对象思想中,方法的调用者的类型在编译期间并不能确定,那在内联的时候,应该用哪个对象的方法内联呢?这导致了不确定性。那是不是就没办法执行方法内联的优化手段了呢?

Java团队已经解决了这个问题,引入了“类型继承关系分析”(Class Hierarchy Analysis, CHA)技术。基于整个应用程序的类型分析技术,用于确定在目前已知类中,某个接口是否有多于一种的实现,某各类是否存在子类,子类是否为抽象类等信息。

在进行方法内联时,如果遇到方法是被对象调用,也就是虚方法的时候,则向CHA查询。

如果只有一个版本,直接进行内联

如果有多个版本,使用内联缓存完成内联。

内联缓存是记录上次方法调用者的信息。

那大家看到无论哪种内联方式,都是很不可靠的,这属于“激进优化”,JVM预留了“逃生门”,也就是如果出现异常,退回到解释状态重新执行。 方法内联为其他优化手段奠定了基础,方法内联看起来简单,实际上

逃逸分析

方法逃逸:对象在方法中定义,但是被方法外部访问

线程逃逸:对象在线程内部定义,但是被其他线程访问。

逃逸分析并不是优化手段而是为其他优化手段提供依据。

由于如果完全准确的判断一个对象是否会逃逸,需要复杂的分析,这是一个耗时相对高的过程,如果分析下来没有几个对象逃逸,那就得不偿失。所以目前的虚拟机只能采用不太精准的算法来完成逃逸分析。

虽然现在逃逸分析技术不是很成熟,但是这是一个发展方向,如果完成了逃逸分析可以基于分析结果执行如下高效优化:

栈上分配:如果确定对象在方法内不会逃逸,那这个对象可以分配在栈上,随着方法调用结束,栈帧出栈,对象也被销毁。这样减轻了分代收集的压力,同时也提升了运行效率。

同步消除:因为同步是耗时的过程,如果确定某个变量不会在线程内逃逸,那肯定被其他线程访问到,也就不会有竞争条件,是线程安全的,则可以取消同步。

标量替换:标量就是无法再分解成更小的数据了,比如int/long等。如果对象不会逃逸,并且对象成员变量拆散都恢复到了原始变量,那对象可以不用创建,直接创建若干个标量来执行逻辑。这样可以做到成员变量直接在栈上读写并且为后续优化提供基础。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值