JVM系列之:关于逃逸分析的学习

 2023-02-08
原文作者:hresh 原文地址:https://juejin.cn/post/7079758206590779399

本文为《深入学习 JVM 系列》第十九篇文章

上文讲解完方法内联后,JIT 即时编译还有一个最前沿的优化技术: 逃逸分析 (Escape Analysis) 。废话少说,我们直接步入正题吧。

逃逸分析

首先我们需要知道,逃逸分析并不是直接的优化手段,而是通过动态分析对象的作用域,为其它优化手段提供依据的分析技术。具体而言就是:

逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。即时编译器判断对象是否逃逸的依据有两种:

  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。

    简单来说就是,如类变量或实例变量,可能被其它线程访问到,这就叫做线程逃逸,存在线程安全问题。

  2. 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。(未知代码指的是没有被内联的方法调用)

    比如说,当一个对象在方法中定义之后,它可能被外部方法所引用,作为参数传递到其它方法中,这叫做方法逃逸,

方法逃逸我们可以用个案例来演示一下:

    //StringBuffer对象发生了方法逃逸
    public static StringBuffer createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
      }
    
      public static String createString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
      }

关于逃逸分析技术,本人想过用代码展示对象是否发生了逃逸,比如说上述代码,根据理论知识可以认为 createStringBuffer 方法中发生了逃逸,但是具体是个什么情况,咱们都不清楚。虽然 JVM 有个参数 PrintEscapeAnalysis 可以显示分析结果,但是该参数仅限于 debug 版本的 JDK 才可以进行调试,多次尝试后,未能编译出 debug 版本的 JDK,暂且没什么思路,所以查看逃逸分析结果这件事先往后放一放,后续学习 JVM 调优再进一步来学习。

基于逃逸分析的优化

即时编译器可以根据逃逸分析的结果进行诸如同步消除、栈上分配以及标量替换的优化。

同步消除(锁消除)

线程同步本身比较耗费资源,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks(默认开启)可以开启同步消除。 这个取消同步的过程就叫同步消除,也叫锁消除。

我们还是通过案例来说明这一情况,来看看何种情况需要线程同步。

首先构建一个 Worker 对象

    @Getter
    public class Worker {
    
      private String name;
      private double money;
    
      public Worker() {
      }
    
      public Worker(String name) {
        this.name = name;
      }
    
      public void makeMoney() {
        money++;
      }
    }

测试代码如下:

    public class SynchronizedTest {
    
    
      public static void work(Worker worker) {
        worker.makeMoney();
      }
    
      public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
    
        Worker worker = new Worker("hresh");
    
        new Thread(() -> {
          for (int i = 0; i < 20000; i++) {
            work(worker);
          }
        }, "A").start();
    
        new Thread(() -> {
          for (int i = 0; i < 20000; i++) {
            work(worker);
          }
        }, "B").start();
    
        long end = System.currentTimeMillis();
        System.out.println(end - start);
        Thread.sleep(100);
    
        System.out.println(worker.getName() + "总共赚了" + worker.getMoney());
      }
    
    }

执行结果如下:

    52
    hresh总共赚了28224.0

可以看出,上述两个线程同时修改同一个 Worker 对象的 money 数据,对于 money 字段的读写发生了竞争,导致最后结果不正确。像上述这种情况,即时编译器经过逃逸分析后认定对象发生了逃逸,那么肯定不能进行同步消除优化。

换个对象不发生逃逸的情况试一下。

    //JVM参数:-Xms60M -Xmx60M  -XX:+PrintGCDetails -XX:+PrintGCDateStamps
    public class SynchronizedTest {
    
      public static void lockTest() {
        Worker worker = new Worker();
        synchronized (worker) {
          worker.makeMoney();
        }
      }
    
      public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
    
        new Thread(() -> {
          for (int i = 0; i < 500000; i++) {
            lockTest();
          }
        }, "A").start();
    
        new Thread(() -> {
          for (int i = 0; i < 500000; i++) {
            lockTest();
          }
        }, "B").start();
    
        long end = System.currentTimeMillis();
        System.out.println(end - start);
      }
    
    }

