谈谈JVM GC 收集器

前言:

目前已经发展到jdk11了。很多资料上的垃圾收集器还停留在1.7以前。本文基于收集器的发展路线,从前到后汇总和简单分析一下JVM垃圾收集器的roadmap。本文暂且从对内存区管理和回收特色方面分为分代收集和非分代两个part。

Part I、分代收集阶段

一、新生代收集器

1.Serial New

这是最早的新生代收集器,也是jdk1.5之前默认的收集器,在GC log里可以经常看到[DefNew 的字样,说的就是这个收集器。它是基于复制算法(算法不在本文描述范畴)实现的,单线程,而且需要stop the world,所以新生代不能太大,否则对于停顿来讲是比较影响交互响应的。

2.Parallel New

这是对单线程的Serial的一种改进,ParNew收集器是并行的,在多CPU的场景下会有比串行收集器更好的性能,除此之外,实现算法跟Serial完全一样。这种收集器在采用CMS(后文会讲到,一种老年代收集器)时,默认新生代会采用ParNew收集器。需要注意的是,如果CPU数量为1个或者少于4个时,该种收集器的性能并不会比Serial要好。因为除去上下文切换,以及占用用户线程CPU时间片,导致用户线程被拖慢。

3.Parallel Scavenge

这也是一种新生代垃圾收集器,PSYoungGen它采用的也是复制算法,它与前两种收集器最大的区别是,它关注的是吞吐量而不是延迟。也被称为是吞吐量优先的收集器。其中,吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。主要使用场景:主要适合在后台运算而不是太多交互的任务,高吞吐量则可以最高效率的利用CPU时间,尽快的完成程序的运算任务。当然,如果想要降低停顿时间,相应的也会影响吞吐量。几个重要的参数:

  • 控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数

MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

  • 设置吞吐量大小的 -XX:GCTimeRatio参数。

GCTimeRatio参数的值应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。

  • 自适应策略开关UseAdaptiveSizePolicy参数。

-XX:+UseAdaptiveSizePolicy是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)

在实现上比前两种改进了很多,但是一直到1.6以后才真正用起来,这是因为,它不能跟CMS收集器一起配合工作。在此之前,使用该种新生代收集器的话,老年代收集器必须使用Serial Old收集器。

二、老年代收集器

1.Serial Old

这个是jdk1.2以前的默认收集器,实现算法使用的是标记-整理算法,单线程,stop the world。性能就不用提了。

2.Parallel Old

这个是Serial的多线程版本,同样的使用了标记-整理算法。但是如果CPU数量少的话性能一样不好。但是现在无论是PC还是server CPU数量都不再是性能瓶颈限制了,所以目前它跟Parallel Scavenge的配合是吞吐量优先场景的优先收集器选择。

3.CMS

CMS,Concurrent Mark Sweep,这是一款真正的并发收集器。前面的都是讲的并行,并行和并发的区别,在操作系统原理这本书中有清晰的讲解,在此不解释了。从名字上可以看出来,这个收集器是基于“标记-清除”算法的。到这里我们可以知道,它是有明显的缺点的,我们先讲一下它的收集过程和优点:

收集过程主要分为4步,第一、初始标记, 第二、并发标记,第三、重新标记,第四、并发清除,其中第一步和第三部是需要stop the world,但是时间很短,第一步只是做GC Root可达性的初始标记,第三部标记第二步中变动的对象,耗时最长的第二步和第四部是可以与用户线程并发执行的。从全局上来讲是并发执行的。

它的优点比较明显,就是能够全局上与用户线程并发执行。是第一款真正意义上的并发收集器。

缺点也比较明显:

内存碎片:由于它使用的是标记-清除算法,内存碎片的存在会导致在剩余空间还很多的情况下使得大对象无法分配,而提前触发一次full gc。full gc导致的停顿时间会很长。影响体验。对于空间碎片,CMS提供了-XX:+UseCMSCompactAtFullCollection参数,应用于在FULL GC后再进行一个碎片整理过程。-XX:CMSFullGCsBeforeCompaction,多少次不压缩的full gc后来一次带压缩的。

浮动垃圾(Floating Gargbage):由于清除的时候是并发清除的,这时候用户态产生的垃圾必然无法在本次收集过程中收集掉。也就会产生浮动垃圾。如果之前收集没有收集到足够多有效空间的话也会提前触发full gc的过程。另外,由于会产生浮动垃圾,那么触发CMS的过程就不能等到空间完全用满的情况下。CMS同样也提供了参数来控制触发时间,e.g. -XX:CMSInitiatingOccupancyFraction=90 和-XX:+UseCMSInitiatingOccupancyOnly=true,说明让JVM使用自定义的occupation配置,配置值是90。这个值在1.5的时候默认是68%,到了1.6的时候,默认调到了92。

