《深入理解JAVA虚拟机》第十、十一章 编译运行期优化

第十、十一章 编译运行期优化

常说的编译其实是一段“不确定”的操作过程,可能是指一个前端编译器把.java文件转变成.class文件,也可能是指虚拟机后端运行期编译器(JIT编译器)把字节码变成机器码的过程,还可能是指静态提前编译器(AOT编译器)直接把.java文件编译成本地机器代码的过程。虚拟机把性能的优化集中在后端的即时编译器中,这样可以让不是javac产生的Class文件也能享受到编译器优化的好处。
编译过程中比较有代表性的编译器:

  1. 前端编译器:Sun的javac、Eclipse JDT中的增量式编译器
  2. JIT编译器 :HotSpot VM的C1、C2编译器
  3. AOT编译器:GNU Compiler for the Java(GCJ)、Excelsior JET

  javac这类编译器对代码的运行效率几乎没有任何优化措施,虚拟机的设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的Class文件(如JRuby、Groovy等语言的Class文件)也同样能享受编译器优化所带来的好处。但是javac做了许多针对java语言编译过程的优化措施来改善程序员的编码风格和提高编码效率,下面来看具体看看javac。

javac编译器编译过程

从Sun Javac的代码来看,编译过程大致可以分为3个过程,分别是:

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

语法分析与填充符号表过程
  词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。
  语法分析是根据Token序列构造抽象语法树的过程。填充符号表:符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表中K-V值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等)。填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,此过程的出口是一个待处理列表(To Do List),包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。
语义分析与字节码生成过程:语义分析过程分为标注检查以及数据及控制流分析两个步骤。
  标注检查:标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。
  数据及控制流分析:可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障
  解语法糖:语法糖指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
  字节码生成:不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作:实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段添加到语法树之中的。字符串的加操作替换为StringBuffer或StringBuilder(取决于目标代码的版本是否大于或等于JDK 1.5)的append()操作等。

java语法糖

  语法糖是编译器实现的一些“小把戏”,虽不能提供实质性的功能改进,但是一定程度上可以提升语法严谨性。Java语言中的泛型只在源码中存在,在编译后的字节码中,就已经替换为原来的原生类型(Raw Type,也称为裸类型),所以泛型是java语言的一颗语法糖。

泛型与类型擦除

  泛型和类型擦除:Java语言中的泛型只存在于程序源码中,在编译后的字节码文件中,早已经替换为原生类型,并且在相应位置插入了强制转换型代码。Java语言中的泛型实现方法称之为类型擦除,(当两个相同的方法参数使用泛型进行重载时,泛型擦除成相同的原生类型后两个方法完全相同并不能通过编译。但如果两个方法的返回值不同时,可以通过,只要在Class文件中描述符不一样就共存)。

public class TestFanXin {
	public static void main(String[] args) {
		Map<String, String> map = new HashMap<String, String>();
		map.put("hello","您好");
		map.put("how are you","吃了没");
		System.out.println(map.get("hello"));
		System.out.println(map.get("how are you"));
	}	
}

  将TestFanXin.class文件反编译得到:


public class TestFanXin
{
  public static void main(String[] args)
  {
    Map map = new HashMap();
    map.put("hello", "您好");
    map.put("how are you", "您好");
    System.out.println((String)map.get("hello"));
    System.out.println((String)map.get("how are you"));
  }
}

  JCP组织引入Signature、LocalVariableTable等属性用于解决伴随泛型而来的参数化类型的识别Signature:作用是存储一个方法在字节码层面的特征签名,保存的类型不是原生类型,而是包含了参数化类型的信息擦除法所谓的擦除,仅仅是对方法的code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能够通过反射手段获取到泛型类中传入的参数化类型的根本依据。

自动装箱、拆箱与遍历循环

  自动装箱、拆箱与遍历循环(foreach)这些语法糖与上面的泛型相比,难度与深度会大很多。类似自动装箱和拆箱的语法糖错误的使用是很多程序员新手犯的问题。

  • 当 "=="运算符的两个操作数都是包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)。另外,对于包装器类型,equals方法并不会进行类型转换
  • 在通过valueOf方法创建Integer对象的时候,如果数值在[-128,127]之间,便返回指向IntegerCache.cache中已经存在的对象的引用;否则创建一个新的Integer对象。
public class TestAutoZhuangXiang {
	public static void main(String[]args){
	    Integer a=1;
	    Integer b=2;
	    Integer c=3;
	    Integer d=3;
	    Integer e=321;
	    Integer f=321;
	    Long g=3L;
	    Long h = 2L;
	    System.out.println(c==d);
	    System.out.println(e==f);
	    System.out.println(c==(a+b));
	    System.out.println(c.equals(a+b));
	    System.out.println(g==(a+b));
	    System.out.println(g.equals(a+b));
	    System.out.println(g.equals(a+h));
	}
}

