代表 Java 未来的 ZGC 深度剖析,牛逼!
JAVA 程序最爽的地方是它的 GC 机制,开发人员不需要关注内存申请和回收问题。同时,JAVA 程序最头疼的地方也是它的 GC 机制,因为掌握 JVM 和 GC 调优是一件非常困难的事情。在 ParallelOldGC、CMS、G1 之后,JDK11 带来的全新的「ZGC」为我们解决了什么问题?Oracle 官方介绍它是一个 Scalable、Low Latency 的垃圾回收器。所以它的目的是「降低停顿时间」,由此会导致吞吐量会有所降低。吞吐量降低问题不大,横向扩展几台服务器就能解决问题了啦。
ZGC 目标
如下图所示,ZGC 的目标主要有 4 个:
支持 TB 量级的堆。这你受得了吗?我们生产环境的硬盘还没有上 TB 呢,这应该可以满足未来十年内,所有 JAVA 应用的需求了吧。
最大 GC 停顿时间不超 10ms。这你受得了吗?目前一般线上环境运行良好的 JAVA 应用 Minor GC 停顿时间在 10ms 左右,Major GC 一般都需要 100ms 以上(G1 可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟 Root 扫描有关,而 Root 数量和堆大小是没有任何关系的。
奠定未来 GC 特性的基础。牛逼,牛逼!
最糟糕的情况下吞吐量会降低 15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。
另外,Oracle 官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十 G 堆的停顿时间是 10ms 以下,几百 G 甚至上 T 堆的停顿时间也是 10ms 以下。
ZGC 概述
接下来从几个维度概述一下 ZGC。
New GC
Single Generation
Region Based
Partial Compaction
NUMA-aware
Colored Pointers
Load Barriers
ZGC tuning
Change Log
New GC
ZGC 是一个全新的垃圾回收器,它完全不同以往 HotSpot 的任何垃圾回收器,比如:PS、CMS、G1 等。如果真要说它最像谁的话,那应该是 Azul 公司的商业化垃圾回收器:「C4」,ZGC 所采用的算法就是 Azul Systems 很多年前提出的 Pauseless GC,而实现上它介于早期 Azul VM 的 Pauseless GC 与后来 Zing VM 的 C4 之间。不过需要说明的是,JDK11 中 ZGC 只能运行在 Linux64 操作系统之上。JDK14 新增支持了 MacOS 和 Window 平台:
如下图所示,是 ZGC 和 Parallel 以及 G1 的压测对比结果(CMS 在 JDK9 中已经被标记 deprecated,更高版本中已经被彻底移除,所以不在对比范围内)。我们可以明显的看到,停顿时间方面,ZGC 是 100%不超过 10ms 的,简直是秒天秒地般的存在:
接下来,再看一下 ZGC 的垃圾回收过程,如下图所示。由图我们可知,ZGC 依然没有做到整个 GC 过程完全并发执行,依然有 3 个 STW 阶段,其他 3 个阶段都是并发执行阶段:
Pause Mark Start
这一步就是初始化标记,和 CMS 以及 G1 一样,主要做 Root 集合扫描,「GC Root 是一组必须活跃的引用,而不是对象」。例如:活跃的栈帧里指向 GC 堆中的对象引用、Bootstrap/System 类加载器加载的类、JNI Handles、引用类型的静态变量、String 常量池里面的引用、线程栈/本地(native)栈里面的对象指针等,但不包括 GC 堆里的对象指针。所以这一步骤的 STW 时间非常短暂,并且和堆大小没有任何关系。不过会根据线程的多少、线程栈的大小之类的而变化。
Concurrent Mark/Remap
第二步就是并发标记阶段,这个阶段在第一步的基础上,继续往下标记存活的对象。并发标记后,还会有一个短暂的暂停(Pause Mark End),确保所有对象都被标记。
Concurrent Prepare for Relocate
即为 Relocation 阶段做准备,选取接下来需要标记整理的 Region 集合,这个阶段也是并发执行的。接下来又会有一个 Pause Relocate Start 步骤,它的作用是只移动 Root 集合对象引用,所以这个 STW 阶段也不会停顿太长时间。
Concurrent Relocate
最后,就是并发回收阶段了,这个阶段会把上一阶段选中的需要整理的 Region 集合中存活的对象移到一个新的 Region 中(这个行为就叫做「Relocate」,即重新安置对象),如上图所示。Relocate 动作完成后,原来占用的 Region 就能马上回收并被用于接下来的对象分配。细心的同学可能有疑问了,这就完了?Relocate 后对象地址都发生变化了,应用程序还怎么正常操作这些对象呢?这就靠接下来会详细说明的 Load Barrier 了。
Single Generation
单代,即 ZGC「没有分代」。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。
那么为什么 ZGC 就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本。用符合我们国情的话来解释,大概就是说:工作量太大了,人力又不够,老板,先上个 1.0 版本吧!!!
Region Based
这一点和 G1 一样,都是基于 Region 设计的垃圾回收器,ZGC 中的 Region 也被称为「ZPages」,ZPages 被动态创建,动态销毁。不过,和 G1 稍微有点不同的是,G1 的每个 Region 大小是完全一样的,而 ZGC 的 Region 大小分为 3 类:2MB,32MB,N×2MB,如此一来,灵活性就更好了:
Partial Compaction
部分压缩,这一点也很 G1 类似。以前的 ParallelOldGC,以及 CMS GC 在压缩 Old 区的时候,无论 Old 区有多大,必须整体进行压缩(CMS GC 默认情况下只是标记清除,只会发生 FGC 时才会采用 Mark-Sweep-Compact 对 Old 区进行压缩),如此一来,Old 区越大,压缩需要的时间肯定就越长,从而导致停顿时间就越长。
而 G1 和 ZGC 都是基于 Region 设计的,在回收的时候,它们只会选择一部分 Region 进行回收,这个回收过程采用的是 Mark-Compact 算法,即将待回收的 Region 中存活的对象拷贝到一个全新的 Region 中,这个新的 Region 对象分配就会非常紧凑,几乎没有碎片。垃圾回收算法这一点上,和 G1 是一样的。
NUMA-aware
NUMA 对应的有 UMA,UMA 即 Uniform Memory Access Architecture,NUMA 就是 Non Uniform Memory Access Architecture。UMA 表示内存只有一块,所有 CPU 都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且 CPU 核心数越多,竞争就越激烈。NUMA 的话每个 CPU 对应有一块内存,且这块内存在主板上离这个 CPU 是最近的,每个 CPU 优先访问这块内存,那效率自然就提高了:
服务器的 NUMA 架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC 是能自动感知 NUMA 架构并充分利用 NUMA 架构特性的。
Colored Pointers
Colored Pointers,即颜色指针是什么呢?如下图所示,ZGC 的核心设计之一。以前的垃圾回收器的 GC 信息都保存在对象头中,而 ZGC 的 GC 信息保存在指针中。每个对象有一个 64 位指针,这 64 位被分为:
18 位:预留给以后使用;
1 位:Finalizable 标识,次位与并发引用处理有关,它表示这个对象只能通过 finalizer 才能访问;
1 位:Remapped 标识,设置此位的值后,对象未指向 relocation set 中(relocation set 表示需要 GC 的 Region 集合);
1 位:Marked1 标识;
1 位:Marked0 标识,和上面的 Marked1 都是标记对象用于辅助 GC;
42 位:对象的地址(所以它可以支持 2^42=4T 内存):
通过对配置 ZGC 后对象指针分析我们可知,对象指针必须是 64 位,那么 ZGC 就无法支持 32 位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是 32 位)。
Load Barriers
这个应该翻译成读屏障(与之对应的有写屏障即 Write Barrier,之前的 GC 都是采用 Write Barrier,这次 ZGC 采用了完全不同的方案),这个是 ZGC 一个非常重要的特性。在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个 Load Barriers。那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用 obj.fieldA 并赋给引用 o(fieldA 也是一个对象时才会加上读屏障)。如果这时候对象在 GC 时被移动了,接下来 JVM 就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算 GC 把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要 STW。那么,JVM 是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是 Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是 Good Color,那么正常往下执行即可:
这个动作是不是非常像 JDK 并发中用到的 CAS 自旋?读取的值发现已经失效了,需要重新读取。而 ZGC 这里是之前持有的指针由于 GC 后失效了,需要通过读屏障修正指针。
后面 3 行代码都不需要加读屏障:Object p = o 这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB 不是对象引用,而是原子类型。
正是因为 Load Barriers 的存在,所以会导致配置 ZGC 的应用的吞吐量会变低。官方的测试数据是需要多出额外 4%的开销:
那么,判断对象是 Bad Color 还是 Good Color 的依据是什么呢?就是根据上一段提到的 Colored Pointers 的 4 个颜色位。当加上读屏障时,根据对象指针中这 4 位的信息,就能知道当前对象是 Bad/Good Color 了。
「扩展阅读」:既然低 42 位指针可以支持 4T 内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有 48bit,4 位是颜色位,就只剩 44 位了,所以受限于目前的硬件,ZGC 最大只能支持 16T 的内存,JDK13 就把最大支持堆内存从 4T 扩大到了 16T。
ZGC tuning
启用 ZGC 比较简单,设置 JVM 参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为 ZGC 调优参数并不多,远不像 CMS 那么复杂。它和 G1 一样,可以调优的参数都比较少,大部分工作 JVM 能很好的自动完成。下图所示是 ZGC 可以调优的参数:
下面对部分参数进行更加详细的说明。
UseNUMA
ZGC 默认是开启支持 NUMA 的,不过,如果 JVM 探测到系统绑定的是 CPU 子集,就会自动禁用 NUMA。我们可以通过参数-XX:+UseNUMA 显示启动,或者通过参数-XX:-UseNUMA 显示禁用。如果运行在 NUMA 服务器上,并且设置-XX:+UseNUMA,那对性能提升是显而易见的。
UseLargePages
配置 ZGC 使用 large page 通常就会得到更好的性能,比如在吞吐量、延迟、启动时间等方面。而且没有明显的缺点,除了配置过程复杂一点。因为它需要 root 权限,这也是默认并没有开启使用 large page 的原因。
ConcGCThreads
ZGC 是一个并发垃圾收集器,那么并发 GC 线程数就非常重要了。如果设置并发 GC 线程数越多,意味着应用线程数就会越少,这肯定是非常不利于应用系统稳定运行的。这个参数 ZGC 能自动设置,如果没有十足的把握。最好不要设置这个参数。
ParallelGCThreads
这是个并行线程数,与上一个参数 ConcGCThreads 有所不同,ConcGCThreads 表示 GC 线程和应用线程「并发」执行时 GC 线程数量。而 ParallelGCThreads 表示 GC 时 STW 阶段的「并行」GC 线程数量(例如第一阶段的 Root 扫描),这时候只有 GC 线程,没有应用线程。笔者这里解释了 JVM 中「并发和并行的区别」,也是 JVM 中比较容易理解错误的地方。
ZUncommit
掌握这个参数之前,我们先说一下 JVM 申请以及回收内存的行为。以前的垃圾回收器比如 ParallelOldGC 和 CMS,只要 JVM 申请过的内存,即使发生了 GC 回收了很多内存空间,JVM 也不会把这些内存归还给操作系统。这就会导致 top 命令中看到的 RSS 只会越来越高,而且一般都会超过 Xmx 的值(参考文章:)。
不过,默认情况下,ZGC 是会把不再使用的内存归还给操作系统的。这对于那些比较注意内存占用情况的应用和服务器来说,是很有用的。这种行为可以通过 JVM 参数-XX:-ZUncommit 关闭。不过,无论怎么归还,JVM 至少会保留 Xms 参数指定的内存大小,这就是说,当 Xmx 和 Xms 一样大的时候,这个参数就不起作用了。
和这个参数一起起作用的还有另一个参数:-「XX:ZUncommitDelay=sec」,默认 300 秒。这个参数表示不再使用的内存最多延迟多长时间才会被归还给操作系统。因为不再使用的内存不应该立即归还给操作系统,这样会造成频繁的归还和申请行为,所以通过这个参数来控制不再使用的内存需要经过多久的时间才归还给操作系统。
Change Log
接下来,我们看一下从 JDK11 到 JDK15 这 5 个版本,ZGC 都迭代了哪些特性:
JDK 15 (under development)
Improved NUMA awareness
Support for Class Data Sharing (CDS)
Support for placing the heap on NVRAM
JDK 14
macOS support (JEP 364)
Windows support (JEP 365)
Support for tiny/small heaps (down to 8M)
Support for JFR leak profiler
Support for limited and discontiguous address space
Parallel pre-touch (when using -XX:+AlwaysPreTouch)
Performance improvements (clone intrinsic, etc)
Stability improvements
JDK 13
Increased max heap size from 4TB to 16TB
Support for uncommitting unused memory (JEP 351)
Support for -XX:SoftMaxHeapSIze
Support for the Linux/AArch64 platform
Reduced Time-To-Safepoint
JDK 12
Support for concurrent class unloading
Further pause time reductions
JDK 11
Initial version of ZGC
Does not support class unloading (using -XX:+ClassUnloading has no effect)
看完三件事❤️
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
关注公众号 『 java 烂猪皮 』,不定期分享原创知识。
同时可以期待后续文章 ing🚀
作者:阿飞 javaer
原文出处:club.perfma.com/article/679…
评论