对CPU比较敏感:这个是肯定的,并发执行,如果CPU资源有限,反而会适得其反。

Part II 非分代收集 

一、G1(Garbage First)

G1收集器,是比前面的更优秀,真正有突破的一款垃圾收集器。其实在G1中还是保留了分代的概念,但是实际上已经在新生代和老年代中没有物理隔离了。在G1中,内存空间被分割成一个个的Region区,所谓新生代和老年代,都是由一个个region组成的。同时G1也不需要跟别的收集器一起配合使用,自己就可以搞定所有内存区域。整体上来讲不是一个分代收集器,是一个通吃收集器。这也是JVM内存管理和垃圾收集的一个发展趋势。从后面zgc中我们可以更清晰的看到这个变化。

G1采用了标记-整理算法,避免了CMS中的内存碎片问题,另外它能达到可控的垃圾时间。是一款优秀的收集器。即便如此,从2004年第一篇论文发表到真正商用推出,也是到了jdk1.7。实现上并不是那么容易的。

G1的工作过程:

初始标记:这个过程跟CMS第一个过程差不多,只是标记一下GC Root关联的对象。

并发标记:这个过程时间比较久,分析GC Root到所有对象的可达性分析。如果从GC Root节点开始遍历所有对象会比较耗时,实际上JVM也不是这么做的。JVM是使用Remembered Set保存了对象引用的调用信息,在可达性分析的时候只需要同时遍历remembered set就好了,不需要从根节点开始挨个遍历。

最终标记:由于并发标记阶段,用户线程仍然在工作,会对标记产生一些偏差,这时候需要通过remembered set log来记录这些改变,在这个阶段将改变合并到remembered set中。完成最终标记。

筛选清除:通过标记整理的算法,根据用户配置的回收时间,和维护的优先级列表,优先收集价值最大的region。收集阶段是基于标记-整理和复制算法实现。

二、ZGC

zgc是jdk11中要发布的最新垃圾收集器。完全没有分代的概念,先说下它的优点吧,官方给出的是无碎片,时间可控,超大堆。

The goal of this project is to create a scalable low latency garbage collector capable of handling heaps ranging from a few gigabytes to multi terabytes in size, with GC pause times not exceeding 10ms

话不多说,先给看看SPECjbb 2015基准测试吧:

 是不是有种甩所有其他收集器N条街的感觉。目前还没有正式推出和商用,也没有更多的实践数据,从其实验效果以及理论实现上来讲毫不夸张啊。看看传说中的R大对此的评论:

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。

其实Azul JDK的皇牌 C4 垃圾收集 ,早就同样以最高十毫秒停顿成为江湖传说。 曾在Azul的R大, 看着JDK11 ZGC的算法和结果倍感熟悉,与ZGC的领队Per Liden大大聊完之后,确认了ZGC跟 Azul Pauseless GC,是,等,价,的。(R大御览本文时 -  其他同学是预览,R大是御览,想半天,选定了“等价”这个字眼)--摘自“https://mp.weixin.qq.com/s/KUCs_BJUNfMMCO1T3_WAjw” 

多篇幅介绍一下它的八大特征吧:

1. 所有阶段几乎都是并发执行的:

这里的并发(Concurrent),说的是应用线程与GC线程齐头并进,互不添堵。

说几乎,就是还有三个非常短暂的STW的阶段,所以ZGC并不是Zero Pause GC啦。

R大:“比如开始的Pause Mark Start阶段,要做根集合(root set)扫描,包括全局变量啊、线程栈啊啥的里面的对象指针,但不包括GC堆里的对象指针,所以这个暂停就不会随着GC堆的大小而变化(不过会根据线程的多少啊、线程栈的大小之类的而变化)”   -- 因此ZGC可以拍胸脯,无论堆多大停顿都小于10ms

2. 并发执行的保证机制,就是Colored Pointer 和 Load Barrier

3. 像G1一样划分Region,但更加灵活

不过G1一开始就把堆划分成固定大小的Region,而ZGC 可以有2MB,32MB,N× 2MB 三种Size Groups,动态地创建和销毁Region,动态地决定Region的大小。256k以下的对象分配在Small Page, 4M以下对象在Medium Page,以上在Large Page。所以ZGC能更好的处理大对象的分配。

4. 和G1一样会做Compacting-压缩

ZGC是Mark-Compact ,会将活着的对象都移动到另一个Region,整个回收掉原来的Region。而G1 是 incremental copying collector,一样会做压缩。

粗略的看一下回收过程:

1)Pause Mark Start -初始停顿标记

停顿JVM地标记Root对象,1,2,4三个被标为live。

2)Concurrent Mark -并发标记

