ZGC新一代垃圾收集器笔记

Scroll Down

垃圾收集器概述

自Java中引入垃圾收集器以来,垃圾收集器的发展从未停止过。Java中成熟的垃圾回收器有串行垃圾收集器、并行垃圾收集器、并发垃圾回收器、并发标记回收期(Concurrent Mark Sweep,CMS)、垃圾优先回收器(Garbasge First,也称为G1).在JDK11中引入了一款新的垃圾回收器ZGC,在JDK12中又引入了另一款新的垃圾回收器Shenandoah。虽然新的垃圾回收器不断涌现,但是垃圾回收的基本算法变化不大。简单地说,回收算法主要有复制、标记清除、标记压缩。JVM中不同的垃圾回收器都是基于这些基本算法实现的,不同的垃圾回收器区别在于:选择的算法不同,实现时后台线程采用的并行/并发方式不同。

1.1 垃圾回收算法

Garbage Collection(GC)垃圾回收(垃圾收集)指的是程序不关心对象在内存中的生存周期,创建后只需要使用,不用关心何时释放以及如何释放,由JVM自动管理内存、释放这些对象所占用的空间。垃圾回收器的历史非常悠久。目前的垃圾回收算法主要有两类:

  • 引用计数法:在堆内存中分配对象时,会为对象分配一段额外的空间,这个空间用于维护一个计数器,如果对象增加了一个新的引用,则将增加计数器的值;如果一个引用关系失效,则减少计数器的值。当一个对象的计数器的值变为0,则说明该对象已经被废弃,处于不活跃状态,可以被回收。引用计数法需要解决循环依赖的问题。
  • 可达性分析法(也称为根引用分析法):基本思路就是通过根集合(root set)作为起始点,从这些节点出发,根据引用关系开始搜索,所经过的路径称为引用链,当一个对象没有被任何引用链访问到时,则证明此对象是不活跃的,可以被回收。在JVM中常见的根(root)有线程栈帧(thread frame,用于跟踪线程中活跃对象)、符号表(symbol dictionary)、字符串表(string table)、对象监视器(object synchronizer)、元数据对象(universe)等,这些根共同构成了根集合。

按照不同的标准分成两类:

  • 从垃圾回收算法实现主要分为复制(copy)、标记清除(mark-sweep)和标记压缩(mark-compact)。
  • 从回收方式上可以分为串行回收、并行回收、并发回收。
  • 从内存管理上可以分为代管理和非代管理。

1.2 JVM垃圾回收器

JVM垃圾回收器基于分代管理和回收算法,结合回收的方式,实现了串行回收器、并行回收器、CMS、G1、ZGC和Shenandoah。这些垃圾回收器从程序执行方式的角度可以分为以下3类:

  • 串行执行:应用程序和垃圾回收器交替执行,垃圾回收器执行的时候应用程序暂停执行。串行执行指的是垃圾回收器有且仅有一个后台线程执行垃圾对象的识别和回收。
  • 并行执行:应用程序和垃圾收集器交替执行,垃圾回收器执行的时候应用程序暂停执行。并行执行指的是垃圾回收器有多个后台线程执行垃圾对象的识别和回收,多个线程并行执行。
  • 并发执行:应用程序和垃圾回收器同时运行,除了在某些必要的情况下垃圾回收器需要暂停应用程序的执行,其余的时候在应用程序运行的同时,垃圾回收器的后台线程也运行,如标识垃圾对象并回收垃圾对象所占的空间。
执行方式垃圾回收器
串行执行串行垃圾回收器
并行执行并行垃圾回收器
并发执行CMS、G1、ZGC、Shenandoah

1.2.1 串行回收

使用单线程进行垃圾回收,在回收时应用程序(mutator)都需要执行暂停(Stop The World,STW)。新生代通常采用复制算法,老年代通常采用标记压缩算法。

1.2.2 并行回收

使用多线程进行垃圾回收,在回收时应用程序需要暂停,新生代通常采用复制算法,老年代通常采用标记压缩算法。
在并发回收时,如果发现内存不足,需要对整个堆进行垃圾回收,在FullGC时需要STW,并且是串行回收。

1.2.3 CMS

整个回收期间划分成多个阶段:初始标记、并发标记、重新标记、并发清除等。在初始标记和重新标记阶段需要暂停应用程序线程,在并发标记和并发清楚期间工作线程可以和应用程序并发进行。这个算法通常适用于老年代,新生代可以采用并行复制回收,也可以采用串行复制算法。
同样,在老年代回收时,因为是并发执行,如果在分配内存时发现内存不足,则需要进行FGC,也需要STW并对整个内存进行串行回收。

1.2.4 G1

