我对垃圾回收的理解

159 阅读33分钟

本文的内容来自阅读《深入理解Java虚拟机》周志明第三版、《深入Java虚拟机:JVM G1 GC的算法与实现》中村成洋、《垃圾回收算法手册:自动内存管理的艺术》后总结的学习笔记。

垃圾回收概述

什么是垃圾

在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

垃圾回收移动过程

1.首先,任何新对象最初都是被分配到 eden 空间,两个survivor空间(S0 <=> from survivor、S1 <=> to survivor)最开始时都是空的。

2.当 eden 空间对象满时,将触发一个Minor GC。 

3.存活对象移动到第一个survivor空间(S0),年龄标记为1,然后清除 eden 空间。 

4.在第二次Minor GC中,eden空间也会发生同样的事情,eden中存活对象移动到一个空的survivor空间(S1),年龄记为1。而第一个survivor空间(S0)中的依旧存活的对象也移动到S1,同时这些对象的年龄再+1。一旦所有存活对象都被移动到S1后,S0和eden都会被清除。请注意,此时S1空间中有不同年龄的对象。

5.交换S0和S1的角色,新的S0就是原来的S1,也就是每次都从S0移动到S1中去。

6.在第三次Minor GC中,将重复相同的过程。Eden和S0中的存活对象都移动到S1,eden和S0被清除,交换S0和S1的角色。

7.重复前面的过程,在Minor GC 之后,当survivor 空间的对象达到某个年龄阈值(默认15)时,它们将从新生代晋升到老年代中。

8.随着Minor GC的不断发生,继续有对象被提升到老年代空间。

具体更细节参见[内存分配策略](www.yuque.com/docs/share/… 《5.内存分配策略》)

分代GC方式统一定义

  • Partial GC(部分收集):不是完整收集整个java堆的垃圾收集。
    • Minor GC/Young GC(新生代收集):只收集新生代的垃圾收集。
    • Major GC/Old GC(老年代GC):只收集老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。发生Major GC,通常会伴随着至少一次的Minor GC,而Major GC速度会比Minor GC慢十倍以上。另外,“Major GC”这个说法现在有点混淆了,不同资料上经常指的不同,还需根据上下文区分到底是老年代的收集还是整堆收集。
    • Mixed GC(混合收集):收集整个新生代和部分老年代的垃圾收集。目前只有G1收集器有这种行为。
  • Full GC(整堆收集):收集整个java堆和方法区的垃圾收集。

垃圾回收算法

标记阶段

判断对象存活有两种方式:引用计数和可达性分析

引用计数法

给对象添加一个引用计数器(在对象的对象头里保存一个整型的引用计数器属性),每当有一个对象引用它的时候,计数器+1,当引用失效时,计数器-1,任何时刻只要计数器是0那就表示这个对象是死亡对象。

优点:简单,高效,垃圾对象容易识别;

非致命缺点:需要单独的字段存储引用计数器,计数器会经常更新,增加一丢丢的空间和时间的开销;

致命缺点:循环引用,导致漏判(明明计数器的值不是0,但它却应该是死亡对象);

public class RefCountGC {
    //这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
    Object reference = null;
    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();
        obj1.reference = obj2;
        obj2.reference = obj1;
        obj1 = null;
        obj2 = null;
        //显式的执行垃圾回收行为
        //这里发生GC,obj1和obj2能否被回收?
        System.gc();
    }
}

可达性分析法

同样简单高效,且可以解决循环引用的问题,防止内存泄露。

基本思路:

以根对象集合为起点按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达。使用这个算法后,内存中存活的对象都会被根对象集合直接或间接连接着,搜索所走过的路径叫做引用链,如果对象没有任何引用链相连就是不可达的,意味着这个对象是死亡对象。被引用链连接的对象才是存活对象。

GC Roots是什么?

  1. 虚拟机栈中的引用的对象
