深入理解Java垃圾回收——垃圾收集器

《深入理解Java垃圾回收——虚拟机高效回收的背后》讲述了垃圾回收的理论思想,本篇文章来深入了解垃圾回收的实践:垃圾收集器。

在讲解垃圾收集器之前必要要统一几点认知:
1.用户线程:执行应用程序的用户线程。
2.垃圾回收线程:虚拟机为了执行垃圾收集而创建的垃圾回收线程。
3.并行(Parallel):描述的是多条垃圾收集线程之间的关系;多条垃圾回收线程并行运行,此时的用户线程处于阻塞状态。
4.并发(Concurrent):描述的是垃圾收集线程和用户线程之间的关系;垃圾回收线程和用户线程并发运行,此时的用户线程正常运行,不会因垃圾回收而被阻塞。
5.衡量垃圾收集器的最重要的三项指标:内存占用、吞吐量、延迟性。这三个构成了“不可能三角”,即一款优秀的垃圾收集器通常最多能达成其中两项,不可能三项同时达成。当然,随着垃圾收集技术的发展,这三者的总体表现是越来越好的。

我们带着以上5点认知来开启垃圾收集器的探索之旅。

Serial垃圾收集器

基于标记—复制算法,单线程,用于年轻代。

Serial垃圾收集器在工作的过程中,不论是标记阶段还是回收阶段,都是单线程运行的,用户线程全程处于阻塞状态,吞吐量和延迟性的性能不太好,而然它依然是虚拟机在客户端模式下默认选择的年轻代垃圾收集器。

PerNew垃圾收集器

基于标记—复制算法,多线程并行,用于年轻代。

随着Java应用程序变得越来越大这一事实的背景下,Java虚拟机管理的内存越来越大。标记阶段中,GC Roots根节点枚举由于基于OopMap数据结构的优化手段,不会随着堆内存的变大而明显降低枚举速度。但是,从根节点开始遍历对象图这一过程却会变得越来越长。Serial垃圾收集器在这种背景下,显得心有余而力不足。
PerNew垃圾收集器可以多条垃圾收集线程并行运行,用户线程处于暂停阻塞状态。

PerNew垃圾收集器可以看作是Serial垃圾收集器的并行版本,其内部有很多代码都是和Serial共用的。

Parallel Scavenge垃圾收集器

基于标记—复制算法,多线程,用于年轻代,更关心吞吐量指标。

Parallel Scavenge垃圾收集器的很多特点都和PerNew垃圾收集器相似,最大的不同在于它更关注于吞吐量

吞吐量 = 应用程序运行时间 / (应用程序运行时间 + 垃圾收集时间)

吞吐量指标背后其实指的是更加关注处理器资源的利用率。吞吐量越高,处理器资源利用率就越高。吞吐量指标适合在后台运算不需要太多与用户交互的分析任务。比如:离线数据分析服务、定时任务服务等。

控制吞吐量的参数:
-XX: MaxGCPauseMillis -> 最大垃圾收集停顿时间;
垃圾收集器仅在进行垃圾回收时,尽可能的不超过这个设定值。不要天真的以为把这个值设定的越小越好,它是提升是以牺牲新生代内存空间和吞吐量为代价的:值设定的过小,停顿时间确实会变短,但是触发GC的次数也会变得更多,吞吐量反而降低。
-XX:GCTimeRatio -> 吞吐量大小;
值介于[0, 100]的整数,指的是垃圾收集时间占总的运行时间的比率。默认值为99,相当于最大运行1%的运行时间用于垃圾收集。
-XX:+UseAdaptiveSizePolicy -> 垃圾收集器自适应调节策略
这是一个开关参数,当开启之后,就不要手动指定新生代、老年代的大小了,虚拟机会监控系统的运行情况自动生成一个配置策略并应用。我们只需配置堆内存的大小,剩下的交给虚拟机自己调节。

Serial Old垃圾收集器

基于标记—整理算法,单线程,老年代。Serial垃圾收集器的老年代版本。和Serial垃圾收集器一样,简单粗暴,没啥好说的。

Parallel Old垃圾收集器

Parallel Scavenge垃圾收集器下的老年代唯一选择,基于标记—整理算法,多线程并行,老年代。

在Parallel Old垃圾收集器诞生之前,Parallel Scavenge垃圾收集器一直处于一个尴尬的位置,因为老年代只能选择Serial Old垃圾收集器,整体的吞吐量效果不明显。直到Parallel Old垃圾收集器诞生,以吞吐量优先的目标才真正意义上得以实现。如果应用程序更关注系统资源则可以采用Parallel Scavenge +Parallel Old的组合。

CMS垃圾收集器

基于标记—清除算法,多线程并发,老年代,更关心低延时。

在CMS垃圾收集器诞生之前,垃圾收集线程和用户线程是无法并发运行的。
CMS垃圾收集器的运作大致分为以下几步:
1.初始标记:根节点枚举,这个过程很快,阻塞用户线程。
2.并发标记:从根节点开始遍历对象图,垃圾收集线程和用户线程并发运行。
3.重新标记:基于增量更新的方式将并发标记过程中由用户线程导致引用关系变化的对象关系重新标记(不包括因用户线程运行而产生的新的GC Roots!)。这个过程比初始标记慢,但仍比并发标记的过程快很多。此过程依然要阻塞用户线程。
4.并发清除:直接删除掉被标记为死亡的对象,垃圾收集线程和用户线程并发运行。

CMS垃圾收集器是经典垃圾收集器中唯一实现与用户线程并发运行的垃圾收集器,他的低延时、低停顿也是这个原因。
然而它的缺点也很明显:
1.对系统资源敏感:多条垃圾收集线程运行会占用更多的系统资源

CMS默认的垃圾收集线程数=(处理器核心数量 +3)/4,假设是2核处理器,那么垃圾收集过程中,将占用50%以上的处理器资源用户垃圾收回收,吞吐量将大大降低。实际情况中,大部分成熟的应用程序生产环境的处理器核心都在8核以上,因此垃圾收集所占用的处理器资源影响没有想象中那么大。

2.无法处理“浮动垃圾”。

在并发标记阶段中,由于垃圾收集线程和用户线程是并行运行的,CMS垃圾收集器可以基于增量更新技术解决已有的对象引用关系的变化以解决“对象消失”的问题,但由用户线程产生的新的对象则无法处理。这意味着,在垃圾收集过程中,必须要预留一部分空间用于存储这部分“浮动垃圾”。JDK1.5时,默认老年代使用了68%时,触发Major GC,JDK1.6时,为了更好的性能提升,默认设置为92%.

以上的缺点还能进一步挖掘出极有价值的信息出来:
1.如果比例设置过大,那么CMS垃圾收集器在运行过程中,没有足够的空间预留分配新的对象,就会出现一次“并发收集失败”,从而不得不启动预备方案:暂停所有用户线程,临时启用Serial Old垃圾收集器重新进行老年代的垃圾收集工作,导致用户线程停顿时间过长!

2.CMS是基于标记—清除算法的,因此在回收完成后,会产生较多的空间碎片,当空间碎片过多无法为找到连续空间给对象分配时,不得不提前触发一次Full GC。

3.假设垃圾回收速度大于新对象分配的速度,上面的问题是不是就迎刃而解了呢?(其实这就是指导新一代垃圾收集器设计的思想基础!)。

在文章中(包括之前写的文章),有很多地方使用了“经典的垃圾收集器”、“新一代垃圾收集器”这些字眼,其实这不是乱使用的。使用“经典”二字也并非是这些垃圾收集器过时了,仅仅是用于区分从G1垃圾收集器开始之后的新一代垃圾收集器。 “经典”和“新一代”是如何区分的呢?答案就是设计思想。
经典的垃圾收集器的设计思想基于“分代理论”,
而从G1垃圾收集器开始的新一代垃圾收集器的设计思想基于“Region”内存布局。

