总结

比如jit即时编译中一个方法中定义了一个对象但是没有被其他地方引用,而栈中又放得下的话就会将这个对象放到栈里面

逃逸分析确定某个指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或线程中。
简单来讲,JVM 中的
逃逸分析**可以通过分析对象引用的使用范围(即动态作用域),来决定对象是否要在堆上分配内存,也可以做一些其他方面的优化。

正文

为了防止歧义,可以换个说法:

Java 对象实例和数组元素都是在堆上分配内存的吗?

答:不一定。满足特定条件时,它们可以在(虚拟机)栈上分配内存。

Java中的对象都是在堆上分配的吗? - 图1

JVM 内存结构很重要,多多复习

这和我们平时的理解可能有些不同。虚拟机栈一般是用来存储基本数据类型、引用和返回地址的,怎么可以存储实例数据了呢?

这是因为 Java JIT(just-in-time)编译器进行的两项优化,分别称作逃逸分析(escape analysis)和标量替换(scalar replacement)。

Java中的对象都是在堆上分配的吗? - 图2

注意看一下 JIT 的位置

中文维基上对逃逸分析的描述基本准确,摘录如下:

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或是返回到调用者子程序。

如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中被访问到的地方无法确定——这样指针就成功 “逃逸” 了。如果指针存储在全局变量或者其它数据结构中,因为全局变量是可以在当前子程序之外访问的,此时指针也发生了逃逸。

逃逸分析确定某个指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或线程中。

简单来讲,JVM 中的逃逸分析可以通过分析对象引用的使用范围(即动态作用域),来决定对象是否要在堆上分配内存,也可以做一些其他方面的优化。

关于逃逸分析,以下的例子说明了一种对象逃逸的可能性。

  1. static StringBuilder getStringBuilder1(String a, String b) {
  2. StringBuilder builder = new StringBuilder(a);
  3. builder.append(b);
  4. return builder; // builder通过方法返回值逃逸到外部
  5. }
  6. static String getStringBuilder2(String a, String b) {
  7. StringBuilder builder = new StringBuilder(a);
  8. builder.append(b);
  9. return builder.toString(); // builder范围维持在方法内部,未逃逸
  10. }

以 JDK 1.8 为例,可以通过设置 JVM 参数 - XX:+DoEscapeAnalysis、-XX:-DoEscapeAnalysis 来开启或关闭逃逸分析(默认当然是开启的)。

下面先写一个没有对象逃逸的例子。

  1. public class EscapeAnalysisTest {
  2. public static void main(String[] args) throws Exception {
  3. long start = System.currentTimeMillis();
  4. for (int i = 0; i < 5000000; i++) {
  5. allocate();
  6. }
  7. System.out.println((System.currentTimeMillis() - start) + " ms");
  8. Thread.sleep(600000);
  9. }
  10. static void allocate() {
  11. MyObject myObject = new MyObject(2019, 2019.0);
  12. }
  13. static class MyObject {
  14. int a;
  15. double b;
  16. MyObject(int a, double b) {
  17. this.a = a;
  18. this.b = b;
  19. }
  20. }
  21. }

然后通过开启和关闭 DoEscapeAnalysis 开关观察不同。

关闭逃逸分析

  1. ~ java -XX:-DoEscapeAnalysis EscapeAnalysisTest
  2. 76 ms
  3. ~ jmap -histo 26031
  4. num #instances #bytes class name
  5. ----------------------------------------------
  6. 1: 5000000 120000000 me.lmagics.EscapeAnalysisTest$MyObject
  7. 2: 636 12026792 [I
  8. 3: 3097 1524856 [B
  9. 4: 5088 759960 [C
  10. 5: 3067 73608 java.lang.String
  11. 6: 623 71016 java.lang.Class
  12. 7: 727 43248 [Ljava.lang.Object;
  13. 8: 532 17024 java.io.File
  14. 9: 225 14400 java.net.URL
  15. 10: 334 13360 java.lang.ref.Finalizer
  16. # ......

开启逃逸分析

  1. ~ java -XX:+DoEscapeAnalysis EscapeAnalysisTest
  2. 4 ms
  3. ~ jmap -histo 26655
  4. num #instances #bytes class name
  5. ----------------------------------------------
  6. 1: 592 11273384 [I
  7. 2: 90871 2180904 me.lmagics.EscapeAnalysisTest$MyObject
  8. 3: 3097 1524856 [B
  9. 4: 5088 759952 [C
  10. 5: 3067 73608 java.lang.String
  11. 6: 623 71016 java.lang.Class
  12. 7: 727 43248 [Ljava.lang.Object;
  13. 8: 532 17024 java.io.File
  14. 9: 225 14400 java.net.URL
  15. 10: 334 13360 java.lang.ref.Finalizer
  16. # ......

可见,关闭逃逸分析之后,堆上有 5000000 个 MyObject 实例,而开启逃逸分析之后,就只剩下 90871 个实例了,不管是实例数还是内存占用都只有原来的 2% 不到。

另外,如果把堆内存限制得小一点(比如加上 - Xms10m -Xmx10m),并且打印 GC 日志(-XX:+PrintGCDetails)的话,关闭逃逸分析还会造成频繁的 GC,开启逃逸分析就没有这种情况。这说明逃逸分析确实降低了堆内存的压力。

但是,逃逸分析只是栈上内存分配的前提,接下来还需要进行标量替换才能真正实现。

所谓标量,就是指 JVM 中无法再细分的数据,比如 int、long、reference 等。相对地,能够再细分的数据叫做聚合量。

仍然考虑上面的例子,MyObject 就是一个聚合量,因为它由两个标量 a、b 组成。通过逃逸分析,JVM 会发现 myObject 没有逃逸出 allocate() 方法的作用域,标量替换过程就会将 myObject 直接拆解成 a 和 b,也就是变成了:

  1. static void allocate() {
  2. int a = 2019;
  3. double b = 2019.0;
  4. }

可见,对象的分配完全被消灭了,而 int、double 都是基本数据类型,直接在栈上分配就可以了。所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配。

JVM 提供了参数 - XX:+EliminateAllocations 来开启标量替换,默认仍然是开启的。显然,如果把它关掉的话,就相当于禁止了栈上内存分配,只有逃逸分析是无法发挥作用的。

在 Debug 版 JVM 中,还可以通过参数 - XX:+PrintEliminateAllocations 来查看标量替换的具体情况。

除了标量替换之外,通过逃逸分析还能实现同步消除

(synchronization elision),当然它与本文的主题无关了。

举个例子:

  1. private void someMethod() {
  2. Object lockObject = new Object();
  3. synchronized (lockObject) {
  4. System.out.println(lockObject.hashCode());
  5. }
  6. }

lockObject 这个锁对象的生命期只在 someMethod() 方法中,并不存在多线程访问的问题,所以 synchronized 块并无意义,会被优化掉:

  1. private void someMethod() {
  2. Object lockObject = new Object();
  3. System.out.println(lockObject.hashCode());
  4. }

原文

https://mp.weixin.qq.com/s?__biz=MzI2MTIzMzY3Mw==&mid=2247489790&idx=4&sn=c8a2d2bef4f6eb284bf37d1c3ad8a6db&chksm=ea5cd598dd2b5c8ef7da9577ce4e4ebd06189ba53d8e9a2d2a9b10d24f5267b6e9d87f2cad36&scene=126&sessionid=1581355000