从执行方式来看,垃圾回收器的发展经历了最初期的串行执行,到并行执行用于提高执行效率。再到目前主流的并发执行用于减少垃圾回收器停顿时间。第一款成熟的并发执行垃圾回收器是CMS。CMS是一款非常成功的垃圾回收器,是使用最多和最广泛的垃圾回收器,但是其复杂性给程序员的使用带来了不便,所以需要设计一款简单的垃圾回收器来替代CMS,G1应运而生。
G1是JDK1.7 Update4及后续版本开始正是提供的。G1致力于多CPU和大内存服务器上对垃圾回收提供实时目标和高吞吐量。
连续内存将导致垃圾回收时间过长,停顿时间不可控。所以G1将堆拆分成一系列的分区(heap region),这样在一个时间段内,大部分垃圾回收操作就只是针对一部分分区执行,而不是整个堆或老年代,从而满足在指定的停顿时间内完成垃圾回收的动作。在G1里,新生代就是一系列的内存分区,这意味着不用再要求新生代是一个连续的内存块。类似地,老年代也是有一系列的分区组成。在JVM运行时,从内存管理角度不需要预先设置分区是老年代还是新生代分区,而是在内存分配时决定:当新生代需要空间时,则分区被加入到新生代中;当老年代需要内存空间时,则分区被加入到老年代中。事实上,G1通常的运行状态是:映射G1分区的虚拟内存随着时间的推移在不同的代之间切换。
G1新生代的回收方式是并行回收,采用复制算法。与其他JVM垃圾回收器一样,一旦发生新生代回收,整个新生代都会被回收。这就是我们常说的新生代回收。但是G1和其他垃圾回收器不同之处在于:①G1会根据预测时间动态地改变新生代大小;②G1老年代的垃圾回收方式与其他JVM垃圾回收器对老年代处理有着非常大的不同。G1老年代的回收不会为了释放老年代的空间而对整个老年代处理,相反,在任意时刻只有一部分老年代区会被回收,并且这部分老年代将在下一次增量回收时与所有新生代分区一起被回收,这就是我们所说的混合回收(Mixed GC)。在选择老年代分区时,优先考虑垃圾多的分区。
老年代分区的选择涉及G1的并发标记算法,这个过程称为并发标记阶段。并发标记是指并发标记线程和应用程序线程同时运行,它有4个典型的子阶段:初始标记子阶段、并发标记子阶段、再标记子阶段和清理子阶段。

  1. 初始标记子阶段
    负责标记所有从根集合直接可达的对象,根集合是对象图的起点,初始标记需要将应用程序线程暂停,也就是需要一个STW的时间段。在混合回收中的初始标记子阶段和新生代的初始标记几乎一样。实际上混合回收的初始标记子阶段是借用了新生代回收的结果,即新生代垃圾回收后的新生代Survivor分区作为根,所以混合回收一定发生在新生代回收之后,且不需要再执行一次初始标记。这就是所谓的借到。
  2. 并发标记子阶段
    当YGC执行结束之后,如果发现满足并发标记的条件,并发线程就开始进行并发标记。根据新生代的Survivor分区开始并发标记。并发标记的时机是在YGC后,只有内存消耗到一定的阈值后才会触发。在G1中,这个阈值通过参数InitingHeapOccupancyPercent控制(默认值是45,表示当已经分配的内存加上本次将分配的内存超过总容量的45%时就可以开始并发标记)。多个并发标记线程启动,每个线程每次只扫描一个分区,从而标记出存活对象。在标记的时候还会计算存活的对象的数量,同时会计算存活对象所占的内存大小,并计入分区空间。
    并发标记子阶段会对所有分区的对象进行标记。这个阶段并不需要STW,故标记线程和应用程序线程并发运行。使用Snapshot-AtThe-Beginning(SATB)算法进行并发标记。
  3. 再标记子阶段
    再标记是最后一个标记阶段。在该阶段中,G1需要一个STW的时间段,找出所有未被访问的存活对象,同时完成存活内存数据计算。引入该阶段是为了能够达到结束标记的目标。要结束标记过程,需要满足3个条件:
  • 从根(Survivor)出发并发标记子阶段已经标记出所有的存活对象。
  • 标记栈是空的。
  • 所有的引用变更对象都被处理了。这里的引用变更对象包括新增空间分配的对象和引用变更对象,新增空间所有对象被认为都是活跃的,引用变更处理的对象通过一个队列记录,在该子阶段会处理这个对象中所有的对象。
    这个子阶段是并行执行的。
  1. 清理子阶段
    再标记子阶段之后是清理子阶段,该子阶段也需要一个STW的时间段。清理子阶段主要执行以下操作:
  • 统计存活对象,统计的结果将会用来排序分区,以用于下一次的垃圾回收时分区的选择。
  • 交换标记位图,为下一次并发标记做准备。
  • 把空闲分区放到空闲分区列表中。这里的空闲分区指的是全都是垃圾对象的分区,如果分区中还有活跃对象,则不会释放,真正的释放的动作发生在混合回收中。
    该阶段比较容易引起误解的地方在于,清理子阶段并不会清理垃圾对象,也不会执行存活对象的复制。也就是说,在极端的情况下,该阶段结束之后,空闲分区列表将毫无变化,JVM的内存使用情况也毫无变化。
    该子阶段也是并行执行的。
    并发标记阶段完成之后,在下一次进行垃圾回收的时候就会回收垃圾比较多的老年代分区。这时进行的垃圾回收被称为混合回收,混合回收和YGC最大的区别就是混合回收不仅仅回收所有的新生代分区,也回收部分垃圾多的老年代分区,所以JVM在实现混合回收时重用了YGC的所有代码,两者的不同之处就在于是否回收老年代分区。
    最后,同样在垃圾回收过程或者并行执行过程中,当内存不足需要进行FGC时,也需要STW对整个内存进行串行回收。在JDK10中对FGC做了改进,把串行回收改进成并行回收,注意是并行的FGC,而不是并发回收。
    在G1工作时,还有两个值得注意的地方:
  • G1的引用集RSet处理,它是并发执行的,目的是记录对象的引用关系,能减少垃圾回收过程中的停顿时间。
  • 在G1中,并发标记算法使用SATB算法,该算法也是G1并发标记的核心。