G1垃圾收集器

基于Region的内存布局形式和局部收集的设计。垃圾收集器的发展里程碑(底层设计思想的进步)。

在G1收集器出现之前,所有的垃圾收集器的垃圾回收范围要么是整个新生代,要么是整个老年代,要么是整个堆(Full GC)。而G1垃圾收集器则跳出了这个局限,采用Region的内存布局,将Java堆划分成若干个Region区域,在垃圾收集时,优先对回收效益最好的区域,这便是G1垃圾收集器下的Mixed GC模式。它具体是怎么实现的呢?

G1垃圾收集器下的堆内存虽然也遵循着分代理论,但它和经典的分代方式有明显的差异:它把连续的内存空间划分为多个独立的内存区域(Region),每一块区域都可以根据需要成为Eden区、Survivor区或者老年代区。G1垃圾收集器能够针对扮演不同角色的区域采取不同的处理策略,这样,不论是新产生的对象还是存活了一段时间的对象或者是熬过多次回收的老对象,都能够有很好的回收效果。

Region中还有一类特殊的Humongous区域,专门用于存储大对象。G1认为只要对象大小超过了一个Regison容量大小,就可以被判定为大对象。
Region大小参数设置:-XX: G1HeapRegionSize, 取值范围为1~32MB,且应为2的n次幂。

G1垃圾收集器的回收策略与经典垃圾收集器不同,它能够建立可预测的停顿时间模型,很“智能”。具体实现思想如下:
1.G1垃圾收集器以Region作为单次回收的最小单位;即每次回收都是Region的整数倍,这样可以有计划的避免在整个堆中进行全区域的垃圾收集。
2.G1垃圾收集器会去跟踪每个Region区域的垃圾“价值”大小(即回收所得的空间大小和回收所需的时间经验值),并在后台维护一个优先级列表。
3.当需要回收时,会根据用户设定允许的回收停顿时间优先回收价值收益最大的Region区域。这样,就可以做到在有限的时间内尽可能的获取更高的垃圾回收效率。

可以通过参数-XX: MaxGCPauseMills来设定允许的最大停顿时间,默认200ms;
由于一块Region可能代表Eden区、Survivor区或者是老年代区域,在回收时通常会回收多块Region区域,因此,一次回收可能会同时包括Eden、Survivor和老年代,原因就在于G1垃圾收集器的回收策略是优先回收那些回收价值最大的区域。

G1垃圾收集器这种“化整为零”的设计思路,看起来很简单,但其实有很多需要困难需要解决,我们关注最重要的几点。

跨Region引用对象如何解决?
分代理论中有一条跨代假说:跨代引用相比同代引用占极少数,因此采用了记忆集的思想解决跨代引用。同样的,跨Region引用相比同Region引用占少数,因此也使用记忆集的方式解决跨代引用,只不过,G1下的记忆集实现要相对更复杂。

G1收集器的记忆集本质上是一个哈希结构,Key记录了其他Region指向“我”的起始地址,Value是一个“我”指向别的Region的卡表集合。这种双向的卡表结构相比原来的卡表结构要复杂的多,同时,Region数量相比于传统的分代数量要多得多,因此G1收集器比传统的收集器有着更高的内存占用。(还记得开篇提到的“不可能三角”么?)

如何解决对象消失问题?
在《深入理解Java垃圾回收——虚拟机高效回收的背后》 中,阐述了标记过程中对象消失问题,以及采用增量更新和原始快照的解决方案。CMS垃圾收集器采用的是增量更新的方式,而G1垃圾收集器则采用的原始快照的方式。

