实战java虚拟机
深入理解java虚拟机
java语言的“编译期”其实是一段“不确定”的操作过程,
- 因为它可能是指一个前端编译器把*.java文件转换成*.class文件的过程;例如:Sun的
javac
,Eclipse JDT中的增量式编译器(EJC) - 也可能是指虚拟机的后端运行期编译器(
JIT编译器
,Just In Time Compiler)把字节码转换为机器码的过程;例如:HotSpot VM的 C1,C2编译器 - 还可能是使用静态提前编译器(
AOT编译器
,Ahead of Time Compiler)直接把*.java文件编译成本地机器代码的过程。
因为javac这类编译器对代码的运行效率几乎没有任何优化措施,虚拟机团队把对性能的优化集中到了后端的即使编译器(JIT)中,这样也能让那些不是由javac产生的class文件也同样能享受到编译器优化带来的好处.
但是javac做了许多针对java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的java新特性,都是靠编译器的语法糖
来实现,而不是依赖虚拟机底层来支持。
总的来说:
前端编译器(javac)在编译期
的优化过程对程序编码
来说关系更加密切。
即时编译器(JIT)在运行期
的优化过程对于程序运行
来说更重要。
早期(编译期)优化
泛型与类型擦除
泛型是JDK1.5的一项新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别成为泛型类,泛型接口,泛型方法。
java语言中的泛型规则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也成为裸类型),并在相应的地方插入了强制转型代码。因此,对于运行期的java语言来说,ArrayList<Integer>和ArrayList<String>是同一个类,所以泛型技术实际上是java语言的一颗语法糖
,java语言中的泛型实现方法称为类型擦除
,基于这种方法实现的泛型称为伪泛型
。
源代码
public class ParametersizedClear {
public static void main(String[] args) {
Map<String, String> map = new HashMap<String,String>();
map.put("hello", "你好");
map.put("kitty", "凯迪");
System.out.println(map.get("hello"));
}
}
编译后的class文件,再用字节码反编译工具进行反编译后代码:
public class ParametersizedClear
{
public static void main(String[] args)
{
//类型擦除
Map map = new HashMap();
map.put("hello", "你好");
map.put("kitty", "凯迪");
//强转为String
System.out.println((String)map.get("hello"));
}
}
当泛型遇到重载
:
public class GenericOverload {
public void m1(List<Integer> list) {
}
public void m1(List<String> list) {
}
}
上述代码编译出错,是因为编译之后类型被擦除了,变成了一样的原生类型List<E>
.
条件编译
public static void main(String[] args) {
if(true){
System.out.println("true");
}else {
System.out.println("false");
}
}
编译结果为:
public static void main(String[] args) {
System.out.println("true");
}
类似的:
public static void main(String[] args) {
if(false){
System.out.println("false");
}
}
编译后的结果:
public static void main(String[] args) {
}
编译时计算
如果程序中出现了计算表达式,如果计算表达式能够在编译时确定,那么表达式的计算会提前到编译阶段,而不是在运行时计算。
比如很多时候,为了增强代码的可读性,往往不会把最终的数值写到代码中,通常倾向于把计算过程写在代码中:
for (int i = 0 ; i < 60 * 60 * 24 ; i++){
// do sth
}
看到这段代码,可能会给人产生一种怀疑,是不是每次循环都要计算一次呢?查看这段代码生成的字节码如下:
0: iconst_0
1: istore_1
2: iload_1
3: ldc #2 // int 86400
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return
Switch语句的优化
对于switch语句,编译器会产生两种字节码指令:tableswitch和lookupswitch.
其中tableswitch效率高于lookupswitch,但tableswitch只能处理case情况是连续的数值,而lookup可以处理不连续的情况。一般来说,总是希望可以尽可能的使用tableswitch.
public void switchMethod(int i) {
switch (i){
case 1: break;
case 2: break;
case 5: break;
default:
System.out.println("default");
}
}
使用javap查看生成的字节码:
0: iload_1
1: tableswitch { // 1 to 5
1: 36
2: 39
3: 45 ####填充了case3,4 跳转到default
4: 45 ####
5: 42
default: 45
}
36: goto 53
39: goto 53
42: goto 53
45: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
48: ldc #4 // String default
50: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
53: return
可以看到,为了使用tableswitch,编译器将离散的数据进行了填充,其中case3,4都跳转到了default
晚期(运行期)优化
java程序最初是通过解释器(Interpreter
)进行解释的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为热点代码(Hot Spot Code)
.为了提高代码的执行效率,在运行时,虚拟机将会把这些代码编译成与平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,JIT编译器
)
开启JIT编译器
java虚拟机有3种执行方式,分别是解释执行、混合模式和编译执行。默认情况下处于混合模式。
- 使用
java -Xint -version
命令,开启解释执行模式,所有的代码均解释执行,不做任何JIT编译。 - 使用
java -Xcomp -version
命令,开启编译模式,对于所有函数,无论是否是热点代码,都会被编译执行。
JIT编译阈值
默认情况下,JIT使用混合模式运行(指定参数为-Xmixed
),在混合模式,只会对热点代码进行即时编译。对于是否为热点代码,虚拟机内有一个阈值进行判断,当函数调用次数超过这个阈值时,就被认为是热点代码,进行即时编译。
在Client模式,这个阈值时1500次。在Server模式下阈值为10000次。使用参数-XX:CompileThreshold来设置这个阈值,使用参数-XX:+PrintCompilation可以打印出即时编译的日志。
多级编译器
java虚拟机中拥有客户端编译器和服务端编译器两种编译系统,一般称为C1和C2编译器。使用-client 参数时,虚拟机会使用C1编译器,使用-server参数时,会使用C2编译器。
C1编译器的特点是编译速度快,C2的特点是会做更多的编译时优化,因此编译时间会长于C1,但是编译后的代码质量会高于C1.
为了使C1和C2在编译速度和执行效率之间取得一个平衡,虚拟机支持一种叫多级编译的策略:
- 0级(解释执行):采用解释执行,不采集性能监控数据。
- 1级(简单的C1编译):采用C1编译器,进行最简单的快速编译,根据需要采集性能数据。
- 2级(有限的C1编译):采用C1编译器,进行更多的优化编译,可能会根据第1级采集的性能数据,进一步优化编译代码
- 3级(完全的C1编译):完全使用C1编译器,会采集性能数据进行优化
- 4级(C2编译):完全使用C2编译器,进行完全的优化
要使用多级编译器可以使用参数-XX:+TieredCompilation
打开多级编译器的策略,且必须在使用-server
模式启动才有效果。
公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术。它的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需用E替换即可。假设如下代码:
int d = (c * b) * 12 + a + (a + b * c);
//表达式可能被视为
int d = E * 12 + a + (a + E);
//编译器还可能进行另一种优化
int d = E * 13 + a * 2;
方法内联
方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。JIT编译器默认会打开内联优化。
例:
public class InLineMain {
static int i = 0 ;
static void inc(){
i++;
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for(int j = 0 ; j < 1000000; j++) {
inc();
}
System.out.println((System.currentTimeMillis() - start));
}
}
方法内联之后,循环体会变成:
for(int j = 0 ; j < 1000000; j++) {
i++;
}
使用命令:-server -XX:+InLine
,打开内联优化,输出耗时;
使用命令:-server -XX:-InLine
,关闭方法内联优化。
内联的优化效果非常显著,但是它会增大系统执行代码的体积,因此对于大的方法体,使用内联需要谨慎。虚拟机提供了一些参数来控制对于多大的代码允许内联
-XX:FreqInLineSize
可以控制热点方法进行内联的体积上限。
逃逸分析
逃逸分析