1. RSet处理:RSet是一个抽象概念,记录对象在不同代际之间的引用关系,目的是加速垃圾回收的速度。JVM使用的是根对象引用的收集算法,即从根集合出发,标记所有存活的对象,然后遍历对象的每一个成员变量并继续标记,直到所有的对象标记完毕。在分代垃圾回收中,新生代和老年代处于不同的回收阶段,如果还是采用这样的标记方法,不合理也没必要。假设问哦们只回收新生代,如果标记时把老年代中的活跃对象全部标记,但回收时并没有回收老年代,则浪费了时间。同理,在回收老年代时有同样的问题。当且仅当我们要进行FGC时,才需要对内存做全部的标记。所以算法设计者做了这样的设计-用一个RSet记录从非收集部分指向收集部分的指针的集合。为了提高RSet存储效率,使用了3种数据结构:

  • 稀疏表,通过哈希表的方式(哈希表底层使用数组)来存储。
  • 细粒度表,通过数组来存储,每个数组元素指向引用者分区中512字节内存块对本分区的引用情况。
  • 粗粒度位图,通过位图来表示,每1位表示对应的分区有引用到本分区。
    G1新引入了Refine线程,它实际上是一个线程池,有两大功能:
  • 用于处理新生代分区的抽样,并且在满足响应时间这个指标的情况下,更新新生代分区的数目,通常由一个单独的线程来处理。
  • 更新RSet。对于RSet的更新并不是同步完成的,G1会把所有引用关系都先放入一个队列中,称为Dirty Card Queue(DCQ),然后使用Refine线程来消费这个队列完成引用关系的记录。正常来说有G1ConcRefinementThreads个线程来处理。实际上除了Refine线程更新RSet之外,GC工作线程或者应用线程也可能会更新RSet;DCQ通过Dirty Card Queue Set来管理;为了能够快速,并发地处理,每个Refine线程只负责DCQS中的某几个DCQ。

使用RSet进行垃圾回收实际上有两个重大的缺点:

  • 需要额外的内存空间;这一部分通常是G1最大的额外开销,一般会达到1%~20%。
  • 可能会导致浮动垃圾;由于根据RSet回收,而RSet里面的对象可能已经死亡,这个时候被引用对象会被认为活跃对象,实质上它是浮动垃圾。
    所有有必要对RSet进行优化,根据垃圾回收的原理来逐一分析哪些引用关系需要记录到RSet中:
  • 分区内部有引用关系,无论是新生代分区还是老年代分区内部的引用,都无需记录引用关系,因为回收的时候是针对一个分区而言,即这个分区要么被回收,要么不回收.如果分区回收,会遍历整个分区,所以无需记录这种额外的引用关系.
  • 新生代分区到新生代分区之间有引用关系,这种无需记录,原因在于G1的YGC/Mixed GC/FGC回收算法都会全量处理新生代分区,所以它们会被遍历,所以无需记录新生代到新生代之间的引用.
  • 新生代分区到老年代分区之间有引用关系,这无需记录,对于G1中YGC针对的新生代分区,无需知道这个引用关系,混合回收发生时,G1会使用新生代分区作为根,那么遍历新生代分区时自然能找到新生代分区到老年代分区的引用,所以也无需记录这个引用关系,对于FGC来说更是如此,所有的分区都会被处理.
  • 老年代分区到新生代分区之间有引用关系,这需要记录,在YGC的时候有两种根:一个是栈空间/全局空间变量的引用,另外一个就是老年代分区到新生代分区的引用.
  • 老年代分区到老年代分区之间引用关系,这需要记录,在混合回收的时候可能只有部分分区被回收,所以必须记录引用关系,快速找到哪些对象是活的.
  1. SATB算法
  2. G1中的屏障
    在G1中使用了两种屏障:读屏障和写屏障.其中写屏障是为了处理RSet引入的,而读屏障是为了SATB并发标记引入的.