并发地递归标记其他对象,5和8也被标记为live。

3)Relocate - 移动对象

对比发现3、6、7是过期对象,也就是中间的两个灰色region需要被压缩清理,所以陆续将4、5、8  对象移动到最右边的新Region。移动过程中,有个forward table纪录这种转向。

R大这里又赞扬了一下C4/ZGC的Quick Release特性:活的对象都移走之后,这个region可以立即释放掉,并且用来当作下一个要扫描的region的to region。所以理论上要收集整个堆,只需要有一个空region就OK了。而RedHat的Shenandoah 因为它的forward pointer的设计,则需要有1/2个Heap是空的。

4)Remap - 修正指针

最后将指针都妥帖地更新指向新地址。这里R大还提到一个亮点: “上一个阶段的Remap,和下一个阶段的Mark是混搭在一起完成的,这样非常高效,省却了重复遍历对象图的开销。”

5. 没有G1占内存的Remember Set,没有Write Barrier的开销

G1 保证“每次GC停顿时间不会过长”的方式,是“每次只清理一部分而不是全部的Region”的增量式清理。那独立清理某个Region时 , 就需要有RememberSet来记录Region之间的对象引用关系, 这样就能依赖它来辅助计算对象的存活性而不用扫描全堆, RS通常占了整个Heap的20%或更高。这里还需要使用Write Barrier(写屏障)技术,G1在平时写引用时,GC移动对象时,都要同步去更新RememberSet,跟踪跨代跨Region间的引用,特别的重。而CMS里只有新老生代间的CardTable,要轻很多。

ZGC几乎没有停顿,所以划分Region并不是为了增量回收,每次都会对所有Region进行回收,所以也就不需要这个占内存的RememberSet了,又因为它暂时连分代都还没实现,所以完全没有Write Barrier.

6. 支持Numa架构

现在多CPU插槽的服务器都是Numa架构了,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。JDK的 Parallel Scavenger 算法支持Numa架构,在SPEC JBB 2005 基准测试里获得40%的提升。原理嘛,就是申请堆内存时,对每个Numa Node的内存都申请一些,当一条线程分配对象时,根据当前是哪个CPU在运行的,就在靠近这个CPU的内存中分配,这条线程继续往下走,通常会重新访问这个对象,而且如果线程还没被切换出去,就还是这位CPU同志在访问,所以就快了。但可惜CMS,G1不支持Numa,现在ZGC 又重新做了简单支持。

7. 并行

在ZGC 官网上有介绍,前面基准测试中的32核服务器,128G堆的场景下,它的配置是:20条ParallelGCThreads,在那三个极短的STW阶段并行的干活 -  mark roots, weak root processing(StringTable, JNI Weak Handles,etc)和 relocate roots ;4条ConcGCThreads,在其他阶段与应用并发地干活 - Mark,Process Reference,Relocate。 仅仅四条,高风亮节地尽量不与应用争抢CPU 。ConcCGCThreads开始时各自忙着自己平均分配下来的Region,如果有线程先忙完了,会尝试“偷”其他线程还没做的Region来干活,非常勤奋。

8. 不分代

没分代,应该是ZGC唯一的弱点了。所以R大说ZGC的水平,处于AZul早期的PauselessGC  与 分代的C4算法之间 - C4在代码里就叫GPGC,Generational Pauseless GC。分代原本是因为most object die young的假设,而让新生代和老生代使用不同的GC算法。但C4已经是全程并发算法了,为什么还要分代呢?

R大说:

“因为分代的C4能承受的对象分配速度(Allocation Rate), 大概是原始PGC的10倍。

如果对整个堆做一个完整并发收集周期,持续的时间可能很长比如几分钟,而此期间新创建的对象,大致上只能当作活对象来处理,即使它们在这周期里其实早就死掉可以被收集了。如果有分代算法,新生对象都在一个专门的区域创建,专门针对这个区域的收集能更频繁更快,意外留活的对象更也少。

而Per大大因为分代实现起来麻烦,就先实现出比较简单可用的单代版本。所以ZGC如果遇上非常高的对象分配速率,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让ZGC有更大的喘息空间。”

其实看完ZGC的特点,还是不足还是有的。但是作为一个实验阶段的产品,已经有了很多令人欣喜的特点和真正大幅度的改变。傻瓜式调优不远了。

总结:

本文从早期的Serial系列收集器到最新的zgc收集器,简要介绍了各种收集器的特点,工作过程和使用场景。时间仓促,难免有些问题,大家有问题可以留言讨论。

refer:https://mp.weixin.qq.com/s/KUCs_BJUNfMMCO1T3_WAjw

猜你喜欢

转载自blog.csdn.net/michaelgo/article/details/82226733