输出结果如下:

    56
    Heap
     PSYoungGen      total 17920K, used 9554K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
      eden space 15360K, 62% used [0x00000007bec00000,0x00000007bf5548a8,0x00000007bfb00000)
      from space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
      to   space 2560K, 0% used [0x00000007bfb00000,0x00000007bfb00000,0x00000007bfd80000)
     ParOldGen       total 40960K, used 0K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
      object space 40960K, 0% used [0x00000007bc400000,0x00000007bc400000,0x00000007bec00000)
     Metaspace       used 4157K, capacity 4720K, committed 4992K, reserved 1056768K
      class space    used 467K, capacity 534K, committed 640K, reserved 1048576K

在 lockTest 方法中针对新建的 Worker 对象加锁,并没有实际意义,经过逃逸分析后认定对象未逃逸,则会进行同步消除优化。JDK8 默认开启逃逸分析,我们尝试关闭它,再看看输出结果。

    -Xms60M -Xmx60M  -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+PrintGCDateStamps

输出结果变为:

    73
    2022-03-01T14:51:08.825-0800: [GC (Allocation Failure) [PSYoungGen: 15360K->1439K(17920K)] 15360K->1447K(58880K), 0.0018940 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
    Heap
     PSYoungGen      total 17920K, used 16340K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
      eden space 15360K, 97% used [0x00000007bec00000,0x00000007bfa8d210,0x00000007bfb00000)
      from space 2560K, 56% used [0x00000007bfb00000,0x00000007bfc67f00,0x00000007bfd80000)
      to   space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
     ParOldGen       total 40960K, used 8K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
      object space 40960K, 0% used [0x00000007bc400000,0x00000007bc402000,0x00000007bec00000)
     Metaspace       used 4153K, capacity 4688K, committed 4864K, reserved 1056768K
      class space    used 466K, capacity 502K, committed 512K, reserved 1048576K

经过对比发现,关闭逃逸分析后,执行时间变长,且内存占用变大,同时发生了垃圾回收。

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁,如上述案例所示,lockTest 方法中的加锁操作没什么意义。

事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。

标量替换

在讲解 Java 对象的内存布局时提到过,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程大都是可见的(除开 TLAB)。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

但是目前 Hotspot 并没有实现真正意义上的栈上分配,而是使用了标量替换这么一项技术。

所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。

若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为 标量 。相对的,如果一个数据可以继续分解, 那它就被称为 聚合量 (Aggregate),Java 中的对象就是典型的聚合量。

标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。

如下述案例所示:

    public class ScalarTest {
    
      public static double getMoney() {
        Worker worker = new Worker();
        worker.setMoney(100.0);
        return worker.getMoney() + 20;
      }
    
      public static void main(String[] args) {
        getMoney();
      }
    
    }

经过逃逸分析,Worker 对象未逃逸出 getMoney()的调用,因此可以对聚合量 worker 进行分解,得到局部变量 money,进行标量替换后的伪代码:

    public class ScalarTest {
    
      public static double getMoney() {
        double money = 100.0;
        return money + 20;
      }
    
      public static void main(String[] args) {
        getMoney();
      }
    
    }

对象拆分后,对象的成员变量改为方法的局部变量,这些字段既可以存储在栈上,也可以直接存储在寄存器中。标量替换因为不必创建对象,减轻了垃圾回收的压力。

另外,可以手动通过-XX:+EliminateAllocations可以开启标量替换(默认是开启的), -XX:+PrintEliminateAllocations(同样需要debug版本的JDK)查看标量替换情况。

栈上分配

故名思议就是在栈上分配对象,其实目前 Hotspot 并没有实现真正意义上的栈上分配,实际上是标量替换。

在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着 JIT 编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否需要创建对象,是否可以将堆内存分配转换为栈内存分配。

部分逃逸分析

C2 的逃逸分析与控制流无关,相对来说比较简单。Graal 则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析(partial escape analysis)。它解决了所新建的实例仅在部分程序路径中逃逸的情况。

如下代码所示:

    public static void bar(boolean cond) {
      Object foo = new Object();
      if (cond) {
        foo.hashCode();
      }
    }
    // 可以手工优化为:
    public static void bar(boolean cond) {
      if (cond) {
        Object foo = new Object();
        foo.hashCode();
      }
    }

假设 if 语句的条件成立的可能性只有 1%,那么在 99% 的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。

部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行 if-else 分支的程序路径之中。

我们通过一个完整的测试案例来间接验证这一优化。

    public class PartialEscapeTest {
      long placeHolder0;
      long placeHolder1;
      long placeHolder2;
      long placeHolder3;
      long placeHolder4;
      long placeHolder5;
      long placeHolder6;
      long placeHolder7;
      long placeHolder8;
      long placeHolder9;
      long placeHoldera;
      long placeHolderb;
      long placeHolderc;
      long placeHolderd;
      long placeHoldere;
      long placeHolderf;
    
      public static void foo(boolean flag) {
        PartialEscapeTest o = new PartialEscapeTest();
        if (flag) {
          o.hashCode();
        }
      }
    
      public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
          foo(false);
        }
      }
    
    }

