目录

  1. 概述
  2. 逃逸分析算法
  3. 对象的逃逸状态
  4. 逃逸分析的作用
    1. 栈上分配
    2. 同步消除
    3. 标量替换
  5. 实际问题
  6. HotSpot 逃逸分析
  7. 逃逸分析总结
  8. JVM 逃逸分析参数
  9. 附录

概述

❓什么是逃逸分析

  • 在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针
  • 逃逸分析可以确定某个指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或线程中
  • 简单点理解,逃逸分析(Escape analysis)就是确定变量范围的一种分析技术

If a subroutine allocates an object and returns a pointer to it, the object can be accessed from undetermined places in the program – the pointer has “escaped”. Pointers can also escape if they are stored in global variables or other data structures that, in turn, escape the current procedure. ——wiki

如果子程序创建并返回一个对象指针,在程序运行阶段,谁会使用这个对象指针是不确定的,此时就称这个不确定调用者的对象指针发生了逃逸,同样的,如果将指针存储在全局变量或者其他类型的数据结构时,由于全局变量可以在当前子程序之外访问,此时的指针也发生了逃逸

逃逸分析算法

Java Hotspot 编译器实现下面论文中描述的逃逸算法:

[Choi99] Jong-Deok Choi, Manish Gupta, Mauricio Seffano,Vugranam C. Sreedhar, Sam Midkiff,“Escape Analysis for Java”, Procedings of ACM SIGPLAN OOPSLA Conference, November 1, 1999

📓算法是上下文相关和流敏感的,并且模拟了对象任意层次的嵌套关系,所以分析精度较高,只是运行时间和内存消耗相对较大,如果对逃逸分析等底层原理非常感兴趣,建议读一读

对象的逃逸状态

✨从逃逸分析的角度来看,一个对象的逃逸与否存在以下三种状态:全局逃逸(Global Escape)、参数逃逸(Arg Escape)和没有逃逸

1️⃣全局逃逸,即一个对象的作用范围逃出了当前方法或者当前线程,存在以下几种场景:

  • 对象是一个静态变量
  • 对象是一个已经发生逃逸的对象
  • 对象作为当前方法的返回值

2️⃣参数逃逸,即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调用方法的字节码确定的

3️⃣没有逃逸,即方法中的对象没有发生逃逸

逃逸分析的作用

编译器可以使用逃逸分析的结果作为优化的基础:

  • 将堆分配转化为栈上分配。如果某个对象在子程序中被分配,并且指向该对象的指针永远不会逃逸,该对象就可以在分配在栈上,而不是在堆上。在有垃圾收集的语言中,这种优化可以降低垃圾收集器运行的频率
  • 同步消除。如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步
  • 分离对象或标量替换。如果某个对象的访问方式不要求该对象是一个连续的内存结构,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中

栈上分配

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了

同步消除

🎶线程同步的代价是相当高的,同步的后果是降低并发性和性能,所以如果能够尽可能减少线程间同步的发生就会提高系统的吸能

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

1
2
3
4
5
6
public void f() {
Object obj = new Object();
synchronized(obj) {
System.out.println(obj);
}
}

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

1
2
3
4
public void f() {
Object obj = new Object();
System.out.println(obj);
}

标量替换

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

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

1
2
3
4
5
6
7
8
9
10
11
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}

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

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

🤔标量替换有什么好处尼?

  • 可以大大减少堆内存的占用,避免造成 GC 压力
  • 因为一旦不需要创建对象了,那么就不再需要分配堆内存了,所以标量替换为栈上分配提供了很好的基础

实际问题

Java 的堆分配、内置线程和 Sun HotSpot 动态编译器的结合创建了一个关于逃逸分析优化的候选平台。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Main {
public static void main(String[] args) {
example();
}
public static void example() {
Foo foo = new Foo(); //alloc
Bar bar = new Bar(); //alloc
bar.setFoo(foo);
}
}
class Foo {}
class Bar {
private Foo foo;
public void setFoo(Foo foo) {
this.foo = foo;
}
}

在这个示例中,创建了两个对象(用 alloc 注释),其中一个作为方法的参数。方法 setFoo()接收到 foo 参数后,保存 Foo 对象的引用。如果 Bar 对象保存在堆中,那么 Foo 的引用将逃逸。但在这种情况下,编译器可以使用逃逸分析确定Bar 对象本身并没有逃逸 example()的调用。这意味着 Foo 引用无法逃逸。因此,编译器可以安全地在栈上分配两个对象

HotSpot 逃逸分析

Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上

✨逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中
1
2
3
4
5
6
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除

1
2
3
4
5
6
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

上述方法如果想要StringBuffer sb不发生逃逸,可以这样写

1
2
3
4
5
6
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析,如果使用的是较早的版本,开发人员则可以通过:

  • 选项-XX:+DoEscapeAnalysis显式开启逃逸分析
  • 通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果

📓开发中能使用局部变量的,就不要使用在方法外定义

逃逸分析总结

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

🤔逃逸分析这么强大,为什么没有全面普及?

  • 根本原因就是无法保证逃逸分析的性能消耗一定能高于分析的消耗。虽然经过逃逸分析可以做标量替换栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
  • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了
  • 虽然这项技术并不十分成熟,但是它是即时编译器优化技术中一个十分重要的手段
  • 虽然通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,在理论上是可行的,但取决于 JVM 设计者的选择,HotSpot JVM 中并没有这么做,在 HotSpot 虚拟机中可以明确所有的对象实例都是创建在堆上
  • intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

JVM 逃逸分析参数

参数含义
-XX:+EliminateAllocations开启标量替换(默认打开),允许将对象打散分配到栈上
-XX:+PrintEliminateAllocations显示标量替换详情
-XX:+DoEscapeAnalysis启用逃逸分析

附录

面试 Java 逃逸分析
JVM 之逃逸分析
wiki
深入理解 Java 中的逃逸分析
对象和数组并不是都在堆上分配内存的