垃圾回收器(一)Serial、Parallel、CMS与三色标记详解

2,766 阅读12分钟

引言

上篇文章我们主要围绕对象的创建过程展开描述,本篇文章我们把思路切换到对象的回收,对于JVM的整个知识点而言,对象的回收才是我们真正要关心的。本篇涉及到的一些JVM参数比较多,详细的可以参考官方的解释:docs.oracle.com/javase/8/do…

垃圾回收算法

在目前Hotspot内部所实现的垃圾回收策略中,主要用到了3种垃圾回收算法:标记复制标记清除标记整理

标记-清除

比如有一大块内存空间,通过GC Roots 找到可用对象,将垃圾对象清除掉,把垃圾对象占用的内存腾出来。假设在清理之前对象在内存中的占用是这样子的,黑色表示垃圾对象,蓝色表示存活对象:

image.png 在使用标记清除算法进行一次GC之后,内存就变成了这样子: image.png 图中,白色部分表示垃圾对象被清理后留下来的可用内存,蓝色表示存活对象。

  • 缺点:
  1. 清除之后留下的内存不是规整的,存在大量的内存碎片,此时如果有一个大对象要分配进来的话,没有可以分配的内存,就会触发一次GC。
  2. 在对象进行内存分配的时候,在内存不规整的情况下,会使用空闲列表的方式,而这种方式会占用一些内存。
  • 优点:
  1. 仅需将垃圾对象清理掉,不用像其他算法一样做多余的动作,效率比较高。
  2. 实现简单。

标记-整理

对于标记清除做了一次优化,将有用的对象挪动到一边,将另一边的垃圾对象清除掉。比如,清理之前是这样子的,黑色是垃圾对象,蓝色是有用对象: image.png GC之后的结果: image.png

  • 优点
  1. 解决了内存碎片的问题,避免因内存碎片产生的GC。
  • 缺点
  1. 因为要做一次内存的整理,这个过程中涉及到内存地址的移动,性能稍差些。

标记-复制

比如将内存空间分为A,B两部分,A用来存放对象,B空着,回收的时候将A空间的根可达对象进行标记,将有用对象复制到B,再把A里面的垃圾对象清理掉。 image.png

  • 缺点:
  1. 内存的占用率低,有一部分内存会空着。
  2. 因为也会设计到内存地址的移动,存活对象很多的情况下也会影响性能。
  • 优点:
  1. 不存在内存碎片问题。
  2. 效率高,因为是批量清理一整块内存,而不是找到具体的内存地址一个一个的清理。

分代收集理论

从上面的几种垃圾回收算法中不难发现,每种方式都各有利弊,那么JVM该通过什么方式去权衡,选择合适的回收算法呢:根据反复的测试结果来看,Oracle官方发现98%的对象都是朝生夕死的,不需要占用内存太久,存活率低;而剩余的10%可以理解为顽固对象,要在内存中占用很久,存活率比较高,基于这个因素,JVM把对象分开进行管理,也就是分代收集 ,将内存划分为年轻代和老年代;因为年轻代在执行几分钟或者几秒后剩余的对象很少,这种情况下该用那种回收器呢,我们一一分析:

  • 标记-清除: 因为垃圾对象居多,清除的话会清除很多垃圾对象,对于性能会有影响。
  • 标记-整理: 比清除强不了多少,而且还附带一次整理,性能也不行。
  • 复制: 仅需复制少量的存活对象,性能不会影响太大。 展开一通分析之后我们发现,年轻代使用复制算法比较合适一些,但是上面我们也说过他的弊端,空间利用率太小了,针对这种情况JVM特意做了一种优化,引入了eden区域用来存放大量的对象,剩余出来两块很小的空间用来存放复制算法后的存活对象,这样我们既可以高效的回收,又可以降低空间利用率小带来的影响。然而老年代是经过几次回收都存活的对象,很难再次被回收,基于这种垃圾对象很少的情况下,清除和整理算法都可以做。所以现在出现了这样的结果: image.png

垃圾回收器

垃圾回收算法是一套回收理念,不同的垃圾回收器针对这套理念实现的时候会做一些优化,目前,Hotspot所实现的垃圾回收器有:Serial、Parallel、ParNew、CMS、G1、ZGC。不论是什么样的垃圾回收器,在回收垃圾的时候都会停止所有的业务线程,单独让GC线程进行垃圾回收(这个过程也被成为STW),因为如果业务线程还在执行的话可能会打乱对象之间的引用关系,GC在进行标记的时候会混乱,所以必须要STW。 在JVM调优中,不论是年轻代还是老年代,我们的调优目的有两个:1是避免OOM;2是减少STW的时间,让用户卡顿的感知减少。

Serial

为了满足分代收集理念,Serial收集器分别在年轻代和老年代各实现了一个版本,老年代是Serial Old,使用标记整理算法。Serial在回收垃圾的整个过程中都是采用单线程的方式,所以STW的时间会很长,内存越大,STW时间越长,用户的卡顿时间就很长,现在我们生产环境堆分配的一般都是几个G以上的,所以Serial收集器一定会很慢很慢,在很早之前的jdk版本中有使用,现在都不主动用这个了,只有在使用CMS回收器并发清理失败的情况下系统会默认回退到这种方式。 image.png

  • 相关参数: -XX:+UseSerialGC:使用Serial回收器。

Parallel

多线程的垃圾收集器,默认情况下启用的线程数是和CPU核心数相同。老年代是Parallel Old,使用标记整理算法,也是jdk1.8中使用的默认垃圾回收器。因为是使用多线程进行回收,所以STW的时间相对Serial来说会更短。这里会涉及到一个吞吐量的概念:吞吐量 = 用户应用程序运行的时间 / (应用程序运行的时间 + 垃圾回收的时间)。所以在Parallel收集器中,吞吐量是很高的,适用于追求吞吐量的系统。 image.png

  • 相关参数: -XX:+UseParallelGC:开启ParallelGC。
    -XX:+UseParallelOldGC:开启老年代的ParallelGC,和上面的任意开启一个就行。
    -XX:ParallelGCThreads:指定线程数。

我们可以通过参数:-XX:+PrintCommandLineFlags 将JVM的已经设置好的参数打印出来,发现他默认的GC就是Parallel: image.png

ParNew

和Parallel的实现基本一样,唯一不同的是它可以和CMS搭配使用,而Parallel不可以,当设置了回收器是cms的时候,JVM则会默认开启ParNew作为年轻代的回收器且无法关闭,对于Parallel的一些参数也可以在ParNew里面用。

CMS(Concurrent Mark Sweep)

尽管垃圾回收器从单线程发展到多线程,但是STW很长的问题始终是存在的,虽然说Parllel的STW时长可能会短一点,但还是没有做到极致,在CMS中STW的停顿时间得到了很好的解决:CMS在回收的过程中允许和GC线程和用户线程同时执行(并行)且将标记对象的过程延长,每次只标记一点点,以获取最短回收停顿时间为目标。同时,CMS也是垃圾收集器发展过程中的转折点,从CMS开始之后的垃圾回收器都是基于并行做GC的。这里要注意:CMS是使用标记清除算法进行垃圾回收的。

  • CMS的垃圾回收过程主要分为以下5步:
  1. 初始标记:记录能被GC Root直接引用的对象,触发一次STW,但是这次STW很快,因为在标记的过程中不会标记一整条引用链的对象,如图所示,只记录红色箭头关联到的对象,不记录黑色箭头。image.png
  2. 并发标记:从GC Roots的直接引用对象开始依次扫描(对上面的黑色箭头的链路做扫描),这个过程可能需要很久,用户线程和GC线程同时执行,不会产生STW,因为在扫描的过程中用户线程还在不断的执行所以可能会出现被标记过的对象又变成了垃圾。
  3. 重新标记:这个过程会STW,主要是对并发标记所产生的浮动垃圾进行重新标记,比并发标记快很多。
  4. 并发清理:GC线程和用户线程同时执行,将未标记的对象进行清理。此时如果出现新增对象则会把他进行标记,等待下次GC。
  5. 并发重置:重置本次GC过程中的标记数据。

整个CMS的回收过程,可以用一张图来更清晰的看一下: image.png 在初始标记触发STW的时候它的标记方式还是原始的更改对象头MarkWord的GC标记字段,但是在并发标记阶段,因为是用户线程和GC线程同时在跑,所以这里采用的是三色标记的方式进行垃圾标记:

