逃逸分析技术算是在JVM面试题偶有提及的一个考察点,当然如果你能够讲解JVM工作原理的时候提及这一点,这一定会增加面试官对你的好感,通过对本篇文章的阅读,你将能够从容的解决以下几个面试题:
- 什么是逃逸分析技术?
- 逃逸分析技术解决什么问题?带来什么好处?
- 如何更好的理解或者运用逃逸分析技术?
什么是逃逸分析
逃逸分析技术是JVM用于提高性能以及节省内存的手段,在JVM编译语境下也就是我们常说的JIT阶段,关于逃逸分析的概念,引用《深入理解Java虚拟机》的说法:
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
只要编译阶段判定当前对象并没有发生逃逸,那么它就会采用栈上分配、标量替换、同步锁消除等手段提升程序执行性能和节省内存开销,具体场景还得查看是发生方法逃逸还是线程逃逸。
那么我们又该如何判断对象是否逃逸呢?我们不妨基于上述的判断条件来看看这个示例,假设我们现在有一个user类,我们通过UserService进行初始化,那么请问这段代码是否发生逃逸呢?
public class UserService {
private User user;
public void init() {
user = new User();
user.setId(RandomUtil.randomInt(10));
user.setName(RandomUtil.randomString(3));
}
}
答案当然是肯定的,因为这段代码方法内所创建的对象被外部的main函数所引用,也就是我们所说的方法逃逸。
再来看看这段代码,典型的在方法内创建然后被外部函数所引用,也就是所谓的方法逃逸:
public User createUser() {
User user = new User();
user.setId(RandomUtil.randomInt(10));
user.setName(RandomUtil.randomString(3));
return user;
}
而这段stringBuffer 已经被其他线程实例所访问到,也就是典型的线程逃逸:
public static void main(String[] args) throws InterruptedException {
StringBuffer stringBuffer = new StringBuffer();
CountDownLatch countDownLatch = new CountDownLatch(3);
//调用appendStr操作stringBuffer
new Thread(() -> {
appendStr(stringBuffer);
countDownLatch.countDown();
}).start();
//循环拼接操作stringBuffer
new Thread(() -> {
for (int i = 0; i < 10; i++) {
stringBuffer.append("aaa");
}
countDownLatch.countDown();
}).start();
//循环拼接操作stringBuffer
new Thread(() -> {
for (int i = 0; i < 10; i++) {
stringBuffer.append("aaa");
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println(stringBuffer);
}
如何运用到逃逸分析技术
1.栈上分配(针对未逃逸或方法逃逸)
下面这段代码仅在方法内部完成对象创建或者打印,其对象并没有被外部方法所引用和暴露,对象就没有发生逃逸,对于没有发生逃逸的代码或者上文中方法逃逸的代码端,JIT会通过栈上分配减少内存占用和GC压力。
Map<Integer, User> userMap = new HashMap<>();
public int getUserAgeById(int id) {
User user = new User();
user.setId(RandomUtil.randomInt(10));
user.setName(RandomUtil.randomString(3));
//打印用户信息
printUserInfo(user);
}
2.分离对象或标量替换(针对未逃逸)
如果仅仅是操作未逃逸对象的某些简单运算,我们同样可以只在栈帧内使用这个对象,如此JVM就会将这个对象打散,将对象打散为无数个小的局部变量,实现标量替换。
如下所示,这段代码没有发生任何逃逸,JVM会避免创建Point ,而是通过栈上创建基本变量完成逻辑操作:
public static void main(String args[]) {
alloc();
}
class Point {
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
进而直接标量替换,直接在栈上分配x和y的值,完成输出打印。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
3.同步锁消除(针对未逃逸线程)
这一点就比较有趣了,我们都知道使用StringBuffer可以保证线程安全,因为其操作函数都有带synchronized关键字,那么请问这段代码会上锁吗?
public void appendStr(int count) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < count; i++) {
sb.append("no: " + i + " ");
}
}
答案是不会,因为我们当前操作的StringBuffer 对象并没有发生线程逃逸,它仅仅在函数内部进行字符串操作,所以针对appendStr内部逻辑,其直接将其优化为StringBuilder:
4.线程逃逸分析的更进一步
请问实例方法调用静态方法,StringBuffer作为变量传入,是否发生逃逸,直接创建一个main方法调用这段代码,方法是否发生逃逸?
public void appendStr(int count) {
StringBuffer sb = new StringBuffer();
loop(count, sb);
}
private static void loop(int count, StringBuffer sb) {
for (int i = 0; i < count; i++) {
sb.append("no: " + i + " ");
}
}
答案是发生了方法逃逸,但是没有发生线程逃逸,但我们的代码是单线程执行这段代码,即使StringBuffer 由外部传入,函数内部依然可以进行锁消除将其内部的拼接逻辑用StringBuilder进行字符串拼接:
再来看看这段代码,请问发生逃逸了吗?
public void appendStr(int count) {
StringBuffer sb = new StringBuffer();
loop(count, sb);
}
private static String loop(int count, StringBuffer sb) {
for (int i = 0; i < count; i++) {
sb.append("no: " + i + " ");
}
return sb.toString();
}
答案是没有发生线程逃逸,返回的字符串还是没有被外部线程操作,所以最终还是被转为StringBuilder:
而下面这段代码就是典型的逃逸,可以看到多线程场景下StringBuffer 被多线程共享和访问,此时JIT优化就会视为对象逃逸:
public static void main(String[] args) throws InterruptedException {
StringBuffer stringBuffer = new StringBuffer();
CountDownLatch countDownLatch = new CountDownLatch(3);
//调用appendStr操作stringBuffer
new Thread(() -> {
appendStr(stringBuffer);
countDownLatch.countDown();
}).start();
//循环拼接操作stringBuffer
new Thread(() -> {
for (int i = 0; i < 10; i++) {
stringBuffer.append("aaa");
}
countDownLatch.countDown();
}).start();
//循环拼接操作stringBuffer
new Thread(() -> {
for (int i = 0; i < 10; i++) {
stringBuffer.append("aaa");
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println(stringBuffer);
}
public static void appendStr(StringBuffer stringBuffer) {
for (int i = 0; i < 10; i++) {
stringBuffer.append(i);
}
}
所以appendStr在判定线程逃逸之后,并没有将StringBuffer变为StringBuilder:
小结
合理的在栈帧上解决问题可以避免对象逃逸,从而让JIT尽可能的去进行优化,这一点我想应该是一个Java程序员对于代码的极致追求了。