一步步带你理解CMS与G1垃圾回收器

新生代和老年代垃圾回收算法不同的原因

在新生代中。每次垃圾回收都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活的对象的复制成本就可以完成回收。而老年代中因为对象存活率高,没有额外空间对它进行担保,就需要用到"标记——清除"或者"标记整理"算法进行回收。

垃圾回收器之间的关系

image.png

图中上半部分是新生代垃圾回收器,下面是老年代垃圾回收器,连线就是可以搭配使用。没有最好的垃圾回收器,只有最适合的,存在即合理。

Serial(串行)/Serial Old

JVM刚诞生的时候就存在的最古老的垃圾回收器。特点是单线程,独占式,适合单CPU,一般在客户端模式下使用。

这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收。(可以把停顿时间控制在100ms左右)。如果超过这个内存大小,回收速度就会很慢。所以说这个垃圾回收器现在已经变成了鸡肋。(实际一般是当做CMS的替补使用)

image.png

运行时可通过参数设置,使新生代和老年代使用串行垃圾回收器。

Paraller(平行) Scaven/Paraller old

为了提高效率,从JDK1.3开始。JVM开始使用了多线程的垃圾回收机制。关注吞吐量的垃圾回收器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务。主要适合后台运算而没有太多交互的任务,也就是CPU密集型任务。(扩展:当CPU进行集中数据运算时,属于CPU密集型,而当CPU大部分时间处于等待IO的读写操作时,就属于IO密集型。注:IO密集型主要工作的是硬盘。所以可以更加了解机械硬盘和固态硬盘的区别

吞吐量:所谓的吞吐量就是CPU用于运行代码的时间与CPU消耗时间的比值,即吞吐流量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。相当于用暴力手段处理STW的时间,并没有从根本去优化,但是依然取得了良好的效果。虚拟机总共运行了100分钟,其中垃圾回收占用了1分钟,那么吞吐量就是99%。(再说个更好记的:妈妈今天让我学习8小时,我学半小时,玩1个半小时手机。8小时时间到了,我跟妈妈说妈!我学完了!,实际我的吞吐量很低。简而言之可以理解我们的专注度)。

该垃圾回收器适合回收堆空间上百兆~几个G的内存。

image.png

运行时可通过参数设置,使新生代和老年代使用平行(多线程)垃圾回收器。

-XX:MaxGCPauseMillis

此参数可以主动设置STW的停顿时间。不过并不是把这个参数值设置小了就能使垃圾回收速度变快。垃圾收集停顿时间缩短是以牺牲吞吐量(垃圾回收变频繁)和新生代空间为代价换去的。当新生代空间变小时,垃圾回收的触发会变得越来越频繁,以换取更频繁的垃圾回收处理,从而变相使每次的STW的时间变少。但是每次启动也需要耗费时间。因此频繁的进行垃圾回收反而使吞吐量更低了。因此在多线程垃圾回收器中,这个值设置成默认的就可以了。此参数主要是为了G1垃圾回收器做服务的。

-XX:+UseAdaptiveSizePolicy

此参数默认开启。当参数激活后,就不需要人工置顶分配新生代大小,Eden,Survivor区的比例。晋升老年代对象大小等细节参数了。虚拟机会根基当前系统的运行情况收集性能监控信息。动态调整这些参数以提供最合适的停顿时间或最大吞吐量。

ParNew

多线程垃圾回收器,与CMS进行配合。对于CMS而言,其本身回收老年代,与之配对的新生代垃圾回收器有Serial与ParNew可选。ParNew和Paraller Scaven基本没区别,多线程,多CPU的,停顿时间比Serial少。

CMS(ConcurrentMark Sweep)

注:此处只包含老年代CMS垃圾回收器的流程

image.png

Cms垃圾回收器是一种以获取最短停顿时间为目标的垃圾回收器。目前很大一部分JAVA应用集中在互联网或者B/S系统的服务端上。这类应用尤其重视服务的响应速度,希望系统停顿时间最短,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器是基于”标记-清除”算法实现的。它在运作的过程中相对于前面几种收集器来说更为复杂。分为四个步骤。

CMS垃圾回收的四个步骤

初始标记

短暂,仅仅标记一下GC Roots能直接关联到的对象。速度很快。(这么做的目的是把垃圾分拨了。初始标记之后的过程中还会产生新的GC Roots,已经对应的引用链,但是之后的引用产生的垃圾留给下一次垃圾回收了)。

并发标记

较长,和用户线程同时进行。标记所有GCRoots(初始标记的)所关联的跟可达对象。这里的时间比较长,所以采用并发处理。

重新标记

短暂,为了修正并发标记期间因为用户继续运作而导致产生变动的那一部分对象的标记记录。这个阶段停顿时间会比初始标记稍微长一些,但远比并发标记的时间短。此处很多第一次学习的朋友可能有些想不通。(反正我第一次没咋搞明白,现在也不确定是不是理解对了)。首先,用户线程与GC线程并发执行会操作什么情况呢?GC线程在标记垃圾的时候对于它本身的线程也是串行的。也就是当它已经标记过的对象之后的引用发生过任何改变他都无法在标记过程中回过头去处理。

好了,那么现在又分了两种情况:用户线程把GC 线程已经标记存活的对象标记为了死亡,此时会发生什么情况?答案是这一波这个实际死亡的对象我GC线程不处理,让它多活一阵子,等到下次垃圾回收再处理。这也就是我们说的浮动垃圾。

那么还有一种,是GC线程把它当做垃圾了,但是这个对象偏偏又被其他已经被GC线程判定存活的对象重新引用。此时根据可达性分析,这个对象就不是垃圾了,却又无法被继续进行并发标记中的GC线程标记为复活。此时,那些被复活的对象可能被当做死亡对象回收了。那么如何避免这个问题呢?就需要重新标记来处理。

简而言之,并发标记标记了百分之90甚至99的正确的存活垃圾。重新标记就是在”医生火化病人”之前再次确认一下患者是否死透了,也就是将我们漏标的复活垃圾再次给他标记存活。那么具体是如何做的,之后的文章会提到三色标记的漏标问题以及处理方案。

并发清除

由于整个标记中耗时最长的并发标记和并发清除过程,垃圾回收线程和用户线程都是一起工作的。所以总的来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

很明显,这个过程是相对漫长的,CMS让这个相对漫长的GC工作与用户线程并行执行。正因为有了这个步骤被大家誉为"关注于吞吐量"的垃圾回收器。很明显,这大幅度减少了STW的时间,可是这么做的代价是什么呢?

缺点

CPU敏感

cms对处理器资源非常敏感,毕竟采用了并发的收集与并发回收(用户多线程+GC线程)。所以当核心数小于线程数时,对用户的影响较大。当并发标记与并发清除时,加入的GC线程就会使CPU执行更多的任务。我们知道CPU执行,当线程数大于CPU核心数时,CPU将开始频繁的切换上下文以达到并发执行的效果。但是上下文的切换就会导致程序运行速度不可避免的收到影响。

浮动垃圾

由于CMS并发清理的过程中,用户线程还在不断地产生垃圾。此时产生的垃圾因为没有被标记,因此不会被清除。所以这部分垃圾只能留到下一次垃圾回收重新标记清除了。这种垃圾被称为”浮动垃圾”。

其实浮动垃圾很多比较官方的话都不是让我们很容易理解。其实浮动垃圾之所以不好回收,是因为在并发标记阶段,用户线程与GC线程是并发执行的。当GC线程在某一时刻确认某个对象时根可达的,而下一刻用户线程又把可达关系链断掉了。此时GC线程是很难回过头把这个对象再识别为垃圾的。这就是浮动垃圾。但是浮动垃圾对我们的实际回收影响是不大的。因此留给下一次垃圾回收就好。

预留空间

由于用户线程和GC线程是并行发生的。在GC标记垃圾的时候,用户线程依旧在不断地产生垃圾。因此需要为它们预留出一部分内存。也意味着CMS垃圾回收器不能像其他老年代回收器那样,等到老年代几乎完全占满的时候再触发垃圾回收。在JDK1.6版本中老年代空间使用率阈值(92%)。

如果预留的空间不足时,就会出现Concurrent Mode Failure。此时虚拟机将临时启用Serial Old来替代CMS。(暂停用户线程,但只能启动后单线程老年代垃圾回收器。无论何时,CMS都保持着与多线程老年代垃圾回收器的距离。始终有你没我,有我没你)。

会产生空间碎片

CMS独占标记清除算法,会产生垃圾碎片。

我们可以想象。随着程序运行的时间越来越久,老年代的内存会越来越乱。此时Full GC的频率会越来越高。这与一个服务器长时间稳定运行的特性明显是违背的。

最大的问题就是CMS采用的标记清除算法,会有内存碎片。当内存碎片较多的时候,给大对象的分配带来了很大的麻烦(毕竟要占据连续的空间)。为了解决这个问题,CMS提供了一个参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的。如果分配不了大对象,就进行内存碎片的整理(使用标记整理算法)。

image.png

而代替CMS来进行标记整理的,是最最原始的Serial Old垃圾回收器。又由于CMS的使用场景往往都是内存较大的服务器(几十个G)。此时Serial Old的工作效率想而知。动辄几个小时的标记整理时间是用户无法接受的。因此在最早CMS应用场景中,很多用户规定。每天或每几天必须重启一次服务器再重新运行。

  

CMS总结

总的来说,CMS是JVM推出的一款并发垃圾回收器,非常具有代表性。但是CMS标记清除算法导致内存碎片的问题很严重。即时在内存碎片相当影响性能时,CMS会在FullGC时使用标记整理算法重新整理内存。但是FullGC本身就是一个我们尽量避免出现的问题(相当耗时,可能造成卡顿影响用户体验)。而空间碎片遭遇大对象时很可能提前进行FullGC。所以没有一个版本是默认CMS的。

扩展:为什么不采用标记整理算法

因为在CMS是并发清除垃圾。而标记整理会修改对象的地址,从而对引用造成影响,从而破坏用户线程的正常运行。

此回收器适合回收堆内存几个G到20G左右。可以成为服务器的垃圾回收器。

那么。如何保证大内存服务器的垃圾回收可以保持长期稳定的进行,并且也能保持用户的良好体验呢?

G1(Garbage First1)

设计思想

随着JVM中内存的增大。STW的时间成为了JVM急迫需要解决的问题。如果依照传统的分代模型,总是逃不出STW不可预测的这个点。(垃圾清除总是一波一波的清除)。

为了实现STW的时间可预测性,首先要有一个思维上的改变。G1将堆内存”化整为零”,将堆内存划分成多个大小相同的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden区,Survivor区,老年区,甚至大对象区。回收器能对扮演不同角色的Region采用不同的策略去处理。这样无论是新创建的对象还是已经存活了一段时间的,熬过多次回收的旧对象都能取得很好的收集效果。

如果面试,直接说”化整为零”的思想,面试官就知道你是个懂行的。

Region

Region有可能是Eden,也有可能是Survivor,还有可能是一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量大小一半的对象即可判定为大对象。而那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humobgous的Region之中。G1进行回收大多是情况下都把Humongous的Region当做老年代的一部分处理。

image.png

参数设置

开启参数

-XX:+UseG1GC

Region区分大小

-XX:+G1HeapRegionSize

一般建议逐渐增大该值。随着size的增大,垃圾存活的时间会更久,GC间隔也会更长。但每次GC的时间也会更长

最大GC暂停时间

MaxGCPauseMillis

这个参数就重要啦。之前我们说这个参数在之前提到的多线程垃圾回收器Paraller Scaven/Paraller old也提到过可以设置,但是在Paraller Scaven/Paraller old中设置此参数是基本没什么效果甚至起反效果的(因为它是并没有办法真正意义的控制GC时间,如果最大暂停时间调小,那么它只能通过降低新生代的空间。收集3M空间的垃圾肯定比5M快。但缺点依旧明显,这明显加快了GC的频率,降低了吞吐量)。

G1就是为了追求这个参数的可控性而出现的。而G1能够做到控制垃圾回收停顿时间的关键在于,它要追踪每个Region的回收价值,它要清楚每个Region里有多少对象是垃圾,如果对这个Region做回收,会消耗多少时间,尽量在有限的时间内回收更多的垃圾对象,把垃圾回收造成的STW时间控制在开发指定的时间范围内。

如下图中,三个STW的时间的总时间加起来会尽可能控制在我们设置的MaxGCPauseMillis之内。而我们设置的MaxGCPauseMillis值,理论上不会让我们感受到卡顿,这样就可以保证服务器的长时间稳定运行,也突破了内存大小对我们垃圾回收的限制。

运行过程

image.png

G1的运行过程大约可以分为4个步骤:

初始标记

仅仅标记以下GC Roots能直接关联到的对象,并且修改TAMS指针,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要STW,但速度很快,而且是借助Minor GC的时候同步完成的。所以G1收集器这个阶段并没有额外的停顿。

TAMS是什么?

这段话是我自己的理解:首先,我们在无论使用标记清除算法,还是标记整理算法。我们都需要保证在一个一致性的快照上进行对象图的遍历。这个快照有些类似STAB快照,但它真正的作用是在做初始标记的时候确认整个需要遍历的对象图。因此在GC回收垃圾时,那些新增的GC Roots以及新增对象并不纳入本次GC的范围,全部留给下一次GC处理。更好的将数据分离开来,这样理解也更好说服后续三色标记为什么漏标问题一定是灰色到白色的引用链断开,接到其他已扫描的黑色引用上才算漏标。而白色到白色对象的引用链断开接到黑色对象不算(此处不知道有没有读者和博主一样打破脑门也不是很清楚的。书上和其他博客都理所当然的这么讲了)。

上面都是铺垫,那么TAMS就是G1将用户线程在并发标记时新增的对象排除本次GC流程的手段。G1为每一个Region区域设计了两个名为TAMS的指针。从Region区域划分出一部分空间用于记录并发回收过程中的新对象,认为他们一定是存活的,不纳入本次GC范围。

并发标记

从GC Roots开始对堆中对象进行可达性分析。递归扫描整个对象图,找出要回收的对象。这个阶段耗时比较长,但可以与用户程序并发执行。当对象图扫描完成以后,并发时有引用变动的对象(这里分为浮动垃圾和漏标问题。浮动垃圾本文有详细介绍。漏标问题下一章节会细写)。

最终标记(STW)

G1使用STAB(原始快照)的方式处理漏标问题。

筛选回收

别的垃圾回收可以并发回收,G1当然也可以了!但是为啥要STW下来专门回收呢?重点就在于筛选二字。Region的设计产生后会对每一个Region的回收成本进行排序。此时根据我们设置的期望STW时间值,来筛选出一个Region集合进行回收。然后把决定回收的那一部分Region的存活对象复制到空的Region中。最后再清理掉整个整个旧的Region空间。

看看,瞧瞧。这不就是复制算法+标记整理算法么?

而又由于标记整理算法会导致对象的活动,因此必须STW,再由多条GC线程并行完成。

特点

并行与并发

G1能充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短STW的时间。并且在并发标记阶段可以让用户线程与GC线程并发执行。

分代收集

与其他收集器一样,分代的概念在G1中依然得以保留。虽然G1从整体来是基于”标记-整理”算法实现的收集器。从局部(两个Region之间)上来看是”复制”算法实现的。但无论如何,这两种算法都意味着G1运行期间没有内存碎片的问题。收集后能提供规则可用内存,利于长时间运行,分配大对象不会因为找不到连续内存空间而导致Full GC。

追求停顿时间

-XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1 会自行分析得到可回收的Region集合进行回收。(于此同时,G1追求的也不是一次把垃圾回收干净,只是在规定时间内收集,尽管可能会造成回收更加频繁,但是每次STW时间可控就基本让人感觉不到)。

使用场景

内存越大越好,CMS和G1一般来说的平衡点在6~8G。然后就是G1的天下。

为什么特点中不包含CPU敏感

G1相对于CMS少了一步并发清除。(并发清除不修改引用关系才可以并发处理)。所以CPU没那么敏感。

Stop The Word

观看第一代垃圾回收器(Serial/Serial Old)。无论新生代还是老年代都会暂停所用户线程,并开启GC线程开始清理,直至垃圾回收完毕。这个暂停被称作”Stop The Word”。但是这种Stop The Word给用户线程带来了极为不好的体验。新生代的Eden区满了就需要进行一次垃圾回收。假如我们使用1小时会占满一次Eden区,那么此时电脑可能会暂停响应五分钟。如果久而久之触发了老年代的垃圾回收(老年代默认是新生代的2倍内存),我们直接可以来一次课间休息了。这也是早期JVM被C/C++诟病性能的一个原因。所以JVM团队一直在努力消除或者降低STW的占用时间。

猜你喜欢

转载自blog.csdn.net/weixin_47184173/article/details/109635083