垃圾收集器
垃圾收集器
收集算法是内存回收的方法论,而垃圾收集器是内存回收的实践者。

JDK8 时,默认收集器组合是(Parallel Scavenge + Parallel Old),JDK 9 之后,(Serial,CMS) 以及(ParNew,Serial Old)的组合已经被废弃了,默认收集器也变成了 G1。
垃圾回收就像打扫房间一样,当你在打扫房间的时候同时又在制造垃圾,那么房间很难打扫干净,因此 Java 垃圾回收一个被人所诟病的点就是 Stow The World(stw),直译就是停止这个世界,即 Java 垃圾回收会导致某段时间内进程完全无响应,在当前越来越追求低时延的环境下,这是很多系统不愿意接受的。
除了时延,还有一个关注的方向是吞吐量,比如把房子全部打扫一遍,那么接下来很久可能都不用再打扫了,如果每次只打扫一块区域,那么确实打扫的很快,但接下来又会频繁打扫。
因此虽然随着技术的进步,收集器的综合表现(内存占用、延迟、吞吐量)在提高,但直到现在还没有最好的收集器出现,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。
Serial
Serial 收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

Serial 收集器简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint,为保证垃圾收集而存储信息的额外空间)最小的。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,Serial 收集器对运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都与 Serial 收集器完全一致。

虽然如此,它却是不少运行在服务端模式下的 HotSpot 虚拟机,尤其是 JDK7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。CMS 收集器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
但 JDK9 开始,G1 是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作,可以理解为从此以后,ParNew 合并入CMS,成为它专门处理新生代的组成部分。
ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越 Serial 收集器。
Parallel Scavenge
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。
- XX:MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。但是垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的,收集 500MB 的区域肯定比收集 300MB 的区域快;
- XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5% (即1/(1+19)),默认值为 99,即允许最大 1%(即1/(1+99))的垃圾收集时间。
自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。参数-XX:+UseAdaptiveSizePolicy 激活之后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量(按照上面两个参数的配置)。
Serial Old
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

Parallel Old
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。在此之前,新生代的Parallel Scavenge 收集器一直处于相当尴尬的状态,因为老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,收集效率会被拖累导致整体效率甚至不如 (ParNew+CMS)。
直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。

