深入理解Java虚拟机——程序编译与代码优化

一早期(编译期)优化

1概述

Java语言的“编译期”是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,just in time compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,ahead of time compiler)直接把*.java文件编译成本地机器代码的过程。下面列举了这三类编译过程中一些比较有代表性的编译器:                                                     Ø 前端编译器:sun的javac、eclipse JDT中的增量式编译器(ECJ)。

Ø JIT编译器:HotSpot VM 的C1、C2编译器。

Ø AOT编译器:GNU Compiler for the java、Excelsior JET。

这三类过程中最符合大家对java程序编译认知的应该是第一类,在后面的讲解里,提到的“编译期”和“编译器”都仅限于第一类编译过程。限制了编译范围后,对于“优化”二字的定义就需要宽松一些,因为javac这类编译器对代码的运行效率几乎没有任何优化措施(在JDK1.3之后,javac-O优化参数就不再有意义了)。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的class文件也同样能享受到编译器优化带来的好处。

但是javac做了许多针对编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

2.javac 编译器

分析源码是了解一项技术实现内幕的最有效的手段,javac编译器不像HotSpot虚拟机那样使用c++语言(包含少量C语言)实现,它本身就是一个由java语言编写的程序,这为纯java的程序员了解它的编译过程带来了很大的便利。

2.1 javac 的源码与调试

虚拟机规范严格定义了Class文件的格式,但是对如何把java源码文件转变为class文件的编译过程未作任何定义,所以这部分内容是与具体JDK实现相关的。从sun javac的代码来看,编译过程大致可以分为三个过程,分别是:

  • 解析与填充符号表过程;
  •  插入式注解处理器的注解处理过程;
  • 分析与字节码生成过程。

Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述三个过程的代码逻辑集中在这个类的compile()和compile2()方法里。整个编译最关键的处理是由8个方法来完成的,分别是:

[javascript] view plaincopy

1. initProcessAnnotations(processors);//准备过程:初始化插入式注解处理器  

2. delegateCompiler =   

3.     processAnootations(  //过程2:执行注解处理  

4.     enterTrees(stopIfError(CompileState.PARSE,    //过程1.2:输入到符号表  

5.     parseFiles(sourceFileObject))),      //过程1.1:词法分析、语法分析  

6.     classnames);  

7.       

8. delegateCompiler.compile2();   //过程3:分析及字节码生成  

9.     case BY_TODO:  

10.     while(! todo.isEmpty())  

11.     generate(desugar(flow(attribute(todo.remove()))));  

12.     break;  

13.     //generate,过程3.4:生成字节码  

14.     //desugar,过程3.3解语法糖  

15.     //flow,过程3.2:数据流分析  

16.     //attribute,过程3.1:标注      

