程序编译与代码优化(前期优化与后期优化)

从计算机程序出现的第一天起,对效率的追求就是程序天生的坚定信仰,这个过程犹如一场没有终点,永不停歇的F1方程式竞赛,程序员是车手,技术平台则是赛道上飞驰的赛车.


java语言的"编译期"其实是一段"不确定"的操作过程,因为它可能是一个前端编译器(编译器的前端)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器)把字节码转变为机器码的过程;还可能是使用静态提前编译器(AOT编译器)直接把*.java文件编译成本地机器代码的过程.

前端编译器:把java文件转变成class文件

后端运行期编译器:把字节码转变为机器码

静态提前编译器:把java文件转变为机器码


这三类编译过程的一些代表编译器:

前端编译器:Sun的javac. Eclipse JDT中的增量式编译器(ECJ)

JIT编译器:HotSpot VM 的C1,C2编译器

AOT编译器:GUN GCJ,Excelsior JET


虚拟机设计团队把对性能的优化集中到了后端的即时编译器中

java中即时编译器运行期的优化过程对于程序运行来说更重要,而前端编译器编译期的优化过程对于程序编码来关系更加密切.

javac编译器本身是一个由纯java语言编写的程序

HotSpot虚拟机使用C++和少量C实现


早期(编译期)优化


javac编译器的编译过程大致可以分为3个过程,分别是:

  1. 解析填充符号表过程.
  2. 插入式注解处理器注解处理过程.
  3. 语义分析字节码生成过程

解析和填充符号表过程

1.解析步骤包括词法分析语法分析

  • 词法分析:将源代码的字符流转变为标记(Token)集合
  • 语法分析:根据Token序列构造抽象语法树(AST)

2.填充符号表步骤

  • 符号表是由一组符号地址符号信息构成的表格,它登记的信息在编译的不同阶段都要用到.

注解处理器

  • 可以把注解处理器看做是一组编译器的插件,在这些插件里面,可以读取,修改,添加.语法树中任意元素.如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止.每一次循环称为一个round,也就是回环过程.语法树的任意元素,甚至包括代码注释都可以在插件之中访问到.

语义分析和字节码生成过程

  • 语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的.而语义分析的主要任务是对结构正确源程序进行上下文有关性质的审查.

语义分析过程分为标注检查以及数据及控制流分析两个步骤

  • 标注检查的内容包括诸如变量使用前是否已被声明.变量与赋值之间的数据类型是否能够匹配等.标注检查期间还有一个重要的动作称为常量折叠(int a = 1+2;  >>>int a = 3;)
  • 数据及控制流分析可以检查出诸如程序局部变量在使用前是否有赋值,方法的每条路径是否都有返回值,是否所有的受查异常都被正确处理了等问题.(final修饰的变量如何保证不变性,在编译期保证,运行期没影响)
  • 解语法糖:语法糖指在计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用,增加代码可读性,java中最常用的语法糖主要有:泛型,变长参数,自动装箱/拆箱等.虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖

字节码生成

字节码生成阶段不仅仅是把上述步骤所生成的信息转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转化工作.如实例构造器()方法类构造器()方法在此阶段添加到语法树中.还有一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuilder的append()操作等.

程序编译与代码优化(前期优化与后期优化)_第1张图片

程序编译与代码优化(前期优化与后期优化)_第2张图片


泛型遇见重载:擦除动作导致方法的特征签名变得一模一样.

java的一些其他语法糖:内部类.枚举类,断言语句(assert),对枚举和字符串的switch支持,try语句中的定义和关闭资源等.

虚拟机参数:

开启断言:-ea

关闭断言:-da

以上就是javac前端编译器的优化内容与过程.


晚期(运行期)优化

HotSpot虚拟机中,java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为"热点代码".为了提高热点代码的执行效率,在运行时虚拟机将会把这些代码编译成和本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器).

HotSpot虚拟机中同时包含解释器和编译器.两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行.在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率.同时,解释器还可以作为编译器激进优化时的一个"逃生门".让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时可以通过逆优化退回到解释状态继续执行(部分没有解释器的虚拟机中也会使用不进行激进优化的C1编译器担任"逃生门"的角色),因此整个虚拟机执行架构中,解释器与编译器经常配合工作.

HotSpot虚拟机中内置了两个即时编译器,默认采用解释器和其中一个编译器直接配合的方式工作,使用哪个编译器,取决于虚拟机运行的模式.

Client Compiler称为C1=========C1不进行激进优化

Server Compiler称为C2

虚拟机参数:

强制运行在Client模式:-client

强制运行在Server模式:-server