1.2.5 ZGC

ZGC是为了解决G1的不足,G1的不足如下:
G1的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,在回收时采用部分内存回收(YGC时会回收所有新生代分区,在混合回收时会回收所有新生代分区和部分老年代分区),支持的内存也可以达到几十个GB或者上百了GB.为了进行部分回收,G1实现了RSet管理对象的引用关系.基于G1设计上的特点,导致存在以下问题:

  • 停顿时间过长.通常G1的提供时间要达到几十到几百毫秒.
  • 内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%-20%左右.
  • 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于100GB的系统中,会因为内存过大而导致停顿时间增长.

ZGC作为新一代的垃圾回收器,在设计之初就定义了三大目标:支持TB级内存,停顿时间控制在10ms之内,对程序吞吐量影响小于15%.
ZGC有以下的特点:

  • 不分代的垃圾回收器,即垃圾回收时对全量内存进行标记,但是回收时仅针对部分内存回收,优先回收垃圾比较多的页面.
  • 仅支持Linux64位系统,不支持32位平台
  • 不支持使用压缩指针
  • 内存分区管理,且支持不同的分区粒度,在ZGC中分区称为页面(page),有小页面,中页面和大页面3种。
  • 具有颜色指针(color point),通过设计不同的标记位区分不同的虚拟空间,而这些不同的标记位指示的不同虚拟空间通过mmap映射在同一物理地址;颜色指针能够快速实现并发标记、转移和重定位。
  • 设计了读屏障,实现了并发标记和并发转移的处理。
  • 支持NUMA,尽量把对象分配在访问速度比较快的地方。

ZGC内存管理

对象的分配直接关系到内存的使用率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响应用程序的运行。
ZGC为了支持大字节(TB)级内存,设计了基于页面(page)的分页管理(类似于G1的分区Region);为了能够快速地进行并发标记和移动,对内存空间重新进行了划分,这就是ZGC中新引入的Color Pointers;同时ZGC为了能够更加高效的管理内存,设计了物理内存和虚拟内存两级内存管理。

2.1 操作系统地址管理

物理内存非常直观,就是真实存在的,其大小就是插在主板内存槽上的内存条的容量大小。
虚拟内存是伴随着操作系统的硬件的发展出现的。虚拟地址是操作系统根据CPU的寻址能力,支持访问的虚拟空间。
当程序试图访问一个虚拟内存页面时,这个请求会通过操作系统来访问真正的内存。首先到页面表中查询该页是否已经映射到物理页面中,并记录在页表中。如果已记录,则会通过内存管理单元把页码转换成页框码,并加上虚拟地址提供的页内偏移量形成物理地址后去访问物理内存;如果未记录,则意味着该虚拟内存页面还没有被载入内存,这是MMU就会通知操作系统发生一个页面访问错误(也称缺页故障),接下来系统就会启动所谓的请页机制,即调用相应的操作系统操作函数,判断该虚拟地址是否为有效地址,如果是有效地址,就从虚拟内存中将该地址指向的页面读入内存中的一个空闲页框中,并在页表中添加相对应的表项,最后处理器将从发生错误页面错误的地方重新开始运行;如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。当然也存在这样的情况;在请页成功之后,内存中已经没有空闲物理页框了,这时,系统必须启动所谓的交换机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不再使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。对移出一面根据两种情况来处理;如果该页从未被修改过,则删除它;如果该页曾经被修改过,则系统必须将该页写回辅存。

2.2 ZGC内存管理

ZGC为了能高效、灵活地管理内存,实现了两级内存管理;虚拟内存和物理内存,并且实现了物理内存和虚拟内存的映射关系。这和操作系统中虚拟地址和物理地址设计思路基本一致。ZGC主要的改进点就是重新定义了虚拟内存和物理内存的映射关系。