语法糖(Syntactic Sugar,也称糖衣语法,是由英国计算机科学家Peter J. Landin发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java在现代编程语言之中属于低糖语言(相对于C#及许多其他jvm语言来说),尤其是JDK1.5之前的版本,低糖语法也是java语言被怀疑已经落后的一个表面理由。Java中最常用的语法糖主要是泛型、变长参数、自动装箱拆箱,等等,虚拟机运行时不支持这些语法,它们在编译阶段被还原回简单的基础语法结构,这个过程就被称为解语法糖。

3.java语法糖的味道

几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少代码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用含糖的语法容易让程序员产生依赖,无法看清语法糖的糖衣背后程序代码的真实面目。

总而言之,语法糖可以看做是编译器实现的一些小把戏,这些小把戏可能会使得效率有一个大提升,但我们也应该去了解这些小把戏背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。

3.1泛型与类型擦除

泛型是JDK1.5的一项新特性,它的本质是参数化类型(Parameterized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

泛型思想早在C++语言的模板(Template)中就开始生根发芽,在java语言还没有出现泛型时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如在哈希表的存取中,JDK1.5之前使用HashMapget()方法,返回值就是一个Object对象,由于java语言里面所有的类型都继承于java.lang.Object,那Object转型成任何对象都是有可能的。但是也因为有无限的可能,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会被转嫁到程序运行期中。

泛型技术在C#java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面泛型无论在程序源码之中、编译后的IL中(Intermediate Language,中间语言,这时候泛型是一个占位符)还是在运行期的CLRcommon language runtime)中都是切实存在的,ListList就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现成为类型膨胀,基于这种方法实现的泛型被称为真实泛型。

Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经被替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此对于运行期的java语言来说,ArrayListArrayList就是同一个类。所以说泛型技术实际上是java语言的一颗语法糖,java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型被称为伪泛型。

代码1是一段简单的java泛型例子,我们看一下它编译后的结果:

1. class="java">public static void main(String[] args){  

2.         Map map = new HashMap();  

3.         map.put("hello", "你好");  

4.         map.put("how are you", "吃了没");  

5.         System.out.println(map.get("hello"));  

6.         System.out.println(map.get("how are you"));  

7.     }  

把这段代码编译成class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了java泛型出现之前的写法,泛型类型都变回了原生类型:

1. public static void main(String[] args){  

2.         Map map = new HashMap();  

3.         map.put("hello", "你好");  

4.         map.put("how are you", "吃了没");  

5.         System.out.println((String)map.get("hello"));  

6.         System.out.println((String)map.get("how are you"));  

7.         }  

当初JDK设计团队为什么选择类型擦除的方式来实现java语言的泛型支持呢?是因为实现简单、兼容性考虑还是别的原因?我们不得而知,但确实有不少人对java语言提供的伪泛型颇有微词,当时甚至连《thinging in java》的作者Bruce Eckel也发表了一篇文章《这不是泛型!》来批评JDK1.5的泛型实现。

1. public class GenericTypes {  

2.   

3.     public static void method(List list){  

4.         System.out.println("invoke method(List list)");  

5.     }  

6.       

7.     public static void method(List list){  

8.         System.out.println("invoke method(List list");  

9.     }  

10.     }  

请想一想,上面这段代码是否正确,能否编译执行?答案是不能被编译的,是因为参数ListList编译之后都被擦除了,变成了一样的原生类型List,擦除动作导致这两个方法的特征签名变的一模一样。初步来看,无法重载的原因已经找到了,但是真的如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的一部分原因,请再看一下下面的代码:

1. public class GenericTypes {  

2.   

3.     public static String method(List list){  

4.         System.out.println("invoke method(List list)");  

5. return "";  

6.     }  

7.       

8.     public static int method(List list){  

9.         System.out.println("invoke method(List list");  

10.     }  

11.     return "1";  

12.     }  

这两段代码的差别,是两个method方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。重载的时候,方法名要一样,但是参数类型和个数不一样,返回值类型可以相同也可以不相同。无法以返回型别作为重载函数的区分标准。  

上面代码中的重载当然不是根据返回值来确定的,之所以这次编译和执行能成功,是因为两个method()方法加入了不同的返回值后才能共存在一个class文件中。之前介绍过class文件内容中方法表(method_info),方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法的共存于一个class文件中。

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

自动装箱、拆箱与遍历循环是java语言里使用的最多的语法糖。如下代码所示:

public static void main(String[] args){  

1.         List list = Arrays.asList(1,2,3,4);  

2.           

3.         int sum = 0;  

4.         for(int i:list){  

5.             sum += i;  

6.         }  

7.         System.out.println(sum);  

8.         }  

9.     //上述代码编译之后的变化:  

10.     public static void main(String[] args){  

11.         List list = Arrays.asList(new Integer[]{  

12.                 Integer.valueOf(1),  

13.                 Integer.valueOf(2),  

14.                 Integer.valueOf(3),  

15.                 Integer.valueOf(4),  

16.         });  

17.           

18.         int sum = 0;  

19.         for(Iterator localIterator = list.iterator(); localIterator.hasNext();){  

20.             int i = ((Integer)localIterator.next()).intValue();  

21.             sum += i;  

22.         }  

23.         System.out.println(sum);  

24.         }  

上面第一段代码中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数五种语法糖,上面第二段代码则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例子中的Integer.valueOf()Integer.intValue()方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。

这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,下面的代码演示了自动装箱的一些错误用法:

1. public static void main(String[] args){  

2.         Integer a = 1;  

3.         Integer b = 2;  

4.         Integer c = 3;  

5.         Integer d = 4;  

6.         Integer e = 321;  

7.         Integer f = 321;  

8.         Long g = 3L;  

9.           

10.         System.out.println(c == d);    //false 值不等  

11.         System.out.println(e == f);    //false  堆位置不同  

12.         System.out.println(c == (a + b)); //true   "+"运算符拆包  

13.         System.out.println(c.equals(a + b));//true equals 值比较  

14.         System.out.println(g == (a+b));//true   "+"运算符拆包  

15.         System.out.println(g.equals(a+b));//false   类型不同  

16.     }  

请思考两个问题:一是代码中的6句打印语句输出时什么?二是6句打印语句中,解除语法糖后参数是什么样?鉴于包装类的“==”运算在没有遇到算数运算的情况下不会自动拆箱,而且它们的equals()方法不会处理数据转型的关系,我们建议在实际编码中应该尽量避免这样使用装箱与拆箱。integer 的范围:-128~127,在这个范围内的integerint是一样的,还没有在堆中开辟空间。

3.3条件编译

许多程序设计语言都提供了条件编译的途径,如CC++中使用预处理器指示符(#ifdef)来完成条件编译。CC++的预处理器最初的任务是解决编译时的代码依赖关系(众所周知的#include预处理指令),而在java语言之中并没有使用预处理器,因为java语言天然的编译方式(编译器并非一个一个的编译java文件,而是将所有的编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无需使用预处理器。那java语言是否有办法实现条件编译呢?

Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。如下代码所示,此代码中的if语句不同于其他java代码,它在编译阶段就会被运行,生成的字节码之中只包括System.out.println("block 1");一条语句,并不会包含if语句及另外一个分支中的System.out.println("block 2");

1.     

2.     public static void main(String[] args){  

3.         if(true){  

4.             System.out.println("block 1");  

5.         }else{  

6.             System.out.println("block 2");  

7.         }  

8.         }  

9.     //此代码编译后class文件的反编译结果:  

10.     public static void main(String[] args){  

11.             System.out.println("block 1");  

12.     }  

只能使用条件为常量的if语句才能达到上述效果,如果使用常量和其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如下面代码所示,就会被编译器拒绝编译:

1. public static void main(String[] args){  

2.         while(false){  

3.             System.out.print("");  

4.             }  

java语言中条件编译的实现,是java语言的一个语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖的阶段完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的java语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个java类的结构。

除了我们介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的switch支持、在try语句中定义和关闭资源等,可以通过跟踪javac源码、反编译class文件等方式了解它们的本质实现。


 

二.晚期(运行期)优化

1.概述

JAVA最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”而将它们编译成本地机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器(Just In Time Compiler)。

       Java虚拟机规范并没有规定虚拟机内必须要有即时编译器。但是,即时编译器性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心最能体现技术水平的部分。

2.HotSpot虚拟机内的即使编译器

       HOTSPOT和J9都是解释器和编译器并存,保留解释器的原因是,加快启动时间,立即执行,当运行环境中内存资源限制较大时,解释器可以节约内存,解释器还可以作为激进优化的编译器的“逃生门”(称为逆优化Deoptimization),而编译器能把越来越多的代码编译成本地代码后,获取更高的执行效率。

       HOTSPOT内置了两个即时编译器,clientcompilerservercompiler,称为C1,C2clientcompiler获取更高的编译速度,servercompiler来获取更好的编译质量),默认是采用解释器与其中一个编译器直接配合的方式工作。HOTSPOT会根据自身版本和宿主机器的性能自动选择运行模式,用户也可以使用-client-server来决这种解释器编译器搭配的方式成为混合模式,用户还可以使用-Xint强制虚拟机使用“解释模式”,也可以使用-Xcomp强制“编译模式”。

被编译的触发条件:

1.   被多次调用的方法

2.   被多次执行的循环体(栈上替换)OSR On StackReplacement

判断是否是热点代码的行为成为热点探测:hot spotdetection,主要的热点探测方式主要有两种:

1.   基于采样的热点探测,JVM会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那就认定为热点方法。简单高效,精度不够。

2.   基于计数器的热点探测,统计方法执行次数。(HOTSPOT使用这种方式)

HOTSPOT有两个计数器:方法调用计数器和回边计数器

方法调用计数器client默认1500次,server默认10000次,可以通过参数-XX:CompileThreshold来设定。调用方法时,会先判断是否存在编译过的版本,如果有则调用该版本,否则计数器加1,然后看方法调用计数器和回边计数器之和是否超过方法调用计数器的阈值。超过,则提交编译请求

方法调用计数器并不是统计方法调用绝对次数,而是一个相对执行频率,超过一定时间,如果方法调用次数不足以让它提交给编译器,则计数器就会被减少一半,这种现象称为热度衰减(Counter Decay)进行热度衰减的动作是在垃圾回收时顺便进行的,而这段时间就被称为半衰周期(Counter Half Life Time可用-XX:-UseCounterDecay来关闭热度衰减,用-XX:CounterHalfLifeTime来设置半衰时间。

回边计数器用于统计方法中循环体的执行次数。字节码遇到控制流向后跳转 的指令成为回边。建立回边计数器统计的目的就是为了触发OSR编译。回边的控制参数有:

-XX:BackEdgeThreshold,-XX:OnStackReplacePercentage。

       1.在Client模式下,回边计数器阀值计算公式:方法调用计数器阀值乘以OSR比率,然后除以100.

       2.在server模式下,回边计数器阀值计算公式:方法调用计数器阀值乘以(OSR比率,然后减去解释器监控比率的差值)除以100。

       与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。

编译过程

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

1.   第一阶段,一个平台独立的前段将字节码构造成一种高级中间代码表示(HIR)。

2.   第二阶段,一个平台相关的后端从HIR中产生低级中间代码表示(LIR),而在此之前会在HIR上完成一些优化。

3.   最后节点是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上座窥孔优化,然后产生极其代码。

       对Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器。它会执行所有的经典的优化动作,如:无用代码消除,循环展开,循环表达式外提,公共子表达式消除,常量传播,基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除,空值检查消除。

 

3编译优化技术

       JDK设计团队几乎把代码的所有优化措施都集中在了即使编译器,所以一般来说即即时编译器产生的本地代码会比Javac产生的字节码更优秀。接下来介绍几种景点优化技术:

       1.公共子表达式消除:如果一个表达式E已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。若这种优化仅限于程序基本块内,称为局部公共子表达式消除;若这种优化的范围涵盖了多个基本块,就称为全局公共子表达式消除。

       2.数组边界检查消除:在Java语言中访问数组元素的时候系统将会自动进行上下界的范围检查,即检查i必须满足i>=0 && i

       3.方法内联:它除了消除方法调用的成本之外,更重要的意义是为其他优化手段建立良好的基础。由于Java语言中默认的实例方法就是虚方法,对于虚方法,编译器做内联的时候根本就无法确定应该使用哪个方法版本。为了解决虚方法的内联问题,引入了“类型继承关系分析”。编译器在进行内联时,如果是非虚方法,那么直接进行内联,如果是虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个逃生门,万一加载了导致继承关系发生变化的新类,那就需要退回到解释状态执行,或者重新编译。

       4.逃逸分析:它是为其他优化手段提供依据的分析技术。基本行为就是分析对象动态作用于,当一个对象在方法里面被顶以后,它可能被外部方法所引用,称为线程逃逸。若能证明一个对象不会逃逸到方法或线程之外,就可以进行一些高效的优化,如:栈上分配(对象所占用的内存空间可以随栈帧出栈而销毁,若在堆里分配的话,回收和整理内存都需要消耗时间),同步消除(线程同步本身就是一个相对耗时的过程,若确定不会逃逸出线程,对这个变量就不需要实施同步措施),标量替换(将Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问,若不会逃逸的话,执行程序的时候将可能不创建对象,而直接创建它的若干个被这个方法使用到的成员变量来代替)。

 

4Java与c/c++编译器对比

       Java与c/c++的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比。Java可能会下列原因导致输出本地代码有一些劣势:

1.   首先,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。

2.   其次,Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言的语义或访问非结构化内存。

3.   第三,Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于c/c++语言,这就意味着运行时对方法接受者进行多态选择的频率要远远大于c/c++语言,也以为即时编译器在进行一些优化时的难度远远大于c/c++的静态优化编译器。

4.   第四,Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。

5.   第五,Java语言中对象的内存分配都是在堆上进行的,只有方法中的局部变量才能在栈上分撇。

Java语言的这些性能上的劣势都是为了换取开发效率上的优势,动态安全、动态扩展、垃圾回收这些特性都为Java语言的开发效率做出了很大的贡献。

你可能感兴趣的:(书籍笔记,Java)