因为client模式不支持64位,所以64位jvm不能用client模式启动

解释器与编译器搭配使用的方式在虚拟机中称为"混合模式"(Mixed Mode)

编译器完全不介入工作,代码全部使用解释方式执行的方式称为"解释模式"(Interpreted Mode)

优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程的方式称为"编译模式"(Compiled Mode)

虚拟机参数:

查看版本及运行模式:-version

强制运行在解释模式:-Xint

强制运行在编译模式:-Xcomp

即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也会有影响.

JDK1.7以后默认开启分层编译,分层编译根据编译器编译,优化的规模与耗时,划分出不同的编译层次:

第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译.

第1层:也称为C1编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑.

第2层(或2层以上):也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化.

实施分层编译后Client Compiler和Server Compiler将会同时工作,用Client Compiler获取更高的编译速度,用Server Compiler获取更好的编译质量,在解释执行的时候也无须再承担性能监控信息的任务.


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

被多次调用的方法.

被多次执行的循环体.

第一种:方法调用触发的编译,JIT编译器以整个方法体作为编译对象.

第二种:循环体触发的编译,JIT编译器以整个方法体作为编译对象.

第两种方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(OSR编译),即方法栈帧还在栈上,方法就被替换了.


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

  • 基于采样的热点探测:虚拟机会周期性的检查栈顶,经常出现在栈顶的方法就是"热点代码",好处简单,高效,还容易获取方法调用的关系,缺点不精准,如线程阻塞扰乱热点探测.
  • 基于计数器的热点探测:虚拟机会为每个方法(甚至是代码块)建立计数器,统计执行次数,如果执行次数超过一定的阈值就是"热点代码",好处是结果更加精确和严谨,缺点实现复杂,要为每个方法建立并维护计数器,且不能直接获取到方法的调用关系.

HotSpot虚拟机采用的是基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器回边计数器,当计数器超过阈值溢出了,就会触发JIT编译.


方法调用计数器

方法调用计数器统计的是方法被调用的次数,默认阈值在Client模式下是1500次,在Server模式下是10 000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来设定与更改.

程序编译与代码优化(前期优化与后期优化)_第3张图片

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即方法被调用的次数,当超过一定的时间限制,如果方法的调用次数仍然没有超过阈值,那这个方法的调用计数器就是被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期.

虚拟机参数:

关闭热度衰减:-XX:-UserCounterDecay

设置半衰周期的时间(单位是秒):-XX:CounterHalfLifeTime


回边计数器

回边:在字节码中遇到控制流向后跳转的指令称为"回边"

回边计数器作用是统计一个方法中循环体代码执行的次数,显然,建立回边计数器的目的就是为了触发OSR编译.

虚拟机参数:

间接调整回边计数器的阈值:-XX:OnStackReplacePercentage

为什么是间接呢?因为回边计数器阈值是通过一个公式计算出来的

虚拟机运行在Client模式下,回边计数器阈值的计算公式:

  • 方法调用计数器阈值(CompileThreshold)×OSR比率OnStackReplacePercentage)/100
  • 其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机的回边计数器的阈值为13995

