一、概述
语言要在虚拟机上执行,必须先翻译成机器代码,翻译的方式有两种,一种是编译期静态翻译为机器码,一种是编译器翻译为某种表示,运行期在翻译成机器码来执行。
编译器可分为多种类型,1、编译器把java源文件编译成class文件的前端编译器,如javac和eclipse的jdt增量编译器;2、运行期把.class文件翻译成本地机器代码的JIT编译器,如HotSpot VM的C1、C2编译器;3、直接把java源文件编译成本地机器码的提前编译器(Ahead Of Time,AOT),如GNU Compiler for Java。后续我们提到的编译器是指运行期编译器。
java语言是一种解释型语言(语言规范有这定义么?如果用AOT编译呢),编译期把java文件编译成class文件,运行期再把class文件翻译成机器语言。而翻译也有两种方式,一是通过解释器,每执行一次代码就一条条的翻译成本地代码;一种是通过编译器,编译成本地代码后执行。
jvm spec没有规定要用解释器或者编译器来执行字节码。HotSpot VM是编译器+解释器协作完成字节码的运行。通过java -version可用查看当前虚拟机的执行模式,是混合模式;通过-Xint可以让虚拟机以解释模式执行;通过-Xcomp可以让虚拟机以编译模式运行:
D:\>java -version
java version "1.7.0_01"
Java(TM) SE Runtime Environment (build 1.7.0_01-b08)
Java HotSpot(TM) Client VM (build 21.1-b02, mixed mode, sharing)
D:\>java -Xint -version
java version "1.7.0_01"
Java(TM) SE Runtime Environment (build 1.7.0_01-b08)
Java HotSpot(TM) Client VM (build 21.1-b02, interpreted mode, sharing)
D:\>java -xcomp -version
Unrecognized option: -xcomp
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
虚拟机可以利用解释执行快速启动应用,在运行期根据执行的情况,把热点代码编译成机器代码来执行,编译本身是比较耗时的,但是本地代码执行速度更快。虚拟机可以利用两者协作在启动响应时间和运行期执行效率之间获得折衷。另外解释器可以作为编译器的“逃生门”,运行期编译器可以采取一些比较激进的优化措施,比如说把某个接口的实例的虚方法直接编译为本地代码,而等在后续执行过程中发现接口实例是另外一个类实例的时候,就回退这种优化,叫做逆优化,回退到解释器来执行。(这种描述是否严谨?)
HotSpot VM的编译器分为Client Complier和Server Complier,简称C1和C2编译器。C1编译器做一些快速的优化,C2做一些更耗时的优化但是产生更高效的代码。JDK6加入了多级编译器,解释器可以和C1、C2编译器一起协同运行,JDK7 -server模式下默认启用多级编译器:1、第0级:采用解释器解释执行,不采集性能监控数据,可以升级到第1级;2、第1级,采用C1编译器,会把热点代码快速的编译成本地代码,如果需要可以采集性能数据。3、第2级,采用C2编译器,进行更耗时的优化,甚至可能根据第1级采集的性能数据采取激进的优化措施。
像JRockit虚拟机是没有解释器的,因为它的目标是在服务器上运行,直接采用解释模式,启动过程虽然稍长但是运行期效率更高。
D:\>java -version
java version "1.6.0_22"
Java(TM) SE Runtime Environment (build 1.6.0_22-b04)
Oracle JRockit(R) (build R28.1.1-14-139783-1.6.0_22-20101206-0241-windows-ia32, compiled mode)
此处提到的优化,同时包含了编译期优化和运行期优化。
二、编译期优化
1、javac的编译过程,编译过程不是了解的重点,详细了解需要结合编译原理的整个过程来。此处大概提一下javac的编译过程:解析和填充方法表 -> 注解处理 -> 分析和字节码生成
2、语法糖衣。语法糖衣是指加入到语言中的一些语法特性,为语言使用者带来代码编写上的便利,但是不影响语言本身的功能,甚至都不直接在编译后的代码中体现出来。
java从jdk5后加入了很多语法糖衣,如泛型、自动拆装箱、循环遍历(还有呢?):
public static void main(String[] args) {
List list = new ArrayList();
list.add("1");
list.add("2");
Integer i = 3;
i += 2;
for(String s : list){
System.out.println(s);
}
}
编译后的字节码:
// Method descriptor #15 ([Ljava/lang/String;)V
// Stack: 2, Locals: 5
public static void main(java.lang.String[] args);
new java.util.ArrayList [16]
dup
invokespecial java.util.ArrayList() [18]
astore_1 [list]
aload_1 [list]
ldc [19]
invokeinterface java.util.List.add(java.lang.Object) : boolean [21] [nargs: 2]
pop
aload_1 [list]
ldc [27]
invokeinterface java.util.List.add(java.lang.Object) : boolean [21] [nargs: 2]
pop
iconst_3
invokestatic java.lang.Integer.valueOf(int) : java.lang.Integer [29]
astore_2 [i]
aload_2 [i]
invokevirtual java.lang.Integer.intValue() : int [35]
iconst_2
iadd
invokestatic java.lang.Integer.valueOf(int) : java.lang.Integer [29]
astore_2 [i]
aload_1 [list]
invokeinterface java.util.List.iterator() : java.util.Iterator [39] [nargs: 1]
astore 4
goto 70
aload 4
invokeinterface java.util.Iterator.next() : java.lang.Object [43] [nargs: 1]
checkcast java.lang.String [49]
astore_3 [s]
getstatic java.lang.System.out : java.io.PrintStream [51]
aload_3 [s]
invokevirtual java.io.PrintStream.println(java.lang.String) : void [57]
aload 4
invokeinterface java.util.Iterator.hasNext() : boolean [63] [nargs: 1]
ifne 52
return
Line numbers:
[pc: 0, line: 13]
[pc: 8, line: 14]
[pc: 17, line: 15]
[pc: 26, line: 17]
[pc: 31, line: 18]
[pc: 41, line: 20]
[pc: 63, line: 21]
[pc: 70, line: 20]
[pc: 80, line: 24]
Local variable table:
[pc: 0, pc: 81] local: args index: 0 type: java.lang.String[]
[pc: 8, pc: 81] local: list index: 1 type: java.util.List
[pc: 31, pc: 81] local: i index: 2 type: java.lang.Integer
[pc: 63, pc: 70] local: s index: 3 type: java.lang.String
Local variable type table:
[pc: 8, pc: 81] local: list index: 1 type: java.util.List
Stack map table: number of frames 2
[pc: 52, full, stack: {}, locals: {java.lang.String[], java.util.List, java.lang.Integer, _, java.util.Iterator}]
[pc: 70, same]
泛型list.add处添加的是Object(line 11);i处自动调用了intValue和valueOf(line 26);for循环处编译为了Iterator进行操作(line 54)
三、运行期优化
1、热点代码及如何确定热点代码
在HotSpot VM mixed mode中,只有热点代码才会被编译成本地代码。什么样的代码才是热点代码呢?执行频繁的代码:1、频繁执行的方法;2、频繁执行的代码块,如循环体。什么样才算执行频繁呢?执行次数达到一定上限。上限又是多少?后续会讲到。
另外,怎么确定一个方法或者代码块的执行次数呢?通过计数法,方法调用是方法调用计数器,代码块是回边计数器。
方法调用计数器计数具体有两种方式:1、采样计数,运行期定期对栈顶的方法进行采样,采集方法调用次数信息。此方法简便,但是有时候失真,比如方法长时间阻塞时。2、调用计数,为每一个方法维护一个计数器,方法没调用一次就加1,如果方法调用计数器+回边计数器超过了阙值(通过ComplieThreshold设置)就申请编译。编译的时候,方法会继续以解释的方式执行,等编译完成后再次调用方法的时候就执行编译后的代码。编译完后会把方法直接引用指针指向编译后的代码。可以通过-XX:-BackgroundCompilation方式禁止后台编译,这种情况下,会停止以解释方式执行,等待编译完成。(对代码块和方法都有效么?)
回边计数器是在回边指令执行的时候进行判断是否有编译后的代码,如果没有则判断引用计数器(方法调用计数器+回边计数器)是否达到阙值,如果没有达到则加1,然后解释执行,否则提交编译请求,虽然是代码块频繁执行,但是编译的时候确实编译整个方法。由于代码块可能是在代码块在解释执行过程中直接切换到本地代码执行,所以也叫做栈上替换(OSR,OnStackReplacement),代码块的编译请求也叫OSR编译请求。那岂不是要替换整个栈帧??
那多少次的调用次数会启动编译呢?默认的,HotSpot Client VM下执行1500次的方法和Server模式下10000次的代码可以算得上热点代码:
package com.yymt.jvm.syn.runtime.optimize;
public class FrequentCodeTest {
private static int cnt = 1500;
public static void main(String[] args) {
for (int i = 0; i < cnt; i++) {
looped();
}
}
private static int looped() {
int i = 0;
i++;
return i;
}
}
通过参数-XX:+PrintComplication启动vm,看到如下:
1 java.lang.String::equals (88 bytes)
2 java.lang.String::hashCode (60 bytes)
3 java.io.Win32FileSystem::normalize (143 bytes)
4 java.lang.String::indexOf (151 bytes)
5 java.lang.String::charAt (33 bytes)
6 java.lang.String::lastIndexOf (156 bytes)
7 java.lang.AbstractStringBuilder::append (40 bytes)
8 java.io.Win32FileSystem::isSlash (18 bytes)
9 java.lang.Object:: (1 bytes)
10 s java.lang.StringBuffer::append (8 bytes)
11 java.io.Win32FileSystem::normalize (231 bytes)
12 com.yymt.jvm.syn.runtime.optimize.FrequentCodeTest::looped (7 bytes)
如果把cnt设置为1499则没有第12条的优化。如果再加上-server参数,则只有在cnt>=10000时候才会优化了。
而对于频繁执行的代码块,Client模式下回边计数器是通过OSR比率(OnStackReplacePercentage)*CompileThreshold/100 计算得到。默认osr比率是933,client的ComplieThreshold为1500,此处为13995,此处要注意方法调用本身也会计一次方法调用的,所以下边的代码在15899时候会编译loopedMethod,但是在15898时候不会:
package com.yymt.jvm.syn.runtime.optimize;
public class FrequentCodeTest {
private static int cnt = 15899;
/**
* JVM参数:
* -XX:+PrintCompilation
-XX:CompileThreshold=10000
-XX:OnStackReplacePercentage=159
* @param args
*/
public static void main(String[] args) {
loopedMethod();
}
private static int loopedMethod() {
int i = 0;
i++;
for (int j = 0; j < cnt; j++) {
i++;
}
return i;
}
}
输出:
1% com.yymt.jvm.syn.runtime.optimize.FrequentCodeTest::loopedMethod @ 10 (25 bytes)
Server模式下计算有些不同,是CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage) / 100,InterpreterProfilePercentage是解释器监控比率,实际我在执行的时候达到计算之后并不触发编译。什么原因呢?
package com.yymt.jvm.syn.runtime.optimize;
public class FrequentCodeTest {
private static int cnt = 12000;
/**
* JVM参数:
* -server
-XX:+PrintCompilation
-XX:CompileThreshold=10000
-XX:OnStackReplacePercentage=140
-XX:InterpreterProfilePercentage=40
-XX:-BackgroundCompilation
* @param args
*/
public static void main(String[] args) {
System.out.println(loopedMethod());
}
private static int loopedMethod() {
int i = 0;
i++;
for (int j = 0; j < cnt; j++) {
i++;
if(i % 1000 == 0){
System.out.print(i);
}
}
return i;
}
}
2、编译过程
字节码->方法内联、常量传播等->HIR(SSA)->空值检查消除、数组边界检查消除等->优化后的HIR->LIR->寄存器分配、窥孔优化->机器码生成->本地代码
3、一些编译优化技术
a、方法内联,方法调用本身是有代价的,要从常量池找到方法地址,然后保存当前栈帧状态,压入新栈帧启动调用过程,调用完弹出,并恢复调用者栈帧。而在运行期,如果方法很频繁的执行,就会运行期把方法内联到调用者方法内部,减少频繁调用的开销:(频率值如何确定?执行过程中内联进去么?弹出当前栈帧,恢复调用者栈帧,栈帧其他数据怎么处理?a方法内联到b方法中后,下次调用到b的时候,会是已经内联过的版本么?)
package com.yymt.jvm.syn.runtime.optimize;
public class InlineTest {
/**
* VM参数:-XX:+PrintInlining
* @param args
*/
public static void main(String[] args) {
int r = 0;
for(int i = 0;i < 100000;i++){
r += getValue(i);
}
System.out.println(r);
}
public static int getValue(int i){
i++;
return i;
}
}
输出:
......
@ 9 com.yymt.jvm.syn.runtime.optimize.InlineTest::getValue (5 bytes)
......
由于java是动态分派的,所以invokevirtual指令调用的类的实例方法就不能简单的内联,因为运行期可能有多个版本,jvm团队想了
一些办法,比如类型继承关系分析判断继承体系中接口或抽象类方法是否只有一个实现,如果是只有一个,则可以通过激进行为优化,
把方法内联,但是预留逃生门--守护内联,监控类型加载情况,如果加载了导致体系发生变化的类,则需要抛弃已经内联的版本
(就算此处没有用到?为何不是运行过程中监控实际类型?)如果通过继承关系分析发现有方法有多个实现版本,则使用内联缓存,
第一次方法调用时,内联一个版本,后续执行时候先检查方法接受者是否一样,如果一样的使用内敛缓存中内敛过的方法,否则取消内敛,
通过虚方法表分派。
b、数据边界检查,jvm在数组访问过程中会检查访问的下标是否越界,这本来是一个为了安全性提供的功能,但是像下边这段代码,在数组访问过程中,每次都去校验,性能损耗也是很大的。jvm在运行期做了优化,只用校验用来访问数组的起始下标在0到数组最大长度-1之内就行了。通过数据流分析实现。
int [] arrs = new int[10000];
for(int i = 0;i < 10000;i++){
arrs[i] = i;
}
c、公共子表达式消除,如果计算a = b * c + c * d + b * c * e,观察返现b*c需要执行两次,jvm在运行期会对这个问题进行优化,设E = b*c ,a = E + c * d + E * e,保存中间结果集,减少计算次数。
d、逃逸分析,如果方法执行过程中,创建了一个对象,并且这个对象没有赋值给静态变量引用,也没有赋给别的对象的字段,即除了当前局部变量表外,没有通过赋值再次添加该对象到gc reference chains中,这个对象就叫做非逃逸对象。对于这种对象,我们就可以做一些优化:
*栈上分配:由于对象是私有的,可以把对象分配在栈上,随方法调用分配内存,结束回收内存,不用再通过堆上gc释放,并且访问效率要高一些。
*标量替换:基本类型如int、long、reference无法进一步分解的数据了,就叫做标量,而像对象是由很多标量组成,叫做聚合量,如果一个对象是非逃逸的,则可以将其用标量分配在栈空间,每次访问都是访问基本类型数据
*同步消除:栈是私有的空间,所以不存在多线程共享栈资源,如果该对象的方法有同步方法,可以消除同步,减少同步资源消耗。
该技术目前尚不成熟,-server模式下可以通过参数-XX:+DoEscapeAnalysis开启逃逸分析,-XX:+PrintEscapeAnalysis
(jdk1.7.0_01-b08 实际使用时候不认识该命令)打印逃逸分析结果。-XX:+EliminateAllocations开启标量替换,
-XX:+PrintEliminateAllocaiton打印标量替换(同样不认识)。-XX:+EliminateLocks开启同步锁消除。