JVM基础回顾记录(三):常见垃圾收集器

前言

如果说上一篇是在介绍垃圾回收时的一些理论知识,那么本篇将要介绍的垃圾收集器就是这些理论的实践者,开始之前,我们先通过一张图简单梳理一下上一篇提到的理论知识:

图1

接下来就让我们认识一下这些理论的实践者们:)

Serial & Serial Old

Serial收集器是最基础,也是历史最悠久的一款收集器,单线程工作,基于复制算法实现,在jdk1.3.1之前,它是新生代收集器的唯一选择。其中Serial Old是它的老年代版本,收集原理一致,唯一不同的是它采用标记-整理算法实现;Serial开始工作时,会全程STW,直到其收集完毕为止:

图2 Serial收集器运行示意图

图3 Serial Old收集器运行示意图

因为没有采用「并发标记对象图」的方式,所以不会有并发问题,但这也意味着STW的耗时会很高(事实上直到CMS的出现才真正实现了并发标记操作)。从最初的jdk到现在的jdk,HotSpot虚拟机团队一直在为降低STW的时间而做着努力,从最原始的Serial,到现在的G1,每一次更新都融合了各种开发者们的奇思妙想。

尽管Serial很原始,但由于它逻辑简单、对内存的开销小,仍然在java客户端模式下的GC发挥着重要作用,不过正常情况下的一个服务端项目都不会使用客户端模式,所以一般也就不会选用Serial。

ParNew

基于复制算法实现的一款新生代收集器,本质上是Serial的多线程版本,仅仅是在垃圾回收时支持了多线程收集:

图4 ParNew收集器运行示意图

在jdk5发布时,出现了一款强势的老年代收集器:CMS,但它并不能跟当时最新的新生代收集器Parallel Scavenge共用,只能选择Serial和ParNew,在指定使用CMS收集器时新生代就会默认使用ParNew,CMS的出现巩固了ParNew对新生代的统治地位,直到更先进的G1出现。

Parallel Scavenge & Parallel Old

启动参数:-XX:+UseParallelGC

Parallel Scavenge是基于复制算法实现的一款新生代收集器,跟ParNew一样支持GC多线程处理:

图5 Parallel Scavenge收集器运行示意图

顾名思义,Parallel Old就是对应的老年代收集器,基于标记-整理算法实现,它也是支持GC多线程处理:

图6 Parallel Old收集器运行示意图

既然Parallel Scavenge跟ParNew基本上一模一样,那它的特色是什么呢?答案就是两个收集器的侧重点不一样,其他的收集器都在尽可能缩短STW的时间,只有Parallel Scavenge在关注整体吞吐量,上篇说过吞吐量的计算公式:

吞吐量表达式

Parallel Scavenge的实现目标是让吞吐量可控;它有以下几个重要参数来控制吞吐量:

  • -XX:MaxGCPauseMillis:最大GC停顿时间,将尽量保证GC的时间不超过这个设定值;这里有个易混点,根据上面的公式推算,将这个值设置的越小岂不是吞吐量就越大?并不是,因为这里要缩短GC停顿时间,就需要牺牲掉新生代的大小,新生代越小越好回收,自然就不会超过这个设定值,但新生代的缩小,意味着minor gc的次数更频繁了,比如之前可能10s才触发一次回收,一次耗时100ms,但现在可能就是5s触发一次,每次耗时70ms,这样看单次耗时的确下降了,但单位时间内的总耗时却上升了,整体吞吐量反而在降低。
  • -XX:GCTimeRatio:GC时间占总时间的比率(上面公式的倒数就是这个值),计算公式:ratio = 1/(1+设定值),默认值为99,即ratio=1/100,也就是GC运行的时间占总时间的1%
  • -XX:UseAdaptiveSizePolicy:动态调整开关,打开后可以动态调整新生代空间大小,就不再需要人工设置新生代相关的参数了;虚拟机会根据当前系统的运行情况,收集性能监控信息,动态调整新生代相关参数以提供最合适的停顿时间或最大吞吐量。自适应调节策略也是这个收集器区别于其他收集器的一个重要特征。

遗憾的是Parallel Old到了jdk1.6才出现,在这之前Parallel Scavenge并不能和性能更好的老年代收集器CMS共用,只能和Serial Old搭配使用,这样的组合吞吐量甚至比不上ParNew+CMS,Parallel Old出现后,“吞吐量优先”收集器终于有了名副其实的搭配组合,如果你比较看中吞吐量或者处理器资源较为紧缺,都可以优先考虑这种组合,这个组合还是jdk1.8中默认的GC策略。