运行结果:
        在这里插入图片描述
  使用反编译工具反编译TestAutoZhuangXiang.class后可以看出:

public class TestAutoZhuangXiang
{
  public static void main(String[] args)
  {
    Integer a = Integer.valueOf(1);
    Integer b = Integer.valueOf(2);
    Integer c = Integer.valueOf(3);
    Integer d = Integer.valueOf(3);
    Integer e = Integer.valueOf(321);
    Integer f = Integer.valueOf(321);
    Long g = Long.valueOf(3L);
    Long h = Long.valueOf(2L);
    System.out.println(c == d);
    System.out.println(e == f);
    System.out.println(c.intValue() == a.intValue() + b.intValue());
    System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue())));
    System.out.println(g.longValue() == a.intValue() + b.intValue());
    System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue())));
    System.out.println(g.equals(Long.valueOf(a.intValue() + h.longValue())));
  }
}

条件编译

  java语言中条件编译的实现也是一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段。

public class TestTiaojianBianyi {
	public static void main(String[]args){
	    if(true){
	        System.out.println("block 1");
	    }else{
	        System.out.println("block 2");
	    }
	}
}

反编译结果:

public class TestTiaojianBianyi
{
  public static void main(String[] args)
  {
    System.out.println("block 1");
  }
}

HotSpot 虚拟机内的即时编译器

  在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些热点代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称 JIT 编译器)。即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必需要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,它也是虚拟机内中最核心且最能体现虚拟机技术水平的部分。

HotSpot并存解释器和编译器

  HotSpot虚拟机采用解释器与编译器并存的架构,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。解释器还可以作为编译器激进优化的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时,可以通过逆优化退回到解释状态继续执行。
       在这里插入图片描述
  HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器,虚拟机默认采用解释器与其中一个编译器直接配合的方式工作。
  由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。HotSpot虚拟机采用分层编译(Tiered Compilation)的策略,其中包括:

  • 第0层:程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译
  • 第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑
  • 第2层:也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
      使用分层编译后,C1和C2会同时工作,许多代码可能会被多次编译,用C1获取更高的编译速度,用C2获取更好的编译质量。

HotSpot 虚拟机编译对象与触发条件

  在运行过程中会被即时编译器编译的“热点代码”有两类:被多次调用的方法和被多次执行的循环体。在这两种情况下,都是以整个方法作为编译对象,这种编译方式被称为栈上替换(On Stack Replacement,简称OSR编译,即方法栈帧还在栈上,方法就被替换了)
  判断一段代码是不是热点代码,是不是需要触发即时编译,并不一定需要知道代码被调用了多少次,目前主要的热点探测判定方式有两种:
基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程地栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”

  • 优点:实现简单、高效,还可以很容易地获取方法调用关系
  • 缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测

基于计数器的热点探测(Counter Based Hot Spot Detection):采用这个种方法的虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”

  • 优点:统计结果相对来说更加精确和严谨

  • 缺点:实现复杂

  在HotSpot虚拟机中使用的是第二种,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都由一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译
  方法调用计数器:用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式在是10000次,可通过-XX: CompileThreshold来设定

  • 方法被调用时,先检查该方法是否存在被JIT编译过的版本
  • 存在:优先使用编译后的本地代码来执行
  • 不存在:将此方法的调用计数器值加1,执行下一步
  • 判断方法调用计数器与汇编计数器值之和是否超过方法调用计数器的阈值
  • 超过阈值:向即时编译器提交一个该方法的代码编译请求。默认不会同步等待编译请求完成,而是继续解释执行,当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译版本
  • 未超过:解释执行
      如果不做任何设置,方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一般,这个过程称为方法调用计数器的热度衰减(Counter Decay)
          在这里插入图片描述
    回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge) ,回边计数器阈值计算公式:
  • Client模式:方法调用计数器阈值(CompileThreshold) *OSR比率(OnStackReplacePercentage) / 100 ==> 默认值为13995
  • Server模式:方法调用计数器阈值(CompileThreshold) * (OSR比率(OnStackReplacePercentage) -
    解释器监控比率(InterpreterProfilePercentage) / 100) ==> 默认值为10700
  • 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本
  • :优先执行已编译代码
  • :把回边计数器的值加1,执行下一步
  • 判断方法调用计数器与回边计数器值之后是否超过回边计数器的阈值
  • 超过:提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在编译器中执行循环,等待编译器输出编译结果
  • 未超过:解释执行
      与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数
          在这里插入图片描述
    注:上述图11-2和图11-3都仅描述了Client VM的即时编译方式,对于Server VM 执行情况会更加复杂一些。

