分代收集理论

现在大多数的垃圾回收都是基于分代理论设计的,不同厂商对于内存的操作细节都不太一样,所以这里不讨论具体垃圾回收算法的细节,只是介绍垃圾回收算法的一些思想。

分代收集虽然是一套理论,但是却是符合大多数程序运行的经验法则,它建立在以下两个假说之上:

  • 弱分代假说
    绝大多数对象都是朝生夕灭的
  • 强分代假说
    熬过了多次垃圾回收的对象,一般更难以消亡。

垃圾回收分类

分代收集理论将内存(JAVA堆)分为了若干个区域,这样垃圾回收的时候就可以针对不同的区域进行回收,依据回收区域的不同,JAVA垃圾回收可以分为:Minor GC(Young GC)、Major GC(Old GC)和Full GC三种。由于不同区域存储对象的生存和消亡特征的不同,他们所使用的垃圾回收算法也不同,按垃圾回收算法划分,又可以分为以下几类:标记-删除算法、标记-复制算法、标记-整理算法。

按算法划分

  • 标记-删除算法
    标记-删除算法是最基础的收集算法,他主要分为两个步骤:标记哪些活着的对象然后删除不用的对象(也可以反着来)。
    标记-删除算法有两个很明显的缺点,一个是随着标记对象的增多,效率会越来越低,第二个就是删除对象之后,产生了很多碎片化的空间,不便于后面对象的存储。

  • 标记-复制算法
    为了解决标记-删除算法执行效率低的问题,所以出现了标记-复制算法,也称为半区复制算法。复制算法的原理是把内存分为大小相同的两片区域,每次把活着的对象复制到其中的一片内存区域中,然后删除另一片内存区,这样做提高了垃圾回收的执行效率,但是也很明显的浪费了一半的内存空间。
    标记-复制算法一个很经典的场景就是用在了HotSpot的Survivor区,我们知道新生代区域分为了Eden、From Survivor和To Survivor区,默认情况下Eden: From Survivor: To Survivor = 8:1:1 ,这两个Survivor区就是使用了标记-复制算法。也正是因为使用了标记-复制算法,所以新生代区域的可用内存大小为Eden+1个Survivor区的大小。

  • 标记-整理算法
    标记-清除算法和标记-整理算法最大的区别在于前者是一个非移动式的回收算法,后者是一个移动式的回收算法。在标记阶段,把存活的对象向内存的一端移动,清理掉边界以外的数据。

    移动存活的对象,其实是一个风险很大的操作,同时还需要暂停用户的所有操作(Stop The World)。
    如果移动对象,垃圾回收的过程会比较复杂,因为要把所有活着的对象移动到内存的一端,更新所有对象的引用,但是新对象分配内存空间时就比较简单。
    如果不移动对象,垃圾回收时几乎不会停顿(停顿时间很短),但是在新对象分配内存空间时就比较复杂。
    由于内存的分配和访问比垃圾回收器执行的频率高很多,所以整体来看,移动对象还是比较划算的。

按回收区域划分

  • Minor GC/Young GC
    目标:目标只是新生代的垃圾收集。
    时机:我们知道新生代又分为Eden、From Survivor和To Survivor三个区域(如下图所示),当Eden无法装下新的对象的时候,就会触发Minor GC(Young GC),Minor GC的逻辑是对Eden和From Survivor中的对象进行一次可达性分析,找到活跃的对象,然后将活跃的对象复制到To Survivor区(如果To Survivor空间不足,数据将会被放在老年代中),最后清空Eden和From Survivor区,同时,原来的To Survivor将会变成From Survivor。

  • Major GC/Old GC
    目标:目标只是老年代的垃圾回收。目前只有CMS收集器会有单独收集老年代的行为。
    时机:如果存入老年代的对象太大,就会触发老年代的GC行为(CMS收集器可能会单独进行老年代的GC),GC过后如果对象可以存储进来就OK,如果不能存储进来,程序就要OOM了。
    需要说明的是,这里只能说是触发了老年代的GC行为,至于是Major GC还是Full GC,这里不做详细的区分(很多人也都认为Major GC等同于Full GC),但是有一点可以确定的是Full GC是针对整个堆和方法区的垃圾回收行为。

  • Mixed GC
    混合收集,目标是收集整个新生代和部分老年代的垃圾回收行为,目前只有G1收集器有这种行为。

  • Full GC
    目标:是针对整个JAVA堆和方法区的垃圾回收。
    时机:简单来说,当进入老年代的对象大于老年代的剩余空间时就会触发Full GC。下面是4种可以触发Full GC的具体情况:

    1. 显式调用System.gc()Runtime.getRuntime().gc()
    2. 当老年代内存使用率达到一定阈值(可参数配置)
    3. 空间分配担保。当Minor GC前会先检查老年代的连续可用空间是否大于新生代所有对象的总空间,如果小于新生代所有对象的总空间,则检查是否允许担保失败(通过虚拟机参数:HandlePromotionFailure设置),如果不允许则直接触发Full GC,如果允许,则检查老年代最大连续可用空间是否大于新生代历次晋升到老年代的平均对象大小,如果小于,则直接触发Full GC。
    4. 元空间不足进行扩容时,达到了最大限度,也会触发Full GC。

垃圾回收演示

如果出现了一个大对象,触发Minor GC之后,大对象是存在Eden区还是老年代呢?不同的垃圾回收器的处理方式是不同的,下面我们通过代码来演示一下Serial和Parallel Scavenge这两个GC收集器的差异(Serial收集器已经要逐渐退出历史舞台了,目前我电脑上的JAVA8默认使用的是Parallel,这里对比这两个收集器一方面是为了测试不同的收集器提供一个思路,另一方面也是为了演示如何查看GC日志)。

