前言
Java中的对象,一般我们都理解为是放在堆内存上。实际上,是不一定的,JVM会先判断,是否能够在栈上分配,如果栈上分配失败,才会在堆上分配。
接下来,一起深入了解。
一、栈上分配示例
上代码:
arduino
public class AllotOnStackTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
private static void alloc() {
User user = new User();
user.setId(777);
user.setName("歪歪歪");
}
}
public class User {
private int id;
private String name;
public User(int id, String name) {
super();
this.id = id;
this.name = name;
}
// get set 略
}
我本机是Mac 64位操作系统,物理内存为8G。不设置任何JVM堆大小的参数,根据上一篇《如果不通过JVM参数指定堆大小,你知道默认值是多少吗?》,那么可以计算出, 默认初始堆大小=128MB,默认最大堆大小=2G。
user对象实例的大小,按《new一个对象时,JVM内部究竟藏了什么小秘密?》 篇,知道一个对象在内存的布局=对象头(Markword + kclass)+ 实例数据 + 对齐填充。
不考虑字符串内容字面量的大小,每次调用alloc
方法大约需要的堆内存空间大致如下:
arduino
对象头信息:12字节(MarkWord 8字节 + Klass 4字节)
实例数据:8字节(int字段 4字节 + String引用 4字节)
对象头 12字节 + 实例数据 8字节 = 20字节
对象需要以8字节的整数倍来对齐填充,所以需要再对齐填充4字节, 即最终结果为24字节。
那么,如上的代码调用了1亿次alloc()方法,如果是分配到堆上,大概至少需要1GB堆空间,前面计算出默认堆初始大小只有128MB,所以必然会触发GC。
我们来运行一下代码,看是否真的会触发GC。
测试一: 只加 XX:+PrintGC 输出GC日志
ruby
设置的JVM参数:-XX:+PrintGC
输出结果:
11
Process finished with exit code 0
发现没有任何GC日志输出 。发生了什么???
测试二 : 显式设置JVM堆大小,并且设置的很小,并且为了证明设置的JVM参数是否剩下,代码输出当前的堆大小,代码如下:
csharp
设置的JVM参数:-Xmx15m -Xms15m -XX:+PrintGC
public class AllotOnStackTest {
public static void main(String[] args) {
// 当前最大堆大小
long maxHeapSize = Runtime.getRuntime().maxMemory();
// 当前初始堆大小
long totalHeapSize = Runtime.getRuntime().totalMemory();
System.out.println("Max Heap Size: " + maxHeapSize / (1024 * 1024) + " MB");
System.out.println("Total Heap Size: " + totalHeapSize / (1024 * 1024) + " MB");
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("歪歪歪");
}
}
执行结果:
Max Heap Size: 15 MB
Total Heap Size: 15 MB
12
可以看出,我们设置的jvm堆大小已经生效,但是,依旧没有任何GC日志输出!
测试三: 同样的代码,使用如下参数:
ruby
-Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
或
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
执行结果:
Max Heap Size: 15 MB
Total Heap Size: 15 MB
[GC (Allocation Failure) 4096K->661K(15872K), 0.0016196 secs]
[GC (Allocation Failure) 4757K->677K(15872K), 0.0008550 secs]
[GC (Allocation Failure) 4773K->669K(15872K), 0.0010727 secs]
...
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0009095 secs]
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0009381 secs]
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0008605 secs]
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0010484 secs]
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0025930 secs]
[GC (Allocation Failure) 5327K->1231K(15872K), 0.0009968 secs]
1813
可以看到会疯狂输出大量GC日志,这到底怎么回事儿呢?
莫急,我们且来学习一下对象的逃逸分析及其栈上分配内存机制,问题将会揭晓。
二、对象栈上分配
我们通过JVM内存分配可以知道,在Java中,对象通常分配在堆上。当一个对象不再被引用时,它会成为垃圾对象,最终由垃圾回收器回收内存。如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。
为了减少临时对象在堆内分配的数量,JVM引入逃逸分析 的优化技术,用于确定对象的生命周期和对象是否可以从方法中逃逸到方法外部。如果发现一个对象不会逃逸,即它只在方法内部使用,那么JVM可以优化这个对象的分配方式。
当JVM通过逃逸分析确定一个对象不会逃逸时,它就可以尝试将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析 :就是分析对象动态作用域,用于确定对象的生命周期和对象是否可以从方法中逃逸到方法外部。例如作为调用参数传递到其他地方中。
我们来看下面的代码:
csharp
public User test1()
{
User user = new User();
user.setId(1);
user.setName("妲己");
//TODO 其他代码逻辑
return user; // 对象会作为返回值返回出去
}
public void test2() {
User user = new User();
user.setId(1);
user.setName("姬发");
//TODO 其他代码逻辑
}
如上的代码,很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定。test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
JVM对于这种情况,可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis
)来优化对象在内存分配位置,使其通过标量替换优先分配在栈上(栈上分配 ),JDK7之后默认开启逃逸分析 ,如果要关闭使用参数(-XX:-DoEscapeAnalysis
)。
标量替换 :通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。这种优化可以降低堆内存的压力,减少垃圾回收的开销,提高程序的性能。
标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量,通常是对象。而在JAVA中对象就是可以被进一步分解的聚合量,对象包含多个成员变量,这些成员变量可以单独存在,并且可以被拆解为标量或其他聚合量。聚合量在标量替换过程中会被拆解成标量,然后这些标量会在栈帧或寄存器上分配。
标量替换的目标是将聚合量拆解成标量,以充分利用栈帧或寄存器的空间,从而减少堆上对象的分配和垃圾回收的开销。
至此,应该就能理解我们最开始的代码实例的测试一 和 测试二,为何没有被触发GC的原因了。因为我当前JDK版本是JDK8,JVM默认开启逃逸分析。方法alloc()
中user
对象通过逃逸分析,显然不会逃逸到方法外部,所以user
对象是会被分配在栈上而不是堆上,虽然调用了1亿次,但每次方法执行结束,user
对象都会随着栈帧出栈一起被销毁和回收内存了。
csharp
private static void alloc() {
User user = new User();
user.setId(777);
user.setName("歪歪歪");
}
而测试三, 设置了如下的JVM参数后,发生大量GC:
ruby
-Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
是因为:
ruby
-Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations中
-XX:-DoEscapeAnalysis
表示关闭了逃逸分析;
ruby
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
-XX:-EliminateAllocations
表示关闭了标量替换
关闭逃逸分析 或 关闭标量替换,都将导致栈上分配失效,从而对象在堆内存内存上分配,当堆内存空间不足时,将触发GC。
结论:栈上分配依赖于逃逸分析和标量替换。
写到最后
今天主要学习了对象的栈上分配机制,来总个小结:
-
破除迷思:Java对象并不一定都是分配在堆内存上
-
逃逸分析:JVM通过逃逸分析(JDK7以后默认开启)来确定对象是否会被外部访问,如果否,将优先考虑先分配在栈上而不是堆上。
-
栈上分配:如果JVM确定一个对象不会逃逸,它可以选择将这个对象分配在线程的栈上而不是堆上。栈是线程私有的内存区域,当方法执行结束时,栈上的数据会被自动销毁。栈上分配依赖于逃逸分析和标量替换。
- 标量替换:就是将对象拆解为其成员变量。例如,如果一个对象包含整数、浮点数和其他基本数据类型的字段,那么这些字段将被单独分配到栈上。解决不会因为没有一大块连续空间导致对象内存不够分配的问题。
- 标量:标量即不可被进一步分解的量,JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等)。
- 聚合量:聚合量就是可以被进一步分解的量,通常是对象,JAVA中对象就是可以被进一步分解的聚合量,对象包含多个成员变量。
-
JVM参数:
- 逃逸分析:-XX:+DoEscapeAnalysis(JDK7以后默认开启)
- 标量替换:-XX:+EliminateAllocations(JDK7以后默认开启)
-
栈上分配性能优势:通过逃逸分析和栈上分配,JVM可以减少垃圾回收的频率和开销。这有助于提高应用程序的性能,特别是在存在大量临时对象的情况下,因为这些对象可以更快地释放,而不会给垃圾回收器带来过大的压力。