深入理解JVM1-2.2-自动内存管理-垃圾收集器与内存分配策略(垃圾收集器及其选择)

经典垃圾收集器

垃圾收集器是对垃圾收集算法的实现,由于《Java虚拟机规范》中并没有明确垃圾收集器的实现细节。所以不同的厂商对于垃圾收集器都有不同的实现。而这里的经典垃圾收集器主要针对的是JDK 7 Update 4 之后JDK11正式发布之前,Oracle JDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。下图是各款经典垃圾收集器之间的关系:

HotSpot垃圾收集器

这里的垃圾收集器都有各自不同的优缺点,还没有出现一个没有短板的收集器,因此后续的工作实际上是根据工作场景选择合适的垃圾收集器。(希望以后能有一个完美的垃圾收集器)。

(注意:Serial+CMS与ParNew+Serial Old的组合在JDK9中已经被废除)

Serial收集器

Serial是最基础、存在时间最长的收集器,在JDK1.31之前,其是HotSpot虚拟机新生代唯一的收集器。

其特点是单线程,其中的含义包括两层:

  • 只会启动一个线程进行垃圾收集。
  • 当Serial运行的时候,用户线程必须停止。

其原理如图:

Serial运行示意图

就大多数场景来说,可能这种完全停止用户的场景并不是理想的行为。例如堆过大的时候,那么用户线程需要停顿的时间就会加长。例如对于一个C端应用,停顿几秒甚至十几秒的时间是非常致命的。

也因为如此,HotSpot团队也一直在研究新的收集器来缓解这种情况。从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等。

但实际上其也有优点和缺点:

优点

  1. 简单、高效,Serial收集器由于没有线程交互的开销。对于Java堆较小的环境中,其停顿时间并不会很长,其专一性也可以提升效率(比如最近流行的微服务或者桌面端程序)。

缺点

  1. 收集时需要停止用户线程,对于大的Java堆,停顿时间会非常长,很致命。

ParNew收集器(新生代-多线程)

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之 外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

其(搭配Serial Old)原理如图:

parNew收集器原理

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处。但由于目前除Serial之外,仅有该收集器可以与CMS收集器搭配使用,所以其仍然是不少服务端HotSpot虚拟机的新生代收集器。

CMS是HotSpot在JDK5推出的一个跨时代的老年代收集器,其意义在于可以使垃圾收集与用户线程并行执行。遗憾的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。

但是随着G1收集器的登场,CMS也不再是官方的推荐老年代收集器。G1作为全堆收集器,不再需要其他收集器配合,因此ParNew收集器也不再被广泛使用。

值得注意的是:ParNew收集器并不一定就比Serial收集器性能更好,特别是在单线程,单核的情况下。甚至由于线程间交互的开销,在使用超线程实现的多线程环境下的表现可能比Serial更差。

Parallel Scavenge收集器(吞吐量)

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是 能够并行收集的多线程收集器。其主要特点是:Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。这两个参数实际上互斥的,一个优秀就会牺牲另一个的性能。

  • -XX:MaxGCPauseMillis:允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的 时间不超过用户设定值。当把这个值设的很低时,即停顿的时间很低,那么相应停顿次数就会增加,那么吞吐量就会减少。
  • XX:GCTimeRatio:允许的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的 比率,相当于吞吐量的倒数。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。

该收集器还提供了一个-XX:+UseAdaptiveSizePolicy参数,其作为一个开关属性,可以设定是否让Parallel Scavenge自己决定新生区的大小(-Xmn)、Eden、Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适设定的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收 集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用 途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用[1],另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

原理图如下:

Serial-SerialOld运行示意图

需要说明一下,Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非 直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相 当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PSMarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。但是由于Serial Old单线程的性能问题,其吞吐量能甚至不如ParNew加CMS。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

其原理如图:

Parallel-ParallelOld收集器组合

CMS收集器(停顿时间)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。其基于标记-清除算法实现。

它的运作 过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

