JVM逃逸分析机制
简单来说,逃逸分析是分析了对象是否只在当前函数范围内使用,来确定是否在栈上进行分配,主要涉及到栈是函数运行完,立即清理的,所以不需要等到gc了,为了大大缓解了gc的压力。
一、定义
JVM 逃逸分析(Escape Analysis)是一种可以有效优化 Java 程序性能的技术手段,它是 Java 虚拟机在编译阶段(即时编译器,Just In Time compiler,JIT)进行的一项重要的优化分析工作。逃逸分析主要用于判断对象的作用域是否会 "逃逸" 出当前方法、线程或者其他特定的代码范围,如果对象不会逃逸,那么虚拟机就可以基于此进行一些针对性的优化。
二、判断对象是否逃逸的依据和场景
(一)方法逃逸(Method Escape)
-
含义:如果一个对象在方法内部被创建,但有可能在该方法执行结束后,其引用被外部方法、线程或者其他代码所获取并访问,那么这个对象就发生了方法逃逸。
-
示例场景: 比如,在一个方法中创建了一个对象,然后将这个对象作为返回值返回给调用者,那么这个对象显然就逃逸出了它被创建的方法,外部代码可以通过获取返回值的方式来操作该对象。如下代码所示:
public class EscapeExample {
public static Object createObject() {
Object obj = new Object();
return obj;
}
}
在上述 createObject
方法中创建的 Object
实例 obj
就发生了方法逃逸,因为它被作为返回值传递到了方法外部。通过逃逸分析后,就不允许对象obj在栈上分配,因为这里的obj需要返回给调用者。
如果void返回类型,就允许在栈上分配,因为不需要返回给调用者,方法执行结束,栈是函数运行完,立即清理的,所以不需要等到gc了,大大缓解了gc的压力。
另外一种常见的情况是,将对象作为参数传递给其他方法,并且在其他方法中该对象的引用有可能被保存下来或者继续传播,例如:
public class AnotherEscapeExample {
public static void setObjectInList(List<Object> list) {
Object obj = new Object();
list.add(obj);
}
}
在 setObjectInList
方法中,创建的 Object
实例 obj
被添加到了传入的 List
中,而这个 List
是外部传入的,意味着 obj
的引用可以通过这个 List
在方法外部被访问到,所以 obj
也发生了方法逃逸。
(二)线程逃逸(Thread Escape)
-
含义:如果一个对象在某个线程中被创建,但有可能被其他线程访问到,那么该对象就发生了线程逃逸。
-
示例场景: 例如,在多线程环境下,一个对象被创建后存放在了一个可以被多个线程共享的静态变量中,如下代码:
public class ThreadEscapeExample {
static Object sharedObject;
public static void createAndShareObject() {
sharedObject = new Object();
}
}
在 createAndShareObject
方法中创建的 Object
实例被赋值给了静态变量 sharedObject
,由于静态变量是所有线程都可以访问到的,所以这个对象就发生了线程逃逸,其他线程可以获取并操作这个 sharedObject
。
三、基于逃逸分析的优化策略
(一)栈上分配(Stack Allocation)
-
原理:通常情况下,Java 对象是在堆内存中分配空间的,但如果经过逃逸分析发现一个对象不会逃逸出方法(即没有方法逃逸),那么这个对象就可以直接在栈上分配内存。栈内存有一个特点,就是随着方法的执行结束,栈帧出栈,栈上分配的对象所占用的内存空间会自动被回收,无需像堆内存那样依赖垃圾收集器来回收,这样可以减少堆内存的使用压力,同时也提高了内存回收的效率,因为省去了垃圾收集的相关开销。
-
示例: 考虑如下代码:
public void localObjectAllocation() {
for (int i = 0; i < 1000; i++) {
Object localObj = new Object();
// 对localObj进行一些简单操作,比如调用其toString方法等
System.out.println(localObj.toString());
}
}
如果经过逃逸分析确定 localObj
在每次循环中都不会逃逸出 localObjectAllocation
这个方法,那么 JVM 就可以将这些 Object
实例直接在栈上进行分配,循环结束后,随着方法栈帧的销毁,这些对象占用的内存自动释放,无需进行堆内存的分配和垃圾收集等操作。
(二)标量替换(Scalar Replacement)
-
原理:标量(Scalar)是指那些不能再分解的数据类型,比如基本数据类型(int、double、boolean 等)以及对象的引用等。在 Java 中,对象是由多个成员变量等组成的聚合体。如果经过逃逸分析发现一个对象不会逃逸,并且这个对象可以拆解为若干个标量来替代它进行使用,那么 JVM 就会采用标量替换的优化策略。即将对象的成员变量等按照使用的顺序和逻辑,用对应的标量在栈上或者寄存器中进行表示和操作,这样就相当于消除了对象的实例化过程,进一步节省了内存空间和提高了执行效率。
-
示例: 假设有如下一个简单的类:
class Point {
private int x;
private int y;
}
在某个方法中有如下代码:
public void usePoint() {
Point point = new Point();
point.x = 10;
point.y = 20;
int sum = point.x + point.y;
}
如果经过逃逸分析确定 point
对象不会逃逸出 usePoint
方法,JVM 可能会进行标量替换,直接在栈上用两个 int
类型的变量(分别对应 point
的 x
和 y
属性)来替代 Point
对象的实例,在后续的代码执行中按照原来操作 Point
对象的逻辑去操作这两个 int
变量,避免了创建 Point
对象实例以及相关的内存分配等开销。
(三)同步消除(Synchronization Elimination)
-
原理 :在多线程编程中,为了保证线程安全,我们经常会对代码块或者方法添加
synchronized
关键字来实现同步。但如果经过逃逸分析发现某个加锁的对象不会发生线程逃逸,也就是只有当前创建它的线程能够访问到它,那么这个对象实际上不存在多线程并发访问的情况,此时 JVM 就可以把对应的同步锁操作消除掉,避免了获取和释放锁带来的性能开销,提高代码的执行效率。 -
示例 : 如下代码中,原本对
localObj
进行了加锁操作:
public void synchronizedLocalObject() {
Object localObj = new Object();
synchronized (localObj) {
// 对localObj进行一些操作
System.out.println(localObj.toString());
}
}
如果逃逸分析判断 localObj
不会逃逸出这个方法,也就是不会被其他线程访问到,那么 JVM 就可以消除这里的 synchronized
操作,按照正常的非同步代码逻辑来执行后续对 localObj
的操作,减少了加锁和解锁的性能损耗。
四、总结
JVM 逃逸机制通过对对象逃逸情况的分析,能够挖掘出可以进行性能优化的潜在机会,然后采用栈上分配、标量替换、同步消除等优化策略,在不改变 Java 代码语义的前提下,有效地提高 Java 程序的运行性能,尤其是在内存使用和执行效率方面,让 Java 应用在各种复杂的场景下能够更加高效地运行。不过需要注意的是,逃逸分析本身也是有一定的性能开销的,不同的 JVM 实现以及不同的编译参数设置等都会影响逃逸分析的效果以及相关优化策略的实施情况。