目录
一、什么是ZGC垃圾收集器
ZGC(Z Garbage Collector)是一款在jdk11中加入的具有实验性质的低延迟的垃圾收集器,在jdk15中去掉实验的标识,成为具有商用的垃圾收集器。垃圾收集停顿时间控制在10毫秒以内(在jdk16之后停顿时间已经控制到1毫秒以内)的一款低停顿的垃圾收集器。如果非要给ZGC下一个定义的话,ZGC收集器是一款基于Region内存布局的,不设分代(不分老年代、新生代)的,使用了读屏障、染色指针和内存多重映射等技术来实现的基于标记-整理算法实现的,以低延迟为首要目标的一款并发的垃圾收集器。使用–XX:+UseZGC 参数可以启用 ZGC。
二、ZGC的内存模型
ZGC与G1收集器在内存布局上类似,也是使用基于Region(有的资料叫做Page/ZPage)的堆内存布局。但与G1不同的是,ZGC的Region是具有动态性的(动态创建和销毁以及动态的区域容量大小),根据容量大小分为 小、中、大三类容量。
- 小型Region(Small Region):容量固定为2MB,用于放置大小小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大小大于等于256KB但小于4MB的对象。
- 大型Region (Large Region):容量不固定,可以动态变化,但必须为2MB的整倍数,用于放置大于等于4MB的对象。每个大型Region中只会存放一个大对象,所以大Region实际容量并不一定比中型Region的容量大。
三、收集过程
在了解ZGC的收集过程之前,我们先了解一下染色指针和多重映射的概念。
染色指针
我们知道一个对象是由对象头,对象体,对齐填充等组成,而对象头又分为Mark Word、Class Pointer 、Array Length(数组对象时存在)。而在对象头中存储了一些HashCode、分代年龄、锁标记等额外信息。在Hotspot虚拟机中的垃圾收集器,除了把标记直接记录在对象头之外,还有的记录在特殊的独立的数据结构上(例如G1使用BitMap的结构来记录标记信息)。ZGC的染色指针是一种直接将少量的额外信息存储在指针上的技术。
染色指针示意图
如上图所示,指针的高18位(jdk13之后,扩展到高16位)不能用来寻址,但剩余的46位指针所支持的64TB仍能满足大多数的服务器需求。ZGC将其高4位提取出来存储4个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态(Marked0/Marked1)、是否进入了重分配集(即被移动过,Remapped)、是否只能通过finalize()方法才能访问到。由于这些标记位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次方),而通过jdk13的扩展,现在ZGC可以管理的内存空间范围为(8MB-16TB,2的44次方)。
由于受染色指针技术的限制ZGC使用的平台限制如下图,也就是说ZGC现阶段只能用在64位的JVM虚拟机上。
染色指针的优势
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这得益于染色指针的自愈(Self-Healing)特性。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然可以省略一些专门的记录操作。而实际上ZGC并没有使用写屏障,因为它不设分代,天然没有跨代引用的问题。
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。例如从jdk13之后可以管理的最大内存扩展到16TB。
多重映射
内存多重映射(Multi-Mapping),就是使用 mmap 把不同的虚拟内存地址映射到同一个物理内存地址上。
ZGC 为了更灵活高效地管理内存,使用了内存多重映射,把同一块儿物理内存映射为 Marked0、Marked1 和 Remapped 三个虚拟内存。
当应用程序创建对象时,会在堆上申请一个虚拟地址,这时 ZGC 会为这个对象在 Marked0、Marked1 和 Remapped 这三个视图空间分别申请一个虚拟地址,这三个虚拟地址映射到同一个物理地址。 Marked0、Marked1 和 Remapped 这三个虚拟内存作为 ZGC 的三个视图空间,在同一个时间点内只能有一个有效。ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收。
收集过程
开始标记( Mark Start)
这个阶段会有STW,STW 的时间跟 GC Roots 数量成正比,耗时比较短,主要对Root集进行扫描,标记Root集中存活对象,即标记根可达对象。
并发标记(Concurrent Mark/Remap)
这个阶段是根据上一步标记的根对象去遍历对象图做可达性分析的阶段。ZGC的标记是在指针上而不是在对象上做标记。这个阶段有以下几点需要注意
-
GC 标记线程访问对象时,如果对象地址视图是 Remapped,就把对象地址视图切换到 Marked0,如果对象地址视图已经是 Marked0,说明已经被其他标记线程访问过了,跳过不处理。
-
标记过程中Java 应用线程新创建的对象会直接进入 Marked0 视图。
-
标记过程中Java 应用线程访问对象时,如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0。
-
标记结束后,如果对象地址视图是 Marked0,那就是活跃的,如果对象地址视图是 Remapped,那就是不活跃的。
注:标记阶段的活跃视图也可能是 Marked1。
Q:思考为什么会有Marked0和Marked1两个视图
A:这里采用两个视图是为了区分前一次标记和这一次标记。如果这次标记的视图是 Marked0,那下一次并发标记就会把视图切换到 Marked1。这样做可以配合 ZGC 按照页回收垃圾的做法。如下图:
第二次标记的时候,如果还是切换到 Marked0,那么 2 这个对象区分不出是活跃的还是上次标记过的。如果第二次标记切换到 Marked1,就可以区分出了。
这时 Marked0 这个视图的对象就是上次标记过程被标记过活跃,转移的时候没有被转移,但这次标记没有被标记为活跃的对象。Marked1 视图的对象是这次标记被标记为活跃的对象。Remapped 视图的对象是上次垃圾回收发生转移或者是被 Java 应用线程访问过。
标记结束再标记(Mark End)
这个阶段需要 STW,但是需要标记的对象少,耗时很短,是在并发标记阶段结束之后,重新进行一次标记,因为是和用户线程并发执行,标记过程中可能会有引用关系发生变化而导致的漏标记问题。再标记阶段重新标记并发标记阶段发生变化的对象,还会对非强引用(软应用,虚引用等)进行并行标记。
并发预备重分配(Concurrent Prepare For Relocate)
这个阶段需要统计出本次收集过程中需要清理那些Region,将这些Region组成重分配集(Relocation Set),注意区分和G1中的回收集(Collection Set )的区别。并选择下一步Relocation要用到的Region集合。ZGC每次都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
初始转移(Relocate Start)
转移就是把活跃对象复制到新的内存,之前的内存空间可以被回收。初始转移需要扫描 GC Roots 直接引用的对象并进行转移,这个过程需要 STW,STW 时间跟 GC Roots 成正比。此时会将Roots的对象移动到选好的Region集合中。
并发重分配(Concurrent Relocate)
重分配是ZGC执行的核心阶段,这个阶段要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确知道一个对象是否处于重分配集(从 marked0、marked1、Remapped 的标记看出),如果用户线程此时并发的访问位于重分配集中的对象,这次访问就会被预置的内存屏障拦截(只有从堆内存中读取对象的引用时,才会触发),然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的自愈(Self-Healing)。这样的好处是,只有第一次访问对象时会慢,另外由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕,就可以清除该Region,但是转发表还需要保留,当其他对象再访问旧对象时,指针自愈就会帮助找到新对象。
并发转移过程 GC 线程和 Java 线程是并发进行的,转移过程中对象视图会被切回 Remapped 。转移过程需要注意以下几点:
-
如果 GC 线程访问对象的视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
-
如果 GC 线程访问对象的视图是 Remapped,说明被其他 GC 线程处理过,跳过不再处理。
-
并发转移过程中 Java 应用线程创建的新对象地址视图是 Remapped。
-
如果 Java 应用线程访问的对象被标记为活跃并且对象视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
并发重映射(Concurrent Remap)
重映射所做的就是修正整个堆中指向重分配集中的旧对象的所有引用。ZGC的并发重映射并不是一个迫切去完成的任务,由于指针的自愈,所以不是急切去修正,所以ZGC将并发重映射延迟到下一次并发标记的时候去处理,一旦所有的指针都被修正之后,记录旧对象引用关系的转发表就可以被释放掉了。
ZGC整体使用了标记复制加整理的思想,不过ZGC可以根据当前的内存使用情况,选择将存活对象整理到一个全新的空Region集合中或者某个存有对象的Region中。由于整个GC的流程中只有三处需要暂停,然而这三处的STW时间都是亚毫秒级别。整个GC的流程延迟总体不会超过1ms。
四、优缺点
ZGC的优势已经不需要多说了,更高的吞吐量,更低的停顿时间(1ms内)就是很大优势了,另外它还不需要维护很多的记忆集(因为它没有跨代引用,所以不需要记录那些跨代引用的记忆集),另外它完全没有用到写屏障,对用户线程带来的负担也要小很多。
ZGC有不足之处吗?假设ZGC需要回收一个很大的堆,这个垃圾收集过程需要持续10分钟(ZGC整个过程都是并发回收,注意回收时间和停顿时间的区别),而在这个过程中,又发生高速率的对象分配,那么这些对象就很难进入当次收集的标记范围,那么就会把这些对象当作存活对象,这就产生了大量的浮动垃圾。如果这个过程持续时间很久,那么堆中所剩的空间就会越来越少。
五、参数配置
-XX:+UseZGC 开启ZGC -XX:MinHeapSize, -Xms 最小堆内存 -XX:InitialHeapSize,-Xms 初始堆内存 -XX:MaxHeapSize, -Xmx 最大堆内存 -XX:SoftMaxHeapSize jdk13添加新参数,目前只能用于ZGC,Java堆增长的软限制。当设置时,GC将努力使堆不超过软的最大堆大小。 -XX:ConcGCThreads GC线程数量,jdk17开始动态增加减少该线程数,不需要设置 -XX:ParallelGCThreads STW阶段使用线程数,默认是总核数的60% -XX:UseDynamicNumberOfGCThreads 动态GC线程数 -XX:UseLargePages 使用大页 -XX:UseTransparentHugePages 透明大页,Linux上,使用启用透明大页面的ZGC需要内核>= 4.7 -XX:UseNUMA 使用NUMA,numa是非统一内存访问,默认启用 -XX:SoftRefLRUPolicyMSPerMB 软引用对象被回收,调整为0,表示0忍耐度,所有的软引用对象都要尽快的被释放 -XX:AllocateHeapAt 指定使用的文件系统路径例如-XX:AllocateHeapAt=/hugepages -XX:ZAllocationSpikeTolerance ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC -XX:ZCollectionInterval ZGC发生的最小时间间隔,默认值0,单位秒 -XX:ZFragmentationLimit Relocate时,会根据当前region是否碎片化已大于ZFragmentationLimit,是则回收region,relocate至current candidate relocation set,默认值25。 -XX:ZMarkStackSpaceLimit 标记栈的size受ZMarkStackSpaceLimit参数控制,默认8G,合法值32MB到1TB之间 -XX:ZProactive 是否启用主动回收,默认开启 -XX:ZUncommit 返还未使用的内存给OS -XX:ZUncommitDelay 如果内存在指定的时间内未使用,将内存返还给OS,默认是300s -XX:ZStatisticsInterval 指定统计数据输出之间的时间间隔(秒)。 -XX:ZVerifyForwarding 检验转发表。 -XX:ZVerifyMarking 检验标记集。 -XX:ZVerifyObjects 检验对象。 -XX:ZVerifyRoots 检验根节点。 -XX:ZVerifyViews 检验堆视图访问
代码测试
package com.wssnail.test;
import java.util.ArrayList;
import java.util.List;
/**
* @author 熟透的蜗牛
* @version 1.0
* @description: TODO
* @date 2023/4/3 23:23
*/
public class TestZGC {
private static String[] strArr = new String[]{"中国人民万岁", "梅西好样的,梅西好样的梅西好样的梅西好样的梅西好样的梅西好样的梅西好样的梅西好样的", "我爱看世界杯,我爱看世界杯我爱看世界杯我爱看世界杯我爱看世界杯我爱看世界杯我爱看世界杯我爱看世界杯我爱看世界杯"};
public static void main(String[] args) {
List<String[]> list = new ArrayList<>();
for (; ; ) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(strArr);
}
}
}
/**
* -XX:+UseZGC 开启ZGC
* -XX:MinHeapSize, -Xms 最小堆内存
* -XX:InitialHeapSize,-Xms 初始堆内存
* -XX:MaxHeapSize, -Xmx 最大堆内存
* -XX:SoftMaxHeapSize jdk13添加新参数,目前只能用于ZGC,Java堆增长的软限制。当设置时,GC将努力使堆不超过软的最大堆大小。
* -XX:ConcGCThreads GC线程数量,jdk17开始动态增加减少该线程数,不需要设置
* -XX:ParallelGCThreads STW阶段使用线程数,默认是总核数的60%
* -XX:UseDynamicNumberOfGCThreads 动态GC线程数
* -XX:UseLargePages 使用大页
* -XX:UseTransparentHugePages 透明大页,Linux上,使用启用透明大页面的ZGC需要内核>= 4.7
* -XX:UseNUMA 使用NUMA,numa是非统一内存访问,默认启用
* -XX:SoftRefLRUPolicyMSPerMB 软引用对象被回收,调整为0,表示0忍耐度,所有的软引用对象都要尽快的被释放
* -XX:AllocateHeapAt 指定使用的文件系统路径例如-XX:AllocateHeapAt=/hugepages
* -XX:ZAllocationSpikeTolerance ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC
* -XX:ZCollectionInterval ZGC发生的最小时间间隔,默认值0,单位秒
* -XX:ZFragmentationLimit Relocate时,会根据当前region是否碎片化已大于ZFragmentationLimit,是则回收region,relocate至current candidate relocation set,默认值25。
* -XX:ZMarkStackSpaceLimit 标记栈的size受ZMarkStackSpaceLimit参数控制,默认8G,合法值32MB到1TB之间
* -XX:ZProactive 是否启用主动回收,默认开启
* -XX:ZUncommit 返还未使用的内存给OS
* -XX:ZUncommitDelay 如果内存在指定的时间内未使用,将内存返还给OS,默认是300s
* -XX:ZStatisticsInterval 指定统计数据输出之间的时间间隔(秒)。
* -XX:ZVerifyForwarding 检验转发表。
* -XX:ZVerifyMarking 检验标记集。
* -XX:ZVerifyObjects 检验对象。
* -XX:ZVerifyRoots 检验根节点。
* -XX:ZVerifyViews 检验堆视图访问
*/
-Xms2G
-Xmx2G
-XX:+UseZGC
-XX:ReservedCodeCacheSize=256m
-XX:InitialCodeCacheSize=256m
-XX:ConcGCThreads=2
-XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120
-XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions
-XX:+ZProactive
#输出到文件
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=d:\logs\gc-%t.log:time,tid,tags:filecount=5,filesize=10m
#输出到控制台
-Xlog:gc*
参考文章:
1、12 张图带你彻底理解 ZGC
2、https://wiki.openjdk.org/display/zgc/Main