深入理解JVM 之 逃逸分析
逃逸分析的理论学习及实验
准备
逃逸分析的知识:参考 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明 的 11.4.3 逃逸分析部分
逃逸分析的算法:Escape Analysis for Java
此篇论文略深奥,暂时作为辅助作用,此篇不详细讲论文的内容
逃逸分析的基本概念
什么是逃逸分析:
当一个对象指针被多个方法或线程引用时称这个指针发生逃逸。而用来分析这个逃逸的方法,则称为逃逸分析。因此逃逸分析是目前Java虚拟机中比较前沿的优化技术,不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
什么时候进行逃逸分析:
在编译阶段确立逃逸,注意并不是在运行时
逃逸分析的状态
- GlobalEscape(全局逃逸或线程逃逸):
即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
-
ArgEscape(参数逃逸或方法逃逸):
即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。 -
NoEscape(没有逃逸):
即方法中的对象没有发生逃逸。
几种不同程度的逃逸状态
public class EscapeStatus {
public static Object globalVariableObject;
public Object instanceObject;
public void globalVariableEscape(){
globalVariableObject = new Object(); // 静态变量,外部线程可见,发生逃逸 GlobalEscape
}
public void instanceObjectEscape(){
instanceObject = new Object(); // 赋值给堆中实例字段,外部线程可见,发生逃逸 GlobalEscape
}
public Object returnObjectEscape(){
return new Object(); // 返回实例,外部线程可见,发生逃逸 GlobalEscape
}
public void argEscape(){
Object argEscape = new Object();
callArgEscape(argEscape); // 一个对象被作为方法参数传递,发生参数逃逸 ArgEscape
}
public void callArgEscape(Object obj){
}
public void noEscape(){
Object noEscape = new Object(); // 仅创建线程可见,对象无逃逸 NoEscape
}
}
全局逃逸GlobalEscape
public class GlobalEscape {
static class User {
private int id;
private String name;
}
private static User user;
public static void foo() {
user = new User();
user.id = 1;
user.name = "Alice";
}
public static void main(String[] args) {
foo();
}
}
未逃逸NoEscape
public class NoEscape {
class User {
private int id;
private String name;
}
public void foo() {
User user = new User();
user.id = 1;
user.name = "Alice";
}
public static void main(String[] args) {
NoEscape pos = new NoEscape();
pos.foo();
}
}
根据逃逸分析的不同状态,可以怎样优化
- 栈上分配(Stack Allocations)
- 标量替代(Scalar Replacement)
- 锁消除(Synchronization Elimination)
相关参数:
-XX:+PrintGC :打印GC日志
-XX:+DoEscapeAnalysis :启用逃逸分析(默认打开)
-XX:+EliminateAllocations :标量替换(默认打开)
-XX:+EliminateLocks :锁消除(默认打开)
-XX:+PrintEliminateAllocations查看标量替换情况
JVM五大分区:
- 程序计数器
每个线程拥有一个,记录指令位置。大小是固定的,不会出现oom。线程数越多,使用内存越大? - 虚拟机栈
每个方法都会生成一个栈帧。存储局部变量表,方法返回地址 - 本地方法栈
与虚拟机栈一致,存储native方法 - 堆
几乎所有的对象实例在这里生成,会发生GC - 方法区
存储类信息、静态变量、常量 会发生oom
栈上分配
什么是栈上分配:
几乎所有的对象实例,都是在堆上分配的,但存在部分例外,栈上分配就是这种除了堆上分配的例外。
栈上分配有什么好处:
不需要GC介入去回收这个对象,出栈即释放资源,可以提高性能,原理:由于我们GC每次回收对象的时候,都会触发Stop The World(停止世界),这时候所有线程都停止了,然后我们的GC去进行垃圾回收,如果对象频繁创建在我们的堆中,也就意味这我们也要频繁的暂停所有线程,这对于用户无非是非常影响体验的,栈上分配就是为了减少垃圾回收的次数
哪些对象可以栈上分配:
- 小对象(一般几十个byte),在没有逃逸的情况下,可以直接分配在栈上
- 大对象或者逃逸对象无法在栈上分配 (大对象多大?需要验证)
栈上分配需要有一定的前提:
- 开启逃逸分析 (-XX:+DoEscapeAnalysis)
逃逸分析的作用就是分析对象的作用域是否会逃逸出方法之外,在server虚拟机模式下才可以开启(jdk1.6默认开启) - 开启标量替换 (-XX:+EliminateAllocations)
标量替换的作用是允许将对象根据属性打散后分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。默认该配置为开启
什么逃逸状态下可进行栈上分配优化:
NoEscape、ArgEscape
GlobalEscape 状态下能否进行栈上分配优化:
// -Xmx10m -Xms10m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
// 逃逸分析出是GlobalEscape的逃逸状态,无法进行栈上分配优化。是否开启逃逸分析不会影响结果。
public class EscapeTest2 {
static class User {
private int id;
private String name;
}
private static User user;
public static void foo() {
user = new User();
user.id = 1;
user.name = "Alice";
}
public static void main(String[] args) {
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
foo();
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
}
参数为-Xmx10m -Xms10m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
关闭逃逸分析
结果为
[GC (Allocation Failure) 4709K->2661K(9728K), 0.0003478 secs]
[GC (Allocation Failure) 4709K->2661K(9728K), 0.0003017 secs]
此处省略n个gc信息
[GC (Allocation Failure) 4709K->2661K(9728K), 0.0003630 secs]
time: 1098ms
参数为-Xmx10m -Xms10m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
开启逃逸分析
结果为
[GC (Allocation Failure) 5065K->3017K(9728K), 0.0003543 secs]
[GC (Allocation Failure) 5065K->3017K(9728K), 0.0003543 secs]
此处省略n个gc信息
[GC (Allocation Failure) 5065K->3017K(9728K), 0.0003322 secs]
time: 1106ms
以上结果可以说明:逃逸分析出是GlobalEscape的逃逸状态,无法进行栈上分配优化。是否开启逃逸分析不会影响结果。
NoEscape 状态下是否开启逃逸分析对结果的影响:
// -Xmx10m -Xms10m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
// 逃逸分析出是NoEscape的逃逸状态,可进行栈上分配优化。开启逃逸分析和标量替换会减少gc,提升性能。
public class EscapeTest1 {
class User {
public int id;
public String name;
}
public void foo() {
User user = new User();
user.id = 1;
user.name = "Alice";
}
public static void main(String[] args) {
long beginTime = System.currentTimeMillis();
EscapeTest1 escapeTest = new EscapeTest1();
for (int i = 0; i < 100000000; i++) {
escapeTest.foo();
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
}
参数为-Xmx10m -Xms10m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
关闭逃逸分析
结果为
[GC (Allocation Failure) 4893K->2845K(9728K), 0.0003884 secs]
[GC (Allocation Failure) 4893K->2845K(9728K), 0.0003271 secs]
此处省略n个gc信息
[GC (Allocation Failure) 4893K->2845K(9728K), 0.0004020 secs]
[GC (Allocation Failure) 4893K->2845K(9728K), 0.0004676 secs]
time: 1049ms
参数为-Xmx10m -Xms10m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
开启逃逸分析
结果为
[GC (Allocation Failure) 2048K->716K(9728K), 0.0018283 secs]
[GC (Allocation Failure) 2764K->748K(9728K), 0.0005826 secs]
[GC (Allocation Failure) 2796K->748K(9728K), 0.0006814 secs]
time: 32ms
以上结果可以说明:逃逸分析出是NoEscape的逃逸状态,可进行栈上分配优化。开启逃逸分析和标量替换会减少gc,提升性能。
如何确定大对象界限,使其无法在栈上分配:
// -Xmx5m -Xms5m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
// 数组在长度64以上时会被认为是大对象,无法进行栈上分配优化。
public class EscapeTest5 {
public static void main(String[] args) {
int[] intArray;
long[] longArray;
Integer[] integerArray;
Test[] objectArray;
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
intArray = new int[64]; //不开启逃逸分析速度7749ms,开启速度18ms
//intArray = new int[65]; //不开启逃逸分析速度8144ms,开启速度8804ms
//longArray = new long[64]; //不开启逃逸分析速度14763ms,开启速度27ms
//longArray = new long[65]; //不开启逃逸分析速度15179ms,开启速度14764ms
//integerArray = new Integer[64]; //不开启逃逸分析速度7695ms,开启速度18ms
//integerArray = new Integer[65]; //不开启逃逸分析速度8120ms,开启速度8027ms
//objectArray = new Test[64]; //不开启逃逸分析速度8120ms,开启速度18ms
//objectArray = new Test[65]; //不开启逃逸分析速度8120ms,开启速度8008ms
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
public static class Test{
Integer a;
String b;
}
}
开启逃逸分析参数为:-Xmx5m -Xms5m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
实例化长度\类型 | int | long | Integer | Test |
---|---|---|---|---|
64 | 18ms | 27ms | 18ms | 18ms |
65 | 8804ms | 14764ms | 8027ms | 8008ms |
关闭逃逸分析参数为:-Xmx5m -Xms5m -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
实例化长度\类型 | int | long | Integer | Test |
---|---|---|---|---|
64 | 7749ms | 14763ms | 7695ms | 8120ms |
65 | 8144ms | 15179ms | 8120ms | 8120ms |
以上结果可以说明:数组在长度64以上时会被认为是大对象,无法进行栈上分配优化。
64是可以通过配置(感谢公司大佬帮忙找到)
HotSpot JVM上的一个默认限制是大于64个元素的数组不会进行逃逸分析优化。这个大小可以通过启动参数-XX:EliminateAllocationArraySizeLimit=n来进行控制,n是数组的大小。
int和Integer栈上分配的优化情况:
// -Xmx5m -Xms5m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
// int是直接分配在栈上的,基础数据类型不需要开启逃逸分析就分配在栈上;
// Integer在-128~127时,是缓存的对象,在范围外是重新实例化的对象,所以逃逸分析对重新实例化的对象有效
public class EscapeTest6 {
public static void main(String[] args) {
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
int x = 127; //不开启逃逸分析速度1ms,开启速度1ms
//int x = 128; //不开启逃逸分析速度2ms,开启速度1ms
//Integer x = 127; //不开启逃逸分析速度2ms,开启速度3ms
//Integer x = 128; //不开启逃逸分析速度661ms,开启速度5ms
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
}
以上对多种情况进行试验
- int是直接分配在栈上的,基础数据类型不需要开启逃逸分析就分配在栈上;
- Integer在-128~127时,是缓存的对象,在范围外是重新实例化的对象,所以逃逸分析对重新实例化的对象有效
标量替代(Scalar Replacement)
什么是标量:
标量是指不可分割的量,如java中基本数据类型和reference类型,相对的一个数据可以继续分解,称为聚合量
什么是标量替换:
如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换
如何进行标量替换:
如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量
标量替换需要有一定的前提:
- 开启逃逸分析 (-XX:+DoEscapeAnalysis)
逃逸分析的作用就是分析对象的作用域是否会逃逸出方法之外,在server虚拟机模式下才可以开启(jdk1.6默认开启) - 开启标量替换 (-XX:+EliminateAllocations)
标量替换的作用是允许将对象根据属性打散后分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。默认该配置为开启
什么逃逸状态下可进行标量替换优化:
NoEscape
锁消除(Synchronization Elimination)
什么是锁消除:
线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。
锁消除需要有一定的前提:
- 开启逃逸分析 (-XX:+DoEscapeAnalysis)
逃逸分析的作用就是分析对象的作用域是否会逃逸出方法之外,在server虚拟机模式下才可以开启(jdk1.6默认开启) - 开启锁消除(-XX:+EliminateLocks)
线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。默认该配置为开启
什么逃逸状态下可进行锁消除优化:
NoEscape、ArgEscape
怎样子的锁才能进行锁消除优化:
// -Xmx1m -Xms1m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+EliminateLocks
public class SynTest {
public static void main(String[] args) {
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
SynTest synTest = new SynTest();
synTest.addSyn();// 带synchronized,开启逃逸分析23ms,不开启逃逸分析156675ms
//synTest.add();// 不带synchronized,开启逃逸分析29ms,不开启逃逸分析27ms
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
public static void main1(String[] args) {
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
SynTest synTest = new SynTest();
for(int j = 0; j < 5; j++) {
synTest.addSyn();// 带synchronized,开启逃逸分析15676ms,不开启逃逸分析12756ms
//synTest.add();// 不带synchronized,开启逃逸分析3543ms,不开启逃逸分析4634ms
}
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
public synchronized void addSyn() {
int a = 0;
}
public void add() {
int a = 0;
}
}
结论:在循环内部实例化并调用带锁的方法,可以进行锁消除优化
(为什么循环外面就不可以了呢?)
锁消除对性能的影响:
java.lang.StringBuffer是一个使用同步方法的线程安全的类,非同步的java.lang.StringBuilder类来作为它的备选。这两个类都继承了包私有(注:简单来说就是没有修饰符的类)的java.lang.AbstractStringBuilder类,它们的length方法的实现也非常类似。
@Override
public int length() {
return count;
}
@Override
public synchronized int length() {
return count;
}
// -Xmx1m -Xms1m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+EliminateLocks
public class StringTest {
public static void main(String[] args) {
long beginTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
StringBuilder stringBuilder = new StringBuilder(); // 需在里面实例化
stringBuilder.length();// 不带synchronized,开启锁消除1562ms,不开启锁消除1530ms
/*StringBuffer stringBuffer = new StringBuffer();
stringBuffer.length();// 带synchronized,开启锁消除1577ms,不开启锁消除3707ms*/
}
long endTime = System.currentTimeMillis();
System.out.println("time: " + (endTime - beginTime) + "ms");
}
}
结论: 开启锁消除对带锁的StringBuffer,性能上有显著的提升。
疑问
- 标量替换和栈上分配的区别?
- 大对象不能分配到栈上的界限是由谁决定的?
- 锁消除的锁是什么程度的锁,在前面的StringBuffer的例子中,若将实例化StringBuffer放在循环外,开启锁消除不会提升性能,这里是不是出现锁加粗?