虚拟机运行在Server模式下,回边计数器阈值的计算公式:

  • 方法调用计数器阈值(CompileThreshold)×(OSR比率OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
  • 其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700.

程序编译与代码优化(前期优化与后期优化)_第4张图片


编译过程


编译优化技术

即时编译器优化技术一览:

类型 优化技术
编译器策略

延迟编译

分层编译

栈上替换

延迟优化

程序依赖图表示

基于性能监控的优化技术

乐观空值断言

乐观类型断言

乐观类型增强

乐观数组长度增强

裁剪未被选择的分支

乐观的多态内联

分支频率预测

调用频率预测

基于证据的优化技术

精确类型推断

内存值推断

内存值跟踪

常量拆叠

重组

操作符退化

空值检查消除

类型检测退化

类型检测消除

代数化简

公共子表达式消除

数据流敏感重写

条件常量传播

基于流承载的类型缩减转换

无用代码清除

语言相关的优化技术

类型继承关系分析

去虚拟机化

符号常量传播

自动装箱传播

逃逸分析

锁清除

锁膨胀

清除反射

内存及代码位置变换

表达式提升

表达式下沉

冗余存储消除

相邻存储合并

交汇点分离

循环变换

循环展开

循环剥离

安全点消除

迭代范围分离

范围检查消除

循环向量化

全局代码调整

内联

全局代码外提

基于热度的代码布局

Switch调整

控制流图变换

本地代码编排

本地代码封包

延迟槽填充

着色图寄存器分配

线性扫描寄存器分配

复写聚合

常量分裂

复写移除

地址模式匹配

指令窥孔优化

基于确定有限状态机的代码生成

这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在java源码之上的,只是为了展示方便,使用java语言来表示.

方法内联主要目的有两个:一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础.

优化前的原始代码:

	static class B{
		int value;
		final int get() {
			return value;
		}
	}
	
	public void foo() {
		//这是一种伪代码形式,为的是说明代码优化所作的操作
		y = b.get();
		//do...stuff...
		z = b.get();
		sum = y+z;
	}

当进行了方法内联之后,代码如下所示:

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

接着进行冗余访问消除

把z=b.value替换为z=y

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

进行复写传播

用y代替z

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

最后进行无用代码消除

无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此也称为"Dead Code"

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

可以看出进行优化后,省略了许多的代码语句,这是使用代码来表示,真实是体现在字节码和机器码指令上,所以差距会更加巨大,执行的效率也会更高.


接着我们来看看一些最具有代表性的优化技术

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

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

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

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


公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在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 * 13 + a * 2;


数组边界检查消除:如果有一个数组foo[],在java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,带来安全性的同时,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担.

为了安全,数组边界检查肯定是必须做的,但是数组边界检查是不是必须在运行期间一次不漏地检查则是可以"商量"的.

比如数组下标是一个常量,更加常见的情况是数组访问发生在循环中,并且使用循环变量来进行数组访问.

这样就可以节省很多次的条件判断操作.

除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,还有一种避免思路-----隐式异常处理.java中的空指针检查和算数运算中除数为零的检查都采用了这种思路.

例如以下伪代码:

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

在使用了隐式异常优化之后,虚拟机会把上面伪代码转化为如下伪代码:

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

虚拟机会注册一个Segment Fault信号的异常处理器,这样当foo不为空的时候,对value的访问是不会额外消耗一次对foo判空的开销.代价就是当foo真的为空时,必须转到异常处理器中恢复并抛出空指针异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢.

当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空的话,这样的优化反而会让程序更慢.

与语言相关的其他消除操作还有不少,如自动装箱消除,安全点消除,消除反射等.


方法内联上面已经讲过不过是把目标方法的代码"复制"到发起调用的方法之中,避免发生真实的方法调用而已,再给出一个例子:

	public static void foo(Object obj) {
		if(obj != null) {
			System.out.println("do something");
		}
	}
	public static void testInline(String[] args) {
		Object obj = null;
		foo(obj);
	}

事实上test方法的内部全部都是无用代码,如果不做内联,即使进行了无用代码消除的优化,也无法发现任何"Dead Code",因为如果分开来看,两个方法里面的操作都可能是由意义的.

为了解决虚拟机的内联问题,java虚拟机设计团队引入了一种名为"类型继承关系分析"(CHA)的技术.

在很多情况下虚拟机进行的内联都是一种激进优化,这在高性能的商用虚拟机中很常见,出了内联之外,对于出现概率很小的隐式异常,使用概率很小的分支等都可以被激进优化"移除",如果真的出现了小概率事件,这是才会从"逃生门"回到解释状态重新执行.


逃逸分析:逃逸分析并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术.

逃逸分析的基本行为就是分析对象的动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸.甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸.

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效优化:

  • 栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个好主意,可以缩短对象的生命周期,减轻垃圾收集系统的压力.
  • 同步消除:线程同步本身就是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉.
  • 标量替换:标量是指一个数据已经无法再分解成更小的数据来表示了,如原始数据类型,它们就可以被称为标量.如果一个数据可以继续分解,那它就称作聚合量,java中的对象就是最典型的聚合量.如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换.如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替.将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创造条件.

逃逸分析这项优化尚未足够成熟,不成熟的原因是不能保证逃逸分析的性能收益必定高于它的消耗.

总结:

分层编译

栈上替换

常量折叠

代数化简

公共子表达式消除

无用代码清除

类型继承关系分析

逃逸分析

冗余存储消除

范围检查消除

内联

复写移除

栈上分配

同步消除

标量替换

隐式异常处理

虚拟机参数:

开启断言:-ea

关闭断言:-da

强制运行在Client模式(64位虚拟机不能运行在此模式下):-client

强制运行在Server模式:-server

查看版本及运行模式:-version

强制运行在解释模式:-Xint

强制运行在编译模式:-Xcomp

设置方法调用计数器阈值:-XX:CompileThreshold

关闭热度衰减:-XX:-UserCounterDecay

设置半衰周期的时间(单位是秒):-XX:CounterHalfLifeTime

间接调整回边计数器的阈值:-XX:OnStackReplacePercentage

你可能感兴趣的:(JVM)