JVM垃圾收集器详解 CMS、G1、Shenandoah、ZGC

上一篇我们讲解了一些垃圾回收的理论和一些基础的算法和思想,这一篇主要是jvm从古至今垃圾收集器的实现。

各垃圾回收器

 注:有连线的代表他们可以互相配合使用。

Serial和Serial Old收集器

        最早的一款收集器,看名字就知道该收集器是一个单线程工作的收集器,单线程强调在它进行垃圾收集时必须暂停其他所有工作线程,直到它收集结束,这对很多应用来说是很不友好的。

Serial/Serial OLD收集器运行示意图

 特点:简单而高效(与其他收集器的单线程相比)、占用内存小

Serial对于运行在客户端模式下的虚拟机来说是个很好的选择。

-XX:+UseSerialGC=Serial+SerialOld  打开串行垃圾回收器

ParNew收集器

是Serial收集器的多线程并行版本,除了多线程之外,其他的和Serial一模一样 

ParNew/Serial示意图

ParNew收集器主要 是使用CMS(+UseConcMarkSweepGC)的默认新生代收集器,看图一就知道了在JDK9之后,CMS收集器只能配合ParNew使用。CMS(后面会讲)是JDK5发布的真正意义上支持并发的垃圾收集器,它收集实现了让垃圾收集器与用户线程同时工作。但随着垃圾收集器的不断改进,CMS被G1所替代。

ParNew主要运行在多线程的环境,默认开启的收集器线程和处理器核心相同,可以使用-XX:ParallelGCThreads参数改变线程数。

垃圾回收器的并发和并行概念如下

并行(Parallel):描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,也就是多个垃圾收集线程一起多线程工作,用户线程等待,如ParNew。

并发(Concurrent):描述的是垃圾收集器线程和用户线程之间的关系,说明同一时间垃圾收集器线程和用户线程都在运行,只不过垃圾收集线程占用了一部分的系统资源,用户线程应用程序的处理可能会变慢(吞吐量受影响)。

Parallel Scavenge和ParallelOld收集器

也是一种并行的,基于标记复制的收集器。该收集器的关注点是吞吐量优先*,像CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间。

所谓吞吐量即处理器用于运行用户代码的时间与处理器总消耗时间的比值

吞吐量=运行用户代码的时间/(代码运行时间+运行垃圾收集时间)

像CMS这种关注停顿时间的更适合与用户的及时交互,就比如我宁愿多做几次垃圾回收,使得我每次垃圾回收的时间比较少,而Parallel Scavenge是我每次垃圾回收时间可以长一点,使得我尽量少做垃圾回收这一动作这种高吞吐量的更适合后台运算较多,不需要太多交互的任务。

Parallel Scavenge/ParallelOld运行示意图

 使用-XX:+UseParallelGC -XX:+UseParallelOldGC 参数开启这对cp,只需开启一个另一个自动开启。

 -XX:ParallelGCThreads=n :线程数

-XX:+UseAdaptiveSizePolicy:自动调整伊甸园区和幸存区的比例

-XX:GCTimeRatio=ratio :调整总时间和垃圾回收时间的占比,默认ratio为99

-XX:MaxGCPauseMillis=ms :默认200ms,最大暂停时间

CMS收集器

Concurrent Mark Sweep,关注响应速度,低延迟,老年代,看名字就知道基于标记清除算法。

前面介绍的收集器GC线程运行时都是需要停顿用户线程,好点的也是用并行的垃圾收集缩短时间,而CMS是并发的,用户线程和GC线程可以一起工作,感觉是不是进步了不少?但它的工作过程相对复杂了不少,包括:

  1. 初始标记:标记一下GCRoots直接关联的对象,速度很快
  2. 并发标记:从上一步找到的对象开始并发遍历标记整个对象图,不需暂停用户线程
  3. 重新标记:增量更新用户线程可能在并发期间改动的那些少量对象,速度很快
  4. 并发清除:清理删除掉已经死亡的对象,由于是清理算法,不需要暂停用户线程

初始标记和重新标记任然需要Stop the World。

CMS收集器示意图

