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

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

1.jpg


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

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


逃逸分析


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


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


  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
    简单来说就是,如类变量或实例变量,可能被其它线程访问到,这就叫做线程逃逸,存在线程安全问题。


  1. 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。(未知代码指的是没有被内联的方法调用)
    比如说,当一个对象在方法中定义之后,它可能被外部方法所引用,作为参数传递到其它方法中,这叫做方法逃逸,


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


//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 编译器下的部分逃逸分析。

目录
相关文章
|
27天前
|
监控 算法 Java
jvm-48-java 变更导致压测应用性能下降,如何分析定位原因?
【11月更文挑战第17天】当JVM相关变更导致压测应用性能下降时,可通过检查变更内容(如JVM参数、Java版本、代码变更)、收集性能监控数据(使用JVM监控工具、应用性能监控工具、系统资源监控)、分析垃圾回收情况(GC日志分析、内存泄漏检查)、分析线程和锁(线程状态分析、锁竞争分析)及分析代码执行路径(使用代码性能分析工具、代码审查)等步骤来定位和解决问题。
|
2月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
89 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
2月前
|
存储 SQL 小程序
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
这篇文章详细介绍了Java虚拟机(JVM)的运行时数据区域和JVM指令集,包括程序计数器、虚拟机栈、本地方法栈、直接内存、方法区和堆,以及栈帧的组成部分和执行流程。
45 2
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
|
2月前
|
Java 应用服务中间件 程序员
JVM知识体系学习八:OOM的案例(承接上篇博文,可以作为面试中的案例)
这篇文章通过多个案例深入探讨了Java虚拟机(JVM)中的内存溢出问题,涵盖了堆内存、方法区、直接内存和栈内存溢出的原因、诊断方法和解决方案,并讨论了不同JDK版本垃圾回收器的变化。
44 4
|
2月前
|
Arthas 监控 Java
JVM知识体系学习七:了解JVM常用命令行参数、GC日志详解、调优三大方面(JVM规划和预调优、优化JVM环境、JVM运行出现的各种问题)、Arthas
这篇文章全面介绍了JVM的命令行参数、GC日志分析以及性能调优的各个方面,包括监控工具使用和实际案例分析。
75 3
|
2月前
|
SQL 缓存 Java
JVM知识体系学习三:class文件初始化过程、硬件层数据一致性(硬件层)、缓存行、指令乱序执行问题、如何保证不乱序(volatile等)
这篇文章详细介绍了JVM中类文件的初始化过程、硬件层面的数据一致性问题、缓存行和伪共享、指令乱序执行问题,以及如何通过`volatile`关键字和`synchronized`关键字来保证数据的有序性和可见性。
37 3
|
2月前
|
缓存 前端开发 Java
JVM知识体系学习二:ClassLoader 类加载器、类加载器层次、类过载过程之双亲委派机制、类加载范围、自定义类加载器、编译器、懒加载模式、打破双亲委派机制
这篇文章详细介绍了JVM中ClassLoader的工作原理,包括类加载器的层次结构、双亲委派机制、类加载过程、自定义类加载器的实现,以及如何打破双亲委派机制来实现热部署等功能。
78 3
|
2月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
62 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
2月前
|
小程序 Oracle Java
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
这篇文章是关于JVM基础知识的介绍,包括JVM的跨平台和跨语言特性、Class文件格式的详细解析,以及如何使用javap和jclasslib工具来分析Class文件。
60 0
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
|
2月前
|
存储 Java PHP
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
85 0