深入理解 Java 虚拟机-04 垃圾收集器
收集算法是内存回收的方法论,而垃圾收集器是内存回收的实践者。

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 虚拟机,尤其是 JDK 7 之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了 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 在并发标记时由于与用户线程并发运行,会争抢 CPU 资源,当处理器核心数量不足四个时, CMS 对用户程序的影响就可能变得很大(并发回收线程数(N+3)/4,占比超过 25%);并发执行时用户线程会产生新的垃圾,这部分垃圾只能下次收集,也称为浮动垃圾;由于用户线程在执行,需要预留空间,因此老年代收集要达到某个比例时就开始,即使这样也有可能并发标记失败,进而触发 FullGC(参数 CMSInitiatingOccupancyFraction);由于是清除算法,所以会有内存碎片问题,在执行过若干次(数量 由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表 示每次进入Full GC时都进行碎片整理)。
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 里面的垃圾堆积的"价值"大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis 指定,默认值是200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是"Garbage First"名字的由来。
G1 收集器的工作一般也分为4个阶段:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB 记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的Region中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
但是 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 要高。