public class Test {
    public static void main(String[] args) {
        Test test = new Test();
        test = null;
    }
}
  1. 本地方法栈中引用的对象
  2. 方法区中的静态属性引用的对象
public class Test {
    public static Test t;
    public static void main(String[] args) {
        Test test = new Test();
        test.t = new Test();
        test = null;
    }
}
  1. 方法区中常量引用的对象

public class Test {
    public static final Test t = new Test();
    public static void main(String[] args) {
        Test test = new Test();
        test = null;
    }
}
  1. 异常对象:nullpointexecption , outofmemoryerror;系统类加载器
  2. 被同步锁synchronized持有的对象
  3. 。。。。。。

小技巧:如果一个指针,它保存了堆内存中的一个对象的内存地址,且它不在堆内存里面,那它就是一个root。

finalize()

垃圾回收死亡对象之前,总会先调用这个对象的finalize()方法,且只会调用一次。这个方法可以被重写,重写可以使得该对象被复活。若对象的finalize方法没有被重写或者重写了没做任何操作,该对象会直接被回收。另外,永远不要主动调用这个方法(1.可能导致复活,2.如果写不好会影响GC,比如写个死循环,3.执行没有保障,极端情况下,如果不发生GC,就不会被执行),应该交给垃圾回收机制调用,finalize()方法对应了一个finalize线程,因为优先级比较低,即使主动调用该方法,也不会因此就直接进行回收。

清除阶段

怎么分配内存

内存规整时用——指针碰撞

已分配的内存和空闲内存分表在不同的一侧,通过一个指针指向分界点,当需要分配内存时,把指针往空闲的一端移动与对象大小相等的距离即可。

内存不规整用——空闲列表

已分配的内存和空闲内存相互交错,JVM通过维护一张内存列表记录可用的内存块信息,当分配内存时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录 。

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。

标记-清除算法

过程

标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收掉所有未被标记的对象。

缺点
  1. 空闲空间不连续,产生内存碎片,如果碎片太多,可能导致需要给大对象分配内存时无法找到足够的连续内存,从而不得不提前触发一次GC;
  2. 需要维护一个空闲列表来解决内存分配问题,内存分配时更加复杂;
  3. 执行效率不稳定,如果包含大量对象,那么必须进行大量的标记和清除,执行效率会随着对象数量增长而降低。

标记-复制算法

标记—复制算法简称复制算法

过程

将内存容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,将存活对象复制到另一块上面,然后再把已经使用过的那块内存空间一次清理掉。

适用场景

适合存活对象少,垃圾对象多的情况——新生代(IBM曾经专门做了一项研究对新生代“朝生夕死”的特点做了更量化的诠释:年轻代的对象中有98%熬不过第一轮GC)。如果存活对象多,会产生大量的内存复制的开销。

优点

简单高效,不会产生内存碎片

缺点

需要两倍的空间

标记-整理算法

过程

标记阶段和标记清除算法一致,之后会将存活对象移动到内存空间的一端,清理掉边界以外的地方。最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理

优点

不需要两倍的内存空间,不会产生内存碎片

缺点

效率最低,移动过程中,需要全程暂停应用程序(STW)

算法实现细节

根节点枚举

当要用可达性分析法进行标记对象之前,要先拿到所有的GCRoots,尽管目标很明确,但是GCRoots太多了犹如恒河沙数,如果一个不漏的挨个从方法区,栈区等查找GCRoots将是一个非常耗时的动作,且这段时间必须暂停用户线程,否则一边查找GCRoots,GCRoots一边发生变化,结果的准确性就无法保证。

所以既要暂停用户线程,又要花费很长时间去收集GCRoots,那怎么解决呢?用oopmap来高效实现

oopMap

oop就是普通对象指针,oopmap就是存放这些指针的集合,记录了所有指针的位置(注意:不是指针指向的内存地址)。