本次测试选用的是 JDK11,开启 Graal 编译器需要配置如下参数:

    -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

分别输出使用 C2 编译器或 Graal 编译器的 GC 日志,对应命令为:

    java -Xlog:gc* PartialEscapeTest
    java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler -Xlog:gc* PartialEscapeTest

通过对比 GC 日志可以发现内存占用情况不一致,Graal 编译器下内存占用更小一点。

C2

    [0.012s][info][gc,heap] Heap region size: 1M
    [0.017s][info][gc     ] Using G1
    [0.017s][info][gc,heap,coops] Heap address: 0x0000000700000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
    [0.345s][info][gc,heap,exit ] Heap
    [0.345s][info][gc,heap,exit ]  garbage-first heap   total 262144K, used 21504K [0x0000000700000000, 0x0000000800000000)[0.345s][info][gc,heap,exit ]   region size 1024K, 18 young (18432K), 0 survivors (0K)
    [0.345s][info][gc,heap,exit ]  Metaspace       used 6391K, capacity 6449K, committed 6784K, reserved 1056768K
    [0.345s][info][gc,heap,exit ]   class space    used 552K, capacity 571K, committed 640K, reserved 1048576K

Graal

    [0.019s][info][gc,heap] Heap region size: 1M
    [0.025s][info][gc     ] Using G1
    [0.025s][info][gc,heap,coops] Heap address: 0x0000000700000000, size: 4096 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
    [0.611s][info][gc,start     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
    [0.612s][info][gc,task      ] GC(0) Using 6 workers of 10 for evacuation
    [0.615s][info][gc,phases    ] GC(0)   Pre Evacuate Collection Set: 0.0ms
    [0.615s][info][gc,phases    ] GC(0)   Evacuate Collection Set: 3.1ms
    [0.615s][info][gc,phases    ] GC(0)   Post Evacuate Collection Set: 0.2ms
    [0.615s][info][gc,phases    ] GC(0)   Other: 0.6ms
    [0.615s][info][gc,heap      ] GC(0) Eden regions: 24->0(150)
    [0.615s][info][gc,heap      ] GC(0) Survivor regions: 0->3(3)
    [0.615s][info][gc,heap      ] GC(0) Old regions: 0->4
    [0.615s][info][gc,heap      ] GC(0) Humongous regions: 5->5
    [0.615s][info][gc,metaspace ] GC(0) Metaspace: 8327K->8327K(1056768K)
    [0.615s][info][gc           ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 29M->11M(256M) 3.941ms
    [0.615s][info][gc,cpu       ] GC(0) User=0.01s Sys=0.01s Real=0.00s
    Cannot use JVMCI compiler: No JVMCI compiler found
    [0.616s][info][gc,heap,exit ] Heap
    [0.616s][info][gc,heap,exit ]  garbage-first heap   total 262144K, used 17234K [0x0000000700000000, 0x0000000800000000)
    [0.616s][info][gc,heap,exit ]   region size 1024K, 9 young (9216K), 3 survivors (3072K)
    [0.616s][info][gc,heap,exit ]  Metaspace       used 8336K, capacity 8498K, committed 8832K, reserved 1056768K
    [0.616s][info][gc,heap,exit ]   class space    used 768K, capacity 802K, committed 896K, reserved 1048576K

查看 Graal 在 JDK11 上的编译结果,可以执行下述命令:

    java -XX:+PrintCompilation -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler -cp /Users/xxx/IdeaProjects/java_deep_learning/src/main/java/com/msdn/java/javac/escape ScalarTest > out-jvmci.txt

总结

本文介绍了 Java 虚拟机中即时编译器的逃逸分析,以及基于逃逸分析的优化:同步消除、标量替换和栈上分配。另外还扩展了解了一下 Graal 编译器下的部分逃逸分析。

参考文献

极客时间 郑雨迪 《深入拆解Java虚拟机》 逃逸分析