在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段编译是把.class转换成机器指令的过程。

第一段编译就是​​javac​​命令。

在第二编译阶段,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。这就是传统的JVM的解释器(Interpreter)的功能。为了解决这种效率问题,引入了 JIT(即时编译) 技术。

引入了 JIT 技术后,Java程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

本文主要来介绍下JIT中的优化。JIT优化中最重要的一个就是逃逸分析。

基本原理与逃逸状态

分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部 方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访 问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

  • 全局逃逸(GlobalEscape):对象逃逸出当前方法和线程。例如: 存储在静态字段中的对象、存储在转义对象的字段中或作为当前方法的结果返回的对象。
  • 参数逃逸( ArgEscape):对象作为参数传递或由参数引用,但在调用期间不会全局逃逸。这个状态是通过分析被调用方法的字节码来确定的。
  • 没有逃逸(NoEscape): 对象只在方法内部使用,没有发生逃逸。该对象是一个标量可替换对象,这意味着它的分配可以从生成的代码中删除。

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

例如以下代码:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

第一段代码中的​​sb​​​就逃逸了,而第二段代码中的​​sb​​就没有逃逸。

使用逃逸分析,编译器可以对代码做如下优化:

一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,

​​-XX:+DoEscapeAnalysis​​ : 表示开启逃逸分析
​​-XX:-DoEscapeAnalysis​​ : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,
需要指定-XX:- DoEscapeAnalysis

同步省略

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程
如果同步块所使用的锁对象通过这种分析被证实只能被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。 这个取消同步的过程就叫同步省略,也叫锁消除。
如以下代码:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代码中对hollis 这个对象加锁,但是hollis 对象的生命周期只在发f()方法中国,并不会被其它线程所访问到,所以在JIT编译阶段就会被优化掉
优化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

所以,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。

标量替换

标量 (Scalar) 是指一个无法再分解成更小的数据的数据。Java中原始数据类型就是标量。相对的,那些还可以分解的数据就叫做聚合量(Aggregate),java 中的对象就是聚合量,因为它可以分解成其他聚合量和标量。

若在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量来代替。这个过程就是标量替换

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point1,2;
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

以上代码中,point对象并没有逃逸出alloc() 方法,并且point对象是可以拆解成标量的。那么,JIT就不会直接创建Point对象,而是使用两个标量 int x,int y 来代替Point 对象。

以上代码,经过标量替换后,就会变成:

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

可以看到,Point 这个聚合量经过逃逸分析后,发现他并没有它逃逸, 就会被替换成两个标量了,那么标量替换有什么好处呢? 就是可以大大减少堆内存的占用,因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
标量替换为栈上提供了很好的基础。

栈上分配

在java 虚拟机中,对象是在java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需要在堆上分配内存,也无需进行来集回收了。
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所 占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。

栈上分配可以支持方法逃逸,但不能支持线程逃逸。

这里,还是要简单说一下,其实在现有的虚拟机中,并没有真正的实现栈上分配,对象没有在堆上分配,其实是标量替换实现的。

实战:通过对象数量来观察是否是栈上分配

public static void main(String[] args) {
    long a1 = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
        alloc();
    }
    // 查看执行时间
    long a2 = System.currentTimeMillis();
    System.out.println("cost " + (a2 - a1) + " ms");
    // 为了方便查看堆内存中对象个数,线程sleep
    try {
        Thread.sleep(100000);
    } catch (InterruptedException e1) {
        e1.printStackTrace();
    }
}

private static void alloc() {
    User user = new User();
}

static class User {

}

其实代码内容很简单,就是使用for循环,在代码中创建100万个User对象。

我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。

我们指定以下JVM参数并运行:

-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

在程序打印出cost XX ms后,代码运行结束之前,我们使用[jmap][1]命令,来查看下当前堆内存中有多少个User对象:

~  jps
57152 EscapeAnalysisTest
57186 Jps
63908 
53110 Launcher
63946 Launcher
57151 Launcher
~ jmap -histo 57152

 num     #instances         #bytes  class name
----------------------------------------------
   1:           709       88574512  [I
   2:       1000000       16000000  syns.EscapeAnalysisTest$User
   3:          1826        1580632  [B
   4:          6554         722944  [C
   5:          5011         120264  java.lang.String
   6:           713          81192  java.lang.Class
   7:          1312          59448  [Ljava.lang.Object;
   8:           634          25360  java.util.LinkedHashMap$Entry
   9:           327          16592  [Ljava.lang.String;
  10:           458          14656  java.util.HashMap$Node
  11:            40          12448  [Ljava.util.HashMap$Node;

从上面的jmap执行结果中我们可以看到,堆中共创建了100万个EscapeAnalysisTest$User实例。

在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中。也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的。即所有对象都分配到堆内存中。

接下来,我们开启逃逸分析,再来执行下以上代码:

-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError 

在程序打印出cost XX ms后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象:

chenbiaodeMacBook-Pro:study chenbiao$ jps
63908 
57317 Jps
53110 Launcher
63946 Launcher
57310 Launcher
57311 EscapeAnalysisTest
chenbiaodeMacBook-Pro:study chenbiao$ jmap -histo 57152
57152: No such process
chenbiaodeMacBook-Pro:study chenbiao$ jmap -histo 57311

 num     #instances         #bytes  class name
----------------------------------------------
   1:           709      102934368  [I
   2:        102509        1640144  syns.EscapeAnalysisTest$User
   3:          1826        1580632  [B
   4:          6554         722944  [C
   5:          5011         120264  java.lang.String
   6:           713          81192  java.lang.Class

从以上打印结果中可以发现,开启了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个EscapeAnalysisTest$User对象。也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了10万多。

除了以上通过jmap验证对象个数的方法以外,读者还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。
理论上我们认为开启逃逸分析后 这段代码结束后,堆内存中不应该存在EscapeAnalysisTest$User 对象的 但是我们发现还会有很多,这个只是减少绝大部分,而不是全部消失 大家可以思考一下这个问题 – 提示 - JIT编译原理
开启逃逸分析后 我做了一个测试 提供参考
循环次数 剩余对象数量
  • 100000 40359
  • 1000000 102509
    -10000000 264345
    。。。

逃逸分析并不成熟

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

参考文档:

Java - 你一定要知道的 JVM 逃逸分析
Java编译器优化逃逸分析详解
Java HotSpot™ 虚拟机性能增强
对象和数组并不是都在堆上分配内存的

逃逸分析进阶参考资料:
Java JIT编译器(三):探究内联和逃逸分析的算法原理
基本功 | Java即时编译器原理解析及实践
逃逸分析为何不能在编译期进行?
逃逸分析
部分逃逸分析

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