vlambda博客
学习文章列表

JVM系列--垃圾回收器






前边的一篇文章中我们讲解了Hotspot中垃圾收集的常用算法,标记删除,标记复制,标记整理这三种主要的管理思路。如果说算法只是一种理论模型,那么垃圾收集器就是这种理论模型的实践产物了。


本文主要讨论的垃圾收集器是基于JDK1.8版本的Hotspot虚拟机下的实践。

在Hotspot虚拟机中,其实出现了很多具有代表性特色的垃圾收集器产品。在实际工作中,通常会在系统中针对不同的年龄代使用不同的垃圾收集器组合搭配,因此对它们有一个系统的认识愈加重要。


并行垃圾收集和并发垃圾收集的区别

并行垃圾收集

首先解释一下什么是并行垃圾收集,如下图所示,垃圾收集线程在执行的时候可以有多条线程并行执行,提高收集效率,但是此时工作线程是处于一个stw的状态。

JVM系列--垃圾回收器

并发垃圾收集


多条垃圾收集线程与工作线程一起运行工作,也就意味着此时工作线程和垃圾收集线程需要同时共用系统资源,但是工作线程还是依旧能够响应请求,只不过说处理的效率会被降低一些,但是相比于并行收集器中的等待状态要高效得多。


JVM系列--垃圾回收器

如何理解系统到吞吐量

关于系统的吞吐量用官方的话术来解释是:

运行用户代码时间 / (运行用户代码时间+运行垃圾收集时间)

通过这个公式我们可以推导出来系统中如果垃圾收集耗时越高,系统吞吐量越低,但是如果垃圾收集的时间被人为干预调整,强制缩短为一定范围的话未必会带来较好的影响。(有可能垃圾回收次数增加)


下边我们来逐一认识下不同的垃圾收集器各自的特点。

ps:本文主要是基于分代垃圾回收的思想来介绍垃圾收集器


Serial 收集器

这是一款单线程进行垃圾收集,是Hotspot虚拟机中消耗内存最小的一款收集器。

新生代:标记复制算法

老年代:标记整理算法


评价:简单高效率,尤其是在单核cpu的机器上边,并且在一些客户端应用模式中,这种单线程收集器的优势还是存在的,因为它们不需要进行多线程之间的切换,而且每次进行垃圾收集的时候如果新生代的内存并不是特别大的话(例如几百兆的空间),那么对于对于大部分的软件而言,用户体验影响还是较小的。


这款收集器是早期在Hotspot1.3之前的主流收集器,由于它采用了单线程的收集方式,所以早期的Java应用使用起来都会感觉运行较慢。不过这款历史产品现在在互联网公司中出现的概率也大大降低了,逐渐退居幕后了。




Parnew 收集器

这款垃圾收集器其实本质上设计和serial 收集器没有太多的差别,主要是在垃圾收集的时候采用了多线程进行,效率方面要比serial的单线程收集模式更高了些。在服务端模式下的Hotspot中,这款垃圾收集器是JDK1.7之前主要使用的一种类型。


在JDK1.5的时候,CMS收集器的出现也对Parnew收集器起到了较好的地位巩固效果,因为当时的CMS收集器还不是特别完善,主要用于老年代的垃圾回收,而年轻代的回收只能适配Serial或者Parnew,而大部分开发者都会选择效率更加高效的Parnew收集器。


Parallel Scavenge收集器

parallel scavenge垃圾收集器和Parnew收集器有些类似,是一款新生代的并行垃圾收集器,主要是基于标记复制算法来实现。parallel scavenge的设计初衷就在于控制系统的吞吐量,尽量地减少系统的垃圾回收所需时间。并且其中有参数可以控制每次垃圾回收的时长。


GC的自适应调节策略

parallel scavenge收集器中提供了一个叫做XX:+UseAdaptiveSizePolicy参数,这个参数是一个开关的作用,一旦开启之后虚拟机内部就会根据系统内部的性能指标自动调节新生代的eden区,survivor区,老年代晋升年龄参数等细节点。这种自适应的设计也是parallel scavenge的一大特色。


