目录
GC性能指标整体说明
评估GC性能指标
不可能三角
吞吐量与暂停时间的对比说明
吞吐量VS暂停时间
垃圾收集器发展史
垃圾收集器分类
按碎片处理方式
按工作的内存区间分
不同垃圾收集器详解
Serial
Serial Old
ParNew
ParNew收集器的工作过程如下图:
cms的简单介绍
ParNew收集器的多线程设置
Parallel
Parallel 特点
Parallel Old
CMS
工作过程
优点
G1
G1收集器的地位
Mixed GC模式
工作原理
存在的问题
G1收集器的运作过程
G1 VS CMS
ZGC
简介
内存布局
并发整理算法的实现
ZGC运作过程
优缺点
最后
前面几篇文章我们讲了垃圾收集相关的算法,现在我们开始讲垃圾收集器。
有了虚拟机就一定需要手机垃圾的机制,这就是Garbage Collection,对应的产品我们称为Garbage Collector。
在了解垃圾收集器之前,我们要先了解评价垃圾收集器好坏的性能指标。
吞吐量、暂停时间和内存占用这三者共同构成一个“不可能三角”,三者总体表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中两项。
这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占多多点也无关紧要,硬件性能的提升也有助于降低收集器运行时间对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
简单来说主要抓住两点:
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
吞吐量优先意味着在单位时间内,STW的时间最短:0.2+0.2=0.4
高吞吐量较好,因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作,直觉上,吞吐量越高程序运行越流畅。
低暂停时间(低延迟)较好,因为从最终用户的角度来看,不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低暂停时间是非常重要的,特别是对于一个交互式应用程序。
不幸的是,“高吞吐量”和“低暂停时间”是一对相互竞争的目标(矛盾)。
在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或较小暂停时间),或尝试找到一个两者的折中。
现在的标准:在最大吞吐量优先的情况下,降低停顿时间。
这是几款比较经典的垃圾收集器,我总结了一下它们的发展史。
出现的年份 | 出现在哪一版本 | 名称 | 备注 |
1999年 | JDK 1.3.1 | Serial GC |
ParNew GC 是 Serial GC 的多线程版本 |
2002年 | JDK1.4.2 | Parallel GC 和 CMS |
Parallel GC 在 JDK6 之后成为 Hotspot 默认GC。 |
2012年 | JDK1.7u4 | G1 |
|
2017年 | JDK9 | G1 |
G1 成为默认垃圾收集器,以替代 CMS |
2018年3月 | JDK10 | G1 |
G1 的并行完整垃圾回收,实现并行性能改善最坏情况的延迟。 |
2018年9月 | JDK11 | Epsilon GC |
同时引入 ZGC |
2019年3月 | JDK12 发布 | 增强 G1 |
增加 G1 ,自动返回未使用堆内存给操作系统; 同时,引入Shenandoah GC |
2019年9月 | JDK13 发布 | 增强 ZGC |
增强 ZGC ,自动返回未使用堆内存给操作系统 |
2020年3月 | JDK14 | 删除 CMS |
扩展 ZGC 在 mac 和 windows 的应用 |
Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
Serial收集器的工作过程如下图:
迄今为止,Serial收集器依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。这两点都将在后面的内容中继续讲解。Serial Old收集器的工作过程如图:
没错,和Serial的工作工程一样。
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
在JDK 5发布时,HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
遗憾的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。
可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK 9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial Old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了-XX:+UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。也可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如32个,现在CPU都是多核加超线程设计,服务器达到或超过32个逻辑核心的情况非常普遍)的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
注意从ParNew收集器开始,后面还将会接触到若干款涉及“并发”和“并行”概念的收集器。在大家可能产生疑惑之前,有必要先解释清楚这两个名词。并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器
吞吐量可控
Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,那它有什么特别之处呢?Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是:
自适应调节
自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如图:
工作过程也是和 Parallel 是一样的。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过图3-11可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。

Concurrent Mark Sweep收集器运行示意图:
并发收集、低停顿,
缺点:
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:
如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:
从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。
从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过下图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。

G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制。
以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。
ZGC(“Z”并非什么专业名词的缩写,这款收集器的名字就叫作Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。2018年Oracle创建了JEP 333将ZGC提交给OpenJDK,推动其进入OpenJDK 11的发布清单之中。
ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是ZGC和Shenandoah的实现思路又是差异显著的,如果说RedHat公司开发的Shen-andoah像是Oracle的G1收集器的实际继承者的话,那Oracle公司开发的ZGC就更像是Azul System公司独步天下的PGC(Pauseless GC)和C4(Concurrent Continuously Compacting Collector)收集器的同胞兄弟。
如果不避讳专业术语的话,我们可以给ZGC下一个这样的定义来概括它的主要特征:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,本章为行文一致继续称为Region)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有如图所示的大、中、小三类容量:
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的实现方式。
染色指针技术
ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)。
从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。
但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?
能不能从指针或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?这样的要求并非不合理的刁难,先不去说并发移动对象可能带来的可访问性问题,此前我们就遇到过这样的要求——追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果。
HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),而ZGC的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。
染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。
尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图3-20所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。