-XX:+UseConcMarkSweepGC :使用并发标记清除,基于标记清除并发策略。

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld 是一对,老年代使用标记清除,新生代使用复制的垃圾回收器,当老年代发生并发失败的问题时,又会退化到SerialOld单线程的垃圾回收器。

-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads

-XX:CMSInitiatingOccupancyFraction=percent :当达到percent时,会进行垃圾回收,以预留空间给浮动垃圾(因为CMS给予清除算法,难免会产生浮动垃圾),不能等到堆内存不足再清理,应该预留一点空间保留这些浮动垃圾

-XX:+CMSScavengeBeforeRemark :重新标记前先对新生代进行垃圾回收,以此减轻重新标记的压力,因为重新标记会检测老年带中的对象是否有被新生代的所引用,为了避免扫描新生代的这些垃圾,所以先清理掉,再进行可达性分析算法。

缺点:老年代容易产生碎片,当碎片过多时,可能会产生并发失败,所以需要退化到串行的垃圾回收执行一次,此时需要的时间较久。

现代收集器*:

G1收集器

Garbage First(简称G1),垃圾收集器发展历史的里程碑,JDK9默认收集器,取代了cms。

它开创了面向局部收集基于Region的内存布局,他们是什么呢?

以前的垃圾收集器都是要么收集新生代,要么收集老年代,要么我就全收集,而G1不同,它可以面向堆内存任何地方来组成回收集(Collection Set)进行回收,衡量标准不再是以前的属于哪块分代了(依然遵循分代收集),而是哪块内存的垃圾数量多,回收的收益最大,这就是G1的MixedGC。

 其中的Humongous区域专门用来存储超过一块Region容量一半的大对象;如果对象超过了单个Region的大小,将会用多个连续的Region存放,Region差不多可以看成是老年代。

jdk对这种大对象进行了优化,大对象会优先被回收,并且大对象不会被拷贝,代价大。

通过-XX:G1HeapRegionSize可以调整Region的大小,应为2的N此幂

用户可以设定允许的停顿时间(-XX:MaxGCPauseMillis=ms ,默认200ms)。

G1收集器每次都会按Region为单位,根据用户设定的允许的停顿时间,去寻找那些回收价值比较高的对象(这样可以避免在整个堆中进行收集),后台维护一个优先级列表,按需回收,这也是G1名字的由来,保证了G1在有限的时间获取尽可能高的收集效率

跨带引用?

也就是前面我们讲过的老年代可能会引用新生代的对象的问题,推出了记忆集的解决方案,在G1中,因为划分了Region,所以更复杂了,每个Region都维护了自己的记忆集,记录其他Region指向自己的指针,并标记指向的人在哪些范围之内,用Hash表实现的(Key存放跨带引用本Region的其他Region的起始地址,value存放卡表索引号)。可以看出G1的记忆集占用较多,大概占整堆的10%-20%。

并发问题?

G1的并发问题,也有不同,G1是通过原始快照(SATB)来解决的,G1为每一个Region设计了两个TAMS的指针,把Region的一部分空间划分出来用于并发回收过程中的新对象的分配,新对象地址必须在TAMS指针以上,默认他们是存活的,不纳入回收范围。

停顿时间?

通过-XX:MaxGCPauseMillis=ms参数设置了的停顿预测模型,怎么实现的?G1收集时会记录每个Region的回收耗时,Region记忆集里的脏卡数据等各个可测量的步骤花费的成本,求出平均值等信息,从而在不超过期望停顿时间的约束下获得最高的回收收益。而且如果把停顿时间设置的太小,每次只能回收一点垃圾,时间长了,就会OOM。我们需要适中的选择停顿时间。

具体步骤

  1. 初始标记:标记一下GCRoots直接关联的对象,并修改TAMS指针的值,速度很快,需要停顿用户线程
  2. 并发标记:从上一步找到的对象开始并发遍历标记整个对象图,并重新处理SATB记录下引用变动的记录,不需暂停用户线程
  3. 最终标记:处理并发阶段结束后遗留下来的少量的SATB记录
  4. 筛选回收(Evacution):对各个Region的回收价值排序,根据停顿时间模型制定回收计划,把决定回收的对象所在的Region中的存活对象复制(复制算法)到空Region中,再清理以前的Region,需要STW

除了并发标记外,都是需要停顿用户线程的,G1并不是一味地追求低延迟,只是在可控的延迟内获得更高的吞吐量。

优点

  • 适用于较大的堆,不会随内存的增大而延长STW
  • 软实时、低延迟、可设定停顿时间
  • 同时注重吞吐量和低延迟
  • 整体上是标记整理算法,两个区域之间是复制算法

缺点:

  • 内存占用和执行时的额外执行负载较高

为了减少FullGC的发生,我么可以提前一点进行垃圾收集。

jdk9增加了:-XX:InitiatingHeapOccupancyPercent调整,默认45%,即垃圾超过45%时就开始垃圾回收了。并且该比率可以动态调整

低延迟收集器

最后的两款收集器是Shenandoah和ZGC,被官方称为低延迟垃圾收集器,尚处于实验状态,但肯定是以后的主流收集器。

它们几乎整个工作过程都是并发的,只有初始标记和最终标记这些阶段有短暂的延迟。

内存占用、吞吐量、延迟是衡量垃圾收集器的三项重要指标,它们也被称为不可能三角

Shenandoah

Shenandoah也是基于G1的Region的内存布局形式,也是优先处理价值大的垃圾……;

不同之处:

  1. 我们前面介绍的收集器中,它们有一个特点即整理算法,也就是最后的回收阶段都不能并发,要么是单线程,要么是多线程并行,但Shenandoah支持并发的整理算法
  2. Shenandoah默认不使用分代收集,不会有专门的新生代Region和老年代Region。
  3. Shenandoah摒弃了G1的耗费大量内存的记忆集,改为使用“连接矩阵”的全局数据结构处理跨代引用问题。

连接矩阵类似数据结构中图的邻接矩阵表示方法,假如有n个顶点,就设置一个形如int[n][n]的二维数组,如果第2个顶点指向了第4个顶点,则[4][2]=1,进行标记;把对象理解成图的顶点即可。

工作流程:

  • 初始标记:与G1一样,标记GC Roots能直接关联的对象,需要短暂的STW,停顿时间与堆大小无关,与GC Roots的数量有关。
  • 并发标记:也一样,遍历整个对象图,标记全部可达的对象,时间长度取决于堆中存活对象的数量和对象图的复杂度。
  • 最终标记:处理并发阶段结束后遗留下来的少量的SATB记录,并且在这个阶段就要统计好回收价值最高的Region,将这些Region构成一个回收集。短暂的停顿。
  • 并发清理:直接把那些Region里连一个存活对象都没有的Region清理了,因为该Region的对象全部已经是垃圾了,所以没有并发的问题。
  • 并发回收(关键):与G1的核心差异,并发的把回收集里的存活对象复制一份到空的Region里,肯定会存在并发问题,难就难在在移动对象的同时,用户线程任然可能不停的对被移动的对象不停的读写访问,移动是一次性的行为,但移动过后所有指向该移动对象的指针还是指向原来的旧地址,很难一瞬间全部改变过来,这也是G1不使用并发回收的原因。Shenandoah通过读屏障和"Brooks Pointers"解决该问题的。该阶段的时间长短取决于回收集的大小。
  • 初始引用更新:并发回收结束后需要把堆中所有指向旧对象的引用修正为新引用,即引用更新,初始阶段实际只是建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已经完成对象移动的任务而已。会产生一个非常短暂的停顿。
  • 并发引用更新:真正开始并发引用更新,将旧值改为新值,时间长短取决于内存中涉及到的引用数量的多少。
  • 最终引用更新:前面解决堆中的引用更新,现在是修正存在于GC Roots中的引用(GC Roots的引用的对象可能会被复制到其他的Region),需要停顿,时间和GC Roots的数量有关。
  • 并发清理:上面已经找出回收集并把回收集里的对象正确的复制了一份出去,此时那些回收集自然可以清理掉了,再调用一次并发清理,就完事了。

可以总结为:并发标记、并发回收、并发引用更新

转发指针(Brooks Pointer)

