说不定它更好用!新一代垃圾回收器ZGC,带你探索并实践下

很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰 , 作为新一代的低延迟垃圾回收器 , ZGC在大内存低延迟服务的内存管理和回收方面 , 有着非常不错的表现 。 本文从GC之痛、ZGC原理、ZGC调优实践、升级ZGC效果等维度展开 , 详述了ZGC在美团低延时场景中的应用 , 以及在生产环境中取得的一些成果 。 希望这些实践对大家有所帮助或者启发 。
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器 , 它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小 , 或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB) 。
从设计目标来看 , 我们知道ZGC适用于大内存低延迟服务的内存管理和回收 。 本文主要介绍ZGC在低延时场景中的应用和卓越表现 , 文章内容主要分为四部分:
  • GC之痛:介绍实际业务中遇到的GC痛点 , 并分析CMS收集器和G1收集器停顿时间瓶颈;
  • ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因 , 以及背后的技术原理;
  • ZGC调优实践:重点分享对ZGC调优的理解 , 并分析若干个实际调优案例;
  • 升级ZGC效果:展示在生产环境应用ZGC取得的效果 。
GC之痛很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰 。 GC停顿指垃圾回收期间STW(Stop The World) , 当STW时 , 所有应用线程停止活动 , 等待GC停顿结束 。 以美团风控服务为例 , 部分上游业务要求风控服务65ms内返回结果 , 并且可用性要达到99.99% 。 但因为GC停顿 , 我们未能达到上述可用性目标 。 当时使用的是CMS垃圾回收器 , 单次Young GC 40ms , 一分钟10次 , 接口平均响应时间30ms 。 通过计算可知 , 有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等 , 其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms 。 可见 , GC停顿对响应时间的影响较大 。 为了降低GC停顿对系统可用性的影响 , 我们从降低单次GC时间和降低GC频率两个角度出发进行了调优 , 还测试过G1垃圾回收器 , 但这三项措施均未能降低GC对服务可用性的影响 。
CMS与G1停顿时间瓶颈在介绍ZGC之前 , 首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈 。 CMS新生代的Young GC、G1和ZGC都基于标记-复制算法 , 但算法具体实现的不同就导致了巨大的性能差异 。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中 。 标记-复制算法可以分为三个阶段:
  • 标记阶段 , 即从GC Roots集合开始 , 标记活跃对象;
  • 转移阶段 , 即把活跃对象复制到新的内存地址上;
  • 重定位阶段 , 因为转移导致对象的地址发生了变化 , 在重定位阶段 , 所有指向对象旧地址的指针都要调整到对象新的地址上 。
下面以G1为例 , 通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法) , 分析G1停顿耗时的主要瓶颈 。 G1垃圾回收周期如下图所示:
说不定它更好用!新一代垃圾回收器ZGC,带你探索并实践下文章插图
G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段 。
标记阶段停顿分析
  • 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程 , 该阶段是STW的 。 由于GC Roots数量不多 , 通常该阶段耗时非常短 。
  • 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析 , 找出存活对象 。 该阶段是并发的 , 即应用线程和GC线程可以同时活动 。 并发标记耗时相对长很多 , 但因为不是STW , 所以我们不太关心该阶段耗时的长短 。
  • 再标记阶段:重新标记那些在并发标记阶段发生变化的对象 。 该阶段是STW的 。
清理阶段停顿分析
  • 清理阶段清点出有存活对象的分区和没有存活对象的分区 , 该阶段不会清理垃圾对象 , 也不会执行存活对象的复制 。 该阶段是STW的 。
复制阶段停顿分析
  • 复制算法中的转移阶段需要分配新内存和复制对象的成员变量 。 转移阶段是STW的 , 其中内存分配通常耗时非常短 , 但对象成员变量的复制耗时有可能较长 , 这是因为复制耗时与存活对象数量与对象复杂度成正比 。 对象越复杂 , 复制耗时越长 。
四个STW过程中 , 初始标记因为只标记GC Roots , 耗时较短 。 再标记因为对象数少 , 耗时也较短 。 清理阶段因为内存分区数量少 , 耗时也较短 。 转移阶段要处理所有存活的对象 , 耗时会较长 。 因此 , G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW 。 为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题 。