前言
上篇文章已经为大家详细介绍了 JVM 的垃圾收集机制,那么这次就一起来看看这些机制究竟是怎样应用到具体的垃圾收集器上的吧。Java 语言和 JVM 在不断迭代发展的同时,垃圾收集器也在不断地进化,从最初的的单线程收集器 Serial,到后来的并行收集器 Parallel 和并发收集器 CMS、G1,再到垃圾收集器最前沿成果——超低延迟的 Shenandoah 和 ZGC,还有不做垃圾收集的垃圾收集器 Epsilon (是的你没有看错),正是有了这些垃圾收集器的存在,Java 开发者才得以从繁琐的手动管理中解放出来。下面将为大家一一介绍这些垃圾收集器,全文采用“总-分”结构,先总体认识一下所有的垃圾收集器,在逐个进行介绍。
一、垃圾收集器汇总
下图就是 HotSpot 虚拟机上的已商用的垃圾收集器的关系图 (此图并不包含 Shenandoah 和 ZGC,因为这两者目前都还处于实验阶段,且没有遵循经典的分代收集理论,另外的 Epsilon 也不是常规的垃圾收集器,因此也没出现在此图上)。
图中的连线表示两个垃圾收集器之间可以搭配使用,请注意,JDK 9 已不再支持 Serial + CMS 和 ParNew + Serial Old 的搭配组合。如果觉得数量太多不好记的话,可以把上图中的五个垃圾收集器分为以下三大类:
Serial 类:新生代版本为 Serial,老年代版本为 Serial Old,这两个都是单线程垃圾收集器。另外,ParNew 相比 Serial 只是增加了多线程并行收集的功能,并无其他太大差别。
Parallel 类:包括 Parallel Scavenge 和 Parallel Old,多线程并行垃圾收集器经典组合,这个组合更注重于提高程序的吞吐量。
并发收集器:CMS 和 G1都可以并发进行垃圾收集,其中 CMS 只适用于老年代,而 G1 则横跨新生代和老年代。
并发 (concurrent)与并行 (parallel):这里所说的并发与并行的概念和操作系统里的概念有所不同,这里的并发是指垃圾收集线程和用户线程可以同时执行,而并行是指多个垃圾收集线程同时执行,但用户线程必须暂停。
除了上图这些经典的垃圾收集器,还有一些目前尚处于试验阶段的黑科技收集器,这部分仅做了解即可,万一面试的时候扯到了,还能顺带装一波逼。OracleJDK 11 新加入了 ZGC 收集器(目前还处于实验阶段),OpenJDK 12 中也加入了 其独有的 Shenandoah 收集器 (也处于实验阶段),OracleJDK 和 OpenJDK 的区别这里就不细说了。这两款垃圾收集器都以超低延迟为卖点,也就是尽量缩短垃圾收集时用户线程的暂停 (Stop The World)的时间,这两款收集器都宣称可以把垃圾收集的停顿时间控制在 10 毫秒以内,比之前最牛X的G1的延迟还要短。最后还有适用于微服务领域的 Epsilon,下面就为大家一一介绍这些琳琅满目、五花八门的垃圾收集器。
二、垃圾收集器详解
2.1 新生代收集器
2.1.1 Serial 收集器
Serial 收集器是最基础、历史最悠久的垃圾收集器,在 JDK 1.3.1 之前是 HotSpot 虚拟机新生代收集器的唯一选择。既然如此,也不能指望它有多么强大的功能了,这是一款单线程收集器,不仅只有一个垃圾收集线程,更难受的是它在进行垃圾收集时必须暂停所有用户线程,也就是说垃圾收集时需要全程 “Stop The World”,如图:
可见 Serial 在进行垃圾收集是必须“Stop The World”,而且其单线程的收集效率并不高,可能造成用户程序的长时间停顿。上篇文章已经给大家介绍过了新生代和老年代的概念,接下来补充一下图中安全点的概念:
安全点 (safepoint):安全点是代码指令中特定的位置,这些位置记录着栈和寄存器里那些位置是引用,这样收集器在扫描垃圾对象时就不需要一个不漏地从方法区等 GC Roots 开始查找。安全点位置一般选在方法调用、循环跳转和异常跳转的代码指令处,因为这些位置的代码可以“长时间运行”。
2.1.2 ParNew 收集器
ParNew 收集器实质上就是 Serial 收集器的多线程版本,这也是它的唯一优势,除了同时使用多线程进行垃圾收集之外,其他的行为包括 Serial 所有可用的控制参数 (比如 -XX:SurvivorRatio,-XX:PretenureSizeThreshold,-XX:HandlePromotionFailure等),还垃圾收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致。这两个收集器的底层代码大部分也是相通的。
可以使用 -XX:+/-UseParNewGC选项来强制指定或禁用 ParNew 收集器,ParNew 还有一个特点,就是在使用 -XX:UseConcMarkSweepGC 参数激活 CMS 收集器后,新生代会默认使用 ParNew 收集器。
2.1.3 Parallel Scavenge 收集器
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),虚拟机会根据系统运行状态并收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。
2.2 老年代收集器
2.2.1 Serial Old 收集器
这就是 Serial 的老年代版本,也是单线程收集器,使用标记-整理算法,是 Serial 的黄金搭档:
这个搭档主要用在客户端模式下,除此之外,Serial Old 还有两个用途,那就是和 JDK 5 及之前的 Parallel Scavenge搭配使用,以及作为 CMS 收集器失败之后的备胎。
2.2.2 Parallel Old 收集器
这个是 Parallel Scavenge 的老年代版本,但是直到 JDK 6 才正式提供,之前的 Parallel Scavenge 只能和单线程的 Serial Old 搭配使用,完全发挥不了其优势,Parallel Old 出现后,“吞吐量优先”收集器终于也有了黄金搭档:
2.2.3 CMS 收集器
CMS (Concurrent Mark Sweep) 收集器是一款致力于获取最短停顿时间的收集器,从它的名字中可以看出这款收集器有两个重要特点:一,这是一款可以并发进行垃圾收集的收集器;二,这款收集器是基于标记清除-算法的。它的运作过程相对于之前不能并发的垃圾收集器更加复杂,大体分为以下四个步骤:
-
初始标记 (CMS initial mark)
这一步仅仅是标记一下与 GC Roots 直接关联的对象,虽然不是并发执行,但是速度很快,用户程序会有短暂的暂停。
-
并发标记 (CMS concurrent mark)
这一步比较耗时,需要遍历所有与 GC Roots 有关联的对象,但是可以与用户线程并发执行,所以对用户程序影响不大。
-
重新标记 (CMS remark)
由于并发标记过程中用户程序是不暂停的,所以有可能引起原来的标记对象产生变动,而重新标记的作用就是修正那些变动的标记记录,这一阶段虽然无法并发执行,但是工作量很小,所以持续时间也很短。
-
并发清除 (CMS concurrent sweep)
这一阶段就是清除掉可回收的对象,回想上篇文章介绍的标记-清除算法,在清除掉垃圾对象后并不需要移动存活对象,所以这一阶段可以与用户线程并发执行。
综上,CMS 收集器在运行过程中只需在初始标记阶段和重新标记阶段暂停用户程序,而且时间很短,其他阶段均可与用户程序并发执行,这就是它实现超短停顿的秘密所在。
CMS 的优势很明显,就是并发收集和低停顿,但也不是完美无缺的,它主要有以下三个明显缺点:
CMS 收集器对处理器资源,也就是 CPU 核心数非常敏感,这也是所有并发设计的程序的共同特点。虽然它并发特点带来了低停顿的优势,但是由于挤占了处理器资源,导致总吞吐量降低,程序运行总时间也会相应延长。
CMS 无法处理“浮动垃圾”,因为并发标记阶段和并发清除阶段用户程序是在继续执行的,自然会继续产生垃圾对象,但是这些垃圾对象产生在标记阶段之后,所以无法被标记出来,自然也就无法被清除,而这可能会引发停顿时间较长的 Full GC。
空间碎片,既然 CMS 是基于标记-清除算法的,也就不能避免产生空间碎片了,空间碎片就是不连续的可用内存,这可能导致明明有剩余空间,但就是放不下新对象,从而提前触发 Full GC。
2.3 G1 收集器
Garbage First 收集器,简称 G1,可以说是垃圾收集器技术史上里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局结构。也是从 G1 开始,垃圾收集器,包括后来的 Shenandoah 和 ZGC 都不再局限于只回收新生代或只回收老年代,而是面向整个 Java 堆。
G1 收集器最大的特色就是可预测的停顿,用户可以通过 -XX:MaxPauseMillis 参数 (默认200毫秒)指定期望的最大停顿时间,但不能随意指定,要切合实际,然后 G1 会根据这一目标值筛选并回收那些回收价值最高的可回收对象,那么 G1 是怎样做到这一点的呢?关键就在于 G1 基于 Region 的内存布局,先来看一下 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 的回收过程大致可以分为以下四个步骤:
初始标记 (initial marking):仅仅标记直接与 GC Roots 关联的对象,停顿时间很短;
并发标记 (Concurrent Marking):从 GC Roots 开始对堆中对象进行可达性分析,耗时较长,但能与用户程序并发执行,所以不会停顿;
最终标记 (Final Marking):用户程序并发执行导致对象引用关系变化,修正变化的引用,此阶段会短暂地暂停用户程序;
筛选回收 (Live Data Counting and Evacuation):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,并根据用户所期望的停顿时间指定回收计划,可以自由选择任意多个 Region 构成回收集,然后把需要回收的 Region 中存货对象复制到空的 Region 中,在清理掉旧 Region 的全部空间,这里涉及存活对象的移动,必须暂停用户线程,是由多条收集器线程并行完成的。
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。
2.4 最前沿科技成果:低延迟垃圾收集器
之前最先进的 G1 收集器早在 JDK 7 上就已经发布了成熟版,而截至目前的2020年初,JDK 版本已经来到了 JDK 13,与此同时,垃圾收集器领域也早已有了更先进的黑科技,其中的代表者就是号称可以将停顿时间控制在10毫秒内低延迟收集器——Shenandoah 和 ZGC,它们最牛X的地方在于并发程度更高,连移动存活对象 (也就是标记-整理算法的整理阶段)都可以做到并发执行 (不过二者的实现原理有所区别):
由图可知,相比之前的收集器,Shenandoah 和 ZGC 在工作过程中几乎全程并发,只有在初始标记、最终标记这些阶段有短暂的暂停,而且这些停顿时间与堆容量和堆中对象数量没有正比例关系,这才可以将停顿时间控制在惊人的10毫秒以内。
2.4.1 Shenandoah 收集器
Shenandoah 是由 ReadHat 公司独立发展的新型垃圾收集器,并在2014年贡献给了 OpenJDK,并成为 OpenJDK 12 的正式特性之一,但是以 Oracle 公司的尿性,却不愿把它添加到 OracleJDK 中,这也导致了免费开源的 OpenJDK 反而比商业收费的 OracleJDK 功能更多,实属罕见。
Shenandoah 与 G1 有很多相似之处,比如都是基于 Region 的内存布局,都有用于存放大对象的 Humongous Region,默认回收策略也是优先处理回收价值最大的 Region。不过也有三个重大的区别:
最最重要的区别,Shenandoah 支持并发的整理算法,G1 的整理阶段虽是多线程并行,但无法与用户程序并发执行;
默认不使用分代收集理论;
使用连接矩阵 (Connection Matrix)记录跨 Region 的引用关系,替换掉了 G1 中的记忆级 (Remembered Set),内存和计算成本更低。
Shenandoah 收集器的工作原理相比 G1 要复杂不少,其运行流程示意图如下:
可见 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 与之前各种收集器在停顿时间维度和系统开销维度上的对比:
OracleJDK 并不支持 Shenandoah,如果你用的是 OpenJDK 12 或某些支持 Shenandoah 移植版的 JDK 的话,可以通过以下参数开启 Shenandoah:
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC
2.4.2 ZGC 收集器
Z Garbage Collector,简称 ZGC,是 JDK 11 中新加入的尚在实验阶段的低延迟垃圾收集器。它和 Shenandoah 同属于超低延迟的垃圾收集器,但在吞吐量上比 Shenandoah 有更优秀的表现,甚至超过了 G1,接近了“吞吐量优先”的 Parallel 收集器组合,可以说近乎实现了“鱼与熊掌兼得”。
ZGC 的内存布局
与 Shenandoah 和 G1 一样,ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是, ZGC 的 Region 具有动态性,也就是可以动态创建和销毁,容量大小也是动态的,有大、中、小三类容量:
小型 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
2.5 最奇葩的垃圾收集器——Epsilon
上面介绍的各种收集器,比如 G1、Shenandoah 和 ZGC 等都是越来越复杂,越来越先进, 而 JDK 11 新加入的 Epsilon 却是反其道而行,这款收集器不会做任何垃圾收集的操作,也许叫做“内存分配器”更加合适。虽然很奇葩,但是它还是有用武之地的,比如越来越火的微服务领域,如果系统运行时间很短,在堆内存耗尽之前就可以结束,那么垃圾收集也就没有任何意义了,这正是 Epsilon 的使用场景。
总结
本文为大家介绍了目前 HotSpot 虚拟机上的所有垃圾收集器,有的已经久经沙场,有的仍处于试验阶段,但有望在未来成为主流,在实际应用中,大家可以根据具体场景选择合适的垃圾收集器。
参考资料: