本文的阅读有一定的门槛,请先了解 GC 的基本只知识。
现代垃圾收集器的演进大部分都是往减少停顿方向发展。
像 CMS 就是分离出一些阶段使得应用线程可以和垃圾回收线程并发,当然还有利用回收线程的并行来减少停顿的时间。
基本上 STW 阶段都是利用多线程并行来减少停顿时间,而并发阶段不会有太多的回收线程工作,这是为了不和应用线程争抢 CPU,反正都并发了慢就慢点(不过还是得考虑内存分配速率)。
而 G1 可以认为是打开了另一个方向的大门:只回收部分垃圾来减少停顿时间。
不过为了达到只回收部分 reigon,每个 region 都需要 RememberSet 来记录各 region 之间的引用。这个内存的开销其实还是挺大的,可能会占据整堆的20%或以上。
并且 G1 还有写屏障的开销,虽说用了 logging wtire barrier,但也还是有开销的。
当然 CMS 也用了写屏障,不过逻辑比较简单,啥都没判断就单纯的记录。
其实 G1 相对于 CMS 只有在大堆的场景下才有优势,CMS 比较伤的是 remark 阶段,如果堆太大需要扫描的东西太多。
而 G1 在大堆的时候可以选择部分收集,所以停顿时间有优势。
今天的主角 ZGC 和 G1 一样是基于 reigon 的,几乎所有阶段都是并发的,整堆扫描,部分收集。
而且 ZGC 还不分代,就是没分新生代和老年代。
那它为啥比 G1 要牛皮?今天咱们就来盘一盘。
本文会先介绍 ZGC 的特性,或者说几个关键点,然后再简述下整体回收流程。
基本上看下来对 ZCG 心中就有数了,作为普通的 Javaer,了解到这个程度就差不多了。
好了,让我们进入今天的正题!
ZGC 的目标
垃圾收集器设计出来都有目标的,有些是为了更高的吞吐,有些是为了更低的延迟。
关键字:并发、基于Region、整理内存、支持NUMA、用了染色指针、用了读屏障,对了 ZGC 用的是 STAB。
Concurrent
这个 Concurrent 的意思是和应用线程并发执行,ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW 的。
所以我们先看看 ZGC 的目标:
可以看到只有初始标记、再标记、初始转移阶段是 STW 的。
初始标记就扫描 GC Roots 直接可达的,耗时很短,重新标记一般而言也很短,如果超过 1ms 会再次进入并发标记阶段再来一遍,所以影响不大。
初始转移阶段也是扫描 GC Roots 也很短,所以可以认为 ZGC 几乎是并发的。
而且之所以说停顿时间不会随着堆的大小和存活对象的数量增加而增加,是因为 STW 几乎只和 GC Roots 集合大小有关,和堆大小没啥关系。
这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移需要 STW 所以堆大需要转移对象多,停顿的时间就长了,而 ZGC 有并发转移。
不过并发回收有个情况就是回收的时候应用线程还是在产生新的对象,所以需要预留一些空间给并发时候生成的新对象。
如果对象分配过快导致内存不够,在 CMS 中是发生 Full gc,而 ZGC 则是阻塞应用线程。
所以要注意 ZGC 触发的时间。
ZGC 有自适应算法来触发也有固定时间触发,所以可以根据实际场景来修改 ZGC 触发时间,防止过晚触发而内存分配过快导致线程阻塞。
还有设置 ParallelGCThreads 和 ConcGCThreads,分别是 STW 并行时候的线程数和并发阶段的线程数来加快回收的速度。
不过 ConcGCThreads 数量需要注意,因为此阶段是和应用线程并发,如果线程数过多会影响应用线程。
其实 ZGC 的每个阶段都是串行的,所以理论上其实可以不需要分两类线程,那为什么分了这两类线程?
就是为了灵活设置。分成两类就可以通过配置来调优,达到性能最大值。
对了上面提到 ZGC 的 STW 和 GC Roots 集合大小有关系,所以如果在会生成很多线程、动态加载很多 ClassLoader 等情况下会增加 ZGC 的停顿时间。
这点需要注意。
Region-based
为了能更细粒度的控制内存的分配,和 G1 一样 ZGC 也将堆划分成很多分区。
分了三种:2MB、32MB 和 X*MB(受操作系统控制)。
下图为源码中的注释:
对于回收的策略是优先收集小区,中、大区尽量不回收。
Compacting
和 G1 一样都分区了所以肯定从整体来看像是标记-复制算法,所以也是会整理的。
因此 ZGC 也不会产生内存碎片。
具体的流程下文再做分析。
NUMA-aware
以前的 G1 是不支持的,不过在 JDK14 G1 也支持了。
可能有的同学对 NUMA 不太熟悉,没事我先来解释一波。
在早期处理器都是单核的,因为根据摩尔定律,处理器的性能每隔一段时间就可以成指数型增长。
而近年来这个增长的速度逐渐变缓,于是很多厂商就推出了双核多核的计算机。
早期 CPU 通过前端总线到北桥到内存总线然后才访问到内存。
这个架构被称为 SMP (Symmetric Multi-Processor),因为任一个 CPU 对内存的访问速度是一致的,不用考虑不同内存地址之间的差异,所以也称一致内存访问(Uniform Memory Access, UMA )。
这个核心越加越多,渐渐的总线和北桥就成为瓶颈,那不能够啊,于是就想了个办法。
把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)。
但是因为内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况。
比如 64G 内存一分为二,模块一的内存用了31G,而另一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题。
如果有些策略规定不能访问远程内存的时候,就会出现明明还有很多内存却产生 SWAP(将部分内存置换到硬盘中) 的情况。
即使允许访问远程内存那也比本地内存访问速率相差较大,这是使用 NUMA 需要考虑的问题。
ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配。
对于中、大分区的话就交由操作系统决定。
上述做法的原因是生成的绝大部分都是小分区对象,因此优先本地分配速度较快,而且也不易造成内存不平衡的情况。
而中、大分区对象较大,如果都从本地分配则可能会导致内存不平衡的情况。