一般来说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对象的逃逸状态主要有三种:
- 全局逃逸(GlobalEscape)
即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
(学过Rust对象生命周期的应该很熟悉)
参数逃逸(ArgEscape)
即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。没有逃逸
即方法中的对象没有发生逃逸。
逃逸分析的应用
针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化。
- 锁消除
我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁。
例如:StringBuffer
和Vector
都是用synchronized
修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作。
锁消除的JVM参数如下:
- 开启锁消除:
-XX:+EliminateLocks
- 关闭锁消除:
-XX:-EliminateLocks
锁消除在JDK8中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。
- 标量替换与栈上分配
基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。
将聚合量的成员分解为分散的标量,这就叫做标量替换。
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。当函数返回时,栈上的数据会被直接回收,无需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();
}
参考资料