一道面试题引发的逃逸分析

面试题

在Java中,通过new创建的对象一定分配在堆上吗?

解析

是不是很想回答:是的!然后看到题中的“一定”二字犹豫了?这是一个开放性的面试题,你可以回答一定是的,但是要有个前提,这个前提先不揭秘,下面我们先来看几个概念性的东西:逃逸分析、栈上分配和TLAB(Thread Local Allocation Buffer)。

1. 逃逸分析是什么

逃逸分析(Escape Analysis)是一种可以通过分析对象的作用域来决定将其分配到何处的性能优化技术,为什么说是性能优化技术,因为通过开启逃逸分析,可以节省堆的使用,看到这里的小朋友是不是有很多问号????别急,先来看看逃逸分析是怎么工作的。

在Java中,我们很少提到指针这个概念,那是因为所有的指针相关的东西都被JVM帮我们做了,而分析指针动态范围的方法就称之为逃逸分析,简单来说就是当一个对象的指针被多个方法或线程引用时,我们就称这个指针发生了逃逸。逃逸是指逃出了当前代码块的作用域,被其他的代码块引用了。

2. 对象如何逃逸

在实际开发中,一个对象的作用域主要包括:全局变量、方法返回值、引用参数、方法体内等,这三个不同作用域的对象在经过逃逸分析之后,会得出三种不同的分析结果。

  • 全局逃逸(Global Escape):当一个对象的作用范围跳出了当前方法或者当前线程,则可以判定该对象发生了全局逃逸,比如全局变量、方法返回值等。
  • 参数逃逸(Arg Escape):当一个对象被作为方法参数进行传递或被参数引用时。
  • 没有逃逸(NoEscape):对象的作用范围在一个方法体内。

3. 编译器如何优化逃逸的对象

对象发生了逃逸,也就是被多个线程或方法共享了,那么这个对象必定要分配到能够被线程共享的内存区域,否则这个对象无法被其他线程读取使用。纵观JVM内存模型,官方规定的被线程共享的内存模块有堆和元空间(低版本中有方法区),而被用来存储对象的内存区域就是堆了,所以要是想让对象被线程共享,那么将其放在堆中就可以实现。反而之就是这个对象是线程私有的,那么将其放在虚拟机栈中即可,而对象是一个聚合量(由基础类型和对象的引用等标量聚合而成),它又可以被再次分解成标量,这又叫作标量替换,如果没有发生逃逸的对象在分析之后发现可以不用连续的为其分配空间,对象内的变量可以单独的分析和优化,那么就会使用标量替换将一个对象拆分成若干个部分分别在栈或寄存器上分配空间;并且线程私有的对象不存在资源竞争的情况,所以给这个对象加锁就完全没必要了,这种情况下,虚拟机会自动消除这个对象上的锁。

综上我们可以整理为:

  • 没有发生逃逸的对象可以直接分配在虚拟机栈上,随着线程的结束一并回收;
  • 若开启了标量替换规则,且逃逸分析后判定不需要为某个对象分配连续的存储空间,则会将对象进行标量拆分,然后分别存储在栈或寄存器上;
  • 未发生逃逸的对象是线程私有的,不会出现并发竞争资源的情况,所以不需要为对象加锁,对象上的锁会被虚拟机忽略。

4. 栈上分配

上面说到没有发生逃逸的对象可以直接被分配在线程私有的虚拟机栈上,那么方法栈上的对象在方法执行结束之后,栈帧弹出,对象就被自动回收了,这样做的好处是JVM内存回收效率提高,也减少了GC的的频率。

5. 如何开启逃逸分析和标量替换

在jdk1.8 Server模式下,逃逸分析是默认开启的(其他版本待测试),我们可以通过JVM参数手动的开启和关闭。

1
2
3
4
-XX:+DoEscapeAnalysis:开启逃逸分析,默认
-XX:-DoEscapeAnalysis:关闭逃逸分析
-XX:+EliminateAllocations:开启标量替换,允许将对象打散分配到栈或寄存器上,默认
-XX:-EliminateAllocations:关闭标量替换

6. 小🌰

  • 非栈上分配

    JVM参数:-server -Xmx50m -Xms50m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:-EliminateAllocations

    代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class AllocOnJVM {

    public static void main(String[] args) {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
    method1();
    }
    System.out.println(System.currentTimeMillis() - start);
    }

    public static void method1() {
    byte[] b = new byte[2];
    b[0] = 1;
    }
    }

    执行结果:

    image-20200429131241750

    发生了多次GC,且最终执行时间为15毫秒,从GC的情况来看,对象b是被分配到了堆上,且因为堆空间不足而引起的GC。

  • 栈上分配

    JVM参数:-server -Xmx50m -Xms50m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

    代码和上面一致,我们来看下执行结果:

    image-20200429131322123

    整个代码执行完只用了5毫秒,且未发生GC,同样的配置,同样的代码,执行的结果却不相同,说明栈上分配确实可以提升代码的执行效率。

  • 同步锁消除

    在上文中我们提到栈上分配会将对象上的同步锁消除掉,在jdk1.8中,同步锁消除是默认开启的,我们可以使用命令关闭,同步锁消除必须在开启逃逸分析的前提下才有效。

    1
    2
    -XX:+EliminateLocks:开启同步锁消除,默认
    -XX:-EliminateLocks:关闭同步锁消除
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class AllocOnJVM {

    public static void main(String[] args) {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
    method1();
    }
    System.out.println(System.currentTimeMillis() - start);
    }

    public static void method1() {
    byte[] b = new byte[2];
    synchronized (b) {
    b[0] = 1;
    }
    }
    }

    在开启逃逸分析且关闭锁消除的情况下执行结果:

    JVM参数:-server -Xmx50m -Xms50m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:-EliminateLocks

    image-20200429132238656

    在开启逃逸分析且开启锁消除的情况下执行结果:

    JVM参数:-server -Xmx50m -Xms50m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+EliminateLocks

    image-20200429131833250

    两种情况下对比发现,在开启同步锁消除之后,synchronized好像没有生效,验证成功。

总结

通过我们对这个面试题的解析,发现并不是所有的对象都被分配到堆上,虚拟机为了加快程序运行效率,减少GC带来的额外开销,推出了栈上分配,而栈上分配又是基于逃逸分析的分析结果来决定是否执行的,被分配到栈上的对象如果带有同步锁,可以通过JVM参数配置让同步锁失效。