如果使用这款垃圾收集器的话,只需要去关注这么几个参数:-Xmx 设置最大的堆内存,-XX:MaxGCPauseMillis 设置最大停顿时间,- XX:GCTimeRatio gc的回收周期。


 - XX:GCTimeRatio 参数

表示希望在GC花费不超过应用程序执行时间的1/(1+nnn),nnn为大于0小于100的整数。


换句话说,此参数的值表示运行用户代码时间是GC运行时间的nnn倍。举个官方的例子,参数设置为19,那么GC最大花费时间的比率=1/(1+19)=5%,程序每运行100分钟,允许GC停顿共5分钟,其吞吐量=1-GC最大花费时间比率=95%。


默认情况下,VM设置此值为99,运行用户代码时间是GC停顿时间的99倍,即GC最大花费时间比率为1%


serial old收集器

是serial收集器的老年代版本,也是一个单线程的收集器。采用标记整理算法。一般是给客户端模式的Hotspot使用。


Parallel Old收集器

这款垃圾收集器是Parallel Scavenge收集器的老年版本,支持多线程并发收集,采用来标记整理算法实现。比较适合与Parallel Scavenge收集器一块在一些注重吞吐量,但是系统自身资源有限的环境下使用。笔者自身在工作中比较少接触过这款垃圾收集器,所以在这里不做过多的评论


 CMS垃圾收集器

CMS垃圾收集器的主要特点就是“并发收集+低停顿”。这两个特性让cms成为了如今互联网公司比较常用的一款垃圾收集器。


如何理解CMS垃圾回收器的几个阶段


关于cms垃圾收集器的几个工作阶段,主要分为4个环节,分别是:

  • 初始标记

  • 并发标记

  • 重新标记

  • 并发删除

关于这四个环节可以用下边这张图来做下解释:

JVM系列--垃圾回收器

那么CMS垃圾收集器为什么要设计成四个步骤进行工作呢?

这块我建议大家从自己设计一款垃圾收集器的思路来进行分析,如果要保证工作线程尽量减少停顿,垃圾收集线程高效收集对象的话,将垃圾收集线程和工作线程同时运作会是个不错的设计思路。


1. 初始标记

JVM系列--垃圾回收器


CMS垃圾收集器主要就是基于这个思路点去进行的设计与实践,为了尽量保证回收过程中的准确和影响范围小,它的第一个环节:初始标记 主要过程是标记GC ROOT 可以连接到的那些对象。这个环节只需要简单标记,速度较后边几个环节要快许多。


这一环节为了保证分析的准确性,在Hotspot虚拟机内部的算法实现中是需要又一次stw现象的,目的就是为了保证分析可达性的准确性,能够获得一个较为准确可靠的根枚举快照记录。(假设来标记根节点的时候,GC ROOT所引用的对象还在动态变化,那么这样的可达性分析就是不准确的了)


这种采用stw进行准确性分析的设计思路也是沿用了早期的Exact VM和Classic VM两款虚拟机的特点。


2.并发标记

JVM系列--垃圾回收器



这一环节会从GCROOT关联的对象开始,一步步查询关联的对象信息,构建出整个对象图的,整个环节可能耗时会比较长,但是不会停顿用户线程。注意这时候会和用户线程一起共享相同的计算机cpu资源。这一步骤我们通常称之为可达性分析。


3.重新标记


JVM系列--垃圾回收器

由于上一个环节中,工作线程和垃圾线程其实是并发运行的,所以也就意味着在并发标记的环节中可能会有出现标记过的对象又变成垃圾的情况。所以为了提高准确率,这里需要进行stw操作,并且重新标记处理。


4.并发删除

JVM系列--垃圾回收器

关于并发清除这个阶段,垃圾回收线程是和工作线程一起运作的,主要目的就是将之前几个阶段标记出来的垃圾进行删除回收。


CMS垃圾收集器在使用的时候需要注意那些点?


开启CMS参数:

-XX:+UseConcMarkSweepGC


资源利用率占比高