CMS

CMS是第一款真正实现了GC线程和用户线程并发运行的收集器,作用于老年代,基于标记-清除标记-整理算法实现(n次Full Gc时触发整理逻辑,通过-XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction控制)

为什么非得在发生Full GC的时候才整理?随着老年代的使用,可连续分配的内存可能会越来越少,这岂不是意味着运行一段时间后来一个超出老年代剩余连续内存的大对象会直接Full GC?存疑

这是CMS在整个垃圾收集理论中的选型,红色轨道覆盖的区域为选中项:

图7 CMS垃圾收集选型

根据选型方案,结合我们之前所掌握的理论知识,可以很容易推测出CMS的运行过程:

图8 CMS收集器运行示意图

通过设置-XX:CMSInitiatingOccupancyFraction可控制CMS GC发生的频率,每当老年代的内存使用率超过这项配置的阈值,就触发一次CMS GC,jdk1.5默认68%,到了jdk1.6默认92%

由于标记-整理算法过于负重,会加长STW的时间,因此CMS的做法是将标记-整理和标记-清除混合使用,平常时采用标记-清除算法,当内存碎片化程度影响到对象分配时,才会触发一次标记-整理算法,用来优化内存。通过以下两个参数控制:

  • -XX:+UseCMSCompactAtFullCollection:开关,打开时若发生Full GC,则触发一次内存整理,以此避免内存碎片的产生,代价是这个整理过程需要STW
  • -XX:CMSFullGCsBeforeCompaction:设置在执行固定次数的Full GC之后才对内存空间进行压缩整理,这减少了因整理而发生STW的次数

G1

1.概览

G1采用复制算法实现,它开创了收集器面向局部收集的先河,也是jdk1.9的默认垃圾收集器,收集区域涵盖整个新生代和老年代;G1跟其他垃圾收集器的实现都不太一样,它基于停顿时间模型研发,这是停顿时间模型需要完成的目标:

目标:能支持在指定时间M毫秒内,消耗在GC上的时间大概率不超过N毫秒

为了实现这个目标,G1不再像其他收集器那样要么回收整个新生代(minor gc),要么回收整个老年代(major gc),要么两者全部回收(full gc),而是将堆内存划分为一个个Region块,每一块Region都可以按需扮演成新生代的Eden、Survivor,或者老年代,G1会根据不同角色的Region采取不同的处理策略,这样一来,G1在每次GC时,就可以面向堆内任意Region来组成回收集(Collection Set,简称CSet),然后通过控制回收集内真正需要回收的Region数来控制GC时间。

每块Region的大小可通过这个参数来设置:-XX:G1HeapRegionSize,取值范围1~32M,还得是2的N次幂;这是G1收集器的Region示意图:

图9 G1收集器分区示意图

可以看到,整个java堆被分成了一个个Region,每块Region都扮演着不同的分代角色,虽然它们在内存上不连续,但在逻辑层面,它们就是完整的内存分代区域,接下来介绍一些更加细节的东西~

2.G1如何解决跨代引用问题?

实际上G1不单单是跨代问题那么简单,因为它的同代内存也被用Region分开了,所以这里的问题就变成了:G1如何解决跨Region引用问题?

通过上一篇的介绍,解决跨代引用需要借助记忆集的力量来完成,而记忆集的具体实现细节,就得看各个处理器内部是怎么处理的了,前面的CMS是通过卡表来实现的,那么G1呢?

G1的实现基于哈希表和卡表,Key是其他Region的起始地址,Value是一个存储了卡表索引号的集合,现在我们把Region放大,做一张图辅助理解:

图10

所以在枚举GC Roots时利用每个Region里的RSet解决跨Region引用问题,RSet保证了可达性分析的准确性(RSet仍然是通过写屏障来做的)。

由于每块Region都需要维护一份Rset数据,因此相比其他垃圾收集器传统的分代方式,G1有着更高的内存占用负担,根据经验,G1至少要耗费大约相当于Java堆容量10%~20%的额外内存来维持收集器工作。

3.G1如何解决并发标记问题?

上一篇文档我们介绍了两种解决并发标记问题的方法,一种是增量更新,另一种是原始快照,经过前面的了解我们知道CMS是采用增量更新解决的,这里讲的G1则是通过另一种方式:原始快照解决的。