垃圾收集线程工作时会对方法区,栈区等内存进行扫描,看看哪些位置存储的是引用类型(对象类型)变量,如果是引用类型,那么它就是一个GCRoot,问题是,不是所有的变量都是引用类型的,只有一部分是引用类型的,那些非引用类型的对象收集线程压根不关心,但是还不得不将整个栈扫描一遍,这就会很耗时耗力。

能不能把栈上所有的引用类型的变量的位置全部记录下来,这样,收集线程直接去读取这个记录,不用再扫描整个栈区了。于是,Hotspot使用oopmap来记录引用类型变量的位置,以此来加快根节点枚举的速度。

怎么记录?

类加载完成后,会把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,在特定的位置(即安全点)使用OopMap记录下栈和寄存器哪些位置是引用。

但是问题又来了,在用户线程执行过程中,很多指令都会导致oopmap不断的变化,如果每次发生变化都要去修改oopmap,那又是一件成本很高的事,那怎么使oopmap不频繁的变化且又能保证oopmap的准确性呢?安全点

安全点

并不需要引用关系一发生改变就去更新oopmap,只要在GC之前更新一次就行,所以oopmap只需要在一些指定的代码位置上更新oopmap就行了。这些指定的代码位置就是安全点,由此也可以知道,程序并不是在任何位置上都可以进行GC的,只有在到达安全点时才能停下来GC。

那安全点怎么选取呢?安全点“的选择基本上是以程序”是否具有让程序长时间执行的特征“为标准来选定的。

循环的末尾,方法临返回前,方法调用,可能抛异常的位置

怎么让线程都跑到最近安全点?

1.抢先式中断

抢先式中断是先让所有的用户线程全部暂停,如果发现某个线程没有到达安全点,就让它重新运行一段时间再中断,直到它到达安全点为止。

2.主动式中断,目前都是主动式

主动式中断是设置一个标志位,线程运行时不断主动轮询这个标志位,如果发现标志位为真就在最近的安全点中断挂起,标志位和安全点是重合的,但是另外还要加上所有需要在堆上分配内存的地方,这是为了检查是否即将要发生垃圾回收,避免没有足够的内存分配对象。

但是问题又来了,用户线程处于Sleep或者Blocked状态,这时线程无法响应虚拟机的中断请求,不能再走到安全点的地方中断自己。安全区域

安全区域

安全点的使用似乎解决了OopMap计算的效率的问题,但是这里还有一个问题。安全点需要程序自己跑过去,那么对于那些已经停在路边休息或者看风景的程序(比如那些处在Sleep或者Blocked状态的线程),他们可能并不会在很短的时间内跑到安全点去。所以这里为了解决这个问题,又引入了安全区域的概念。

安全区域很好理解,就是在程序的一段代码片段中并不会导致引用关系发生变化,也就不用去更新OopMap表了,那么在这段代码区域内任何地方进行GC都是没有问题的。这段区域就称之为安全区域。线程执行的过程中,如果进入到安全区域内,就会标志自己已经进行到安全区域了。那么虚拟机要进行GC的时候,就不会管这些已经运行到安全区域的线程,当线程要脱离安全区域的时候,检查当前系统是否处于STW状态,如果处于 STW 则需要等待直至用户线程恢复。

记忆集Rset(remembered set)

记忆集与卡表是用来解决跨代引用问题的。

当年轻代发生垃圾回收的时候,被GCRoots所连接的对象可能在年轻代中也可能在老年代中,而我们只想收集年轻代不想收集老年代,所以没必要对老年代也进行全面的可达性分析,但问题是,确实可能存在老年代中的某些对象引用了年轻代中的某个对象,这个对象不能清除,那怎么快速判断哪些对象是这种对象呢?

事实上,对于位于不同年代的对象之间的引用关系,虚拟机会在程序运行过程中给记录下来,也就是说会在引用关系发生时,在年轻代边上专门开辟一块空间记录下来,这就是Rset。rset记录的就是年轻代被老年代引用的关系。所以,年轻代的GCRoots + Rset才是年轻代收集时真正的GCroots。

