HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)

HotSpot虚拟机进化到1.8以后虚拟机内的收集器越来越先进,越来越复杂。从G1到Shenandoah GC(OpenJDK)到ZGC。

一、G1(Garbage First)

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

1.1、内存布局

G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年带空间。在Region中还有一种特殊的Humongous区域,专门用于存储大对象。

G1 认为只要大小超过了一个Region容量的一半即可判断为大对象。每个Region的大小可以通过参数 -XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。对于超过Region大小的超级对象,会使用N个连续的Humongous Region存放,视为老年代。

G1 仍然保留了新生代和老年代的概念。但是不再固定区域,而是一系列的动态集合。

1.2、核心问题

1.2.1、具体收集处理思路:

G1 收集器跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后再后台维护一个优先级列表,每次根据用户设定的收集停顿时间(使用参数-XX:MaxGCPauseMills指定,默认值是200毫秒)优先处理回收收益最大的那些Region,这也就是“Garbage First”名字的由来。

1.2.2、G1 实现商用所解决的三大问题:

  1. 将java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?

使用记忆集避免全堆作为GC Roots扫描。基本存储结构本质上是哈希表,key是别的Region的起始地址,Value是一个集合,存着卡表的索引号。这种双向的卡表结构,比传统的收集器复杂多,数量多,一般能消耗大约Java堆10%到20%的额外内存。

  1. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

通过原始快照(SATB)算法实现的。同时每个Region设计了TAMS(Top at Mark Start)的指针,用于在并发回收时新对象的分配,这部分默认是存活的,不在收集范围内。

  1. 怎样建立可靠的停顿预测模型?

G1的停顿预测模型是以衰减均值(Decaying Average)为理论基础实现的。G1 记录 每个Region的回收耗时、每个Region记忆集里的脏卡数量等可测量的花费成本,并分析出平均值、标准偏差、置信度等统计信息。当它比平均值更容易收到新数据的影响时,就越容易被回收。

1.3、收集过程

大致分四个步骤:

  1. 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从 GC Roots 开始对堆中对象进行可达性分析,递归扫描这个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

如图:
HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)_第1张图片

1.4、官方文档

https://docs.oracle.com/en/java/javase/14/gctuning/garbage-first-g1-garbage-collector1.html#GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573

二、Shenandoah GC

Shenandoah 收集器大概时最“孤独”的收集器了,因为它是RedHat公司贡献给OpenJDK,同时在OracleJDK中不被支持。

2.1、内存布局

Shenandoah 更像是G1的继承者,都是基于Region的堆内存布局,有着存放大对象的Humongous Region,默认的收集策略也同样是优先处理价值最大的Region。但是。

2.2、核心问题

2.2.1、相对于G1在管理内存方式有着三大明显的区别:

  1. 支持并发的整理算法。
  2. 默认不使用分代收集。
  3. 摒弃了G1中消耗内存和计算资源去维护的记忆集,使用“连接矩阵”(Connection Matrix)。

2.2.2、Brooks Pointer

Shenandoah 用于支持并行整理的核心概念。
Brooks提出使用转发指针(Forwarding Pointer)来实现对象移动与用户程序并发。

此前,处理在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户访问原有内存,就会抛出一个已经预设好异常,在异常处理中去转发新内存。但是这种会导致频繁的用户态和核心态的切换,代价非常大。
Brooks,在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该指针指向自己。当对象拥有了一份新的副本以后,只需要修改一处指针的值,使旧对象上转发指针指向新的对象。

Shenandoah 通过收集器线程和用户线程比较并交换(Compare And Swap,CAS)操作保证并发时对象的正常访问。

使用读屏障。

2.3、收集过程

大致可以分九个步骤:

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

如图:

2.4、官方文档

https://wiki.openjdk.java.net/display/shenandoah

三、ZGC

ZGC收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

3.1、内存布局

ZGC的内存布局和Shenandoah 、G1一样,采用了基于Region的堆内存布局。但是ZGC的Region具有动态性——动态的创建和销毁,以及动态的区域容量大小。

在x64硬件平台下,ZGC的Region可以具有大、中、 小三类容量:

  • 小型 Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型 Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型 Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的对象。每个只放一个大对象,所以完全有可能小于中型Region,一般不会被重分配,太昂贵。

如图:
HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)_第2张图片

3.2、核心问题

3.2.1、染色指针技术(Colored Pointer)

染色指针式一种直接将少量额外的信息存储在指针上的技术。

在64位系统中,理论可以访问的内存高达16*1024*1024TB 字节。实际上,基于需求(用不到那么多内存),性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多的晶体管),一般不会支持64位的物理地址空间。操作系统会施加约束,64位 Linux 则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位 Windows 系统甚至只支持44位(16TB)的物理地址空间。

染色指针技术,在可用的物理地址上,将其高4位提取出来,用于储存四个标志信息。
通过这些标识位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过 finalize() 方法才能访问到。

x86_64下的结构如下:
HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)_第3张图片
使用染色指针的三大优势(原文:https://openjdk.java.net/jeps/333)

  1. 染色指针可以使得一旦某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用掉,而不必等待整个堆中所有指向该 Region 的引用都被修正后才能清理。
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
  3. 染色指针可以作为一种可扩展的储存结构用来记录更多与对象标记、重定位过程相关的数据。

3.2.2、多重映射(Multi-Mapping)

把染色指针中的标志位看做是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常的进行寻址了。

如图:
HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)_第4张图片

3.2.3、支持 “ NUMA-Aware ”的内存分配

NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所涉及的内存架构。每个处理器核心都有属于自己内存管理的内存,如果要访问其他的处理器核心的内存,需要通过 Inter-Connect 通道完成,所以会慢很多。

在 NUMA 架构下,ZGC 收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在 ZGC 之前的收集器就只有针对吞吐量涉及的 Parallel Scavenge 支持 NUMA 内存分配。

3.3、收集过程

大致可划分为四个步骤(都可以并发执行):

  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),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的 “自愈”(Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah 的 Brooks 转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此 ZGC 对用户程序的运行时负载要比 Shenandoah 来得更低一些。还有另外一个直接的好处时由于染色指针的存在,一旦重分配集中某个 Region 的存活对象都复制完毕后,这个 Region 就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都可以自愈的。
  4. 并发重映射(Concurrent Remap):重映射所做的就是修正这个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与 Shenandoah 并发引用更新阶段一样的,但是 ZGC 的并发重映射并不是一个必须要 “迫切” 去完成的任务,因此前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的时为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很 “迫切” 。因此,ZGC 很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新对象关系的转发表就可以释放掉了。

如图:
HotSpot虚拟机面向局部收集的收集器(G1、Shenandoah、ZGC)_第5张图片

3.4、官方文档

https://wiki.openjdk.java.net/display/zgc/Main

https://www.jfokus.se/jfokus18/preso/ZGC–Low-Latency-GC-for-OpenJDK.pdf

参考

《深入理解Java虚拟机》第三版,周志明著。
https://docs.oracle.com/en/java/javase/index.html
https://openjdk.java.net/

你可能感兴趣的:(JVM)