在并发标记阶段产生新的对象怎么办呢?我们在上一篇也提到过,G1的做法是直接将这期间产生的新对象标记为可达的,具体的做法是:G1为每个Region提供两个名为TAMS(分别为prevTAMSnextTAMS,TAMS全称:Top at Mark Start)的指针,把Region中的一部分空间划分出来用于分配这些新增对象,新对象被分配的地址必须要在这俩指针位置以上,G1默认在这个地址以上的对象都是被隐式标记过的,即默认它们是可达的,不计入回收范围。

4.G1回收流程

开始说过,G1的目标是实现GC停顿时间可控,用户可以通过-XX:MaxGCPauseMillis(默认200ms)来设置一个期望的停顿时间,既如此,将分代化整为零就显得非常重要,这样可以通过调整每次回收Region的数量来控制GC时间,这些每次需要被回收的Region就组成了CSet,那么如何挑选需要被回收的Region呢?

G1通过衰减均值为理论基础实现,G1会在每次GC后根据每个Region的回收耗时记忆集内的脏卡数量等数据,分析出衰减均值标准偏差置信度等统计信息,通过这些信息推算出回收成本,然后根据每个Region的回收成本调整Region数量,以实现在获得最高收益的同时不超过期望停顿时间的目标。

G1中垃圾回收有两种子模式:

4.1:YoungGC

当JVM无法将新对象分配到Eden区时,便会触发YoungGC,选定所有的新生代里的Region作为回收集进行回收,每次GC后根据条件推算来控制当前新生代Region的个数上限,从而控制Young GC的开销:

图11

注:图中统一将存活的年轻代对象放入了新的S区,但一些年龄到了的对象可能也会晋升到老年代(O区)。

4.2:MixedGC

选定所有的新生代里的Region,外加根据衰减平均值统计得出的收益较高的若干老年代Region,在用户指定的期望耗时内尽可能的选择收益高的老年代Region,进行混合回收:

图12

整体流程如下:

图13 G1收集器运行示意图

这是G1在整个垃圾收集理论中的选型,红色轨道覆盖的区域为选中项:

图14 CMS垃圾收集选型

5.G1相关参数

参数 缺省值 含义
-XX:+UseG1GC 启用G1
-XX:MaxGCPauseMills 200 设置G1收集过程的目标时间,默认值200ms,不是硬性条件
-XX:G1HeapRegionSize=n 设置单个Region的大小,1~32M,2的幂次方
-XX:G1NewSizePercent 5% 新生代最小值
-XX:G1MaxNewSizePercent 60% 新生代最大值
-XX:ParallelGCThreads STW期间,并行GC线程数
-XX:ConcGCThreads=n 并发标记阶段,并行GC线程数
-XX:InitiatingHeapOccupancyPercent 45% 设置触发标记周期的 Java 堆占用率阈值,默认45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous

5.关于Full GC

G1本身不提供Full GC的能力,当达到触发Full GC的条件时,G1会采用Serial Old施行Full GC操作,那什么情况下才会触发G1的Full GC条件呢?

1.当MixedGC回收速度无法跟上内存分配的速度,导致老年代爆满,老年代无法再为新晋升的对象提供闲置内存,这时就会触发Full GC,我们对G1的调优也应该以尽量不触发FullGC为目标:

  • 增加堆的大小(为整个堆扩容)
  • 增加并发标记的线程数,让并发标记尽快结束
  • 适当调小-XX:InitiatingHeapOccupancyPercent,让G1更早的进入MixedGC周期,加快释放老年代内存

2.YoungGC时,Survivor和Old 区没有足够的空间容纳所有的存活对象,这时也会触发FullGC,解决的唯一办法就是为堆扩容

总结

各垃圾收集器组合启用参数

启用参数 启用后组合的
-XX:+UseSerialGC Serial + Serial Old
-XX:+UseParNewGC ParNew + Serial Old(j8已废)
-XX:+UseParallelGC Parallel Scavenge + Serial Old
-XX:+UseParallelOldGC Parallel Scavenge + Parallel Old
-XX:+UseConcMarkSweepGC ParNew + CMS + Serial Old
-XX:+UseG1GC G1

各垃圾收集器属性

名称 算法 GC多线程处理 与用户线程并发
Serial 复制
Serial Old 标记-整理
ParNew 复制
Parallel Scavenge 复制
Parallel Old 标记-整理
CMS 标记-整理
G1 复制&标记-整理