0%

Java逃逸分析

一般来说Java的对象都是在堆上生成,但是如果一个对象只在当前函数内被使用,那么它可以被分配在栈上。栈上的数据在函数返回时被回收,从而大大减轻了GC的压力。

定义

由Java Hotspot分析对象的使用范围是否只在当前函数内有效,然后控制其在堆分配空间的技术就叫逃逸分析(Escape Analysis)

逃逸分析的相关参数如下:

  • 开启逃逸分析:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

GC性能分析

接下来就一个具体例子测试逃逸分析是否能优化GC的性能。在IDEA中设置VM Option

  • 堆最大10m,最小也10m,来让其尽快进入GC状态:-Xmx10m -Xms10m
  • 开启GC的打印-XX:+PrintGC
  • 由于Java SE 6u23+之后默认开启了逃逸分析,这里需要先关掉它:-XX:-DoEscapeAnalysis

最终VM Option如下:-Xmx10m -Xms10m -XX:+PrintGC -XX:-DoEscapeAnalysis

运行一个不断创建Integer对象的函数:

public static void main(String[] args) {
while(true) {
Integer i = new Integer(114514);
}
}

由于关闭了逃逸分析,默认在堆上分配内存,内存会一直不够,一直GC。控制台不断打印GC的过程:
[GC (Allocation Failure)  6150K->4102K(9728K), 0.0002068 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002554 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002634 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0003115 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002779 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0003062 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002681 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002638 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002211 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0002427 secs]
...

还是把堆控制在10m,开启逃逸分析:-Xmx10m -Xms10m -XX:+PrintGC,再次执行刚才的方法:
[GC (Allocation Failure)  6150K->4102K(9728K), 0.0002779 secs]
[GC (Allocation Failure) 6150K->4102K(9728K), 0.0003062 secs]

一共只打印出两条GC记录,说明开启逃逸分析后的确可以优化GC。

对象逃逸状态

Java对象的逃逸状态主要有三种:

  1. 全局逃逸(GlobalEscape)
    即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
    • 对象是一个静态变量
    • 对象是一个已经发生逃逸的对象
    • 对象作为当前方法的返回值

(学过Rust对象生命周期的应该很熟悉)

  1. 参数逃逸(ArgEscape)
    即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。

  2. 没有逃逸
    即方法中的对象没有发生逃逸。

逃逸分析的应用

针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化。

  1. 锁消除

我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁。

例如:StringBufferVector都是用synchronized修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作。

锁消除的JVM参数如下:

  • 开启锁消除:-XX:+EliminateLocks
  • 关闭锁消除:-XX:-EliminateLocks

锁消除在JDK8中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。

  1. 标量替换与栈上分配

基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。

将聚合量的成员分解为分散的标量,这就叫做标量替换。

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。当函数返回时,栈上的数据会被直接回收,无需GC去堆里分析对象的引用关系了。

标量替换的JVM参数如下:

  • 开启标量替换:-XX:+EliminateAllocations
  • 关闭标量替换:-XX:-EliminateAllocations
  • 显示标量替换详情:-XX:+PrintEliminateAllocations

标量替换同样在JDK8中都是默认开启的,并且都要建立在逃逸分析的基础上。

总结:在平时开发过程中就要可尽可能的控制变量的作用范围了,变量范围越小越好,让虚拟机尽可能有优化的空间。

如下代码第二种实现就让sb对象没能逃逸:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

参考资料

Disqus评论区没有正常加载,请使用科学上网