11.晚期(运行期) 优化

来源:互联网 发布:python 2.7系统要求 编辑:程序博客网 时间:2024/04/27 14:39
1.概述
Java程序最初是通过解释器(Interpreter) 进行解释执行的, 当虚拟机发现某个方法或代码块的运行特别频繁时, 就会把这些代码认定为“热点代码” (Hot Spot Code) 。 为了提高热点代码的执行效率, 在运行时, 虚拟机将会把这些代码编译成与本地平台相关的机器码, 并进行各种层次的优化, 完成这个任务的编译器称为即时编译器(Just In Time Compiler, 下文中简称JIT编译器) 。
2.HotSpot虚拟机内的即时编译器
解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候, 解释器可以首先发挥作用, 省去编译的时间, 立即执行。 在程序运行后, 随着时间的推移, 编译器逐渐发挥作用, 把越来越多的代码编译成本地代码之后, 可以获取更高的执行效率。

2.1 C1编译器和C2编译器
HotSpot虚拟机中内置了两个即时编译器, 分别称为Client Compiler和Server Compiler, 或者简称为C1编译器和C2编译器(也叫Opto编译器) 。 (新版64位jdk已经取消Client模式,只有Server模式)。

2.2 编译模式
解释器与编译器搭配使用的方式在虚拟机中称为“混合模式” (Mixed Mode) , 用户可以使用参数“-Xint” 强制虚拟机运行于“解释模式” (Interpreted Mode) , 这时编译器完全不介入工作, 全部代码都使用解释方式执行。 另外, 也可以使用参数“-Xcomp” 强制虚拟机运行于“编译模式” (Compiled Mode),这时将优先采用编译方式执行程序, 但是解释器仍然要在编译无法进行的情况下介入执行过程, 可以通过虚拟机的“-version” 命令的输出结果显示出这3种模式。

2.3 分层编译
HotSpot虚拟机还会逐渐启用分层编译(Tiered Compilation)的策略,分层编译根据编译器编译、 优化的规模与耗时, 划分出不同的编译层次,其中包括:
第0层, 程序解释执行, 解释器不开启性能监控功能(Profiling) , 可触发第1层编译。
第1层, 也称为C1编译, 将字节码编译为本地代码, 进行简单、 可靠的优化, 如有必要将加入性能监控的逻辑。
第2层(或2层以上) , 也称为C2编译, 也是将字节码编译为本地代码, 但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
2.3 编译对象与触发条件
在运行过程中会被即时编译器编译的“热点代码” 有两类, 即:
被多次调用的方法。
被多次执行的循环体。
第一种情况, 编译器会以整个方法作为编译对象, 这种编译也是虚拟机中标准的JIT编译方式。
第二种情况, 编译器依然会以整个方法(而不是单独的循环体) 作为编译对象。 因为编译发生在方法执行过程之中, 因此称之为栈上替换(On Stack Replacement, 简称为OSR编译, 即方法栈帧还在栈上, 方法就被替换了) 。
A.热点探测
判断一段代码是不是热点代码, 是不是需要触发即时编译, 这样的行为称为热点探测(Hot Spot Detection) , 目前主要的热点探测判定方式有两种:
1).基于采样的热点探测(Sample Based Hot Spot Detection)
采用这种方法的虚拟机会周期性地检查各个线程的栈顶, 如果发现某个(或某些) 方法经常出现在栈顶, 那这个方法就是“热点方法” 。
2).基于计数器的热点探测(Counter Based Hot Spot Detection)
采用这种方法的虚拟机会为每个方法(甚至是代码块) 建立计数器, 统计方法的执行次数, 如果执行次数超过一定的阈值就认为它是“热点方法” 。
B.HotSpot中的热点探测
在HotSpot虚拟机中使用的是基于计数器的热点探测方法, 它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter):
1).方法调用计数器
这个计数器就用于统计方法被调用的次数, 它的默认阈值在Client模式下是1500次, 在Server模式下是10 000次, 这个阈值可以通过虚拟机参数-XX:CompileThreshold来设定。