G1收集器也是使用了Rset这种技术:每一个region中都有一个与之对应的rset,在各个region上记录着自家对象被外面对象引用的情况,这样当进行可达性分析的时候,即保证了不对全堆进行扫描也不会有遗漏。

G1只在两个场景中依赖Rset:

    * 老年代到年轻代的引用
    * 老年代到老年代的引用

卡表

如果老年代和年轻代之前的引用关系很多,把每一个引用关系都记录在rset中,rset会占用很大空间。于是又引入了一个概念——卡表。

卡表是一个位(bit)集合,每一个比特位都可以用来标识老年代的某一个子区域(这个区域就是卡,G1是512字节)中的所有对象是否有持有年轻代对象的引用,这样就不用花大量时间扫描老年代对象了,想要确定每一个对象的引用,可以先扫描卡表,只有卡表标识为1,才需要扫描该区域的老年代对象,为0则一定不包含对年轻代的引用。

一般情况下,rset是一个map,key是region的起始地址,value是一个集合,集合中的元素是卡表的index。

《jvm g1 gc的算法与实现》中的记忆集与卡表的解释

每个区域都有一个转移专用记忆集合,它是通过散列表实现的。图中对象 b 引用了对象 a,因

此对象 b 所对应的卡片索引就被记录在了区域 A 的转移专用记忆集合中。

散列表的键是引用本区域的其他区域的地址,而散列表的值是一个数组,数组的元素是引用方的对象所对应的卡片索引。

对象 b 引用了区域 A 中的对象 a。因为对象 b不是区域 A 中的对象,所以必须记录下这个引用关系。而在转移专用

记忆集合 A 中,以区域 B 的地址为键的值中记录了卡片的索引 2048。因为对象 b 所对应的卡片索引就是 2048,所以对象 b 对对象 a 的引用被准确地记录了下来。

由此我们可以明白,区域间对象的引用关系是由转移专用记忆集合以卡片为单位粗略记录的。因此,在转移时必须扫描被记录的卡片所对应的全部对象的引用。

三色标记法

大前提:

那就是jvm 垃圾回收,它是明确知道自己要回收的是哪些内存区域。这里所说的哪些内存区域并不是简单的指年轻代或者老年代,而是指年轻代或老年代里那些被使用了内存空间(考虑对象分配有空闲列表或指针碰撞,因此很好推算出哪些区域被占用),也就是说,jvm明确知道自己要扫描标记的对象是哪些,在标记阶段,所有的对象都不会是新创建的。在上面那个重要的前提下,再来看三色标记法

G1和CMS都采用了三色标记法标记存活对象,按照是否访问过对象,将对象分成三种颜色:

  • 白色:未被扫描的对象(如果扫描完所有对象,最终还是白色的为垃圾对象)。
  • 灰色:对象本身被扫描完,但还没扫描完这个对象的属性引用的对象。
  • 黑色:该对象和它所有的子对象都扫描完了,确定是存活对象。

整个过程:

  1. 初始时所有对象都是白色的;
  2. 被GCRoots直接引用的对象变成灰色;
  3. 从灰色对象开始往下搜索
    1. 将灰色对象直接引用的对象变成灰色
    2. 将灰色对象本身变成黑色
  4. 重复步骤3,直到没有灰色对象
  5. 结束后只有两种黑色和白色对象,黑色就是存活对象,白色就是垃圾对象

但是三色标记法有个缺点,如果是STW,那么对象间的引用关系是不会变的,但是并发标记阶段用户线程也在继续运行,所以会存在多标或漏标的情况。

多标(又叫浮动垃圾):

假设此时我们遍历到了D对象,此时D被标记成了灰色

此时用户线程发生B取消了对D的引用

这时候B->D的引用没了,D应该是白色,但是因为先前D已经被标记成灰色了,所以D对象仍然会被当成存活对象遍历下去。最终结果:这部分对象仍然会被标记为存活对象,本轮GC不会回收他们的内存。这部分因为并发而造成的本应该回收但是没有回收的对象被称为"浮动垃圾",浮动垃圾不会影响应用程序的正确性,只需要等到下一轮GC到来就会被回收了。另外标记结束后又新增的对象,本轮也无法回收,也属于浮动对象。

漏标

假设GC线程已经遍历到D对象,此时D被标记为灰色

但是此时有代码执行:

Object E = D.next;
D.next = null;
B.next = E;

于是变成了这样

此时D到E的引用消失,B生成了对E的引用。当GC线程继续时,因为D已经没有了对E的引用,所以不会遍历到E,E也就不会标志为灰色,同时B已经标志为黑色了,不会再被遍历,那么也就导致E一直是白色的,最后被当成垃圾处理,这显然与事实不符,E是可达的,但是因为用户线程继续运行的影响导致漏标了E,使得E被当做垃圾回收了,明显影响了应用程序的正确性,这是不可接受的。

分析一下,漏标只有同时满足以下两个条件时才会发生:

1.灰色对象断开了白色对象的引用

2.黑色对象重新引用了白色对象

那怎么解决这漏标的问题呢?CMS采用了增量更新+写屏障的方法,G1采用了原始快照+写屏障的方法

增量更新

增量更新是破坏第二个条件 “黑色对象重新引用了该白色对象“,当黑色对象新增了对白色对象的引用关系时,利用写后屏障,将这个关系记录下来,等并发标记完后,再将这些记录下来的变化关系中的黑色对象为根重新扫描一次。对应着CMS的重新标记阶段。

原始快照

原始快照是破坏第一个条件”灰色对象断开了白色对象的引用“,当灰色对象要删除指向白色对象的引用关系时,利用写前屏障,就将这个要删除的引用关系(原来的旧引用)记录下来,存放在satb_mark_queue中,在并发扫描结束之后,再扫描这个队列,将这些记录过的引用关系中的灰色对象为根,重新扫描一次。但是,如果没有黑色对象再次引用这些白色对象时,那么本该当成垃圾的白色对象会在本次GC中继续存活,只能在下次GC中才能清理,这也是浮动垃圾。

写屏障

写屏障可以看做是虚拟机层面对“引用类型字段赋值”动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,赋值的前后都在写屏障的范围内,赋值前的部分的写屏障叫做写前屏障,在赋值后的部分的写屏障叫做写后屏障。增量更新与原始快照用的都用到了写屏障。

浮动垃圾

来源:

  1. 黑色对象断开了对灰色对象的引用,但灰色对象会被继续扫描,但明显灰色对象及子对象都是垃圾对象,这些是浮动垃圾。
  2. 原始快照(SATB)也会产生浮动垃圾,因为SATB指出,出于收集的目的,在并发标记(整个堆上的标记)开始时处于活动状态的任何对象都被认为是活动的。尽管并发标记过程中用户线程将他们变成死对象,SATB也会认为是存活的对象从而不会回收它们,这也是浮动垃圾。

浮动垃圾只能等到下次GC才能回收,那GC怎么知道哪些垃圾是这次新产生的?哪些垃圾是上次残留下来的浮动垃圾呢?tams指针

TAMS指针

垃圾回收器的实质是内存动态分配与自动回收技术的实现

收集器的两个重要性能指标

暂停时间

暂停所有应用程序线程,让GC线程执行的时间。

吞吐量

吞吐量 = 用户线程工作时间 /应用运行总时间

应用运行总时间 = 用户线程工作时间 + 垃圾回收线程工作时间

垃圾回收线程工作时间 = 并发执行垃圾回收时间(此时用户线程不暂停) + stw时间(此时用户线程暂停)

吞吐量的计算公式强调了在应用程序总运行时间中,用户线程得以执行实际工作的时间比率。一个高吞吐量的垃圾收集器意味着在大部分时间里,CPU资源被用于执行用户的业务逻辑,而不是花在垃圾收集上。

实际上,高吞吐量的垃圾收集器可能会导致较长的STW暂停时间,因为它们可能会选择在收集时暂停所有用户线程以尽快完成更多的垃圾收回工作。

而某些关注低延迟的垃圾收集器,如G1 GC,可能采取频繁但暂停时间短的收集策略,这样可以减少对用户体验的影响,但可能会降低吞吐量,因为CPU资源在并发收集时也被占用。

:::warning 说白了,暂停用户线程来进行垃圾收回 比 并发进行垃圾回收要快,虽然暂停时间长了一点,但是垃圾回收更容易,整体耗时更短,如果并发执行垃圾收回,虽然暂停时间变短,但由于用户线程还在不断工作,对象标记状态还在发生变化,所以整体收回时间更长。

因此,吞吐量大的收集器意味着垃圾回收线程工作时间短,但stw可能很长;暂停时间短的收集器意味需要并发执行垃圾回收,用户线程几乎不能暂停,但是垃圾回收的整体时间变长了。

思考:整体垃圾收回时间长了就长了呗,反正用户线程又不暂停,又不受影响。错,其实虽然用户线程没有暂停,但是由于要和垃圾收集线程一起工作,CPU资源必定被占用,用户程序的处理能力会变慢!

:::

7大垃圾回收器

收集器组合

红色的虚线,表示的是在JDK8中这种组合被废弃,但是还可以使用;在JDK9中就被移除了,完全不能使用这两种组合了。绿色虚线表示的是,JDK14中,这种组合被废弃了。

推荐组合:

    1. ParNew+CMS
    2. Parellel Scavenge+Parellel  Old

并发与并行收集器的区别

  • 并行收集器:同一时间,有多条垃圾收集线程在协同工作,用户线程在等待。
  • 并发收集器:同一时间,垃圾收集线程和用户线程都在运行,但是由于垃圾收集线程占用了一部分系统资源,用户线程处理的吞吐量将受到影响。

JVM运行模式(了解即可)

HotSpot内置两种编译器C1和C2

C1:编译时间短,优化策略简单

C2:编译时间长,优化策略复杂

HotSpot运行有两种模式来适用不同的场景:

  1. 客户端模式

对应参数:-client,使用C1编译器,特点:启动快,运行慢

  1. 服务端模式

对应参数:-server,使用C2编译器,特点:启动慢,运行快

注意:只有早期的32位的版本同时有客户端和服务端,很多版本本身就不再区分客户端模式和服务端模式,就只有服务端模式。

年轻代垃圾回收器

stw(Stop The World)

进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束

Serial

串行/单线程收集器

最早的一款,java诞生之初就有的,jdk1.3之前年轻代唯一的选择,采用复制算法(任何收集器都有stw机制,故不再专门提到)

单线程的意义:

    1. **<font style="color:rgb(64, 72, 91);">它只会使用一个处理器或一条收集线程去完成垃圾收集工作</font>**<font style="color:rgb(64, 72, 91);"> </font>
    2. **<font style="color:rgb(64, 72, 91);">在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束</font>**<font style="color:rgb(64, 72, 91);">。</font>

虽然古老,但是在客户端模式中仍然推荐使用,因为它高效简单,额外内存消耗最小的收集器。

ParNew

并行的年轻代GC,复制算法

除了它是并行的,和serial没有任何区别

只有parnew和serial可以和CMS搭配使用,而serial和cms的组合在jdk9被移除了。

Parallel Scavenge

并行的年轻代GC,复制算法。

和ParNew很相似,区别是:

1. 这个收集器可以控制<font style="color:rgb(64, 72, 91);">的吞吐量,又</font>被称为吞吐量优先垃圾回收器;

