JVM-Learning-3

JVM学习笔记(三):程序编译与代码优化

《深入理解Java虚拟机:JVM高级特性与最佳实践》摘录

引用

下面的内容是我在阅读《深入理解Java虚拟机:JVM高级特性与最佳实践》后摘录下来的要点(我能看懂的),想了解更多细节请在下面的链接中观看。

摘自:《深入理解Java虚拟机:JVM高级特性与最佳实践》 — 周志明
在豆瓣阅读书店查看:https://read.douban.com/ebook/15233695/
本作品由华章数媒授权豆瓣阅读全球范围内电子版制作与发行。
© 版权所有,侵权必究。

早期(编译期)优化

Java语法糖

泛型与类型擦除

Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。例如

1
Map<String,String> map = new HashMap<String,String>();

编译后就会变成:

1
Map map = new HashMap();

这样的类型擦除使得我们在用不同泛型作为参数来重载方法使会变得麻烦:

1
2
3
4
5
6
7
8
public class GenericTypes{
public static void method(List<String>list){
System.out.println("invoke method(List<String>list)");
}
public static void method(List<Integer>list){
System.out.println("invoke method(List<Integer>list)");
}
}

上述代码无法通过编译,因为类型擦除后这两个方法都变成了method(List<E> list),使得这两种方法的特征签名一模一样而无法重载,为了实现这一重载,我们要给方法修改一个没有意义的返回类型:

1
2
3
4
5
6
7
8
9
10
public class GenericTypes{
public static String method(List<String>list){
System.out.println("invoke method(List<String>list)");
return""
}
public static int method(List<Integer>list){
System.out.println("invoke method(List<Integer>list)");
return 1
}
}

上述代码是可以完成方法的重载的,当你调用method(new ArrayList<Integer>())时,会调用第二个method而非第一个,因为擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息。

自动装箱、拆箱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void test(){
Integer a=1
Integer b=2
Integer c=3
Integer d=3
Integer e=321
Integer f=321
Long g=3L
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));
}

上述的代码中结果是:

1
2
3
4
5
6
true
false
true
true
true
false

这是因为自动装箱把上述代码自动装箱和拆箱成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void test(){
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);
System.out.println(c==d);
System.out.println(e==f);
System.out.println(c==Integer.valueOf(a.intValue()+b.intValue()));
System.out.println(c.equals(Integer.valueOf(a.intValue()+b.intValue())));
System.out.println(g==Long.valueOf(a.intValue()+b.intValue()));
System.out.println(g.equals(Integer.valueOf(a.intValue()+b.intValue())));
}

那么这个关键的valueOf()方法的实现是:

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

这里的lowhigh-128127,即当valueOf(3)时是在缓存中取对象并返回,而使用valueOf(321)时是返回一个新的Integer对象。

Longequals方法碰到Long以外的类型统统返回false,同理Integer也是如此

1
2
3
4
5
6
public boolean equals(Object obj) {
if (obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}

遍历循环

for循环的一些简便写法在编译过程会自动转换成正规写法,例如下面的for循环:

1
2
3
for(int i:list){
sum+=i;
}

编译后变成:

1
2
3
4
for(Iterator localIterator=list.iterator();localIterator.hasNext();){
int i=((Integer)localIterator.next()).intValue();
sum+=i;
}

条件编译

Java在使用条件为常量的if语句时,例如下面语句:

1
2
3
4
5
6
7
public static void main(String[]args){
if(true){
System.out.println("block 1");
}else{
System.out.println("block 2");
}
}

编译后变成:

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

但是这种优化必须满足两个条件:

  1. 条件是常量(变量不行,因为是编译期的优化,而变量的值到运行期才能决定)
  2. if语句(while语句不行)

晚期(运行期)优化

HotSpot虚拟机内的即时编译器

解释器与编译器

  • 解释器启动速度快,省去编译的时间,立即执行。
  • 编译器将代码编译成本地代码,执行效率更高。

解释器与编译器各有有点,HotSpot虚拟机结合了解释器和编译器,程序刚运行时使用解释器进行启动,将部分热点代码通过编译器编译成本地代码来加快这一部分代码的运行速度。许多编译器都会采用比较激进的优化,当遇到罕见陷阱时会逆优化到解释器或没有激进优化的C1编译器中继续执行。

  • C1编译器:Client Complier,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  • C2编译器:Server Complier,将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
  • 罕见陷阱:Uncommon Trap,一般指激进优化的假设不成立时的情况,多发生在C2编译器

编译对象与触发条件

热点代码主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

检测热点代码的方法有两种:

  • 基于采样的热点探测
    • 虚拟机周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。
    • 优点:实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可)
    • 缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测
    • 虚拟机为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。
    • 优点:实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。
    • 缺点:统计结果相对来说更加精确和严谨。

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

方法调用计数器

统计方法被调用的次数。

执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

  • 计数器热度的衰减:当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半。
  • 统计的半衰周期:上面方法统计的那一段时间

回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

  • 与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。
  • 当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
  • OSR: On Stack Replace(当前栈替换),当编译完成后直接跳到编译后的版本,与JIT不同,JIT下即使在方法运行完之前就已经编译完也不会跳转,会等到下一次调用该方法时再替换

编译优化技术

公共子表达式消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。

数组边界检查消除

如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。

方法内联

方法内联是通过将被调用的方法的代码放进调用它的方法中来减少方法的真实调用。放进由于Java无法在编译期知道使用的是子类的还是父类的方法(虚方法),所以不能直接内联。需要引入“类型继承关系分析”(Class Hierarchy Analysis,CHA)。

  • 如果是非虚方法,那么直接进行内联就可以了
  • 如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个“逃生门”
  • 如果向CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内联
    • 如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去
    • 如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派

逃逸分析

当一个对象在方法中被定义,可能被外部方法所引用时,称为方法逃逸。当它可能被外部线程访问,称为线程逃逸。

当一个对象不会逃逸到方法或线程之外(无法逃逸)时,就可以针对器做一系列优化。

  • 栈上分配(Stack Allocation):Java默认会将对象在堆上分配,然后在栈上分配对象的引用,该方法通过持有方法的引用来对方法进行访问。方法结束后只是销毁其引用,堆上的对象要等GC进行处理。GC的回收动作需要耗费额外的时间和资源。如果在栈上分配对象,在方法结束时将对象和栈一起销毁能减少GC的压力。
  • 同步消除(Synchronization Elimination):如果一个变量(对象)会线程逃逸,那么就需要线程同步,但是如果线程中的变量无法逃逸,就可以省去这个同步过程。
  • 标量替换(Scalar Replacement):如果这个对象无法逃逸,程序在真正执行时可能不创建这个对象而是直接创建它会被使用的若干个成员变量来代替。
    • 标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,例如Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解。
    • 如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。

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

优势

  1. Java语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些“拖后腿”的特性都为Java语言的开发效率做出了很大贡献。
  2. 由于C/C++编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测(Call Frequency Prediction)、分支频率预测(Branch Frequency Prediction)、裁剪未被选择的分支(Untaken Branch Pruning)等,这些都会成为Java语言独有的性能优势。

劣势

  1. 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。
    • 如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。
  2. Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。
    • 从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等。
  3. Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于C/C++语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于C/C++语言,也意味着即时编译器在进行一些优化(如前面提到的方法内联)时的难度要远大于C/C++的静态优化编译器。
  4. Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行。
    • 译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。
  5. ,Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。
    • 即时通过标量替换Java也会将其分配在栈上,但那是虚拟机经过高度优化后才做到的,并不是像C/C++那样在用户代码中控制其分配。实际上怎么分配是不可控的。