CMS收集器对CPU资源非常敏感。对于并发实现的收集器而言,虽然可以利用多核优势提高垃圾收集的效率,但是由于收集器在运行过程中会占用一部分的线程,这些线程会占用CPU资源,所以会影响到应用系统的运行,会导致系统总的吞吐量降低。CMS默认开始的回收线程数是(cpu数量 + 3) / 4,所以,当机器的CPU数量为4个以上的时候,垃圾回收线程将占用不少于%25的CPU资源,并且随着CPU数量的增加,垃圾回收线程占用的CPU资源会减少。但是,当CPU资源少于4个的时候,垃圾回收线程占用的CPU资源的比例会增大,会影响到系统的运行,假设有2个CPU的情况下,垃圾回收线程将会占据超过50%的CPU资源。所以,在选用CMS收集器的时候,需要考虑,当前的应用系统,是否对CPU资源敏感。


无法处理浮动垃圾

垃圾收集的过程中,无法处理浮动垃圾,所以可能会出现Concurrent Mode Failure问题而导致触发一次Full GC。


浮动垃圾是由于CMS收集器的并发清理阶段,清理线程是和用户线程一起运行,如果在清理过程中,用户线程产生了垃圾对象,由于过了标记阶段,所以这些垃圾对象就成为了浮动垃圾。


CMS无法在当前垃圾收集过程中集中处理这些垃圾对象。由于这个原因,CMS收集器不能像其他收集器那样等到完全填满了老年代以后才进行垃圾收集,需要预留一部分空间来保证当出现浮动垃圾的时候可以有空间存放这些垃圾对象。


在JDK 1.5中,默认当老年代使用了68%的时候会激活垃圾收集,这是一个保守的设置,如果在应用中老年代增长不是很快,可以通过参数 -XX:CMSInitiatingOccupancyFraction 控制触发的百分比,以便降低内存回收次数来提供性能。


在JDK 1.6中,CMS收集器的激活阀值变成了92%。如果在CMS运行期间没有足够的内存来存放浮动垃圾,那么就会导致Concurrent Mode Failure失败,这个时候,虚拟机将启动后备预案,临时启动Serial Old收集器来对老年代重新进行垃圾收集,这样会导致垃圾收集的时间变长,特别是当老年代内存很大的时候。所以对参数-XX:CMSInitiatingOccupancyFraction的设置过高,会导致发生Concurrent Mode Failure,过低,则浪费内存空间。


内存碎片较多

使用的"标记-清除"算法会出现很多内存碎片。过多的内存碎片会影响大对象的分配,会导致即使老年代内存还有很多空闲,但是由于过多的内存碎片,不得不提前触发垃圾Full GC。为了解决这个问题,CMS收集器提供了一个"-XX:+UseCMSCompactAtFullCollection"参数(默认是开启的),用于CMS收集器在必要的时候对内存碎片进行压缩整理。由于内存碎片整理过程不是并发的,所以会导致停顿时间变长。虚拟机还提供了一个-XX:CMSFullGCsBeforeCompaction"参数,来控制进行过多少次不压缩的Full GC以后,进行一次带压缩的Full GC,默认值是0,表示每次在进行Full GC前都进行碎片整理。




三色标记算法

第一次听到这个名字的小伙伴可能会觉得蛮新鲜的,我在这里大概解释一下为什么会有三色标记法这种算法存在。


发明背景

在JVM中,在判断对象是否存活的时候,需要对整个GC ROOT进行遍历构建对象树。如果直接对整个java内存区域进行stw操作,卡顿性影响较大,而且Hotspot中大多数的垃圾收集器都是采用链式追踪的方式来进行对象判断的,所以如果能够设计某种算法将链式追踪的这一个环节进行相关的性能优化,那么对于垃圾收集的效率就会有较高的提升。


假设在进行GC ROOT链式追踪过程中,对整个内存区域都强制stw,那么这样判断对象是否存活的整体准确的,可是性能并不高效,于是Hotspot团队设计了一种基于多种颜色标记对象是否访问过的标记算法  --- 三色标记法。


  • 白色:没有被垃圾收集器访问过。

  • 黑色:表示该对象已经被垃圾收集器访问过,而且相关引用也被扫描过。

  • 灰色:表示对象被垃圾收集器访问过,但是相关外部引用对象并没有被扫描过。


整体思路为:


1.初始状态

这个阶段中,大部分对象都是处于白色的情况,此时垃圾收集器只会遍历到GC ROOT 根对象部分。

JVM系列--垃圾回收器

2.扫描阶段

开始沿着GC ROOT阶段继续往下进行遍历分析,已经完全被垃圾收集器扫描完毕的对象会变成黑色,黑色的对象垃圾收集器也不会再次访问,而灰色对象是还有部分引用没有被扫描,垃圾收集器会继续分析。


JVM系列--垃圾回收器

3.扫描完毕,清理为白色标记的残余垃圾对象。

JVM系列--垃圾回收器

整个流程看起来似乎很简单明了,但是这里面其实仔细揣摩会发现存在设计缺陷。


三色标记法的设计缺陷

对象消失情况

我们之前提及到了,在三色标记的过程中,工作线程和垃圾收集线程其实是并行运作的,所以也就存在“对象消失”的情况,如下图所示:

JVM系列--垃圾回收器

假设当垃圾收集线程访问到对象A的时候,对象B和对象A之间的引用关系断开了,这个时候B对象就不会被垃圾收集线程访问到,此时的B对象是处于白色状态。但是由于垃圾收集线程在进行对象标记的时候,工作线程也是在并行运作的,所以如果这个时候B对象又被C对象引用了,又由于已经标记为黑色的对象垃圾线程是不会再去进行扫描分析的,那么此时的B对象的标记就如下图所示:

JVM系列--垃圾回收器


当整个对象树遍历完成之后,再做分析就会发现此时B对象其实是有被引用的,但是此时B对象却标记为了白色,就会造成可达性分析的不准确,导致”对象消失“情况发生。



增量更新 Incremental Update

为了解决这一问题,在经典的CMS垃圾收集器中是采用了一种叫做增量更新(Incremental Update)的方式来进行优化。这种方式的设计思路是,当黑色对象增加新的引用关系时候需要额外将其记录下来,所以说当并发标记结束后,会进入一个重新标记的阶段将之前有记录下来的对象重新标记为灰色,重新进行分析。这个阶段因为是采用来全面STW机制,所以此时不会再有工作线程运作,可以放心地进行标记分析。

原始快照 Snapshot At The Beginning

还有一种解决思路被称之为原始快照(Snapshot At The Beginning,STAB)。这种思路是从灰色对象删除对象引用的时候,将相应的灰色对象进行记录,当并发扫描结束之后,同样会对这批灰色对象的额外引用进行重新分析。这类思路主要在G1,Shenandoah垃圾收集器中采用。


跨代引用问题如何解决

老年代的对象引用使用到了新生代的某个对象,遍历老年代,代价太大了而且往往跨代引用是特别少的情况。

记忆集合

记忆集合其实是在年轻代中设计一个列表,记录哪些个老年代对象引用了年轻代的对象。那么如何维护这个记忆集合呢?

这时候可以通过使用卡表来进行维护,在Hotspot虚拟机的底层中是采用来卡表+卡页来进行内存的管理,一般来说一个卡页就是512字节的大小,如果某个卡页中存在跨代引用,那么这个区域的整个对象引用都需要扫描一遍,虽然说这样做对于内存的消耗会比较大,但是总比全老年代扫描要好得多。

如何维护卡表

当在并发标记的时候又有对象进行了跨代引用,此时如何去维护卡表呢?Hotspot中有一个写屏障界面,类似于aop一样,当内存中出现一些对象赋值操作的时候,就会有通知。

本文主要介绍了一些经典常见的垃圾收集器,其中对于CMS进行了一些相关的拓展。

关于一些较为新颖的垃圾收集器,如G1,ZGC,Shenandoah等相关技术点会在下一篇文章中进行分析介绍。

最近两周都在处理一些关于工作方面的收尾事情所以这篇文章更新地比较慢。加上春节假期也快到来了,在此我也预祝各位亲爱的读者们新年快乐


长按二维码