假设我们的Eden区大小为8M,一个Survivor的大小为1M,老年代的大小为10M,这时我们需要创建4个对象,大小分别为2M、2M、2M、5M,观察GC的情况。

  • Serial收集器

首先,我们什么都不做,写个空方法来看看程序运行情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author Charlie
* @description 测试Minor GC
* @date 2021/1/23
*/
public class MinorGCTest {

/**
* VM参数 -verbose:gc -Xmx20m -Xms20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
* @param args
*/
public static void main(String[] args) {

}
}

此时的运行结果为:

1
2
3
4
5
6
7
8
9
10
11
Heap
def new generation total 9216K, used 2047K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 24% used [0x00000007bec00000, 0x00000007bedffce8, 0x00000007bf400000)
from space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
to space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
tenured generation total 10240K, used 0K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 0% used [0x00000007bf600000, 0x00000007bf600000, 0x00000007bf600200, 0x00000007c0000000)
Metaspace used 3143K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 346K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

我们什么都没有做,可以看到Eden区的大小为8192K,使用了24%(这里先不去探究这24%是什么内容),两个Survivor和老年代都是空的。

猜测:如果按之前的设想,前三个对象创建完毕之后,在创建第四个对象的时候,程序会进行一次Minor GC(因为Eden最多只能申请到6M的内存,Survivor区太小也放不下一个对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* VM参数 -verbose:gc -Xmx20m -Xms20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
* @param args
*/
public static void main(String[] args) {

int _1M = 1024 * 1024;

byte[] a,b,c,d;
a = new byte[2 * _1M];
System.out.println("create a");
b = new byte[2 * _1M];
System.out.println("create b");
c = new byte[2 * _1M];
System.out.println("create c");
d = new byte[5 * _1M];
System.out.println("create d");

}

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create a
create b
create c
[GC (Allocation Failure) [DefNew: 8027K->479K(9216K), 0.0090355 secs] 8027K->6623K(19456K), 0.0090701 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
create d
Heap
def new generation total 9216K, used 5923K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 66% used [0x00000007bec00000, 0x00000007bf1512e0, 0x00000007bf400000)
from space 1024K, 46% used [0x00000007bf500000, 0x00000007bf577d10, 0x00000007bf600000)
to space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
tenured generation total 10240K, used 6144K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 60% used [0x00000007bf600000, 0x00000007bfc00030, 0x00000007bfc00200, 0x00000007c0000000)
Metaspace used 3191K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 354K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

果然如猜测的一样,创建第四个对象之前,由于新生代内存不足,触发了程序的Minor GC,GC之后,新生代的内存由8027K->479K。此时,老年代的使用率为60%,那么,结合程序的实际情况,我们基本上可以推测出,当需要创建第四个对象(5M)的时候,由于触发了Minor GC,导致了三个2M的对象被转移到了老年代,所以老年代的使用率为60%,由于新生代进行了Minor GC之后,Eden的空间被清空(转移到了老年代),所以第四个5M的大对象被放在了Eden中。

这为什么可以确定是Minor GC而不是Full GC呢?下面是老年代GC和Full GC的日志:

1
2
[GC (Allocation Failure) [DefNew (promotion failed) : 6867K->6821K(9216K), 0.0026594 secs][Tenured: 7168K->7168K(10240K), 0.0019810 secs] 10963K->10675K(19456K), [Metaspace: 3244K->3244K(1056768K)], 0.0046660 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [Tenured: 7168K->7168K(10240K), 0.0014440 secs] 10675K->10657K(19456K), [Metaspace: 3244K->3244K(1056768K)], 0.0014594 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

老年代GC的时候,会有Tenured: 7168K->7168K(10240K), 0.0019810 secs (从数据上看,这里回收老年代的时候,没回收到任何数据,后面会发生OOM),Full GC时,会有明显的Full GC字样。

  • Parallel Scavenge收集器

更换Parallel Scavenge收集器(代码保持不变,只是更新虚拟机参数-verbose:gc -Xmx20m -Xms20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseParallelGC),空跑一次,Eden 区的使用率和空跑时使用Serial收集器时一样,都是24%,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author Charlie
* @description 测试Minor GC
* @date 2021/1/23
*/
public class MinorGCTest {

/**
* VM参数 -verbose:gc -Xmx20m -Xms20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseParallelGC
* @param args
*/
public static void main(String[] args) {

}
}
1
2
3
4
5
6
7
8
9
10
11
Heap
PSYoungGen total 9216K, used 2047K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
eden space 8192K, 24% used [0x00000007bf600000,0x00000007bf7ffce8,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
to space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace used 3143K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 346K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

下面我们同样创建和上文一样的4个大对象,看看结果会怎么样?

同样的代码(换了收集器),运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
create a
create b
create c
create d
[GC (Allocation Failure) [PSYoungGen: 8192K->688K(9216K)] 13312K->5816K(19456K), 0.0009297 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 9216K, used 852K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
eden space 8192K, 2% used [0x00000007bf600000,0x00000007bf6290e8,0x00000007bfe00000)
from space 1024K, 67% used [0x00000007bfe00000,0x00000007bfeac010,0x00000007bff00000)
to space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen total 10240K, used 5128K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
object space 10240K, 50% used [0x00000007bec00000,0x00000007bf102010,0x00000007bf600000)
Metaspace used 3285K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 361K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

可以很明显的发现,创建第四个对象之前,程序没有进行GC,这也就是说第四个对象是直接放在了老年代进行存储。

程序结束后,系统自动进行了一次Minor GC,此时清理了新生代,老年代的使用率还是50%(这个50%也更加验证了第四个对象是被直接存在了老年代)。