上篇文章已经为大家详细介绍了 JVM 的垃圾收集机制,那么这次就一起来看看这些机制究竟是怎样应用到具体的垃圾收集器上的吧。Java 语言和 JVM 在不断迭代发展的同时,垃圾收集器也在不断地进化,从最初的的单线程收集器 Serial,到后来的并行收集器 Parallel 和并发收集器 CMS、G1,再到垃圾收集器最前沿成果——超低延迟的 Shenandoah 和 ZGC,还有不做垃圾收集的垃圾收集器 Epsilon (是的你没有看错),正是有了这些垃圾收集器的存在,Java 开发者才得以从繁琐的手动管理中解放出来。下面将为大家一一介绍这些垃圾收集器,全文采用“总-分”结构,先总体认识一下所有的垃圾收集器,在逐个进行介绍。
下图就是 HotSpot 虚拟机上的已商用的垃圾收集器的关系图 (此图并不包含 Shenandoah 和 ZGC,因为这两者目前都还处于实验阶段,且没有遵循经典的分代收集理论,另外的 Epsilon 也不是常规的垃圾收集器,因此也没出现在此图上)。
HotSpot 虚拟机的垃圾收集器
图中的连线表示两个垃圾收集器之间可以搭配使用,请注意,JDK 9 已不再支持 Serial + CMS 和 ParNew + Serial Old 的搭配组合。如果觉得数量太多不好记的话,可以把上图中的五个垃圾收集器分为以下三大类:
并发 (concurrent)与并行 (parallel):这里所说的并发与并行的概念和操作系统里的概念有所不同,这里的并发是指垃圾收集线程和用户线程可以同时执行,而并行是指多个垃圾收集线程同时执行,但用户线程必须暂停。
除了上图这些经典的垃圾收集器,还有一些目前尚处于试验阶段的黑科技收集器,这部分仅做了解即可,万一面试的时候扯到了,还能顺带装一波逼。OracleJDK 11 新加入了 ZGC 收集器(目前还处于实验阶段),OpenJDK 12 中也加入了 其独有的 Shenandoah 收集器 (也处于实验阶段),OracleJDK 和 OpenJDK 的区别这里就不细说了。这两款垃圾收集器都以超低延迟为卖点,也就是尽量缩短垃圾收集时用户线程的暂停 (Stop The World)的时间,这两款收集器都宣称可以把垃圾收集的停顿时间控制在 10 毫秒以内,比之前最牛X的G1的延迟还要短。最后还有适用于微服务领域的 Epsilon,下面就为大家一一介绍这些琳琅满目、五花八门的垃圾收集器。
Serial 收集器是最基础、历史最悠久的垃圾收集器,在 JDK 1.3.1 之前是 HotSpot 虚拟机新生代收集器的唯一选择。既然如此,也不能指望它有多么强大的功能了,这是一款单线程收集器,不仅只有一个垃圾收集线程,更难受的是它在进行垃圾收集时必须暂停所有用户线程,也就是说垃圾收集时需要全程 “Stop The World”,如图:
Serial / Serial Old 搭配的垃圾收集示意图
可见 Serial 在进行垃圾收集是必须“Stop The World”,而且其单线程的收集效率并不高,可能造成用户程序的长时间停顿。上篇文章已经给大家介绍过了新生代和老年代的概念,接下来补充一下图中安全点的概念:
安全点 (safepoint):安全点是代码指令中特定的位置,这些位置记录着栈和寄存器里那些位置是引用,这样收集器在扫描垃圾对象时就不需要一个不漏地从方法区等 GC Roots 开始查找。安全点位置一般选在方法调用、循环跳转和异常跳转的代码指令处,因为这些位置的代码可以“长时间运行”。
ParNew 收集器实质上就是 Serial 收集器的多线程版本,这也是它的唯一优势,除了同时使用多线程进行垃圾收集之外,其他的行为包括 Serial 所有可用的控制参数 (比如 -XX:SurvivorRatio,-XX:PretenureSizeThreshold,-XX:HandlePromotionFailure等),还垃圾收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致。这两个收集器的底层代码大部分也是相通的。
ParNew / Serial Old 搭配的垃圾收集示意图
可以使用 -XX:+/-UseParNewGC选项来强制指定或禁用 ParNew 收集器,ParNew 还有一个特点,就是在使用 -XX:UseConcMarkSweepGC 参数激活 CMS 收集器后,新生代会默认使用 ParNew 收集器。
Parallel Scavenge 收集器也是一款作用于新生代、基于标记-复制算法的多线程并行垃圾收集器,与 ParNew 有很多相似之处。相比 CMS、G1、Shenandoah 和ZGC 这些致力于降低停顿时间,也就是低延迟的收集器,Parallel Scavenge 是吞吐量 (throughput)优先的收集器,吞吐量是指 CPU 用于运行用户程序的时间与 CPU 总消耗时间的比值:
吞吐量计算表达式
低延迟和高吞吐量的收集器有着不同的适用场景,前者适用于与用户交互较多或需要保证服务器响应质量的场景,低延迟可以带来良好的用户体验,而高吞吐量可以让 CPU 把更多的时间用在运行用户程序上面,可以更快完成任务,适用于交互性不强的后台运算场景。Parallel Scavenge 提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数和直接设置吞吐量大小的 -XX:GCTimeRatio。
Parallel Scavenge 还有一个比较特色的开关参数:-XX:+UseAdaptiveSizePolicy,激活这个参数后,会开启自适应策略,也就是无需我们手动设置新生代大小 (-Xmn)、Eden 与 Survivor 的比例 (-XX:SurvivorRatio)和直接晋升老年代对象大小(-XX:PretenureSizeThreshold),虚拟机会根据系统运行状态并收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。
这就是 Serial 的老年代版本,也是单线程收集器,使用标记-整理算法,是 Serial 的黄金搭档:
Serial / Serial Old 搭配的垃圾收集示意图
这个搭档主要用在客户端模式下,除此之外,Serial Old 还有两个用途,那就是和 JDK 5 及之前的 Parallel Scavenge搭配使用,以及作为 CMS 收集器失败之后的备胎。
这个是 Parallel Scavenge 的老年代版本,但是直到 JDK 6 才正式提供,之前的 Parallel Scavenge 只能和单线程的 Serial Old 搭配使用,完全发挥不了其优势,Parallel Old 出现后,“吞吐量优先”收集器终于也有了黄金搭档:
Parallel Scavenge / Parallel Old 搭配的垃圾收集过程
CMS (Concurrent Mark Sweep) 收集器是一款致力于获取最短停顿时间的收集器,从它的名字中可以看出这款收集器有两个重要特点:一,这是一款可以并发进行垃圾收集的收集器;二,这款收集器是基于标记清除-算法的。它的运作过程相对于之前不能并发的垃圾收集器更加复杂,大体分为以下四个步骤:
初始标记 (CMS initial mark)
这一步仅仅是标记一下与 GC Roots 直接关联的对象,虽然不是并发执行,但是速度很快,用户程序会有短暂的暂停。
并发标记 (CMS concurrent mark)
这一步比较耗时,需要遍历所有与 GC Roots 有关联的对象,但是可以与用户线程并发执行,所以对用户程序影响不大。
重新标记 (CMS remark)
由于并发标记过程中用户程序是不暂停的,所以有可能引起原来的标记对象产生变动,而重新标记的作用就是修正那些变动的标记记录,这一阶段虽然无法并发执行,但是工作量很小,所以持续时间也很短。
并发清除 (CMS concurrent sweep)
这一阶段就是清除掉可回收的对象,回想上篇文章介绍的标记-清除算法,在清除掉垃圾对象后并不需要移动存活对象,所以这一阶段可以与用户线程并发执行。
CMS 垃圾收集过程
综上,CMS 收集器在运行过程中只需在初始标记阶段和重新标记阶段暂停用户程序,而且时间很短,其他阶段均可与用户程序并发执行,这就是它实现超短停顿的秘密所在。
CMS 的优势很明显,就是并发收集和低停顿,但也不是完美无缺的,它主要有以下三个明显缺点:
Garbage First 收集器,简称 G1,可以说是垃圾收集器技术史上里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局结构。也是从 G1 开始,垃圾收集器,包括后来的 Shenandoah 和 ZGC 都不再局限于只回收新生代或只回收老年代,而是面向整个 Java 堆。
G1 收集器最大的特色就是可预测的停顿,用户可以通过 -XX:MaxPauseMillis 参数 (默认200毫秒)指定期望的最大停顿时间,但不能随意指定,要切合实际,然后 G1 会根据这一目标值筛选并回收那些回收价值最高的可回收对象,那么 G1 是怎样做到这一点的呢?关键就在于 G1 基于 Region 的内存布局,先来看一下 G1 和之前垃圾收集器的堆内存布局对比:
G1 之前各款垃圾收集器的堆内存布局
G1 收集器堆内存布局
由此可见,虽然 G1 仍然遵循分带收集理论,但是内存区域不再按照固定大小的新生代和老年代进行划分,而是把连续的 Java 对划分成多个大小相等的独立区域 (Region),每一个 Region 都可以根据需要扮演新生代的 Eden 空间、Survivor 空间或老年代空间。收集器可以对扮演不同角色的 Region 采用不同的策略进行处理,这样无论是新创建的对象还是已经存活了一段时间的对象,抑或是熬过了多次收集的就对象都能获得很好的收集效果。Region 中还有一类特殊的 Humongous 区域,专门用来存放大对象,G1 认为只要大小超过一个 Region 容量一半的对象就可判定为大对象,每个 Region 的大小可以通过参数 -XX:G1HeapRedionSize 设定,取值范围为 1MB~32MB,且为 2 的 N 次幂,对于那些大小超过整个 Region 大小的超大对象,将会被存放在 N 个连续的 Humongous Region 中,G1 一般会把 Humongous Region 看做老年代。
在把内存分成 Region 管理之后,G1 就可以对这些 Region 各个击破了,其停顿时间之所以可控,是因为 G1 在垃圾收集时并不会把整个 Java 堆当做回收区域,而是只收集那些回收价值最高的 Region,保证能在指定最大停顿时间内回收完毕,回收价值是指回收所获得的空间大小及耗费时间的权衡结果。这样就保证了 G1 能在指定时间内获得尽可能高的回收效率。
G1 的回收过程大致可以分为以下四个步骤:
G1 收集器运行过程
G1 和 CMS 都是以低停顿为目标的收集器,所以经常被拿来比较孰优孰劣,虽然 G1 相比 CMS 优势明显,但也并非全方位的碾压,G1相比 CMS 的优缺点如下:
G1 优点:
可以指定最大停顿时间;
分 Region 管理内存,按受益动态确定回收区域;
不会产生内存碎片:G1 的内存布局并不是固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域 (Region),G1 从整体来看是基于“标记-整理”算法实现的收集器,但从局部 (两个Region 之间)上看又是基于“标记-复制”算法实现,不会像 CMS (“标记-清除”算法) 那样产生内存碎片。
G1 缺点:
G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。
按照《深入理解Java虚拟机》作者的说法,CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的分水岭是6GB到8GB。
之前最先进的 G1 收集器早在 JDK 7 上就已经发布了成熟版,而截至目前的2020年初,JDK 版本已经来到了 JDK 13,与此同时,垃圾收集器领域也早已有了更先进的黑科技,其中的代表者就是号称可以将停顿时间控制在10毫秒内低延迟收集器——Shenandoah 和 ZGC,它们最牛X的地方在于并发程度更高,连移动存活对象 (也就是标记-整理算法的整理阶段)都可以做到并发执行 (不过二者的实现原理有所区别):
各种垃圾收集器并发程度对比,绿色表示并发,黄色表示非并发
由图可知,相比之前的收集器,Shenandoah 和 ZGC 在工作过程中几乎全程并发,只有在初始标记、最终标记这些阶段有短暂的暂停,而且这些停顿时间与堆容量和堆中对象数量没有正比例关系,这才可以将停顿时间控制在惊人的10毫秒以内。
Shenandoah 是由 ReadHat 公司独立发展的新型垃圾收集器,并在2014年贡献给了 OpenJDK,并成为 OpenJDK 12 的正式特性之一,但是以 Oracle 公司的尿性,却不愿把它添加到 OracleJDK 中,这也导致了免费开源的 OpenJDK 反而比商业收费的 OracleJDK 功能更多,实属罕见。
Shenandoah 与 G1 有很多相似之处,比如都是基于 Region 的内存布局,都有用于存放大对象的 Humongous Region,默认回收策略也是优先处理回收价值最大的 Region。不过也有三个重大的区别:
Shenandoah 收集器的工作原理相比 G1 要复杂不少,其运行流程示意图如下:
Shenandoah 收集器运行流程
可见 Shenandoah 的并发程度明显比 G1 更高,只需要在初始标记、最终标记、初始引用更新和最终引用更新这几个阶段进行短暂的“Stop The World”,其他阶段皆可与用户程序并发执行,其中最重要的并发标记、并发回收和并发引用更新详情如下:
并发标记( Concurrent Marking)
与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
并发回收( Concurrent Evacuation)
并发回收阶段是 Shenandoah 与之前 HotSpot 中其他收集器的核心差异。在这个阶段, Shenandoah 要把待回收 Region 里面的存活对象先复制一份到其他未被使用的 Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难, Shenandoah 将会通过读屏障和被称为“ Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
Brooks Pointers 简要介绍:这是一种转发指针 (Forwarding Pointer),原理就是在所有的对象上新添加一个指针,初始状态下该指针指向对象本身,而在垃圾回收过程中,如果该对象是存活对象,则需要将其从回收区域移动到目标区域 (其实就是在目标区域复制一个新对象,这就是标记-整理算法的整理阶段,之前的 G1 收集器在此阶段无法与用户程序并发执行),然后把旧对象的转发指针指向新的对象,这样用户程序在并发执行的情况下,就不会访问到旧对象了。
并发引用更新( Concurrent Update Reference)
这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
Shenandoah 的高并发度让它实现了超低的停顿时间,但是更高的复杂度也伴随着更高的系统开销,这在一定程度上会影响吞吐量,下图是 Shenandoah 与之前各种收集器在停顿时间维度和系统开销维度上的对比:
Shenandoah 与之前各种收集器在停顿时间维度和系统开销维度上的对比
OracleJDK 并不支持 Shenandoah,如果你用的是 OpenJDK 12 或某些支持 Shenandoah 移植版的 JDK 的话,可以通过以下参数开启 Shenandoah:
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC
Z Garbage Collector,简称 ZGC,是 JDK 11 中新加入的尚在实验阶段的低延迟垃圾收集器。它和 Shenandoah 同属于超低延迟的垃圾收集器,但在吞吐量上比 Shenandoah 有更优秀的表现,甚至超过了 G1,接近了“吞吐量优先”的 Parallel 收集器组合,可以说近乎实现了“鱼与熊掌兼得”。
ZGC 的内存布局
与 Shenandoah 和 G1 一样,ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是, ZGC 的 Region 具有动态性,也就是可以动态创建和销毁,容量大小也是动态的,有大、中、小三类容量:
ZGC 内存布局
小型 Region (Small Region):容量固定为 2MB,用于放置小于 256KB 的小对象。
中型 Region (M edium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对
象。
大型 Region (Large Region):容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。每个大型 Region 中只会存放一个大对象,这也预示着虽然名字叫作“大型 Region”,但它的实际容量完全有可能小于中型 Region,最小容量可低至 4MB。
与 Shenandoah 一样,ZGC 在工作过程中也几乎是全程与用户程序并发的,重点也是实现了标记-整理算法的整理阶段可以与用户程序并发执行。但是二者的实现方式不同,Shenandoah 是在对象身上添加转发指针的方法,而 ZGC 则是直接在指针上动手脚,也就是传说中的染色指针 (Colored Pointers),这个指针就是 Java 对象的引用,例如:
Object o = new Object();
其中“o” 只是一个引用,也就是指针,指向存在堆上的对象实例,引用自身也是要占内存的,普通引用在32位机器占4个字节,在64位机器上,开启压缩指针 (-XX:+UseCompressedOops) 的话占4个字节,不开启的话占8个字节。ZGC 的染色指针结构如下 (不支持32位机器和压缩指针):
染色指针结构示意图
得益于染色指针上标志位的支持,ZGC 也可以像 Shenandoah 那样,实现了在移动存活对象的过程中可以与用户程序并发执行,且效率更高。ZGC 还用到了很多其他的黑科技,原理过于复杂,就不在这里详述了。
在 JDK 11 及以上版本,可以通过以下参数开启 ZGC:
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
上面介绍的各种收集器,比如 G1、Shenandoah 和 ZGC 等都是越来越复杂,越来越先进, 而 JDK 11 新加入的 Epsilon 却是反其道而行,这款收集器不会做任何垃圾收集的操作,也许叫做“内存分配器”更加合适。虽然很奇葩,但是它还是有用武之地的,比如越来越火的微服务领域,如果系统运行时间很短,在堆内存耗尽之前就可以结束,那么垃圾收集也就没有任何意义了,这正是 Epsilon 的使用场景。
本文为大家介绍了目前 HotSpot 虚拟机上的所有垃圾收集器,有的已经久经沙场,有的仍处于试验阶段,但有望在未来成为主流,在实际应用中,大家可以根据具体场景选择合适的垃圾收集器。
参考资料: