随笔分类
gc
Java 9之后 G1成为了默认的垃圾收集器
收集器在设计时考虑到不同维度的优化,因为它们的目标不同
CMS也是针对垃圾低延时设计的收集器,其在 Java 14中已被抛弃
对于 G1收集器,它有点像介于延迟敏感与吞吐量敏感之间
ZGC是 Java11时引入的 GC,其对于实现低延迟的应用程序具有巨大的价值
how to design a gc?
不同的 gc可以针对其设计指标进行专项设计,以 OpenJdk为例浅谈如何去设计一个 GC
任何实践之前,都需要有强有力的理论支持,设计一个 gc同样如此
OpenJdk在设计低延时的 gc时遵循两个理念:
其一:Generational
即:绝大多数创建的对象都是朝生夕死的
基于此,衍生出新生代、老年代,针对新生代进行专项回收,处理好新生对象晋升老年代的阈值:分代年龄
引入了分代,也带来了新的问题,便是新老对象引用传递的问题,因此也引入了 Card Table Maintenance Barriers
来去引用具体指向
其二:Pararrel Work
引入了多线程,我们要做的便是为这些线程分配任务区间,或者说基于节点的迭代分配,这个节点分配完了下一个,不断迭代
而基于低延时我们想做的便是 gc线程与应用程序线程并发运行,这能够最大程度降低延时,缺点也很明显,并发也就意味着 gc线程会与应用程序线程 share resources
并发运行,并不意味着完全没有 STW,STW仍然存在,但 pause时间短了些,随之我们需要去做些 init操作,如在第一次 stw时寻觅到 Gc Roots,后续并发运行构建存活对象图
并发运行时,应用程序线程可以去更改存活对象状态,也可以去拯救原本被标记为垃圾的对象,换句话说,基于上述理念设计的 Collector是不可靠的,这显然不是我们所希望的,因此我们会尝试去修复依赖保障其可靠性,这也是通过引入 Maintenance Barriers
来实现的;其次便是浮动垃圾的产生,这没法避免,只能留给到下一次 gc
从上述我们也可以看到低延时收集器提升吞吐量的瓶颈在为了保证可靠性而引入的各种屏障上,转换角度,如果我们的设计目标是提升吞吐量,那么上述 Concurrent Threads便需要取消,这能减少很多开销
基于低延时以及高吞吐的指标设计实现的 gc,主要区别在于高吞吐量下我们希望的是一个阶段下线程能百分百地去工作,无论是 gc tds,还是应用程序 tds,二者之间不能有资源的共享便是提升吞吐量的最好法子;而在低延时下我们会将原本整个 pause gc阶段进行分解,基于增量去进行垃圾回收,利用并发时间的错杂性尽可能降低单次 pause时间
Open Jdk的基于吞吐量的指标便是开销不超过 1%,即应用程序的性能不会降低超过 1%,但实际实现时 G1最终指标便是开销不超过 10%,也就是说最坏情况下,使用 G1会使应用程序性能降低到 90%
目前而言,任何 collectors都无法避免 stw,即或多或少都会有 pause phase
并且基于上述所去实现的 collectors,并没法去避免下述的 Long pause:Full GC
A Full Compacting GC:
- Full GC使得 gc不可预测并且会造成应用程序停滞一段时间
- 堆空间没法很好地自适应变化,如堆有着较高的对象晋升率,超出了 gc清理效率
可知,此时难点便是如何去提高可预测性以及可伸缩性
为了解决此问题,我们引入了两个概念:
-
堆区域化
这也是最好的方式,我们不要将堆看成一整个区域,将其进行区域化,即分代后的区域也是由众多区域构成的,基于此我们能够去做更多的优化
-
局部压缩
基于区域化后,我们就没必要针对一整个区域进行垃圾回收,处于回收价值考虑,我们回去回收有限时间内价值最高点区域,其次可基于多个区域进行压缩,不再是以往耗时的针对整个区域进行垃圾回收的压缩器
基于此,此时我们再去设计一个低延时的 gc,设计图如下:
这实际上也是 G1的设计图,但在实际对 G1进行边缘压测时发现其尾部延迟较高,这也是其缺点所在
即,基于上述设计出来的收集器没法去解决尾部延迟问题
因此,我们期望的 GC Collector是可预测的、伸缩性好的、低延时敏感的收集器:
-
仍然保留局部压缩,但赋予其具备并发之能
这是 G1、CMS所不具备的,ZGC独有
-
在单次 STW Phase上设置时间预算,如单次暂停时间不能超过 10ms
-
当超出时间预算时,就退回到并发工作
如仍然是 phase->Concurrent works的工作模式
-
重复相同的工作,并发工作,可预算的 stw,直到此次 gc结束
-
向应用程序 tds寻求帮助
若 gc压缩空间的效率已经满足不了此刻分配对象的需求,可告知应用程序 tds,这些存活对象是二者都会去使用的资源,让应用程序暂缓对这些存活对象的更变或新生对象的分配,应用程序 tds加入压缩 tds中去,加快此次压缩效率,这也是俗称的 "Self-Healing"
此时的设计图:
了解的同学应该知道,这实际上就是 ZGC的设计图了,但区别在于 Java17中 ZGC移除了分代设计(有着自己的考虑)
ZGC is an adaptive, near-real-time, scablable, predictable low latency collector
ZGC:
- 作出了承诺,单次 pause phase不超过 10ms
- pause phase不会随着应用程序堆、活动数据集或根集的大小而增加
- 堆可动态扩展从 4MB ~ 16TB
- 吞吐量方面开销不超过 15%
ZGC中也引入了新的概念:Color Pointers,即对应 Object Address上进行 tags标记:
gc rinse时也是需要一些信息来去判断此对象该执行怎样的操作,当前的状态是怎样的,且这些信息越准确,语义越清晰便越能帮助 gc进行高效 ops,而 ZGC便是选择在对应未使用的 bits上通过标识 tags的方式去描述当前对象的元信息
下图展示 ZGC的 process:
对于 ZGC而言,个人认为其有趣的地方在于尽可能窃取 STW工作,将其并发化处理
一个显要区别便是其并发标记,对于不同的 GC Collector,其标记算法不同,ZGC将其并发化处理:
-
逻辑上将堆分成若干段,段的数目与后台线程有关
每个线程会在分配的 stripe上工作
最小化共享状态
-
通过加载屏障来侦测到哪些对象未被标记
-
并发引用处理
-
线程本地握手
其次,便是引用屏障:
Barriers - Loaded Reference Barrier
-
更新一个 "bad" Reference to a "good" Refernece
基于旧状态的引用便是 bad的,对这些引用进行更新,转变成当前的最新的状态
-
当更新资源内存位置时能够自我治愈
-
所有可见的需要被更新的 ref将会安全地进行标记
-
可见的引用指向都会是正确的位置
这也是 ZGC对我们的再一承诺
最后,便是并发压缩:
-
使用 Load Barrier,用于检测 Collection Set中的指针
如果引用需要更新地址,现将它加入 GC集合中
-
能够自我治愈
每当我们想要移动对象时,应用程序也在工作,我们可以在某一时刻去移动它,从 From到 To区域的移动,但应用程序可能会需要访问这个对象,这个对象的位置在哪,它指向什么,我们会去检查 Collection Set中是否有它,有的话就意味着我们需要移动它,我们会去检查应用程序是否需要它,如果需要的话就让应用程序自己去干这件事,this can be self-Healing
-
使用一小块堆外内存保存查找表
压缩,本质上是对象地址的更变,从一块区域到另一块区域,那么我们怎么知道一个对象改移动到指定的区域,这是通过 "查找表"完成的,通过查找表可快速完成地址的映射转换
现如今,在不同 Java版本中 ZGC的特性有着不同的更变
-
线程本地握手 - Java10
-
读屏障以及颜色指针 - Java11
-
并发引用处理 - Java11
-
并发类型卸载 - Java12
-
取消提交(释放)未使用内存 - Java13
-
Windows、Mac支持 - Java14
-
压缩类指针 - Java15
ZGC会去使用一些 bit tag标识 Ref的元信息,因此不能去压缩对象指针
但可以去压缩类区域,在元空间中
-
引入并发线程栈扫描 - Java16
-
引入 Win、Mac Arm 64的支持
在引入 Thread Shake前,应用程序与后台 GC tds是怎么工作的呢?
当系统触发 gc时,并不是 gc tds立马就会去进行工作,因为此时 Application tds仍然在工作,对于这些 tds需要进入到 Safe Point中去,只有所有的 Application tds进入后,GC tds才会进行工作,当 GC Completed之后,Application tds再开始它们的工作;对于这种模式下,我们常会去探讨所有应用程序进入 Safe Point的耗时,因为这是以最后一个线程进入的时间为准的,因此这也成为了这种模式下的瓶颈
而 ZGC通过引入线程本地握手来解决此问题,先思考一个问题,为什么系统发生 gc时后台线程不能立马进行 rinse ops?简单来说,就是所需资源尚未准备好,gc tds需要获取 Application tds中的一些信息才可以进行对应工作,如线程栈信息,基于这点,ZGC将 Application tds进入 Safe Point进行增量的分解,也就是说任意 Application tds进入到 Safe Point后后立马会有 GC tds会去与之握手 (shake hands),获取到所需的线程栈信息,也就没有必要再和之前以往等待全部就绪再进行工作了
Thread Local Handshakes体现在 ZGC的 STW的初始标记,也是通过这种方式来实现了毫秒以下的暂停时间,因为它也是由小小的这种握手组成的,其实很形象,握完手后,Application tds继续运行,短暂如此
Main - Main - OpenJDK Wiki (java.net), 实际上在对 ZGC、G1进行碎片化的压测时可以发现在极端场景下还是 ZGC能够保持低延时的 Pause Phase,G1还是差了些,因为 G1本身就不是一个低延时的 GC Collector,但其本身也尝试提供优质交付
综上便是个人对 gc的理解,近年来也有一个 GC值得探讨:Shenandoah,可以发现自从 G1之后的 GC Collector都很nb!