CMS
CMS(Concurrent Mark Sweep,直译并发标记清除)收集器是一种以获取最短回收停顿时间为目标的收集器,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤:
初始标记(CMS initial mark) ;
并发标记(CMS concurrent mark);
重新标记(CMS remark) ;
并发清除(CMS concurrent sweep)。
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度。
- CMS 在并发标记时由于与用户线程并发运行,会争抢 CPU 资源,当处理器核心数量不足四个时, CMS 对用户程序的影响就可能变得很大(并发回收线程数为(cpu 数+3)/4,占比超过 25%);
- 并发执行时用户线程会产生新的垃圾,这部分垃圾只能下次收集,也称为浮动垃圾;另外由于用户线程也在执行,需要为其预留空间,因此老年代收集要达到某个比例时就开始,但即使这样也有可能并发标记失败,进而触发 FullGC(参数 CMSInitiatingOccupancyFraction);
- 由于是清除算法,所以会有内存碎片问题,在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 FullGC 前会先进行碎片整理(默认值为 0,表 示每次进入 FullGC 时都进行碎片整理)。
CMS 由于耗时阶段可并行,因此停顿时间较短,使用增量更新算法解决用户线程并行影响对象引用图的问题,处理器核心数少时会影响用户线程,有浮动垃圾,可能并发标记失败,有内存碎片。
G1
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。JDK 9 发布之日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器。
作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。
G1 可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。虽然 G1 也仍是遵循分代收集理论设计,但它是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集旧对象都能获取很好的收集效果。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量(G1HeapRegionSize,1MB~32MB)一半的对象即可判定为大对象,超过了整个 Region 容量的超级大对象, 将会被存放在 N 个连续的 Humongous Region 之中。
G1 还为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
G1 收集器会去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis 指定,默认值是200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。
关于并发标记阶段如何保证收集线程与用户线程互不干扰地运行,CMS 收集器采用增量更新算法实现,而 G1 收集器则是通过原始快照(SATB)算法来实现的。
G1 收集器的工作一般也分为4个阶段:
- 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的, 换言之,它并非纯粹地追求低延迟。
但是 G1 依然会面对跨 Region 引用的问题,解决方案是每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内,由于 Region 的数量远多于分代数,G1 至少要耗费大约相当于 Java 堆容量 10% 至 20% 的额外内存来维持收集器工作。
相比 CMS,G1 的优点有很多,暂且不论可以指定最大停顿时间、分 Region 的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1 也更有发展潜力。与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
不过,G1 相对于 CMS 仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替 CMS 就可以得知这个结论。比起 CMS,G1 的弱项也可以列举出不少,如在用户程序运行过程 中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比 CMS 要高。
(2019年)在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间。
G1 开创了 Region 的内存布局形式,通过原始快照(SATB)算法解决用户线程并行影响对象引用图的问题,增量回收,每次优先回收性价比高的区域,内存占用和负载较高。
ZGC
ZGC 是面向低延迟设计的垃圾收集器,希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内。
在《深入理解Java虚拟机》的第三版,此时的 ZGC 还是不设分代的,如今早已推出了分代 ZGC。
ZGC 也采用基于 Region 的堆内存布局,但 ZGC 的 Region 具有动态性——动态创建和销毁,以及动态的区域容量大小。
- 小型 Region(Small Region):容量固定为 2MB,用于放置小于 256KB 的小对象。
- 中型 Region(Medium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对 象。
- 大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。每个大型 Region 中只会存放一个大对象,这也预示着虽然名字叫作“大型 Region”,但它的实际容量完全有可能小于中型 Region,最小容量可低至 4MB。
对象的死亡其实跟对象中所有的属性都没关系,而是由其是否在引用链上决定的。HotSpot 虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在对象头上(如 Serial 收集器),有的把标记记录在与对象相互独立的数据结构上(如 G1、Shenandoah 使 用了一种相当于堆内存的 1/64 大小的,称为 BitMap 的结构来记录标记信息),而 ZGC 的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上。
ZGC 收集器一个标志性的设计是它采用的染色指针技术,在 64 位操作系统上,理论上的寻址范围是 2^64,但出于需求、性能和成本的考虑,很多架构(AMD64、Linux、Windows)并不会完全支持 64 位,例如 64 位 Linux 支持 47 位(128TB)的进程虚拟地址空间和 46 位(64TB)的物理地址空间。ZGC 的染色指针技术盯上了这剩下的 46 位指针宽度,将其高 4 位提取出来存储四个标志信息,当然这也直接导致 ZGC 能够管理的内存不可以超过 4TB(2的42次幂)。

使用了染色指针技术后,就不再支持压缩指针和 32 位平台了,但它带来的收益也是非常可观的。
- 染色指针可以使得一旦某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用掉,而不必等待整个堆中所有指向该 Region 的引用都被修正后才能清理。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量。
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
不过作为本该由操作系统管理的内存,Java 虚拟机作为一个应用进程,有能力这样自定义内存中不同位的含义吗?
操作系统中其实会使用分页管理机制将不同进程相同索引页面映射到不同的物理地址,Linux/x86-64 平台上的 ZGC 使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着 ZGC 在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。

- 并发标记(Concurrent Mark):并发标记是遍历对象图做可达性分析的阶段,也要经历短暂停顿,与 G1 不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked0、Marked1 标志位。
- 并发预备重分配(Concurrent Prepare for Relocate):根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些Region 组成重分配集(Relocation Set)。重分配集与 G1 收集器的回收集(Collection Set)还是有区别的,它并非为了收益优先的增量回收,而是每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中记忆集的维护成本。
- 并发重分配(Concurrent Relocate):重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的“自愈”(Self Healing)能力。这也意味着在 Region 回收时转发表不能立刻释放掉。
- 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是 ZGC 的并发重映射并不是一个必须要“迫切”去完成的任务,因为指针是具有自愈能力的,重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益)。ZGC 很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
ZGC 的设计理念与 Azul System 公司的 PGC 和 C4 收集器一脉相承,是迄今垃圾收集器研究的最前沿成果,它做到了几乎整个收集过程都全程可并发,短暂停顿也只与 GC Roots 大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。
ZGC 由于每个 Region 并未维护记忆集,甚至连分代都没有,优点是给用户线程带来的负担会小很多,相应它能承受的对象分配速率不会太高。例如 ZGC 准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(ZGC 立的 Flag 是停顿时间不超过十毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范 围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了,唯 一的办法就是尽可能地增加堆容量大小获得更多喘息的时间。
不过现在的 ZGC 已经支持分代了,转转有关于分代 ZGC 的一篇实践文章:JDK21(21.0.2_13)分代ZGC在转转商列服务中的实践。