所以其中初始标记、重新标记这两个步骤仍然需要“Stop The World”,其他两个阶段是可以并发的。

但由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

其原理如图:

CMS原理

CMS从初创性来说是很优秀的,基本实现了与用户进程并发的要求。但其仍然存在以下问题:

  • CMS收集器对处理器资源非常敏感:由于并发,所以收集器本身也需要占用一定的处理器资源,所以如果处理器性能不行,相反回拖慢程序的运行。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。
  • CMS收集器无法处理“浮动垃圾”(Floating Garbage):有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值 来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动 阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满 足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不 得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。
  • CMS无法很好处理内存碎片的问题:CMS是一款基于“标记-清除”算法实现的收集器,其基本原理就决定了回产生内存碎片,并且在碎片过多,导致无法分配大对象的时候,就不得不启动一次Full GC。因此其后提供了-XX:+UseCMS-CompactAtFullCollection参数用来在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程。-XX:CMSFullGCsBeforeCompaction参数用来要求CMS收集器在执行过若干次(数量 由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理。

Garbage First收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集 器面向局部收集的设计思路和基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长 期的)未来可以替换掉JDK 5中发布的CMS收集器。JDK 9发布之 日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。

G1的设计思路与之前的收集器都不相同,其面向整个Java堆,而不仅仅是新生代或者老年代,衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。

虽然G1也仍是遵循分代收集理 论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作 为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免 在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃 圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

其分区示意图如下:

G1收集器原理图

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的 运作过程大致可划分为以下四个步骤:

  • 初始标记(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收集器除了并发标记外,其余阶段也是要完全暂停用户线程的, 换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

其原理图如下:

G1收集器原理图

与CMS 的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存 空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过,G1相对于CMS仍然不是占全方位、压倒性优势的,其仍然具有以下缺点:

  • 就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。
  • 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会 有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行 同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

低延迟垃圾收集器

HotSpot的垃圾收集器从Serial发展到CMS再到G1,经历了逾二十年时间,经过了数百上千万台服 务器上的应用实践,已经被淬炼得相当成熟了,不过它们距离“完美”还是很遥远。

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟 (Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是 要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

图3-14中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。由图 3-14可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿; CMS和G1分别使用增量更新和原始快照(见3.4.6节)技术,实现了标记阶段的并发,不会因管理的堆 内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥 善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优 化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。

各款收集器的并发情况

最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全 部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定 的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在 ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方 夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。

Shenandoah收集器

Shenandoah收集器由于并不是Oracle公司开发的垃圾收集器,不可避免地会受到一些来自“官方”的排挤。Oracle仍明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包 OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说,Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器。

最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献 给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一。这个项目的目标 是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该 目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。

Shenandoah收集器与G1有很多相同的特点:

  • 基于Region的堆内存布局。
  • 存在存放大对象的Humongous Region。
  • 默认的回收策略也同样是优先处理回收价值最大的 Region。

但在管理堆内存方面,其与G1至少有三个明显的不同之处:

  • 最重要的当然是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。而Shenandoah收集器可以与用户线程并发。
  • Shenandoah(目前)是默认不使用分代收集的,换言之,不会有 专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
  • Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率(连接矩阵可以简单理解为一张二维表格,如果Region N有 对象指向Region M,就在表格的N行M列中打上一个标记)。

例如下面的对象引用和连接矩阵的关系:

shenandosh示例

Shenandoah收集器的工作过程大致可以划分为以下九个阶段:

  1. 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍 是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。

  2. 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段 是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。

  3. 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。

  4. 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。

  5. 并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之 中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进 行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通 过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。

  6. 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未 做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收 集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。

  7. 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户 线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它 不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  8. 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。

  9. 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已 再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

其中最主要的有3个阶段:并发标记、并发回收、并发引用更新。

下图中展示了Shenandoah三个并发阶 段的工作过程,还能形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移 动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。

Shenandoah收集器的工作过程

还有一个关键的概念-Brooks Pointer。“Brooks”是一个人的名字,由他提出了Forwarding Pointer的概念。

Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个 新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。

有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显 著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度。

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修 改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转 发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作。

需要注意,Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集 器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该 是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写
操作只能发生在新复制的对象上,而不是写入旧对象的内存中。

不妨设想以下三件事情并发进行 时的场景:

  1. 收集器线程复制了新的对象副本;
  2. 用户线程更新对象的某个字段;
  3. 收集器线程更新转发指针的引用值为新副本地址。

如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对 象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上 Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。

代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。 其开发者也意识到数量庞大的读屏障带来的性 能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改 进为基于引用访问屏障(Load Reference Barrier)的实现,所谓“引用访问屏障”是指内存屏障只拦 截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。

ZGC收集器

ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现 在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但其实现思路与Shenandoah完全不一样,Shenandoah更像是G1的模式,而ZGC更像是Azul System公司的PGC个C4的继承者。

而ZGC的特点在于以下:

  • 基于Region内存布局的。
  • 不设分代的。
  • 使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法。
  • 以低延迟为首要目标。

Region

与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但 与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage)具有动态性——动态创建和销毁,以及动态的区域容量大小。

在x64硬件平台下,ZGC的 Region可以具有如图3-19所示的大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对 象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型 Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配。

ZGC的内存布局

并发整理算法的实现

ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可 能将它称为Tag Pointer或者Version Pointer)。