其他收集器(例如CMS)都是尽可能缩短用户线程停顿时间,而他是为了达到一个可控的吞吐量:它提供两个参数可以精准控制吞吐量

    1. 设置用户线程最大暂停时间
    2. 设置用执行户线程的总时间与垃圾收集总时间的比值
2. 有一个自适应调节策略;
    1. 有一个开关参数,开启后,不需要人工指定新生代大小(-Xmn),Eden与survivor的比例,晋升老年代对象大小等细节了,虚拟机会根据当前系统运行情况收集监控信息,动态调整这些参数以提供最合适的停顿时间或最大吞吐量

老年代垃圾回收器

Serial old

serial收集器的老年代版,串行回收老年代GC,采用标记-整理算法和stw机制,和serial收集器配套使用,

是CMS收集器的后预备方案。

另外:有时候你会在别的监控工具上看到PS MarkSweep这个名字,它底层真正做标记-整理工作的代码和serial old是共同一份代码。

Parallel old

是parallel scavenge的老年代版本,支持并行收集,基于标记-整理算法。

这个收集器是jdk6才提供的,在此之前,parallel scavenge一直处于尴尬地位,原因是选择了parallel scavenge,老年代除了serial old别无他选,而CMS又无法与它配和工作,直到parallel old 出现后,吞吐量优先的收集器终于有了名副其实的黄金搭档。

为什么CMS无法与parallel scavenge配和工作??

1. 目标不一致,CMS是面向低延迟的,parallel scavenge是面向高吞吐量的
2. 技术上,parallel scavenge没有使用HotSpot中原本设计的垃圾收集器的分代框架,而是选择了另外独立的实现。Serial,ParNew,CMS共用了这部分框架代码,在这个框架内的young gc和old gc可以任意搭配使用。

CMS(重点)

全称Concurrent Mark Sweep(采用标记-清除算法),一款以最短回收停顿时间为目标的并发老年代垃圾收集器,第一次实现了让用户线程和垃圾回收线程同时工作。CMS收集器在jdk5中发布,在jdk9被标记为过时了,jdk14被移除了。

工作过程:

  • 初始标记阶段:所有工作线程暂停,标记出GCroots能直接关联到的对象,速度很快;
  • 并发标记阶段:从GCroots直接关联的对象开始直接遍历整个对象图,耗时虽长但不需要暂停工作线程,可以与垃圾收集线程一起并发运行。
  • 重新标记阶段:这个阶段是为了修正并发标记期间,因工作线程继续工作而导致标记产生变动的那一部分对象的标记记录,这个阶段比初始标记阶段稍长,但也远低于并发标记阶段时间。
  • 并发清除阶段:清除掉被标记阶段判定为死亡的对象。由于不需要移动存活对象,因此可以和工作线程同时进行。这也是为什么采用标记-清除算法而不用标记-整理算法的原因。
  • 优点:并发收集,低停顿
  • 缺点:
    1. 对处理器资源敏感:在并发阶段,虽然不会导致用户线程停顿,但却因为占用了一部分处理器的计算能力,导致用户程序变慢了。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说如果处理器核心数在4个或以上,并发回收时垃圾收集线程只占用了25%的处理器运算资源,并且会随着处理器核心数的增加而下降。但是当处理器核心数不足4个时,CMS对用户线程的影响就变得很大了。
    2. 无法处理浮动垃圾:因为在并发标记和并发清除阶段,用户线程还在继续,程序自然会有新的垃圾对象产生,而且这些对象是在标记过程结束以后出现的,CMS无法在当次收集中清除掉它们,只能等待下一次收集时再清理掉,这就是浮动垃圾。
    3. 同样用户线程还在继续,那就必须预留足够内存空间给用户线程使用,所以说CMS不能像其他收集器那样等老年代快满了再收集,而是当堆内存使用率达到某一阈值时,便开始进行回收。jdk1.5时默认是老年代使用了68%就会激活CMS回收,这个有点偏保守,到了jdk1.6时,这个阈值提升到了92%,于是出现了另一种风险:如果CMS运行期间预留的内存无法满足新产生的对象,那就会出现一次‘并发失败’,这时虚拟机启动后预备方案,暂停用户线程执行,临时启用Serial Old重新进行老年代回收,这样停顿时间就很长了。
    4. 因为采用“标记-清除”算法就一定会产生内存碎片,内存碎片过多,当有大对象无法分配时,就会提前进行触发Full GC。为了解决这个问题,CMS提供了一个参数,用于在CMS不得不进行Full GC时开启内存碎片整理过程,内存碎片是问题是解决了,但是停顿时间又变长了,于是又提供了一个参数,用来设置当CMS进行了N次不整理空间的Full GC后,下一次进入Full GC前会先进行碎片整理,默认是0,表示每次进入Full GC前都会整理碎片。不过,这两个参数java9时就开始废弃了。