C1和C2编译过程有所差别

  Server Compiler和Client Compiler两个编译器的编译过程是不一样的。
  Client Compiler是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

  • 第一个阶段:使用一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion, HIR)。HIR使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等。
  • 第二个阶段:使用一个平台相关的前端从HIR中产生低级中间代码表示(Low-Level Intermediate Representaion, LIR),而在此之前会在HIR上完成另外一些优化,如空值检查清除、范围检查清除等
  • 最后阶段:使用平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分离寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码

      在这里插入图片描述
  Server Compiler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,它会执行所有经典的优化动作。Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合。以即时编译的标准来看,Server Compiler编译速度比较缓慢,但依然远远超过传统的静态优化编译器,而且相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销

编译的优化技术

在即时编译器中采用的优化技术有很多,本节主要针对以下四种优化技术:

  • 语言无关的经典优化技术之一:公共子表达式消除
  • 语言相关的经典优化技术之一:数组范围检查消除
  • 最重要的优化技术之一:方法内联
  • 最前沿的优化技术之一:逃逸分析

公共子表达式消除

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

数组边界检查消除

  数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。由于java语言中访问数组元素时,系统将会自动进行上下界的范围检查,这必定会造成性能负担。为了安全,数组边界检查是必须做的,但数组边界检查是否必须一次不漏的执行则是可以“商量”的事情。例如编译器通过数据流分析判定数组下标的取值永远在[0,数组.length)之内,就可以把数组的上下界检查消除
  从更高的角度看,大量安全检查使编写java程序更简单,但也造成了更多的隐式开销,对于这些隐式开销,除了尽可能把运行期检查提到编译期完成的思路之外,还可以使用隐式异常处理

    if(x != null){
           return x.value;
        }else{
         throw new NullPointException();
     }  
  隐式异常优化后:  
    try{
         return x.value;
        }catch(segment_fault){
         uncommon_trap();
     }

方法内联

  方法内联是编译器最重要的优化手段之一,内联的目的:一、消除方法调用成本之外 二、为其他优化手段建立良好的基础。方法内联的优化行为只不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上java虚拟机中的内联过程远远没有那么简单,因为java中的方法大多数是虚方法,虚方法在编译期做内联的时候根本无法确定应该使用哪个方法版本。
  对此java虚拟机设计团队想了很多办法,首先是引入了一种名为“类型继承关系分析”(Class Hierarchy Analysis, CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多余一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
编译器在进行内联:

  • 非虚方法:直接进行内联,这时候的内联是有稳定前提保障的 。
  • 虚方法:向CHA查询此方法只在当前程序下是否有多个目标版本可供选择 。
  • 只有一个:可以进行内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联(Guarded Inlining)。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联游湖的代码就可以一直使用下去。否则,就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
  • 有多个版本:编译器还将进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内联。工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。简单的内联例子:
public class TestNeiLian {
	static class B{
		int value;
		final int get()
		{
			return value;
		}
	}
	public static void main(String[] args) {
		B b  = new B();
		int z = b.get();
		int y = b.get();
		int sum  = z+y;
		System.out.println(sum);
	}
}

内联后,main方法中的代码:


	public static void main(String[] args) {
		B b  = new B();
		int z = b.value;
		int y = b.value;
		int sum  = z+y;
		System.out.println(sum);
	}

逃逸分析

  逃逸分析(Escape Analysis)是目前java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。其基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,如作为调用参数传递到其他方法中,称为方法逃逸;被外部线程访问到,称为线程逃逸
  如果能证明一个对象不会逃逸到方法或线程之外,则可能为这个变量进行一些高效的优化:
栈上分配(Stack Allocation):将对象在栈上分配内存,这样就可以使对象所占内存空间随栈帧出栈而销毁,减小垃圾收集系统的压力。
同步消除(Synchronization Elimination):对象无法被其他线程访问,这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉。
标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

java与C/C++的编译器对比

  java与C/C++的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比。java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能会由于下列原因而导致输出的本地代码有一些劣势:

  1. 即时编译器运行时占用的是用户程序的运行时间,如果编译速度不够快,用户在运行程序时会觉察到重大延迟。因此即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。
  2. java语言是动态的类型安全语言,这就意味着虚拟机必须频繁地进行安全检查,会消耗不少运行时间。
  3. java语言中虚方法的使用频率远远大于C/C++语言,导致即时编译器在进行一些优化时的难度要远大于C/C++的静态优化编译器
  4. java语言时可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,导致许多全局的优化措施都只能以激进优化的方式来完成
  5. java虚拟机中对象的内存分配都是在堆上进行的,而C/C++的对象则有多种分配方式,而且C/C++中主要由用户程序代码来回收分配的内存,C++垃圾回收不存在无用对象筛选,因此效率(指运行效率,而非开发效率)比垃圾收集机制高

  上面说的java语言相对C/C++的劣势都是为了换取开发效率上的优势而付出的代价,而且还有许多优化是java的即时编译器能做而C/C++的静态优化编译器不能做或者不好做的,如别名分析、调用频率预测、分支频率预测、裁剪为被选择的分支等。

猜你喜欢

转载自blog.csdn.net/weixin_41262453/article/details/87883313