三色标记

将对象的标记过程分为三种颜色:白色、灰色、黑色。

  • 白色:对象的默认颜色,从GC Root开始扫描,如果是不可达对象的话就是白色,也就是垃圾对象,在并发清理的时候会清理掉。
  • 灰色:当前对象已经被扫描过,但是当前对象所依赖到的其他对象还没有被扫描。
  • 黑色:当前对象和他所依赖的对象都已经被扫描过。 但是,这种方式会存在漏标多标的问题:

漏标

比如现在有ABCD四个对象,A依赖了B和C,C依赖了D;初始标记完之后A对象已经被扫描过了所以是灰色,其他对象是白色:
image.png
继续往下执行扫描B和C,当B和C扫描完之后,A变成了黑色,B变成了灰色,C是黑色,D还是白色:
image.png
此时如果用户线程把B和D的引用去掉,让C依赖D,建立起C和D的关系之后B变成了黑色:
image.png
那么问题来了,C已经是黑色就不回再对其依赖对象扫描了,但事实上C还有一个依赖对象D没有被扫描。此时如果进行垃圾回收的话D会被回收掉,这就是所谓的漏标问题。

多标

还用上面的例子说,比如现在AB是黑色,C是灰色,D是白色,当GC正在扫描D的时候,B被置空了,从逻辑上来讲B是垃圾,理应被回收,但是因为GC不会对黑色对象做重复扫描所以B还是黑色,在垃圾清理的时候B不会被回收,只能等到下次GC的时候再重新进行标记扫描。这种情况相对于漏标来说还行,起码不会导致系统出BUG。

漏标的解决方案

  • 增量更新 将新增的引用维护到一个集合里面,将引用的源头变为灰色,等待重新标记阶段在重新进行一次扫描。 比如:当D的引用指向了C,则会将C变为灰色,并将C放到一个新增引用的集合里面;在重新标记阶段会将C作为根节开始继续向下扫描。

为什么CMS要使用标记清除

CMS的垃圾回收阶段是并发回收的,如果使用标记整理的话,对象的内存地址会进行移动,因为用户线程还在执行,为了避免因内存地址移动而带来的bug,还需要对用户线程的对象指针进行维护,在这个过程中肯定会STW,这样做就提高了垃圾清理的时长,停顿时间也变长了,不符合CMS以获取最短回收停顿时间为目标的设计初衷。

小结

CMS的回收周期很长,但是他的STW时间是分开的,比如总的STW要100ms,可能他会在初始标记消耗20ms,重新标记消耗80ms,对于用户来说能感知的到停顿时长可能只有80ms,也就是说CMS的设计初衷是为了提高用户体验,减少停顿时间。这是和Parallel最大的不同。正因为CMS的回收周期很长,所以在垃圾很多的情况下可能出现上次的GC周期还没执行完就又触发了GC,被称为”concurrent mode failure“;对于这种情况会回退到Serial的方式进行回收,全程STW。因为是采用标记清除算法,所以会存在内存碎片的问题,通过参数-XX:+UseCMSCompactAtFullCollection 可以设置清除之后再做一次整理。

  • CMS相关参数
    -XX:+UseConcMarkSweepGC:使用CMS垃圾收集器,当设置这个参数后,年轻代默认会开启ParNew。
    -XX:ConcGCThreads:并发的GC线程数,默认是CPU的核数。
    -XX:+UseCMSCompactAtFullCollection:相当于标记整理。
    -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0。
    -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC,默认是92。
    -XX:+UseCMSInitiatingOccupancyOnly:这个参数搭配上面那个用,表示是不是要一直使用上面的比例触发FullGC,如果设置则只会在第一次FullGC的时候使用-XX:CMSInitiatingOccupancyFraction的值,之后会进行自动调整。
    -XX:+CMSScavengeBeforeRemark:在FullGC前启动一次MinorGC,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在标记阶段。 -XX:+CMSParallellnitialMarkEnabled:默认情况下初始标记是单线程的,这个参数可以让他多线程执行,可以减少STW。
    -XX:+CMSParallelRemarkEnabled:使用多线程进行重新标记,目的也是为了减少STW。

日日行,不怕千万里;常常做,不怕千万事。