首先思考一个问题,什么是堆和栈?我们经常听到“堆栈”的概念,那两者是一回事吗?不是的,栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,或者如何处理数据。而堆解决的是数据怎么存怎么放和怎么管理的问题。
比如我们定义了这样一个类:
- class School{
- Student student;
- Teacher teacher;
- }
我们经常说这里的student和teacher是对象的引用,其实就是说我们执行时如果用到了school对象,里面会有两个地址,这两个地址指向的就是堆中实际存储student和teacher的地址。
在我们的应用中,数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。这也意味着,在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,因此堆也是执行垃圾回收的重点区域。
堆针对一个JVM进程来说是唯一的,是所有线程共用的,一个JVM实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。而且这个堆区是在JVM启动的时候即被创建,其空间大小也就确定了,堆是JVM管理的最大一块内存空间,并且堆内存的大小是可以调节的。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。并且规定所有的对象实例以及数组都应当在运行时分配在堆上。
- public class SimpleHeap {
- private int id;//属性、成员变量
-
- public SimpleHeap(int id) {
- this.id = id;
- }
-
- public void show() {
- System.out.println("My ID is " + id);
- }
- public static void main(String[] args) {
- SimpleHeap sl = new SimpleHeap(1);
- SimpleHeap s2 = new SimpleHeap(2);
-
- int[] arr = new int[10];
- Object[] arr1 = new Object[10];
- }
- }
现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Young Generation Space 新生区 Young/New
又被划分为Eden区和Survivor区
Old generation space 养老区 Old/Tenure
Permanent Space 永久区 Perm
Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
Young Generation Space 新生区,又被划分为Eden区和Survivor区
Old generation space 养老区
Meta Space 元空间 Meta
有时候,我们会看到非常类似的名字,例如新生区、 新生代和 年轻代是一个东西 ; 养老区、 老年区和老年代是一个东西; 永久区 和永久代也是一样的东西。
堆空间内部结构,JDK1.8之前从永久代 替换成 元空间
Java中的堆是分成多种类型的,也称为代,为什么分代呢,为什么要这么复杂呢?不分代就不能正常工作了吗?其实不分代完全可以,那所有的对象都在一块,GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,因此性能不高。这就如同把一个学校的所有人不管年级和上什么课科,全都在一个教室进行,想象一下这是什么场景。
经研究,不同对象的生命大小不同,产生和消亡的时间不一样,因此执行一段时间之后,内容空间会变得支离破碎,例如windows自带的机械硬盘自带的磁盘整理会显示如下的问题。
另外,虽然不同对象的周期不同,但是70%-99%的对象是临时对象,也就是很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。如果多回收新生代,少回收老年代,性能会提高很多。
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
具体过程可以先看下图,其中Survivor区比较复杂,后面详细讨论:
具体过程是:
new的对象先放Eden区,此区有大小限制。
当Eden的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(MinorGC),Eden区中不再被其他对象引用的对象就是要被清理的,但是此时仍然有仍然在使用的,此时会将活的对象整理都移动到Survivor0区,之后Eden区就完全清空,可以继续存放新的对象了。
如果Eden区再次被放满了,则要再次执行垃圾回收,在此期间Survivor0区里有些对象也变成垃圾了。也就是Eden区和Survivor0区分别都有垃圾对象和存活对象。此时会将两者存活的对象都移动到Survivor1区。 这里为什么要移动到S1区呢?我们后面再解释。
上面S1区放入对象之后就变成S0区了,而原来的S0区会被全部抹掉,并变成S1区,然后会不断重复上述几个步骤。为什么会样我们也在后面详细解释。
S0区活的对象会在S0和S1之间反复啥时候能去养老区呢?可以设置次数,默认是15次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置。如果一个对象在s0和s1区之间反复移动15次都未被清楚,则会被移动到老年区。
在老年区,相对悠闲。当老年区内存不足时,再次触发GC:Major GC,进行老年区的内存清理。若老年区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
在上面的步骤中,虽然Eden区、Survivor区和老年区都有垃圾回收,但是具体的执行策略、效率和方法是不一样的,我们到后面垃圾回收章节再详细看。
现在开始解释上面第2~4步,Survivor区是怎么进行垃圾回收的,以及为什么要反复移动。
1、我们创建的对象,一般都是优先放在Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为 YGC / Minor GC操作。例如下图中Eden已经满了,红色是我们要被回收的垃圾对象,而绿色是仍然可以使用的对象。此时我们将1和5移动到S0区,而且此时1和5是紧密排列在一起的,没有间隙,这就避免了碎片问题。
在移动元素时,我们还给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加 1。完成该步骤之后相当于Eden区已经完全释放干净了,都可以存放新对象了。
2.随着JVM执行新程序,Eden区可能再次存满,此时又会触发MinorGC操作,此时GC将会把 Eden和Survivor From中的对象进行一次垃圾收集,把存活的对象放到 Survivor To(S1)区,同时让存活的对象年龄 + 1。
在上面操作的时候,当把对象从S0移动到S1之后,S0就被格式化了,变成S1,而上图中的S1则成为下一个S0,也就是说s0区和s1区在互相转换,而且只是变了一下名字,有对象的区域都是S0区。
3、我们继续不断的进行对象生成和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中。
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
为什么要设置两个Survivor区
这里可能感觉奇怪的是为什么要有两个S区,执行垃圾回收的时候时候为什么要移到另外一个,而不是清理自己呢?我们可以通过淘金的例子来解释,假如你要在沙漠里淘金,假如只有一个框,你只能将框的一角的沙子清理干净,然后放分离出来的金子。第二种是准备两个框,一个用来铲沙子,检出来的金子都放到另外一个框里,你觉得哪种更高效?自然是后者。
设置两个Survivor区还有一个很大的好处就是解决了碎片化,碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存就会变得非常困难。这就好比我们爬山的时候,背包里所有东西紧挨着放,最后就可能省出一块完整的空间放相机。如果每件行李之间隔一点空隙乱放,很可能最后就没法装相机了。而我们打包行李一般都是先将大的放好,最后在将小的放到缝隙里去,实在不行就将所有东西倒出来重新摆。这就是要解决碎片化问题,而设置两个Survivor区就是为了实现这种效果。
这里先介绍一下几种空间的大小关系,其中一般情况下的分区情况是:新生代和老年代各占1/3和2/3的堆空间。而新生代的from、to和Eden区在新生代的比例一般为1:1:8,但是该参数也是可以调整的:默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5。
验证一下:
- public class HeapTest {
-
- byte[]a=new byte[1024*100];
-
- public static void main(String[] args) throws InterruptedException {
- ArrayList
heapTests=new ArrayList<>(); - while (true){
- heapTests.add(new HeapTest());
- Thread.sleep(10);
- }
- }
- }
执行情况,此时会按到s1和S0交叉有内容,当一个满的之后会被清空,然后切换到另一个。此时仍然存活的对象会被放到Eden区,当Eden区满了之后,会再将存活的对象放到old区,因此Old区的则一直在增加。
当Old区也被放满了,会先触发full gc对整个堆和方法区进行垃圾回收,如果此时也无法起作用了,就会抛出异常并终止:
通过上面的分析,我们可以看到堆分配内存就像我们收拾房间,我们收拾主卧时,将里面的小东西集中放到次卧里。打扫完之后再收拾次卧时就将次卧里的小物件都收拾到主卧。看似复杂的过程,结合实际场景,就很容易理解了。