逃逸分析
文章目录
1. 简述
首先了解即时编译器(Just-in-time Compilation,JIT)的优化技术(大概小一百个吧),只学习逃逸分析即可。在《JVM运行时数据区域》章节中我们说过所有对象实例分配在堆上也不是那么“绝对”了,其实就是逃逸分析的优化。
2. 逃逸分析 Escape Analysis
逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
方法逃逸的几种方式如下:
public class Test {
public static Object obj;
public void globalVariableEscape() {
// 给全局变量赋值,发生逃逸
obj = new Object();
}
//sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,
//这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。
//如果想要StringBuffer sb不逃出方法,可以这样写: return sb.toString();
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public void instanceEscape() {
// 实例引用发生逃逸
test(this);
}
}
逃逸分析的配置
- -XX:+DoEscapeAnalysis开启逃逸分析(jdk1.7后默认开启)
- -XX:-DoEscapeAnalysis 关闭逃逸分析
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配
- 同步消除
- 标量替换
2.1 栈上分配
如果能够通过逃逸分析确定某些对象不会逃出方法之外,那就可以让这个对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
private static void alloc() {
Point point = new Point(1, 2);
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
point 和 sum 并没有发生逃逸,JIT 在优化的时候,就会把变量和对象分配在栈上,方法执行完后自动销毁,而不需要垃圾回收的介入,从而提高系统性能。
2.2 同步消除(锁消除)
如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)
private static void alloc() {
Point point = new Point(1, 2);
synchronized (point) {
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
}
代码中对 point 这个对象进行加锁,但是 point 对象的生命周期只在 alloc() 方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉。优化成:
private static void alloc() {
Point point = new Point(1, 2);
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
2.3 标量替换
Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。
@Slf4j
public class EscapeAnalysisTest {
private static void alloc() {
Point point = new Point(1, 2);
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
public static void main(String[] args) {
alloc();
}
}
@AllArgsConstructor
class Point {
public int x;
public int y;
}
以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就会不会直接创建 point 对象,而是直接使用两个标量int x ,int y来替代Point对象。以上代码,经过标量替换后,就会变成:
@Slf4j
public class EscapeAnalysisTest {
private static void alloc() {
int x = x;
int y = y;
int sum = x + y;
log.debug("sum:{}", sum);
}
public static void main(String[] args) {
alloc();
}
}
2. 逃逸分析测试
在测试的过程需要开启GC日志,通过-XX:+PrintGCDetails
设置。多个设置用空格隔开(idea)
3.1 栈上分配测试
-XX:+DoEscapeAnalysis 开启逃逸分析(jdk1.7 后默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
代码如下:
@Slf4j
public class EscapeAnalysisTest {
private static void alloc() {
Point point = new Point(1, 2);
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
log.info("耗时:{}", System.currentTimeMillis() - start);
}
}
@AllArgsConstructor
class Point {
public int x;
public int y;
}
开启逃逸分析,执行的时间为32毫秒,日志如下
[2019-04-29 16:49:49 909][main][EscapeAnalysisTest:30]耗时:32
关闭逃逸分析,执行的时间为327毫秒,并且伴随的大量的GC日志信息。日志如下
[GC (Allocation Failure) [PSYoungGen: 31744K->2443K(36864K)] 31744K->2451K(121856K), 0.0067768 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 34187K->1528K(36864K)] 34195K->1544K(121856K), 0.0018649 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33272K->1528K(36864K)] 33288K->1544K(121856K), 0.0013963 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33272K->1528K(68608K)] 33288K->1552K(153600K), 0.0015636 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65016K->1528K(68608K)] 65040K->1552K(153600K), 0.0041579 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[2019-04-29 16:53:37 980][main][EscapeAnalysisTest:30]耗时:327
通过上面开启和关闭逃逸分析:开启逃逸分析,对象没有分配在堆上,没有进行GC,而是把对象分配在栈上。关闭逃逸分析,对象全部分配在堆上,当堆中对象存满后,进行多次GC,导致执行时间大大延长。
即时编译器(Just-in-time Compilation,JIT)
1、使用client编译器时,默认执行为1500次才认为是热代码;
2、使用server编译器时,默认执行为10000次才认为是热代码;
上面的例子开启逃逸分析后,并不是所有的对象都直接在栈上分配,而是通过 JIT 分析此代码是热代码,才进行异步编译成本地机器码,并通过逃逸分析,把对象分配到栈上。(如果是server编译器:在前10000次循环和编译成本地机器码这段时间,对象都会在堆中分配对象,编译成本地机器码后才会在栈上分配)
3.2 同步消除测试
-XX:+EliminateLocks 开启锁消除
-XX:-EliminateLocks 关闭锁消除
锁消除基于分析逃逸基础之上,开启锁消除必须开启逃逸分析
把上面的代码稍微修改了下,加了一个同步块。使用数组替换 point 对象,数组长度大于64的是不会在栈上分配,我们都以堆上分配为例来测试锁消除带来的影响。测试代码如下:
@Slf4j
public class EscapeAnalysisTest {
private static void alloc() {
byte[] arr = new byte[65];
synchronized (arr) {
arr[0]=0;
}
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
log.info("耗时:{}", System.currentTimeMillis() - start);
}
}
开启同步消除,执行的时间为286毫秒,日志如下
[GC (Allocation Failure) [PSYoungGen: 31744K->2536K(36864K)] 31744K->2544K(121856K), 0.0041677 secs] [Times: user=0.05 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 34280K->1576K(36864K)] 34288K->1592K(121856K), 0.0014835 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33320K->1512K(36864K)] 33336K->1528K(121856K), 0.0020325 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33256K->1512K(68608K)] 33272K->1528K(153600K), 0.0043267 secs] [Times: user=0.03 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65000K->1528K(68608K)] 65016K->1544K(153600K), 0.0020884 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65016K->1544K(129536K)] 65032K->1560K(214528K), 0.0014352 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 128520K->0K(129536K)] 128536K->1510K(214528K), 0.0020099 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 126976K->0K(257024K)] 128486K->1510K(342016K), 0.0004067 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 253952K->0K(257024K)] 255462K->1510K(342016K), 0.0006921 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[2019-04-29 17:02:35 158][main][EscapeAnalysisTest:29]耗时:286
关闭同步消除,执行的时间为457毫秒,日志如下
[GC (Allocation Failure) [PSYoungGen: 31744K->2440K(36864K)] 31744K->2448K(121856K), 0.0031128 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 34184K->1544K(36864K)] 34192K->1560K(121856K), 0.0022394 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33288K->1528K(36864K)] 33304K->1544K(121856K), 0.0021265 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33272K->1528K(68608K)] 33288K->1544K(153600K), 0.0019755 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65016K->1528K(68608K)] 65032K->1552K(153600K), 0.0015081 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65016K->1544K(129536K)] 65040K->1568K(214528K), 0.0021424 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 128520K->0K(129536K)] 128544K->1513K(214528K), 0.0030146 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 126976K->0K(257024K)] 128489K->1513K(342016K), 0.0003678 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 253952K->0K(257024K)] 255465K->1513K(342016K), 0.0007280 secs] [Times: user=0.00 sys=0.01, real=0.00 secs]
[2019-04-29 17:02:53 396][main][EscapeAnalysisTest:29]耗时:457
通过上面开启和关闭同步消除:开启后要比关闭后性能提升很多。
3.3 标量替换测试
-XX:+EliminateAllocations 开启标量替换
-XX:-EliminateAllocations 关闭标量替换
标量替换基于分析逃逸基础之上,开启标量替换必须开启逃逸分析
测试代码如下:
@Slf4j
public class EscapeAnalysisTest {
private static void alloc() {
Point point = new Point(1, 2);
int sum = point.x + point.y;
log.debug("sum:{}", sum);
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
log.info("耗时:{}", System.currentTimeMillis() - start);
}
}
@AllArgsConstructor
class Point {
public int x;
public int y;
}
开启标量替换,执行的时间为41毫秒,日志如下
[2019-04-29 17:13:24 218][main][EscapeAnalysisTest:28]耗时:41
关闭标量替换,执行的时间为121毫秒,并且伴随的大量的GC日志信息。日志如下
[GC (Allocation Failure) [PSYoungGen: 31744K->2456K(36864K)] 31744K->2464K(121856K), 0.0038336 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 34200K->1544K(36864K)] 34208K->1560K(121856K), 0.0019355 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33288K->1544K(36864K)] 33304K->1560K(121856K), 0.0017686 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 33288K->1528K(68608K)] 33304K->1544K(153600K), 0.0015481 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 65016K->1512K(68608K)] 65032K->1528K(153600K), 0.0022685 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[2019-04-29 17:13:42 657][main][EscapeAnalysisTest:28]耗时:121
这次我们把标量替换功能关闭,我们发现对象又分配到堆里面了,并执行了多次GC。由此可以看出java中没有实现真正意义上的栈上分配,而是通过标量替换来实现栈上分配。