如果不做任何设置, 方法调用计数器统计的并不是方法被调用的绝对次数, 而是一个相对的执行频率, 即一段时间之内方法被调用的次数。 当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译, 那这个方法的调用计数器就会被减少一半, 这个过程称为方法调用计数器热度的衰减(Counter Decay) , 而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) 。
2).回边计数器
回边计数器, 它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge) 。
虚拟机运行在Server模式下, 回边计数器阈值的计算公式为:方法调用计数器阈值(CompileThreshold) ×(OSR比率(OnStackReplacePercentage) -解释器监控比率(InterpreterProfilePercentage) /100,其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33, 如果都取默认值, 那Server模式虚拟机回边计数器的阈值为10700。

2.3 编译过程
Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器, 也是一个充分优化过的高级编译器, 它会执行所有经典的优化动作, 如无用代码消除(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) 等。
3.编译优化
3.1 优化技术概览



3.2 公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术, 它的含义是: 如果一个表达式E已经计算过了, 并且从先前的计算到现在E中所有变量的值都没有发生变化, 那么E的这次出现就成为了公共子表达式。 对于这种表达式, 没有必要花时间再对它进行计算, 只需要直接用前面计算过的表达式结果代替E就可以了。 如果这种优化仅限于程序的基本块内, 便称为局部公共子表达式消除(Local Common Subexpression Elimination) , 如果这种优化的范围涵盖了多个基本块, 那就称为全局公共子表达式消除(Global Common Subexpression Elimination) 。
示例:int d= (c * b) *12+a+ (a+b * c);

编译器检测到“c * b” 与“b * c” 是一样的表达式, 而且在计算期间b与c的值是不变的。 因此, 这条表达式就可能被视为:
int d= E*12+a+ (a+E);
编译器还可能进行另外一种优化: 代数化简(Algebraic Simplification) , 把表达式变为:
int d=E*13+a*2;
3.3 数组边界检查消除
数组边界检查消除(Array Bounds Checking Elimination) 是即时编译器中的一项语言相关的经典优化技术。
Java语言是一门动态安全的语言,访问数组元素的时候系统将会自动进行上下界的范围检查, 否则将抛出一个运行时异常,对于虚拟机的执行子系统来说, 每次数组元素的读写都带有一次隐
含的条件判定操作, 对于拥有大量数组访问的程序代码, 这无疑也是一种性能负担。数组下标是一个常量,只要在编译期根据数据流分析来确定数组下标的值没有越界, 执行的时候就无须判断了。 更加常见的情况是数组访问发生在循环之中, 并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0, length) 之内, 那在整个循环中就可以把数组的上下界检查消除, 这可以节省很多次的条件判断操作。
A.隐式异常处理
Java中空指针检查和算术运算中除数为零的检查都采用了隐式异常处理的思路。

3.4 方法内联
方法内联, 它是编译器最重要的优化手段之一, 除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。
A.示例:

B.可以内联的方法
1).私有方法、 实例构造器、 父类方法、静态方法等在在编译期进行解析的方法;
2).被final修饰的方法特;
3).遇到虚方法(Java语言中默认的实例方法是虚方法), 则会向CHA(类型继承关系分析” Class Hierarchy Analysis,CHA)查询此方法在当前程序下是否有多个目标版本可供选择, 如果查询结果只有一个版本,那也可以进行内联, 不过这种内联就属于激进优化, 需要预留一个“逃生门” (Guard条件不成立时的SlowPath) , 称为守护内联(Guarded Inlining) 。
4).CHA查询出来的结果是有多个版本的目标方法可供选择, 则编译器还会使用内联缓存(Inline Cache) 来完成方法内联, 这是一个建立在目标方法正常入口之前的缓存, 它的工作原理大致是: 在未发生方法调用之前, 内联缓存状态为空, 当第一次调用发生后, 缓存记录下方法接收者的版本信息, 并且每次进行方法调用时都比较接收者版本, 如果以后进来的每次调用的方法接收者版本都是一样的, 那这个内联还可以一直用下去。 如果发生了方法接收者不一致的情况, 就说明程序真正使用了虚方法的多态特性, 这时才会取消内联, 查找虚方法表进行方法分派。
3.5 逃逸分析
逃逸分析(Escape Analysis) 是目前Java虚拟机中比较前沿的优化技术,是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域: 当一个对象在方法中被定义后, 它可能被外部方法所引用, 例如作为调用参数传递到其他方法中, 称为方法逃逸。 甚至还有可能被外部线程访问到, 譬如赋值给类变量或可以在其他线程中访问的实例变量, 称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外, 也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:
A.栈上分配(Stack Allocation)
如果确定一个对象不会逃逸出方法之外, 那这个对象就可以在栈上分配内存, 对象所占用的内存空间就可以随栈帧出栈而销毁。 在一般应用中, 不会逃逸的局部对象所占的比例很
大, 如果能使用栈上分配, 那大量的对象就会随着方法的结束而自动销毁了, 垃圾收集系统的压力将会小很多。
B.同步消除(Synchronization Elimination)
线程同步本身是一个相对耗时的过程, 如果逃逸分析能够确定一个变量不会逃逸出线程, 无法被其他线程访问, 那这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以消除掉。
C.标量替换(Scalar Replacement)
标量(Scalar) 是指一个数据已经无法再分解成更小的数据来表示了, Java虚拟机中的原始数据类型(int、 long等数值类型以及reference类型等) 都不能再进一步分解, 它们就可以称为标量。 相对的, 如果一个数据可以继续分解, 那它就称作聚合量(Aggregate) , Java中的对象就是最典型的聚合量。 如果把一个Java对象拆散, 根据程序访问的情况, 将其使用到的成员变量恢复原始类型来访问就叫做标量替换。
如果逃逸分析证明一个对象不会被外部访问, 并且这个对象可以被拆散的话, 那程序真正执行的时候将可能不创建这个对象, 而改为直接创建它的若干个被这个方法使用到的成员变量来代替。 将对象拆分后, 除了可以让对象的成员变量在栈上(栈上存储的数据, 有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储) 分配和读写之外, 还可以为后续进一步的优化手段创建条件。
用户可以使用参数-XX: +DoEscapeAnalysis来手动开启逃逸分析, 开启之后可以通过参数-XX: +PrintEscapeAnalysis来查看分析结果。 用户可以使用参数-XX: +EliminateAllocations来开启标量替换, 使用+XX: +EliminateLocks来开启同步消除, 使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。