逃逸分析
:::warning
前提:逃逸分析目前是 Java 虚拟机中比较前沿的技术,并且还很不成熟,需要时间优化
参考博客:JVM对象逃逸
:::
什么是逃逸分析?
定义:编译器根据字节码信息分析对象是否发生逃逸的 [分析技术]{.red}
:::info
① 逃逸分析只是 [代码分析技术]{.red} 而不是 [代码优化技术]{.red}
② 逃逸分析是后续代码优化技术的前提,栈上分配、标量替换、同步消除都依赖于逃逸分析
:::
为什么要使用逃逸分析?
核心:[有效减少堆空间的内存分配压力,避免频繁的垃圾回收对用户线程造成的影响]{.red}
解释:
- 定义中提到逃逸分析只是分析技术,所以严谨来说逃逸分析是没有什么好处的,有好处的只是优化技术
- 但是因为逃逸分析是优化技术的前提,所以这里用逃逸分析指代优化技术
如何判断对象是否发生逃逸?
定义:
- [本方法创建的对象在外部被使用 或者 本方法使用外部方法传递的对象 都被视为对象发生逃逸]{.red}
- 本方法创建的对象在外部被使用:[内部对象逃逸出本方法]{.blue}
- 本方法使用外部方法传递的对象:[外部对象逃逸进本方法]{.blue}
- 只有本方法创建的对象不会被外部使用才认为没有发生逃逸
// 逃逸分析举例
public StringBuilder returnStringBuilder(){
// 其他方法可以引用本方法创建的对象了
return new StringBuilder("对象逃逸了!");
}
// 防止对象逃逸
public String returnStringBuilder()
{
// 思考:为什么这个就不算做逃逸了呢?明明返回了对象
// 回答1:创建的 StringBuilder 对象无法被其他方法引用了,所以这个对象是没有逃逸的
// 回答2:但是 toString 方法又会创建 String 对象,String 对象是可以被其他方法引用的,所以还是逃逸了
// 回答3:真正防止逃逸的方法只有不提供外界可引用的对象
return new StringBuilder("对象逃逸了!").toString();
}- [本方法创建的对象在外部被使用 或者 本方法使用外部方法传递的对象 都被视为对象发生逃逸]{.red}
三种逃逸状态
线程级逃逸(全局逃逸):类的 [成员变量]{.red} 引用方法的返回的对象
- [成员变量意味着无论变量是静态的还是非静态的,都认为是逃逸对象]{.blue}
- [成员变量可以被多个线程访问,所以称为线程级逃逸]{.blue}
public class EscapeAnalysis{
// 实例变量
private StringBuilder sb;
// 线程逃逸
public void threadEscape(){
// 多个线程都可以在这个方法中引用到这个对象::对象逃逸出线程
// 问题:如果给对象加锁,那么还算做逃逸吗?
// 个人观点:应该还是算的, 即使上锁也依然可以访问
this.sb = new StringBuilder("对象逃出线程了!");
}
}方法级逃逸(参数逃逸):
- [本方法将对象作为返回值返回,其余方法可以直接调用]{.blue}
- [本方法将对象作为参数传递到其他方法中,供其他方法使用]{.blue}
- 细节:每个线程引用的对象都是不可能一样的,因为每次都是新建对象,所以对于线程是不可见的,没有逃出线程
// 方法逃逸
// 情况1
public StringBuilder createStringBuilder()
{
return new StringBuilder("对象逃出方法了!")
}
// 情况2
// 这个对象显然并不属于该方法,而是属于其他方法的
// 对象逃逸进这个方法了
public void useStringBuilder(StringBuilder sb)
{
System.out.println(sb.toString());
}
// 对象逃逸出本方法
public void createStringBuilder()
{
// 传递参数
useStringBuilder(new StringBuilder("对象逃逸出方法了!"));
}不逃逸:[只要确保对象不会被其他方法或者线程引用,那么就是没有逃逸的]{.red}
public void noEscapeAnalysis()
{
StringBuilder sb = new StringBuilder();
sb.append("我是对象!");
sb.append("我没有逃逸!");
System.out.println(sb.toString());
}
结论:[尽可能多使用局部变量而不是成员变量,减少堆分配的压力]{.green}
没有逃逸的对象应该怎么处理?
核心:
- [没有发生逃逸 的对象就 可能 会被分配在栈空间上]{.red}
- [存放在栈帧中的对象随着栈帧的结束而被销毁]{.red}
方式:
栈上分配(Stack Allocation)
- 定义:[方法逃逸和没有逃逸的对象]{.red} 将会被分配在栈上而不是堆中
- 优点:[避免堆空间垃圾回收造成的性能消耗(诸如筛选回收对象、整理内存等),对象会随着栈帧的出栈而被销毁]{.red}
- 细节:[栈上分配并没有明确指明对象在栈中如何存储]{.blue}
标量替换(Scalar Replacement)
概念:
标量:无法进一步分解的数据类型都称为标量
int、double、float、引用类型等等都是无法继续分解的标量
聚合量:可以继续分解的数据类型称为聚合量
对象就是非常典型的聚合量
定义:[没有逃逸的对象 会被直接拆解为若干的成员变量存储在栈帧的局部变量表中]{.red}
- [采用标量替换后就不会创建对象了,而是改为创建各个成员变量]{.blue}
- 如果对象的拥有聚合量的成员变量,那么继续拆解就行
细节:[标量替换可以看做是栈上分配的一种具体实现]{.red}
实例:
public static class User
{
private int id;
private String name;
}
public static void main(String[] args)
{
// 创建对象
User user = new User();
// 标量替换:不会直接创建对象了,而是创建局部变量
user.id;
user.name;
}测试代码
// 优化方案: 标量替换
public static class User
{
private int id;
private String name;
}
// 由于对象没有逃逸,所以会采用标量替换的方式创建对象,所以创建消耗的时间也会较少
public static void createUser()
{
User user = new User();
user.id = 1;
user.name = "Fuyusakaiori";
}
// 虚拟机参数设置
// 第一次测试:关闭标量替换
// 第一次参数设置:-Xms100m -Xmx100m -XX:+DoEscapeAnalysis -XX:-EliminateAllocations -XX:+PrintGC
// 第二次测试:开启标量替换
// 第二次参数设置:-Xms100m -Xmx100m -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+PrintGC
public static void main(String[] args)
{
// 开始创建对象的起始时间
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000000; i++)
{
createUser();
}
long end = System.currentTimeMillis();
System.out.println("耗费时间: " + (end - start) + "ms");
}测试结果
- 第一次测试:[没有开启标量替换的情况下创建对象消耗的时间 4228ms,并且会不断进行垃圾回收]{.green}
- 第二次测试:[开启标量替换的情况下创建对象仅消耗 8ms,并且不会进行垃圾回收]{.red}
- 测试结论:标量替换没有真正创建对象,因为根本就没有打印垃圾回收的信息
同步消除(Synchronization Elimination)
定义:[方法逃逸和没有逃逸的对象 采用的同步措施会被 即时编译器 直接消除掉]{.red}
实例:
// 优化方案: 同步消除
// 原始代码
public void SynchronizationElimination()
{
// 每个线程进入该方法都会创建一个新的对象
// 所有线程根本不会访问到同一个对象,那本身就没有上锁的意义啊
User user = new User();
synchronized (user)
{
user.id = 1;
user.name = "Fuyusakaiori";
}
}
// 优化方案:
public void SynchronizationElimination()
{
// 消除同步机制
User user = new User();
user.id = 1;
user.name = "Fuyusakaiori";
}
:::warning
① 同步消除策略非常不好测试,所以不在此进行测试
② 而且感觉同步消除存在一定的问题,既然其余线程都无法访问这个对象,那为什么要上锁呢?根本就不会这么写,那优化什么
:::
逃逸分析是由谁来完成的呢?
- 宽泛地讲逃逸分析显然是由 [执行引擎]{.red} 完成的
- 具体地来讲执行引擎中细分为解释器和即时编译器,逃逸分析实际是由 [C2 即时编译器(服务器端)]{.red} 执行的
不成熟的逃逸分析带来的缺陷
缺陷
[逃逸分析的成本非常高]{.red}
[无法保证经过逃逸分析后的优化效果能够高于逃逸分析的成本]{.red}
极端情况:经过逃逸分析之后发现几乎所有对象都发生了逃逸,那么就浪费了这次分析
细节:[逃逸分析在 JDK 6 以前都是默认不开启的,直到 JDK 7 之后才默认开启]{.blue}
逃逸分析相关参数
- [-XX:+DoEscapeAnalysis:开启逃逸分析 / -XX:-DoEscapeAnalysis:关闭逃逸分析]{.blue}
- [-XX:+EliminateAllocations:开启标量替换 / -XX:-EliminateAllocations:关闭标量替换]{.blue}
- [-XX:+EliminateLocks:开启同步消除 / -XX:-EliminateLocks:关闭同步消除]{.blue}
:::warning
① 两条无法使用的命令:-XX:+PrintEliminateAllocations(打印标量替换结果),-XX:+PrintEscapeAnalysis(查看逃逸分析结果)
② 这两条命令在《深入理解虚拟机》中提到了,但是实际没有办法使用,只可以在调试版本的虚拟机中可以使用
// 虚拟机创建错误 |
:::
回到之前那个问题:对象只能够在堆中分配吗?
- 先回答这个答案吧,[Java 对象目前只能够在堆中分配]{.red}
- 你可能想说,哎,不是有栈上分配技术和标量替换技术吗?为什么还是只能够在堆中分配呢?
- [原因①:Java 虚拟机目前并没有实现栈上分配的技术,而是采用了诸如标量替换这种替代方案]{.green}
- [原因②:标量替换并没有去创建对象,而是创建了对象包含的成员变量,这些成员变量又都是标量,所有压根就没有创建对象]{.green}
- 结论:所以最终依然认为 Java 对象目前仅能够在堆中分配,但是随着 Valhalla 项目的进行,说不定以后就可以真的在栈上分配了
尾声(摘自《深入理解虚拟机》)
Java 对于栈上分配的技术有着明显的弱势
[但是 C/C++ 天生就支持栈上分配,只要不使用 new 关键字创建对象就好了]{.green}
[对于 Java 的竞争者 C# 来说也是支持栈上分配的]{.green}