Java垃圾回收(4)

以下三篇文章是笔者前面所翻译的关于垃圾回收的文章:

G1(Garbage First)垃圾收集器

G1垃圾收集器是hotspot虚拟机中实现的最新的垃圾收集器。从Java 7的第4个更新版本开始就提供了此回收器的支持。Oracle GC小组也公开表示,他们希望在G1中完全实现低停顿GC。

问题:回收大堆意味着要停顿个较长的时间

CMS(Concurrent Mark and Sweep)收集器是当前推荐的低停顿垃圾回收器,但是不幸的是它的停顿时间随老年代区间中可达对象的数量而变化。这意味着堆越小,那么要达到较短停顿的GC就相对容易,但是一旦你开始使用10s千兆或100s千兆字节的堆,时间就会开始增加。

CMS也不能整理堆的“碎片”,所以在某些时候会造成并发模式失效(CMF),进而触发full gc。一旦进入full GC场景,你就可能期望每千兆字节的可达对象的回收停顿时间在1秒以内。如果使用CMS,那么100G大小的堆的话你就等待1.5分钟的停顿时间的定时炸弹发生吧。
Java垃圾回收(4)
良好的GC调优可以解决这个问题,但是有时他也只是把问题推到了一边。并发模式失效(CMF)且因此在足够长的时间之内导致一次Full GC是必然的,除非你是故意避免填充老年代空间的那一小部分人。

G1回收器的堆布局

G1回收器尝试通过分割堆为不同的区域来将一次单独回收的停顿时间和堆的整体大小分离。每个区域时固定大小,大概在1M到32M之间,并且JVM的目标是总共创建2000个区域。
Java垃圾回收(4)
通过前面的文章你可能会想到其他的回收器将堆分为Eden, Survior、Tenured内存池。G1回收器保留了相同类别的内存池,但不是连续的内存块,每个区域是逻辑归类到了这些内存池中。

G1回收器有另外一种类别的区域——Humongous区。这个区域的设计用来存储比大多数对象大得多的对象——比如一个非常长的数组。任何大于分区容量50%的对象都会被存储到Humongous区。它们的实现是通过整合位于内存中的多个连续区域并且把他们看成是一个单独的逻辑区域。
Java垃圾回收(4)

Remembered Sets

当然如果你是通过扫描整个堆然后找出那些对象被标记为可达,那么将堆分成多个区域是没有多大意义的。G1收集器解决这个问题的第一步是将区域分解成称为cards的大小为512个字节的字节段。每一个card在card标记表中有1位是入口位。

每个区域都有一个关联的remembered set,或者称为RSet——已经被写入的cards集合。如果来自另一个区域的存储在card内的一个对象指向当前区域的一个对象,那么一个card就会被记录在该集合中。

当更改器(mutator)写入对象引用时,一个写屏障(write barrier)就会被用来更新remembered set。底层remembered set被分为不同的集合,这样的话不同的线程就可以在无争夺的情况下操作,但是概念上所有的集合都是同一个remembered set的一部分。

并发标记(Concurrent Marking)

为了辨别哪些堆对象可达,G1大部分执行并发标记可达对象。

  • 标记阶段(Marking Phase):标记阶段的目标是找出堆中哪些对象可达。为了存储可达对象,G1使用了标记位图(bitmap)——存储一个单独的bit表示堆上64bits。所有的对象都从他们的root开始追溯,在标记位图中标记存在可达对象的区域。这个阶段大部分是并发的。但是有一个初始标记阶段(Initial Marking Pause)和CMS类似,在这个阶段应用程序需要停顿并且从root对象开始追溯的第一个级别的子对象将会被标记(root可直接访问到的对象)。初始标记阶段完成之后mutator线程会重启。G1需要及时了解堆中对象的可达情况,因为堆不会像标记阶段那样在停顿的同时可以被清理。

  • 重新标记阶段(Remarking Phase):重新标记阶段的目标是将从标记阶段带过来的关于可达对象的信息更新到最新。要做的第一件事情就是决定何时开始重新标记?它由堆被填满的一个百分比触发。计算方式是按照从标记阶段带过来的信息和分配数量来计算,然后告知G1是否计算出来的值超过了要求的百分比。G1使用如前所述的写屏障(write barrier)记录对堆的更改并且存储这些更改信息到一系列的变更缓冲区(change buffers)中。同时在标记位图中标记变更缓冲区(change buffers)中的对象。当达到填充百分比时mutator线程会暂时停顿并且变更缓冲区(change buffers)会被处理,标记变更缓冲区(change buffers)中可达对象。

  • 清除阶段(Cleanup Phase):在此阶段G1已经知道哪些对象可达。因此G1在此阶段更侧重于可用空间更多的区域,下一步就是通过计算给定区域中可达对象的数量提取空闲空间。这是从标记位图上计算出来,并且根据哪些区域最有可能有利于回收来对区域进行排序。被回收的区域被存储在一个被称为回收集合(collection set)或CSet中。

转移(Evacuation)

和并行回收(Parallel GC)和CMS收集器中年轻代回收的hemispheric方法类似,不可达的对象不是被回收。而是将可达对象从一个区域转移并且整个区域被释放。

关于如何回收可达对象,G1是很智能的——它并不是尝试在一定的循环内去回收可达对象。它所指定的区域可能会尽可能多的回收空间并仅是转移哪些可达对象。G1通过计算一个区域中可达对象的比例并且找出可达对象比例最低的一个区域作为目标区域。

对象从其他多个区域转移到空闲区域。这就意味着G1在执行GC的时候压缩了数据。这个操作是通过多线程并行操作。传统的并行GC(Parallel GC)是这么做的,但是CMS不是。

与CMS和并行GC类似,G1也有一个老年代的概念。也就是说如果对象从多次回收中幸存下来,那新生代对象也变成了老对象。这里的回收次数我们称为晋升阈值(tenuring threshold)。如果一个年轻带区域能够在达到晋升阈值(tenuring threshold)的时候幸存下来,并且回收了足够的可达对象从而避免了被转移。那么这个区域就会被晋升。首先作为一个幸存者并且最终成为老年代区域。它从来就没有被撤离。

转移失败(Evacuation Failure)

不幸的是,G1仍然会遭遇类似于CMF(Concurrent Mode Failure)的情况,在这种情况下依然要返回做一次 Stop the World Full GC。这被称为转移失败(Evacuation Failure),这种情况发生的条件是没有任何区域空闲,没有空闲的区域也就意味着没有地方转移对象。

理论上G1中的转移失败(Evacuation Failure)相对于CMS中的CMF(Concurrent Mode Failure)来说是很少发生的。这也是因为G1再运行时压缩了区域而并不是等待压缩然后引发一个错误。

小结

尽管在压缩和降低停顿时间上做了很多的努力,G1也不能保证是能够完全优于其他算法,任何尝试采用它都应该伴随客观和可衡量的性能目标和GC Log分析。所需的方法超出了这篇博文的范围,但希望我会在以后的文章中介绍。

在算法上,G1会遇到其他Hotspot收集器没有的开销。值得注意的是维护remembered sets的开销。并行GC(Parallel GC)仍然是推荐的吞吐量收集器,并且在很多情况下CMS也比G1更好的应对。

如果现在说G1优于其他的收集器还为时过早,但是在一些解决方案中它已经为使用它的开发者提供了很多好处。随着时间的推移,我们将看看G1的性能限制是否真的限制了G1,或者开发团队是否需要更多的工程设计来解决这些问题。

参考:https://www.tuicool.com/articles/JBRreyU
原文:https://www.javacodegeeks.com/2013/07/garbage-collection-in-java-4.html