ZGC
一、来源
- ZGC收集器(Z Garbage Collector)是由Oracle公司研发的。2018年创建了JEP 333将ZGC提交给OpenJDK,推动其进入OpenJDK11的发布清单中。
1.1 是什么
- ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来
实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
1.2 实现目标
- 希望能在尽可能对吞吐量影响不太大的前提下,
实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。
二、ZGC的堆内存布局
- 与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局。
- ZGC的Region具有动态性。
- 动态创建和销毁
- 动态的区域容量大小
分类如下:
小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
**大型Region(Large Region):**容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,所以实际容量可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。
三、并发整理算法的实现。
3.1 算法的由来
- G1收集器的筛选回收阶段是stop the world的,但收集器线程间是并行的,之所以不和用户线程并发执行,是因为G1只回收一部分Region,停顿时间是用户可以控制的。所以并不着急去实现,交给了ZGC去实现。
- 并且因为G1为了不影响吞吐量才选择stw的。停顿用户线程可以最大幅度提高垃圾收集效率。
3.2 实现
3.2.1 读屏障
指针的自愈能力
在ZGC中,当读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。ZGC将这种行为叫做指针的“自愈能力”。
好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当“自愈”完成后,后续访问就不会变慢了。
Shenandoah每次访问都慢,对比发现,ZGC的执行负载更低。
- 因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。
3.2.2 染色指针技术
3.2.2.1 HotSpot虚拟机的标记实现方案有如下几种:
- 把标记直接记录在对象头上(如Serial收集器);
- 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息);
- 直接把标记信息记在引用对象的指针上(如ZGC)
为什么会放在指针上呢?
追踪式收集算法的标记阶段就是看有没有引用,所以可以只和指针打交道而不管指针所引用的对象本身。
例如对象标记过程就是打个三色标记,这些标记本质上只和对象引用有关,和对象本身无关。某个对象只有它的引用关系才能决定它的存活。
3.2.2.2 染色指针的解释
- 染色指针是一种直接将少量额外的信息存储在指针上的技术。目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46为却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4TB(2的42次幂)的内存,也直接导致ZGC可以管理的内存不超过4TB,如图所示:
- 限制:只能在64位系统上,因为ZGC设置就是用的42-46位,32位明显不够嘛。。并且不支持压缩指针(这一块可以参考Java对象模型中的OOP,meta中有一个Klass直接指向Klass,还一个压缩指针)如下。
union _metadata {
之前都是oop,现在直接指向Klass了
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
染色指针如图:
四、ZGC的过程
ZGC运作过程:
- 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,
前后也要经过类似于G1、Shenandoah的初始标记和最终标记(ZGC中就是名字不同而已)的短暂的停顿,整个标记阶段只会更新染色指针中的Marked 0、Marked 1标志位。 停顿时间和堆大小无关,只和GC Roots数量有关。- 总结就是:
并发标记阶段会有两个短暂STW。 - ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
- 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
- ZGC的重分配集只是决定里面的存活对象会被
复制到其他的Region。不是为了效益回收。 JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。- 并发重分配(Concurrent Relocate):
重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。 - ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
- ZGC的染色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢,而Shenandoah的Brooks转发指针是每次都会变慢。 一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表。
- 举例如:因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。
- 并发重映射(Concurrent Remap):重映射所做的就是**
修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。**
五、ZGC的优点
- 低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小。
低停顿,几乎所有过程都是并发的,只有短暂的STW。内存小,ZGC没有写屏障,卡表之类的。吞吐量方面,在ZGC的‘弱项’吞吐量方面,因为和用户线程并发,还是有影响的。**但是!但是!,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。**- G1通过写屏障维护记忆集,才能处理跨代指针,得以实现增量回收。记忆集占用大量内存,写屏障对正常程序造成额外负担。
- 在多核处理器的某种架构下,ZGC优先在线程当前所处的处理器的本地内存上分配对象,以保证内存高效访问。
- 并发停顿方面:ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
- ZGC中没有引入分代,也就没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。
- 并发的标记-整理算法。没有内存碎片。
六、ZGC的缺点
- 承受的对象分配速率不会太高,因为浮动垃圾。
- ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
- 造成回收到的内存空间小于期间并发产生的浮动垃圾所占的空间。
ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。
- ZGC目前只在Linux/x64上可用。以后可能会支持别的吧。也不算啥缺点。。。
6.1 解决办法
- 增加堆容量大小,使得程序得到更多的喘息时间。治标不治本的方案。
- 从根本上解决这个问题,还是需要引入分代收集。让新生对象在一个专门区域创建,然后专门针对这个区域进行更频繁的,更快的收集。
7.ZGC的使用
这部分内容参考ZGC,一个超乎想象的垃圾收集器。主要是为了自己以后看着方便,总结了一下。
如何使用
编译完成之后,已经迫不及待的想试试ZGC,需要配置以下JVM参数,才能使用ZGC.
-XX:+UnlockExperimentalVMOptions ,解锁任何额外的隐藏参数。
-XX:+UseZGC
-Xmx10g
-Xlog:gc
参数说明:
Heap Size
通过-Xmx10g进行设置。
-Xmx是ZGC收集器中最重要的调优选项,大大解决了程序员在JVM参数调优上的困扰。ZGC是一个并发收集器,必须要设置一个最大堆的大小,应用需要多大的堆,主要有下面几个考量:
- 对象的分配速率,要保证在GC的时候,堆中有足够的内存分配新对象。
- 一般来说,给ZGC的内存越多越好,但是也不能浪费内存,所以要找到一个平衡。
Concurrent GC Threads
通过-XX:ConcGCThread = 4进行设置。
并发执行的GC线程数,如果没有设置,在JVM启动的时候会根据CPU的核数计算出一个合理的数量,默认是核数的12.5%,但是根据应用的特性,可以通过手动设置调整。
因为在并发标记和并发移动时,GC线程和应用线程是并发执行的,所以存在抢占CPU的情况,对于一些对延迟比较敏感的应用,这个并发线程数就不能设置的过大,**不然会降低应用的吞吐量**,并有可能增加应用的延迟,因为GC线程占用了太多的CPU,但是如果设置的太小,就有可能对象的分配速率比垃圾收集的速率来的大,最终导致应用线程停下来等GC线程完成垃圾收集,并释放内存。
一般来说,如果低延迟对应用程序很重要,那么不要这个值不要设置的过于大,理想情况下,系统的CPU利用率不应该超过70%。
Parallel GC Threads
通过-XX:ParallelGCThreads = 20
当对GC Roots进行标记和移动时,需要进行STW,这个过程会使用ParallelGCThreads个GC线程进行并行执行。
ParallelGCThreads默认为CPU核数的60%,为什么可以这么大? 因为这个时候,应用线程已经完全停下来了,所以要用尽可能多的线程完成这部分任务,这样才能让STW尽可能的短暂。
参考资料
《深入理解Java虚拟机第三版》