持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第20天,点击查看活动详情
前言
初学java内存结构的时候,我们都会被告诉栈中存局部变量、对象引用,堆中存放对象实例,《Java虚拟机规范》中对Java堆的描述也是:所有的对象实例以及数组都应当在运行时分配在堆上。但实际上对象是可以逃逸出堆的,这样也就引发一个问题:堆是分配对象存储的唯一的选择吗?
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述: 随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
所以Java对象真的可以逃逸。
逃逸分析
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
public class EscapeAnalysis {
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer demo1 = new StringBuffer();
demo1.append(s1);
demo1.append(s2);
return demo1;
}
public static String createString(String s1, String s2) {
StringBuffer demo1 = new StringBuffer();
demo1.append(s1);
dem1.append(s2);
return demo1.toString();
}
}
在上面两个方法中,根据逃逸分析可以看出createStringBuffer()
方法中demo1对象作为返回值逃出当前函数的作用域,发生了“逃逸”。createString()
方法中,将demo1对象转化为字符串作为返回值返回,demo1作用域仍在当前方法作用域中,没有发生“逃逸”。
基于逃逸分析的代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配。将堆分配转化为栈分配。如果一个对象没有发生“逃逸”,就可能将该对象优化为栈分配。
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
同步省略
线程同步本身比较耗费资源,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁。 我们还是通过案例来说明这一情况。
public static void f(){
Ticket ticket = new Ticket();
synchronized (ticket){
ticket.makeTicket();
}
}
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 500000; i++) {
f();
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 500000; i++) {
f();
}
}, "B").start();
}
然后在运行时配置VM options
-Xms60M -Xmx60M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
运行结果:
PSYoungGen total 17920K, used 7121K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
eden space 15360K, 46% used [0x00000000fec00000,0x00000000ff2f4718,0x00000000ffb00000)
from space 2560K, 0% used [0x00000000ffd80000,0x00000000ffd80000,0x0000000100000000)
to space 2560K, 0% used [0x00000000ffb00000,0x00000000ffb00000,0x00000000ffd80000)
ParOldGen total 40960K, used 0K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
object space 40960K, 0% used [0x00000000fc400000,0x00000000fc400000,0x00000000fec00000)
Metaspace used 4357K, capacity 4784K, committed 4992K, reserved 1056768K
class space used 486K, capacity 534K, committed 640K, reserved 1048576K
在f()
方法中针对新建的 hollis 对象加锁,并没有实际意义,经过逃逸分析后认定对象未逃逸,则会进行同步消除优化。JDK8 默认开启逃逸分析,我们尝试关闭它,再看看输出结果。
更改VM options
-Xms60M -Xmx60M -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+PrintGCDateStamps
2022-10-30T12:52:06.492+0800: [GC (Allocation Failure) [PSYoungGen: 15360K->1064K(17920K)] 15360K->1072K(58880K), 0.0015701 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 17920K, used 6393K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
eden space 15360K, 34% used [0x00000000fec00000,0x00000000ff1345c8,0x00000000ffb00000)
from space 2560K, 41% used [0x00000000ffb00000,0x00000000ffc0a020,0x00000000ffd80000)
to space 2560K, 0% used [0x00000000ffd80000,0x00000000ffd80000,0x0000000100000000)
ParOldGen total 40960K, used 8K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
object space 40960K, 0% used [0x00000000fc400000,0x00000000fc402000,0x00000000fec00000)
Metaspace used 4339K, capacity 4720K, committed 4992K, reserved 1056768K
class space used 486K, capacity 534K, committed 640K, reserved 1048576K
闭逃逸分析后,执行时间变长,且内存占用变大,同时发生了垃圾回收。
标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。 相对的,那些还可以分解的数据叫做聚合量(Aggregate) ,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的论若干个成员变量来代替。这个过程就是标量替换。 看下面这个例子。
public class ScalerTest {
public static void main (String [] args) {
alloc();
}
private static void alloc () {
Point point = new Point(1, 2);
System.out.println("point.x=" + point.x + "; point.y=" + point.y);
}
static class Point {
private int x;
private int y;
public Point(int x,int y){
this.x = x;
this.y = y;
}
}
}
以上代码经过标量替换就会变为
private static void alloc () {
int x = 1;
int y = 2;
System.out.println("point.x=" + x + "; point.y=" + y);
}
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢﹖就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。
标量替换参数设置∶ 参数-XX:+EliminateAlfocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。