如何解决“浮动垃圾”?
对于“浮动垃圾”的问题,G1垃圾收集器对每个Region设计了两个名为TAMS指针,在回收过程中,用户线程新产生的对象必须分配在这两个指针之上,同时,G1垃圾收集器默认它们都是存活的,不纳入标记范围。此外, 当垃圾回收速度赶不上新对象分配速度时,G1垃圾收集器也不得不采用Stop The Word方式,触发Full GC。

如何建立可靠的与预测时间停顿模型?
G1垃圾收集器的停顿模型是以衰减平均值的理论基础来实现的。相比传统的平均值,衰减平均值更容易受到新统计的数据影响,普通的平均值代表着整体水平,而衰减平均值能更精确的表示“最近的”平均值。

G1垃圾收集器的运作过程:
1.初始标记
暂停用户线程,进行根节点枚举,同时修改TAMS指针的值,用于下一阶段并发标记时,能够正确分配新产生的对象。
2.并发标记
垃圾收集线程和用户线程并发运行,遍历整个对象图,进行可达性分析。分析完成后还要重新处理SATB记录的引用关系(基于原始快照)。
3.最终标记
暂停用户线程,并对并发标记中中仍有遗留的SATB记录进行处理。
4.筛选回收
依据时间停顿模型,筛选出最优的Region构成回收集,进行回收。这个过程依然要暂停用户线程。

回收的具体方式如下:多条垃圾回收线程并行运行,将Region中存活的对象复制到空的Region中,然后清除掉整个Region。这个过程由于涉及到移动对象,因此必须暂停用户线程。

从G1垃圾收集的运作过程中可以发现,除了并发标记阶段可以和用户线程并发运行,其他阶段都是要暂停用户线程的。因此G1垃圾收集器并不是一味的追求低延迟,而是在延迟可控的情况下可能的提高吞吐量。正因如此,才担当得起“全功能垃圾收集器”的重任与期望!

通过上面的介绍大概能够知晓为什么G1垃圾收集器被称为垃圾收集器发展历史的里程碑了。实际上,它成为里程碑还有一个非常重要的原因:
从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(AllocationRate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1 VS CMS
从JDK9开始,G1垃圾收集器作为服务模式下默认的垃圾收集器,取代了CMS的位置。G1和CMS都很关注停顿时间,那么G1相比CMS到底好在哪里?
从回收算法理论的角度来看,CMS采用的是标记—清除算法,而G1不同,从整体上来看,G1提现了标记—整理算法的思想,但从局部上来看,G1又提现了标记—复制算法的思想,这两种算法都不会产生内存碎片的问题,这一点,CMS输了。
从内存占用角度来看,CMS和G1都存在并发标记阶段,这意味着必须牺牲额外的内存空间来保持对象关系一致性(避免对象消失问题)。然而,G1的记忆集实现比起CMS的实现要复杂,占用的内存更多,这一点,G1输了。
除了以上两点,G1垃圾收集器的可控停顿时间、Region内存布局等方面为新一代垃圾收集器打下了夯实的基础。

ZGC垃圾收集器

ZGC相比于G1垃圾收集器,有着更加让人趋之若鹜的目标:在尽可能不影响吞吐量的情况下,实现在任意堆大小情况下将延迟控制在10ms以内。
要实现这一目标,意味着标记阶段要全程和用户线程并发执行(根节点枚举除外)。ZGC的具体实现比较复杂,但最终它还是基于“指针染色技术”和读内存屏障实现了。
其具体实现比较复杂,完全可以独立一篇文章来介绍。

正所谓成也萧何败萧何,指针染色技术导致ZGC能管理的堆内存有限,最大能够管理4TB的内存。但这一点缺点在它优秀的性能下完全不值得一提。

本篇文章介绍了垃圾收集器发展过程中的主流产物。其实区区6千字没法将其全部发展过程详尽诉说,但结合之前的几篇文章真正掌握Java垃圾回收运作机制以及未来发展方向还是没有问题的。

你可能感兴趣的:(深入理解JVM虚拟机)