JVM编译优化

来源:互联网 发布:王珊数据库视频教程 编辑:程序博客网 时间:2024/05/20 23:26

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

一.HotSpot 内的即时编译器

1.解释器和编译器各有各的优点:

解释器优点:当程序需要迅速启动的时候,解释器可以首先发挥作用,省去了编译的时间,立即执行。解释执行占用更小的内存空间。同时,当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。

编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用根据热点探测功能,,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
因此,整个虚拟机执行架构中,解释器与编译器经常配合工作,如下图所示。
这里写图片描述

HotSpot中内置了两个即时编译器,分别称为 Client Compiler和 Server Compiler ,或者简称为 C1编译器和 C2编译器。目前的 HotSpot编译器默认的是解释器和其中一个即时编译器配合的方式工作,具体是哪一个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与计算机的硬件性能自动选择运行模式,用户也可以使用 -client和 -server参数强制指定虚拟机运行在 Client模式或者 Server模式。这种配合使用的方式称为“混合模式”(Mixed Mode),用户可以使用参数 -Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。另外,使用 -Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 -version 命令可以查看当前默认的运行模式。
这里写图片描述

二.被编译对象和触发条件

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

  • 被多次调用的方法
  • 被多次执行的循环体
    对于第一种,编译器会将整个方法作为编译对象,这也是标准的JIT 编译方式。对于第二种是由循环体出发的,但是编译器依然会以整个方法作为编译对象,因为发生在方法执行过程中,称为栈上替换。
    判断一段代码是否是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种,分别为。
    基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的reduce,容易受到线程阻塞或其他外因扰乱。
    基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。
    HotSpot 使用的是第二种-基于技术其的热点探测,并且有两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
    这两个计数器都有一个确定的阈值,超过后便会触发 JIT 编译。
    这里写图片描述
    第二个回边计数器,作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge )。显然,建立回边计数器统计的目的就是为了触发OSR 编译。关于这个计数器的阈值,HotSpot 提供了-XX:BackEdgeThreshold供用户设置,但是当前的虚拟机实际上使用了 -XX:OnStackReplacePercentage来简介调整阈值,计算公式如下:
    在 Client 模式下, 公式为 方法调用计数器阈值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/ 100。其中 OSR比率默认为 933,那么,回边计数器的阈值为13995。
    在 Server 模式下,公式为 方法调用计数器阈值(Compile Threashold)X(OSR (OnStackReplacePercentage)-解释器监控比率 (InterpreterProfilePercent))/100其中 onStackReplacePercentage 默认值为 140,InterpreterProfilePercentage默认值为 33,如果都取默认值,那么Server 模式虚拟机回边计数器阈值为10700 。
    执行过程,如下图。
    这里写图片描述

三.编译模式

默认情况下,无论是方法调用产生的即时编译请求,还是 OSR 请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行,用户可以通过参数 -XX:-BackgroundCompilation来禁止后台编译,这样,一旦达到 JIT的编译条件,执行线程向虚拟机提交便已请求之后便会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

1.对于 Client 模式而言

**client compiler,又称C1编译器,较为轻量,只做少量性能开销比较高的优化,它占用内存较少,适合于桌面交互式应用。**在寄存器分配策略上,JDK6以后采用的为线性扫描寄存器分配算法,其他方面的优化,主要有方法内联、去虚拟化、冗余消除等。

它是一个简单快速的三段式编译器,主要关注点在于局部的优化,放弃了许多耗时较长的全局优化手段。
1. 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion, HIR)。在此之前,编译器会在字节码上完成一部分基础优化,如 方法内联,常量传播等优化。
2. 第二阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation,LIR),而在此之前会在HIR 上完成另外一些优化,如空值检查消除,范围检查消除等,让HIR更为高效。
3. 第三阶段,在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR 上分配寄存器,做窥孔(Peephole)优化,然后产生机器码。
Client Compiler 的大致执行过程如下图所示:
这里写图片描述
A、方法内联
多个方法调用,执行时要经历多次参数传递,返回值传递及跳转等,C1采用方法内联,把调用到的方法的指令直接植入当前方法中。-XX:+PringInlining来查看方法内联信息,-XX:MaxInlineSize=35控制编译后文件大小。
B、去虚拟化
是指在装载class文件后,进行类层次的分析,如果发现类中的方法只提供一个实现类,那么对于调用了此方法的代码,也可以进行方法内联,从而提升执行的性能。
C、冗余消除
在编译时根据运行时状况进行代码折叠或消除。

2.对于 Server Compiler 模式而言