染色指针优势:
虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:+UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的,在JEP 333的描述页中,ZGC的设计者Per Liden在“描述”小节里花了全文过半的篇幅来陈述染色指针的三大优势:
不过,要顺利应用染色指针有一个必须解决的前置问题:Java虚拟机作为一个普普通通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?这是很现实的问题,无论中间过程如何,程序代码最终都要转换为机器指令流交付给处理器去执行,处理器可不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。这个问题在Solaris/SPARC平台上比较容易解决,因为SPARC硬件层面本身就支持虚拟地址掩码,设置之后其机器指令直接就可以忽略掉染色指针中的标志位。但在x86-64平台上并没有提供类似的黑科技,ZGC设计者就只能采取其他的补救措施了,这里面的解决方案要涉及虚拟内存映射技术,让我们先来复习一下这个x86计算机体系中的经典设计。
在远古时代的x86计算机系统里面,所有进程都是共用同一块物理内存空间的,这样会导致不同进程之间的内存无法相互隔离,当一个进程污染了别的进程内存后,就只能对整个系统进行复位后才能得以恢复。为了解决这个问题,从Intel 80386处理器开始,提供了“保护模式”用于隔离进程。在保护模式下,386处理器的全部32条地址寻址线都有效,进程可访问最高也可达4GB的内存空间,但此时已不同于之前实模式下的物理内存寻址了,处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。如果读者对计算机结构体系了解不多的话,不妨设想这样一个场景来类比:假如你要去“中山一路3号”这个地址拜访一位朋友,根据你所处城市的不同,譬如在广州或者在上海,是能够通过这个“相同的地址”定位到两个完全独立的物理位置的,这时地址与物理位置是一对多关系映射。
不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图所示。

在某些场景下,多重映射技术确实可能会带来一些诸如复制大对象时会更容易这样的额外好处,可从根源上讲,ZGC的多重映射只是它采用染色指针技术的伴生产物,并不是专门为了实现其他某种特性需求而去做的。
ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异,ZGC的运作过程具体如图所示。
ZGC的设计理念与Azul System公司的PGC和C4收集器一脉相承,是迄今垃圾收集器研究的最前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。
相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。
可是,必定要有优有劣才会称作权衡,ZGC的这种选择也限制了它能承受的对象分配速率不会太高,可以想象以下场景来理解ZGC的这个劣势:ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(请读者切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。Azul的C4收集器实现了分代收集后,能够应对的对象分配速率就比不分代的PGC收集器提升了十倍之多。
ZGC还有一个常在技术资料上被提及的优点是支持“NUMA-Aware”的内存分配。NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北桥芯片中的内存控制器也被集成到了处理器内核中,这样每个处理器核心所在的裸晶(DIE)都有属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过Inter-Connect通道来完成,这要比访问处理器的本地内存慢得多。在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在ZGC之前的收集器就只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配,如今ZGC也成为另外一个选择。
在性能方面,尽管目前还处于实验状态,还没有完成所有特性,稳定性打磨和性能调优也仍在进行,但即使是这种状态下的ZGC,其性能表现已经相当亮眼,从官方给出的测试结果来看,用“令人震惊的、革命性的ZGC”来形容都不为过。
图12和图13是ZGC与Parallel Scavenge、G1三款收集器通过SPECjbb 2015的测试结果。在ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用的“Critical Throughput”的话,ZGC的表现甚至还反超了Parallel Scavenge收集器。
而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿,抑或是最大停顿时间,ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条(图3-24a),必须把结果的纵坐标从线性尺度调整成对数尺度(图3-24b,纵坐标轴的尺度是对数增长的)才能观察到ZGC的测试结果。


ZGC原本是Oracle作为一项商业特性(如同JFR、JMC这些功能)来设计和实现的,只不过在它横空出世的JDK 11时期,正好适逢Oracle调整许可证授权,把所有商业特性都开源给了OpenJDK(详情见第1章Java发展史),所以用户对其商业性并没有明显的感知。ZGC有着令所有开发人员趋之若鹜的优秀性能,让以前大多数人只是听说,但从未用过的“Azul式的垃圾收集器”一下子飞入寻常百姓家,相信它完全成熟之后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。
垃圾收集器没有最好,只有最合适,只有充分了解每一款垃圾收集器,才能在当前的场景下选择出最合适的垃圾收集器,所以筒子们加油吧!