在对象的结构布局上动手脚。通常一个对象都有一个对象头,现在在所有对象头前面统一加一个新的引用字段:

 在正常情况下,该引用指向对象自己。如果该对象因为垃圾回收被复制到其他地方了,那么该指针就会指向那个复制的新的对象,并且这一过程在清理旧对象之前。想一想,如果在并发中,有其他引用找该旧对象,但旧对象的引用指向的不是本身,而是那个新对象,就不会发生啥问题了。但是会出现多线程竞争问题,这里就不再多说。

ZGC 收集器

 Z Garbage Collector,JDK11 新加入的具有实验性质的低延迟垃圾收集器。oracle研发。

ZGC 是一款基于 Region 内存布局,(暂时)不设分代,使用读屏障染色指针内存多重映射等技术来实现的可并发的标记-整理算法的垃圾收集器。

ZGC的Region布局不太一样,ZGC的Region其实被称为ZPage具有动态性——动态创建和销毁,以及动态的区域容量大小,ZPage可分为:

  • 小型Region:2MB,用于放置小于256KB的小对象
  • 中型Region:32MB,存放大于256KB小于4MB的对象
  • 大型Region:容量不固定,存放4MB以上的大对象,但只能存放一个对象。不会被重分配

染色指针

颜色指针可以说是ZGC的核心概念。因为他在指针中借了几个位出来做事情,所以它必须要求在64位的机器上才可以工作。并且因为要求64位的指针,也就不能支持压缩指针。

在64位系统,目前Linux操作系统也就能支持46位(win 44位),当然高18位不能用来寻址,但它所支持的46位指针也能支持64TB的内存,对于现在已经能给充分满足服务器的需求。ZGC的染色指针就是将这46位的指针宽度拿出4位充当标志位,弊端就是ZGC管理的内存不能超过4TB。

  • Finalizable:表示是否只能通过finalize()方法才能被访问到,其他途径不行;
  • Remapped:表示是否进入了重分配集(即被移动过);
  • Marked1Marked0:表示对象的三色标记状态;

 为什么最高位16个不能用?

由于X86_64处理器硬件的限制,目前X86_64处理器地址线只有48条,也就是说64位系统支持的地址空间为256TB。为什么处理器的指令集是64位的,但是硬件仅支持48位的地址?最主要的原因是成本问题,即便到目前为止由48位地址访问的256TB的内存空间也是非常巨大的,也没有多少系统有这么大的内存,所以在设计CPU时仅仅支持48位地址,可以少用很多硬件

ZGC 的运行过程大致可划分为以下四个大的阶段,都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段:

在这里插入图片描述

  • 并发标记:遍历对象图做可达性分析,前后也要经历类似 G1、Shenandoah 的初始标记、最终标记的短暂停顿。与 他们不同的是,ZGC 的标记是在指针而非对象,标记阶段会更新染色指针中的 Marked 0、Marked 1 标志位
  • 并发预备重分配:根据特定的查询条件统计出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)。ZGC 划分 Region 的目的并非像 G1 是为了做收益优先的增量回收,ZGC 每次回收都会扫描所有 Region,用范围更大的扫描成本换取维护记忆集的成本。ZGC 的标记过程是针对全堆的,ZGC 的重分配集只是决定里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而不能说回收行为就只针对这个集合里面的 Region
  • 并发重分配:这个过程要把重分配集中的存活对象复制到新的Region 上,并为重分配集中的每个 Region 维护一个转发表记录从旧对象到新对象的转发关系。如果用户线程此时并发访问位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,并根据 Region 上的转发表记录将访问转发到新复制的对象上,同时更新该引用的值,使其指向新对象,这种行为称为指针的自愈(Self-Healing)能力
  • 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用,不过这并不是一项迫切完成的任务,因为即使是旧引用,它也是可以自愈的。因此,ZGC 把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段去完成,反正都是要遍历所有对象图,这样还可以节省一次遍历对象图的开销。一旦所有指针被修正之后,原来记录新旧对象关系的转发表就可以释放掉了
     

GC调优官方文档:

Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide, Release 16

本文部分摘自《深入理解 Java 虚拟机第三版》

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值