以前对象的一些只供虚拟机访问的属性(如,对象的哈希码、分代年龄、锁记录等)一般存放再对象头中,但如果对象发生移动,即不能保证对象访问能够成功或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景。

HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在 对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使 用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),而ZGC的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上。

在不同的平台,内存地址的长度是不一样的,而ZGC想在牺牲一定可管理内存空间的情况下,来使用内存地址中的几位来存储信息。

例如Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对 象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。如下图,当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致 ZGC能够管理的内存不可以超过4TB(JDK13目前已经扩展到16TB)(2的42次幂):

染色指针

而染色指针具有以下优点:

  • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用 掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。

  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上,到目前为止ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)。

  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

    (现在Linux下的64位指针还有前18位并未使用,它们虽然不能用来寻址,却可 以通过其他手段用于信息记录。如果开发了这18位,既可以腾出已用的4个标志位,将ZGC可支持的 最大堆内存从4TB拓展到64TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。)

ZGC的工作流程

ZGC的运作过程大致可划分为以下四个大的阶 段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,
譬如初始化GC Root直接关联对象的Mark Start。

  1. 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的 阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的 短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  2. 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器 的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的 维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面 的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对
    全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
  3. 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分 配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  4. 得益于染色指针的支持,ZGC收集器能仅从引用上就明 确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次 访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。其有两点好处:
    1. 只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。
    2. 由于染 色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于 新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
  5. 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第 一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射 阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所 有对象的,这样合并就节省了一次遍历对象图[9]的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

ZGC的设计理念与Azul System公司的PGC和C4收集器一脉相承,是迄今垃圾收集器研究的最 前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。

但是由于其不存在分代的概念,也为其带来了优点和缺点:

优点:ZGC就完全没有使 用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。

缺点:ZGC的这种选择也限制了它能承受的对象分配速率不会太高。假设其全过程要持续十分钟以 上(切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里 面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范 围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大 量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。

Epsilon收集器

Epsilon收集器并不会真正的进行垃圾收集工作。

从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监 控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效性验证和参考实现。

其主要作用在于剥离垃圾收集器影响的性能测试和压力测试。

传统Java有着内存占用较大,在容器中启动时间 长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。Epsilon也是有着类似的目标,如果读者的应用只要运行数分钟甚至数秒, 只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。

收集器的权衡

对于不同的场景应该具体权衡,如:

  • 收集器特点。
  • 硬件条件。
  • 服务场景。

最好还是经过测试,来选择确定的收集器。

Powered by Hexo and Hexo-theme-hiker

Copyright © 2019 - 2024 My Wonderland All Rights Reserved.

UV : | PV :