JVM要执行一个java文件,需要经过两步:编译、执行。
在这两个过程中,JVM都会进行优化,编译时的优化称为早期(编译期)优化,发生使用javac将.java文件编译为.class文件的时候或者静态提前编译器(AOT编译期)直接把.java文件编译成本地机器码的优化,本文中讨论的是前者。执行时的优化称为晚期(运行期)优化,是使用即时编译器(JIT编译期),将.class 文件的字节码转变为机器码时的优化。
一、 编译步骤
要将java文件编译成字节码,要进行如下步骤:
图 java编译过程的主题代码
1、 解析与填充符号表
首先对文件进行词法、语法分析,与编译原理中类似。前者将字符流转为Token的集合,后者根据Token序列构造抽象语法树。
然后填充符号表。符号表可以使得编译期能够快速识别并获取存储的Token信息,以提高JVM的编译效率。
2、 注解处理器。
JDK1.5之后,java提供了对注解的支持,即那些以@开头的声明语句。
3、 语义分析与字节码生成。
a) 语句分析。包括了标注检查和控制流分析。
标注检查过程中,主要检查一些变量,如;变量使用前是否声明、二元操作符两边是否匹配等。还有一个过程是常量折叠,他能够将诸如a = 2+3的语句直接赋值为5,因此,这种语句在编译期就处理好了,而不会占用运行期的CPU。
控制流分析检查整个执行的流程的正确性。例如:变量使用前是否赋值、是否所有异常都被处理等。
b) 解语法糖与字节码生成
Java中有一些特殊的语法结构,如for each、泛型等,这些结构是将现有的一些方法进行语法封装之后,呈现给我们的更方便的用法,因此,需要将其还原为原来的语句,称为解语法糖。
字节码生成。是编译的最后一个阶段了他将前面生成的信息转化为字节码写到磁盘中,并进行少量的代码添加及转换工作。
二、 Java中的语法糖。
本人很喜欢python中的一些语法,他能够大大提高我们的编程效率。例如在python中对list的定义,只需要 var list = [1,2,3,4]即可;他能够支持2<3<4如此的比较方式;Map类型的只需要var map = {1:’one’;2:’two’},这种方式进行定义…
而在java中,某一些添加的语法糖结构,颇有更高级语言的语法特点。Java中的语法糖结构并不改变语言的内部机制,而仅仅是提高了编译期的“智力”,让他能够理解更多的语法方式。Java中的语法糖有:
1、 泛型。
Java中的泛型,与C++中的泛型不同,BruceEckel称他甚至算不上一个真正意义上的泛型。因为,实际上,java的泛型使用类型擦除的方式实现,是隐式的将一个类转化为Object类型,在使用的时候,再将其强制转化为原来的类型。例如:Map<Integer, String > map 中,map.get(1),实际上执行的只是(String)map.get(1),仅仅是编译器对其偷偷做了手脚。在这种情况下,由于一切都为Object,所以,还是没有问题的。
但既然是泛型,就应该能够代表任何类型,应该是一种Super Object,这种Super Object类型能够调用任何对象的方法,但在java中,除非继承某个类或者实现某个接口才能够使用该类的方法,而没有一种类型是能够调用任何类的任何方法的(反射不属于该范畴)。这种方式,在避免了一些不安全的事件(调用了某个类的不存在的方法),却也失去了极大的便利。
2、 自动装箱、拆箱。
现有的自动拆箱、装箱限于基本数据类型。例如Integer I = 1;的语法,属于自动装箱,编译期能够识别整数1,并将其封装为Integer对象。
据说JDK1.8会有List<Integer> list = [1,2,3],这种语法格式,乍一看,和python中的定义方式还真像。
3、 遍历循环。
对于list类型的for each循环。
4、 变长参数。
使用 method(type1 param1,type2 param2…)的方式,来定义一个方法的参数。
5、 其他:条件编译、内部类、枚举类、断言语句、对枚举和字符串的switch支持(JDK1.7)等。
三、晚期(运行期)优化
早期优化中,JVM将.java文件都编译成了.class文件。有了这些.class 的字节码,在程序的执行过程中,Java的解释器就能随着程序的执行流程,逐条读入这些字节码。由于机器只能够识别机器码,因此,要执行这些字节码,必须现将其翻起成机器可识别的二进制码(机器码)。因此,java程序一边读入字节码,一边翻译成机器码,让CPU执行。字节码翻译成二进制码较之于直接从java代码翻译为二进制码会快很多,但这种方式,很明显,仍会有效率的问题:
1、 每次读入一条字节码,再将其翻译成机器码,在运行上效率肯定有所下降。
2、 翻译只是一次性工作。一些方法可能会重复调用多次,却需要每次都重新翻译为机器码,重复劳动。
3、 缺乏必要的程序优化。
为了改善JVM的效率问题,提出了一种新的编译器:即时编译器(JIT)。JIT编译器并不是JVM所必须的,而是为了改善JVM性能而出现的。他能够将某些翻译过的机器码保存下来,以便程序再次调用某个方法体时可以直接执行机器码,而不用再经历翻译环节,并且将那些热点代码进行优化,以提高效率。这就是为什么JIT技术,能够在一定程度上提高java程序运行效率的原因。
图 JIT编译期与解释器共同工作
关于JIT编译器,JVM规范并没有具体约束这些JIT编译器该如何实现,因此,各个厂商的具体实现都是不同的。下面所述是基于HotSpot虚拟机的。
1、解释器与编译器?
HotSpot虚拟机采用解释器与编译器并存的架构。解释器,就是之前所述的取一条指令,当场翻译成机器码,继而执行,由于无需缓存翻译好的机器码,因此他用到很小部分的内存,这适合于内存受限的情况。编译器,即上面所说的即时编译器,通过在内存中缓存翻译过的机器码以提高JVM的执行效率,适合内存足够的情况。
2、分层编译。
由于并非所有的代码都需要进行即时编译(他需要耗费优化时间、内存成本),故在Hotspot中,根据编译期编译、优化的规模与耗时,编译期有如下的分层结构:
第0层:程序解释执行,可触发第一层编译。该层是最基础的编译,不进行任何的优化、缓存。
第1层:C1层编译。将字节码编译为本地代码,进行简单可靠的优化。
第2层:C2编译。将字节码编译为本地代码,会启用编译耗时较长的优化,甚至可能会有不可靠的激进的优化。
3、关于热点代码?
上面说到,JIT编译器会将热点代码进行缓存、优化。何谓热点代码?有两类:
1) 被多次调用的方法。
2) 被多次执行的循环体。
而判断一个方法为热点代码的量化标准,有两种方法:
1) 基于采样的热点探测。周期性的探测各线程的栈顶方法,找出出现频率高的。这种方法并不准备,结果容易受干扰(如线程等待)
2) 基于计数器的热点探测。为每个方法保留一个计数器,统计被调用的次数,若超过某个“阀值”,则断定为热点方法。因此,在每个方法入口,都会判断是否有翻译过的代码,若没有,则计数器加一,判断是否超过“阀值”,若超过,则为热点代码,保留其编译后的代码。流程如下:
图 方法调用计数器触发即时编译
4、 代码的优化。
现有一些主要的优化技术:
1)公共子表达式消除。用于消除那些重复计算的表达式。
2)数组边界检查消除。消除由于安全性而需要对数组每次访问进行检查的情况。
3)方法内联。将能够内联的方法体直接写入函数的调用者内部,以进行后续的优化。Java的方法默认都是虚方法,而虚方法由于在编译期无法知道其调用的版本,需要在运行期确定(类可能被继承,方法被重写),故无法进行内联。能够在编译期内敛的方法有:私有方法、实例的构造器、父类方法和使用invokestatic指令进行调用的静态方法。对于虚方法的内联,现有“类型继承关系分析”(CHA)的技术能够解决。
4)逃逸分析。通过判断某个对象是否会被外部方法引用,来进一步优化。一个重要的方法是栈上分布。Java中,对象都是分布在堆上的,但堆上垃圾回收代价是较高的,因此,如果一个对象,只是作为一个方法体的局部变量,没有任何的外部引用(传参),那么,可以直接在栈上分布,这样,随着方法的出栈,对象同时被销毁,空间被释放,能够减缓堆上垃圾回收的压力。
四、java与C/C++的效率
java尽管在虚拟机的优化上做了如此多的工作,效率却仍不如C/C++的编译器。究其原因,在于二者在根本上的不同。
Java的JIT编译器在将.class文件编译为机器代码时,需要占用运行时的时间。并且java是动态的类型安全语言,需要在运行期间,保证安全性。光这两点,就使得java在一定程度上,效率不如C/C++。但java是高级语言,而越是高级的语言,如python到更高级的LISP,执行效率必然会更低,因为他帮人类操心了许多细节上的问题。但他带来了编程效率的提升是C/C++无法匹极的。