Server compiler,称为C2编译器,较为重量,采用了大量传统编译优化的技巧来进行优化,占用内存相对多一些,适合服务器端的应用。和C1的不同主要在于寄存器分配策略及优化范围,寄存器分配策略上C2采用的为传统的图着色寄存器分配算法,由于C2会收集程序运行信息,因此其优化范围更多在于全局优化,不仅仅是一个方块的优化。收集的信息主要有:分支的跳转/不跳转的频率、某条指令上出现过的类型、是否出现过空值、是否出现过异常等。
它是专门面向服务端的典型应用,并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++编译器使用-O2 参数时的优化强度,它会执行所有的经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块冲排序(Basic Block Reordering)等,还会实施一些与Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination ,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化 了)等。另外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
Server Compiler 编译器可以充分利用某些处理器架构,如(RISC)上的大寄存器集合。从即时编译的角度来看,Server Compiler 无疑是比较缓慢的,但它的便以速度仍远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server 模式的虚拟机运行。
逃逸分析是C2进行很多优化的基础,它根据运行状态来判断方法中的变量是否会被外部读取,如不会则认为此变量是不会逃逸的,那么在编译时会做标量替换、栈上分配和同步消除等优化。
方法逃逸+线程逃逸
1)方法逃逸:分析对象的动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递给其他方法,称为方法逃逸。
1)线程逃逸:甚至还有可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。
1. 标量替换
如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换;那程序真正执行的时候,将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。
这样做的好处是如果创建的对象并未用到其中的全部变量,则可以节省一定的内存。对于代码执行而言,无需去找对象的引用,也会更快一些。
2. 栈上分配
如果point没有逃逸,那么C2会选择在栈上直接创建Point对象的实例,而不是在JVM堆上。在栈上分配的好处一方面是加快速度,另一方面是回收时随着方法的结束,对象被回收了。
3. 同步消除
如果发现同步的对象未逃逸,因为不存在其它线程参与该对象的竞争,那也就没有必要进行同步了,C2编译时会直接去掉同步。线程同步本身就是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以消除掉。
C2还会基于拥有的运行信息来做其他优化,比如编译分支频率执行高的代码等。

4.对于分层编译而言

Java7默认开启分层编译(tiered compilation)策略,由C1编译器和C2编译器相互协作共同来执行编译任务。C1编译器会对字节码进行简单和可靠的优化,以达到更快的编译速度;C2编译器会启动一些编译耗时更长的优化,以获取更好的编译质量。
(1)解释器不再收集运行状态信息,只用于启动并触发C1编译
(2)C1编译后生成带收集运行信息的代码
(3)C2编译,基于C1编译后代码收集的运行信息进行激进优化,当激进优化的假设不成立时,再退回使用C1编译的代码
解释器与编译器并存
如果选用完全解释策略,那么编译器将停止所有的工作,字节码将完全依靠解释器逐行解释执行。
如果选用完全编译策略,那么解释器仍然会在编译器无法进行的特殊情况下介入运行,这主要是确保程序能够最终顺序执行。
SunJDK之所以未选择在启动时即编译成机器码的原因如下:
(1)静态编译并不能根据程序的运行状态来优化执行的代码,C2这种方式是根据运行状态来进行动态编译的,例如分支判断、逃逸分析等,这些措施会对提升程序执行的性能起到很大的帮助,在静态编译的情况下是无法实现的,给C2收集运行数据越长的时间,编译出来的代码会越优。
(2)解释执行比编译执行更节省内存
(3)启动时解释执行的启动速度比编译再启动更快。

四.编译过程

1.编译期优化(早期优化)

为了保证JRuby,Groovy等语言编译的字节码也能得到性能优化,JVM将性能优化放在了后期的运行时优化,即JIT运行时编译优化中。
具体优化:
1. 编译期优化主要为语法糖,用来实现Java的各种新的语法特性,比如泛型,变长参数,自动装箱/拆箱。
2 .Java语法糖:与字节码无关,编译后会去掉它们。作用仅仅为方便码农写代码,以及将运行时异常在编译期及早发现(如泛型的使用)。
* 泛型与类型擦除
Java泛型只在编译期存在,编译完成后的字节码中会替换为原生类型。故称Java泛型为伪泛型。C#的泛型在运行期仍然存在。
* 条件编译
if语句中使用常量。比如if(false) {},这个语句块不会被编译到字节码中.这个过程在编译时的控制流分析中完成。

2.运行时优化(晚期优化)

  1. 不同JVM的运行时优化策略
    Hotspot采用解释器与编译器并存的构架。
    • 第0层,解释执行,不开启性能监控器,可触发第一层编译
    • 第1层,将字节码编译为机器码,进行简单可靠的优化,可以开启性能监控
    • 第2层,将字节码编译为机器码,会开启一些编译耗时的优化和一些不可靠的激进
  2. 具体优化:
    • 公共字表达式消除
      如果一个表达式E已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
    • 数组边界检查消除
      数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。Java访问数组的时候系统将会自动进行上下界的范围检查,但对于虚拟机的执行子 系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。
      数组边界检查时必须做的,但数组边界检查在某些情况下可以简化。例如数组下标示一个常量,如foo3,只要在编译器根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无须判断了。再例如数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0, foo.length)之内,那在整个循环中就可以把数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
      与语言相关的其他消除操作还有自动装箱消除(Autobox Elimination)、安全点消除(Safepoint Elimination)、消除反射(Dereflection)等。
    • 方法内联
      方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,更重要的是可以为其他优化手段建立良好的基础。
    • 逃逸分析
      逃逸分析(Escape Analysis)并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。

五. Java即时编译与C/C++编译对比

1. 劣势

  1. Java即时编译是在运行时,故会占用用户程序的运行时间。而C/C++是静态编译为机器码的,完全不占用运行时间。
  2. Java运行时不能进行一些比较耗时的优化,故能做的优化也没有C那么多
  3. 多态选择频率远高于C,需要建立虚方法表。也正是多态的存在,使得编译优化难度远高于C。因为多态较难预测代码跳转分支。
  4. Java运行时可以加载新的类,如网络中的二进制流。这使得编译器无法看清程序全貌,全局优化很难进行。
  5. Java对象都是在堆上分配(除了class对象在方法区),而C/C++既可以在堆上,又可以在栈上。栈上分配可以减轻垃圾回收压力,且速度远快于堆。

2. 优势

  1. 可以进行性能监控,热点探测,分支频率预测,调用频率预测,从而有选择性的优化代码