一、Serial GC与Serial Old GC收集器
- Serial收集器
Serial垃圾收集器可以说是最基础的、历史最悠久的垃圾收集器。在JDK1.3.1之前可以说是虚拟机新生代唯一的选择。顾名思义,该收集器是一个单线程工作的垃圾收集器,它属于新生代的垃圾收集器,采用标记-复制算法。注意,这里的“单线程”并不仅仅是说它只会使用一个处理器或者一条收集线程去完成垃圾收集工作,更重重要的是强调它在进行垃圾收集的时候,必须暂停其他所有的工作线程,也就是我们说的"Stop The World"。
这种收集器在如今看起来似乎有点鸡肋了,但事实上它也有优于其他收集器的地方。它简单高效,对于内存资源受限的环境,它是所有收集器中额外消耗内存最小的,对于单核或处理器核心数较小的环境来说,该收集器由于没有线程交互的开销,所以可以获得很高的收集效率。该收集器对于运行在客户段模式下的虚拟机来说是一个很好的选择。
- Serial Old收集器
Serial Old垃圾收集器是Serial收集器的老年代版本,同样也是一个单线程的垃圾搜集器,采用了标记整理算法。该收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。在服务端模式下,也可能有以下两种用途:
(1)在JDK1.5及以下的版本中于Parallel Scavenge收集器搭配使用
(2)做为CMS收集器发生失败时的后备预案,在并发收集时发生Concurrent Mode Failure时使用。
如图,我们发现,用户线程到达Safepoint的时候才会进行GC,那么什么是Safepoint呢?
在讲Safepoint之前,我们得先了解一下有关根节点枚举的问题:
到现在为止,所有垃圾收集器在根节点枚举这个步骤时都是必须暂停用户线程的,因此根节点的枚举肯定会面临“Stop the world”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但是根节点的枚举始终还是必须在一个能保障一致性的快照中才得以进行,也就是说在分析的过程中不能出现对象的引用关系还在不停变化的情况。哪怕是号称停顿时间可控,或者几乎不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点的时候也是需要停顿的。
由于目前主流的Java虚拟机都是准确式垃圾收集(所谓准确式指的是准确式内存管理,虚拟机可以知道内存中某个位置的数据具体是什么类型),所以当用户线程停顿下来后,其实并不需要一个不漏的检查完所有执行上线文和全局引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象的引用的。
在HotSpot虚拟机的解决方案中,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载完成的时候,HotSpot虚拟机就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样垃圾收集器在扫描的时候就可以直接得知这些信息了,并不需要真正一个不漏地从GC Roots开始查找。
那么咱们回过来说一下什么是安全点(Safepoint):
OopMap的出现可以使虚拟机快速准确的完成GC Roots枚举,但是引用关系的变化说这说导致OopMap内容发生变化的指令会非常多,如果为每一条指令都生成对应的OopMap将会消耗大量的额外空间。所以HotSpot并么有这样做,而是只是在特定的位置记录这些信息,而这些位置就是“安全点”。
二、ParNew 收集器
ParNew收集器是一款新生代的垃圾收集器,实质上就是Serial收集器的多线程并行(这里的并行描述的事实多条垃圾收集器之间的关系,说明同一时间有多条线程在协同工作,通常默认此时用户线程处于等待状态)版本,也是采用的标记-复制算法,在激活CMS(使用-XX:+UseConcMarkSweepGC)后的默认新生代收集器。
从JDK9开始,ParNew配合CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。由于官方希望它能完全被G1取代,甚至取消了ParNew加Serial Old以及Serial加CMS这两种组合。而且直接取消了-XX:+UseParNewGC参数。这样,ParNew和CMS只能互相搭配使用了。可以理解为ParNew收集器就此合并进了CMS,成为了它专门处理新生代的组成部分。
ParNew收集器虽然采用多线程并行进行垃圾收集,但是在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,改收集器在通过超线程技术实现的伪双核处理器中都不能百分百保证超越Serial收集器。
三、Parallel Scavenge收集器
Parallel Scavenge收集器和parNew收集器非常的类似,也是一款新生代的垃圾收集器,采用标记-复制算法实现,并行处理垃圾收集工作。Parallel Scavenge与其它收集器的关注点不同,目标是达到一个可控的吞吐量(也就是处理器用于运行代码的时间与总消耗时间的比值),所以该收集器也被称为“吞吐量优先收集器”。 运 行 用 户 代 码 时 间 运 行 用 户 代 码 时 间 + 运 行 垃 圾 收 集 时 间 \frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间} 运行用户代码时间+运行垃圾收集时间运行用户代码时间
该收集器在Parallel Old出现前,非常尴尬,因为只要选择了该收集器,老年代就只能选择Serial Old(这里其实在Parallel Scavenge收集器本身的架构中本身有PS MarkSweep收集器来进行老年代收集,只是PS MarkSweep与Serial Old的实现几乎一样)
Parallel Scavenge提供了两个参数来精确的控制吞吐量。
-
-XX:MaxGCPauseMillis
该参数允许的值是一个大于0的毫秒数,收集器将尽可能保证内存回收所花费的时间不超过用户设定的值。这个值不能调的过小,因为垃圾收集停顿时间的缩短是以牺牲吞吐量和新生代空间为代价的。
-
-XX:GCTimeRatio
该参数的值为一个正整数,表示用户期望虚拟机在GC上的时间不超过程序运行时间的 1 1 + n \frac{1}{1+n} 1+n1,默认为99,含义是尽可能保证应用程序执行的时间为收集器执行时间的99被,也就是收集器的时间消耗不超过总运行时间的1%。
该收集器还有一个参数比较重要:
- -XX:+UseAdaptiveSizePolicy
这是一个开关参数,当激活后,就不会要人工指定新生代大小、Eden区与Survivor区的比列、晋升老年代堆小的大小等比较细节的参数了,虚拟机回根据当前系统运行情况收集性能监控信息,动态调整这些参数用以提供最合适的停顿时间和吞吐量。这种也被称为“自适应调节策略”
四、Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并行收集。采用标记-整理算法实现。这个收集器是直到JDK1.6才开始提供。该收集器出现后“吞吐量优先”收集器才终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源比较紧缺的场合,都可以优先考虑Parallel Old加上Parallel Scavenge这个组合。
五、CMS(Concurrent Mark Sweep)收集器
该收集器是一种以获取最短时间停顿为目标的收集器。我们现在很多的应用都比较关注服务的响应速度,希望系统停顿时间尽可能的短,以给用户带来良好的交互体验,CMS就非常符合这类应用的需求。该收集器是基于标记-清除算法实现的,它的运行过程分为四个步骤:
- 初使标记
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,但是需要暂停用户线程。
- 并发标记
这个阶段其实就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时比较长但是不需要停顿用户线程,可以与用户线程一起并发运行。
- 重新标记
这个步骤是为了修正并发标记期间,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段也是需要暂停用户线程,但是这个停顿时间通常会比初始标记阶段稍微长一些,但是也远比并发标记的时间短。
- 并发清除
这个阶段是清理删除掉标记阶段判断的死亡的对象,由于不需要移动存活的迹象,所以这个阶段也是可以和用户线程并发运行的。
CMS收集器由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体来说,CMS收集器的内存回收过程是与用户线程并发执行的。可以说这是一款“并发低停顿的垃圾收集器”。当然,这款垃圾收集器也有如下几点比较明显的缺点:
- 对处理器资源非常敏感
在并发阶段,它虽然不会导致用户线程停顿,但是却会因为占用了一部分线程而导致引用程序变慢,降低总吞吐量。CMS默认启动的回收线程数为 处 理 器 核 心 数 量 + 3 4 \frac{处理器核心数量+3}{4} 4处理器核心数量+3,也就是说,如果处理器核心数在4个或以上,并发回收时垃圾收集线程只占用少于25%的处理器运算资源,并且会随着处理器核心数量的增加而下降,但是当处理器核心数量不足4个时,CMS对用户程序的影响就会变得非常大。
- 无法处理浮动垃圾
所谓浮动垃圾,就是在CMS的并发标记和并发清理阶段,用户线程还是在继续运行,在这个区间不可避免的还是会产生新的垃圾,但是这部分对象是出现在标记过程结束以后,CMS无法清理掉他们,只有等下一次GC的时候处理,这部分垃圾就是浮动垃圾。、
- 内存碎片化问题
这个问题也就是我们在讨论垃圾回收算法的时候提到的。这是标记-清除垃圾回收算法存在的问题。为了解决这个问题,CMS提供了两个参数:
(1)-XX:++UseCMSCompactAtFullCollection开关参数(默认是开启的,JDK9开始废弃)
用于在CMS收集器不得不进行full GC时开启内存碎片合并整理。这样虽然内存得到了整理,但是停顿时间会变长。
(2)-XX:CMSFullGCsBeforeCompaction(JDK9开始废弃)
作用时要求CMS收集器在执行过若干次不整理空间的Full GC后,下一次进入Full GC前会先进行碎片整理。(默认是0,表示每次进入Full GC 都会进行碎片整理)
六、Garbage First(G1)收集器
该收集器可以算得上是垃圾收集器技术发展历史上里程碑式的成果。我们之前聊的垃圾收集器,垃圾收集的目标要么是针对整个老年代(Major GC),要么就是针对整个新生代(Minor GC),要么就好似整个Java堆进行回收(Full GC)。但是G1则提出了另一种内存布局形式–基于Region的内存布局形式。当然这种内存布局形式也是基于分代理论设计的,但是它不再坚持固定大小以及固定数量的分代区域划分,而是将连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以扮演新生代的Eden空间,Survivor空间,或者老年代空间。当然对于一个超过了Region容量一半的对象,会被判定为大对象,将会用特殊的Humongous来进行存储,如果对象过大则会存储在连续的Humongous中,G1的大多数行为会将Humongous区域当作老年代来看待。
注:
每个Region的大小可以采用-XX:G1HeapRegionSize设定,取值范围1MB~32MB,且应为2的N次幂。
G1可以面向堆内存任何部分来组成回收集进行回收,衡量的标准不再是它属于哪个分代,而是哪块内存中存放的垃圾最多,回收收益最大,这就是G1收集器的Mixed GC模式。G1之所以能够建立可预测的停顿时间模型,是因为它将Region作为单词回收的最小单元,所以每次回收到的空间都是Region大小的整数倍,这样就可以有计划的避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器区跟踪各个Region里面的垃圾堆积的“价值”大小,所谓价值,就是指回收所得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。每次根据用户设定允许的停顿时间(-XX:MacGCPauseMullis,默认200毫秒),优先处理回收价值收益最多的Region。这也是Garbage First名字的由来
G1收集器在不去计算用户线程运行过程中的动作(使用写屏障维护记忆集的操作),可以大致分为以下四个步骤:
- 初始标记
这个阶段仅仅是标记一下GC Roots能直接关联到的对象,修改TAMS指针的值。在这个阶段耗时很短,而且是借用Minor GC的时候同步完成的,所以G1收集器在这个阶段实际上并没有额外的停顿
- 并发标记
从GC Root开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找出需要回收的对象,这个过程耗时较长,但是可以与用户线程并发执行。当对象图扫描完成以后,还要重新处理SATB(Snapshot-at-the-beginning)记录下的在并发时有引用变动的对象。
- 最终标记
对用户线程做林一个短暂的暂停,用于处理并发阶段结束后仍然遗留下来的最后少量的SATB记录。
- 筛选回收
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的创业板工艺对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程的,由多条收集器线程并行完成。
我们可以看到,G1收集器除了并发标记外,其余阶段都是需要完全停止用户线程的,所以说G1收集器也并不能说完全是纯粹的追求低延迟的,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。就是由于G1不仅仅是面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量,所以才选择了完全停止用户线程的方案。
CMS与G1对比:
- 算法
CMS采用标记-清除算法,而G1从整体来看是基于标记-整理算法实现的,而从局部也就是两个Region之间来看,是采用标记-复制算法实现的。
- 运行步骤
CMS与G1相比都是四个步骤,初始标记阶段都会停止用户线程,并发标记和用户线程一起执行,对于G1来说最终标记和筛选回收都会停顿用户线程,而CMS只有重新标记阶段会停止用户线程
- 内存分布
CMS仍然是针对采用的固定比列的新生代、老年代内存布局,而G1采用了基于Region的内存布局,可更加细粒度的针对部分内存进行回收。
- 占用内存
CMS和G1都采用了卡表来处理跨代指针,但是G1的卡表实现要更加的复杂,因为每一个Region都得有一份卡表,这就导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。相对而言CMS的卡表就比较简单了,只有唯一的一份,而且只需要处理老年代到新生代的引用,反过来却不需要。
- 执行负载
CMS采用了写后屏障来更新维护卡表,而G1除了使用写后屏障来进行同样的卡表维护操作外(实际上G1的卡表维护更加的复杂),为了实现原始快照算法(SATB),还需要使用写前屏障来跟踪开发时的指针变化情况。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现时直接的同步操作,而G1就不得不将其实现为类似消息队列的结构,把写前屏障和写后屏障中要做的事情放到队列里,异步处理。
七、Shenandoah收集器
这是一款不是由Oracle(包括之前的Sun)公司的虚拟机团队开发的一款hotspot收集器。不可避免的被“官方”所排挤,OracleJDK在条件编译的时候直接排除掉了Shenandoah收集器,所以这是一款在OpenJDK中存在,而在收费版的OracleJDK中却不存在的垃圾收集器。
这款收集器更像是G1的下一代继承者,甚至还共享了一部分代码。
相对G1的改进:
- 支持并发的整理算法
- 默认不实现分代收集
- 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用了名为“链接矩阵”的全局数据结构来进行记录跨Region的引用关系。
其实就是采用了一张二维表格,例如,RegionN引用了RegionM,则在二维表的N行M列做上标记。如图:
通过该图就能得出哪些Region之间产生了跨Region的引用
该收集器大致分为九个阶段:
- 初始标记
该阶段和G1的初始标记一样,首先标记与GC Roots直接关联的对象,这个阶段仍然是“Stop The World”的,但停顿的时间与堆大小无关,只与GC Roots的数量相关
- 并发标记
与G1一样,遍历对象图,标记全部可达对象,这个过程与用户线程并发的。
- 最终标记
与G1一样,处理剩余的SATB扫描,并在这个阶段统计回收价值最高的Region,将这些Region构成一组回收集。最终标记阶段也会有一小段短暂的停顿。
- 并发清理
这个阶段用于清理掉哪些整个区域连一个存活对象都没有找到的Region
- 并发回收
这个阶段是该收集器区别于其他收集器的核心点。Shenandoah要把回收集里面存活的对象先复制一份到其他未被使用的Region中。通常复制这个操作是需要在冻结用户线程的条件下进行的,因为这涉及到复制前后引用的变化。但是Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行时间长短取决于回收集的大小
- 初始引用更新
引用更新(其实就是复制对象结束后,将复制前的引用修正为复制后的引用)的初始化阶段实质上并没有进行具体的处理,在这个阶段只是为了建立一个线程集合点,确保所有并发回收的线程全部完成对象的移动任务。会产生一个短暂的停顿
- 并发引用更新
与用户线程一起并发进行引用更新操作
- 最终引用更新
解决了堆中的引用更新后,还需要修正存在于GC Roots中的引用,这个阶段是Shenandoah最后一次停顿。停顿时间与GC Roots的数量相关
- 并发清理
经过并发回收与引用更新后,整个回收集中的Region将变层Immdiate Garbage Region了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后的对象分配使用
八、ZGC收集器
ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小都可以把垃圾收集的停顿时间限制在十毫秒以内的地延迟。
ZGC收集器是一款基于Region内存布局的(在一些官方文档中被称为Page或ZPage),(暂时)不设分代的,使用读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款收集器。
关于ZGC的内存分布:
虽然也是基于Region的内存分布,但是和G1以及Shenandoah还是有一些区别的。ZGC的Region具有动态性–动态创建和销毁,以及动态的区域容量大小。
- 小型Region
固定容量为2MB,用于放置小于256KB的对象
- 中型Region
容量固定为32MB,用于存放大于等于256KB但是小于4MB的对象
- 大型Region
容量不固定,可以动态变化,但是必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一份大对象,这也预示着虽然名字叫做“大型Region”,但是可能小于中型的Region。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。
我们知道HotSpot虚拟机的几种收集器对于标记的实现方案各有不同,有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的 1 64 \frac{1}{64} 641 大小的BitMap的数据结构来记录标记信息),而ZGC采用染色指针来进行标记,可以说是最直接的,最纯粹的,它直接把标记信息记在对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。
关于染色指针:
我们知道,对于64位的系统,理论上可以访问的内存可以达到16EB字节。但是实际上由于各种原因,在AMD架构中只支持到了52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前的硬件实际上能支持的最大内存只有256TB。而且操作系统一方还会添加自己的约束,64位的Linux中分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间。
在这个上面的前提下我们知道Linux下的64位系统的高18位不能用来寻址,但是剩余的46位指针所能支持的64TB内存在今天仍能充分满足大型服务器的需要。ZGC就是盯上了这剩下的46位指针的宽度,将其高4位提取出来存储四个标志信息,通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过),是否值能通过finalize()方法才能被访问到。虽然这样进一步压缩了地址空间,ZGC能管理的内存不能超过4TB。但是在如今,4TB已经足够满足大部分服务端需求了。
当然,染色指针的缺点也是显而易见的,有4TB的内存限制,不能支持32位平台,不能支持压缩指针等。
ZGC的运作过程:
- 并发标记
与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析阶段,前后也要经过类似G1、Shenandoah的初始标记、最终标记的短暂停顿,而且这些停顿阶段所做的事情在目标上也是类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上,而不是在对象上进行的,标记阶段会更新染色指针中的marked1、marked0标志位。
- 并发预备重分配
这个阶段根据特定的查询条件统计得出本次收集过程要清理的哪些Region,将这些Region组成重分配集。和G1收集器的回收集不一样,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。
- 并发重分配
这个过程是ZGC执行过程中的核心阶段,这个过程把重分配集中的存活对象复制到新的Region上,并重新分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。由于采用了染色指针,所以我们可以直接从引用上就能明确对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预制的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其指向新对象,ZGC将这种行为称为指针的“自愈”能力
- 并发重映射
这里所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新是一致的,但是ZGC由于存在“指针自愈”,所以不是一个必须要“迫切”去完成的任务。所以ZGC很巧妙的把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正他们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正后,原来记录新旧对象关系的转发表就可以释放了。