G1 (里程碑)

初步认识

  1. garbage first 简称G1,jdk9时是默认垃圾收集器;
  2. 从2004年sun公司发表第一篇关于G1的论文,直到2012年JDK1.7update 4发布,才诞生出可以商用的G1收集器。
  3. G1可以面向堆内存任何部分组成回收集进行回收。
  4. G1的各代存储地址是不连续的,每一代都使用了N个不连续但大小相同的区域region。每一个region可以根据需要,扮演新生代的Eden,survivor,或者老年代角色,G1可以对扮演不同角色的region采用不同的策略去处理。JVM最多可以有2048个Region,一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M。

  1. region中还有一类特殊的humongous区域专门用来存储大对象,G1认为只要大小超过了region容量的一半的对象即可判定为大对象。每个region的大小可以通过-XX:G1HeapRegionSize设定,取值范围1m-32m,且必须是2的n次幂。对于那些超过了整个region容量的超大对象,将会被存放在n个连续的humongous region中。
  2. G1可以建立可预测的停顿时间模型:它将region作为单次回收的最小单元,每次收集的内存空间都是region大小的整数倍,让G1收集器去跟踪每一个region里垃圾堆积的价值大小,价值是指回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定的允许收集停顿时间(-XX:MaxGCPauseMilis,默认200毫秒),优先处理回收价值最大的那些region,这也是它名字的由来。

工作过程

G1提供了两种工作模式:Young GC 和 Mixed GC

Young GC

它在Eden空间耗尽时会被触发,开始对Eden区进行GC,在这种情况下,Eden空间的对象移动到survivor空间,如果survivor空间不够,Eden的部分对象直接晋级到老年代空间,而原来的survivor区的对象移动到新的survivor中,也有一部分晋升到老年代,最终Eden空间为空,GC停止工作,应用线程继续执行。

但是还需考虑一个问题:跨代引用——年轻代的对象还可能存在被老年代对象引用

如果再去扫描整个堆会耗费大量时间,于是G1引入了Rset(remembered set)和卡表的概念。

Mixed GC
初始标记(stw)

所有工作线程暂停,标记出GCroots能直接关联到的对象,速度很快;(同CMS)

并发标记

GC 线程继续扫描在初始标记阶段被标记过的对象,完成对大部分存活对象的标记;

GC 线程和用户线程是并发执行,因为用户线程在执行过程中可能会改变对象之间的引用关系,所以如果只

采用一般的标记方法,可能会发生“标记遗漏”

最终标记(stw)

对上一步没有标记完的对象进行扫描

清理回收(stw)

计算各个region回收价值的大小,进行排序,

Shenandoah

最孤独的一款收集器,现代社会竞争激烈,连一个公司不同团队之间都会有“部门墙”,它是第一款不是由官方oracle也不是sun领导团队开发的收集器,不可避免的会受到官方的排挤,oracle仍然明确拒绝在oraclejdk12中支持shenandoah。它是由Redhat开发,2014年贡献给了openJDK。

TODO

ZGC

TODO

Epsilon

TODO