Java 垃圾收集器

本文仅针对 JDK 1.8

年轻代垃圾收集器

Serial、ParNew、Parallel Scavenge、Epsilon

老年代垃圾收集器

CMS、Serial Old、Parallel Old

同时支持年轻代、老年代

G1、ZGC(暂不分代)、Shenandoah

没有最好、万能的垃圾收集器,我们需要根据不同的应用场景挑选适合的垃圾收集器。

Serial收集器(年轻代:-XX:+UseSerialGC 老年代:-XX:+UseSerialOldGC)

Serial收集器是最基本、历史最悠久的垃圾收集器。它是单线程的,意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,而且在收集过程中,必须暂停其他所有工作线程(STW,Stop The World),直到收集结束。 年轻代采用复制算法,老年代采用标记-整理算法。

Serial简单高效,由于没有线程交互的开销,可以获得很高的单线程收集效率。

Serial Old收集器是Serial收集器的老年代版本,同样单线程。主要有两大用途: 一是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用;另一种是作为CMS收集器的后备方案(当CMS收集器发生Concurrent mode failure时,会降级使用Serial Old)。

Parallel Scavenge收集器(年轻代:-XX:+UseParallelGC,老年代:-XX:+UseParallelOldGC)

Parallel Scavenge 收集器是Serial收集器的多线程版本,除了使用多线程外,其他行为与Serial类似。在收集过程中也会Stop The World。默认收集线程数与CPU核心数相同,可以通过-XX:ParallelGCThreads配置,但一般不推荐修改。

Parallel Scavenge收集器关注点是吞吐量(指的是CPU用于运行用户代码的时间与CPU消耗总时间的比值,高效利用CPU,尽量降低GC时间)。CMS等垃圾收集器关注点更多的是用户线程的停顿时间(提高用户体验)。 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器动作不太了解,可以选择把内存管理优化交给虚拟机。

Parallel Scavenge 年轻代采用复制算法,老年代采用标记-整理算法。 Parallel Scavenga是JDK8默认的年轻代和老年代收集器

ParNew收集器(年轻代: -XX:+UseParNewGC)

与Parallel Scavenge收集器类似,主要区别是它可以和CMS配合使用。年轻代采用复制算法,老年代采用标记-整理算法。

CMS收集器(老年代:-XX:+UseConcMarkSweepGC)

CMS (Concurrent Mark Sweep)收集器是一种以获取最短停顿时间为目标的收集器.它非常注重用户体验,是HotSpot虚拟机第一个真正意义上的并发收集器,它第一次实现了垃圾收集线程和用户线程(基本上)同时工作。CMS的concurrent collection是old gc,不等同于Full gc,它只收集老年代。

CMS使用标记-清除算法(可以通过 -XX:+UseCMSCompactAtFullCollection开启压缩,整理碎片),它的运作比前面几种垃圾收集器更复杂,分以下四个过程:

  1. 初始标记:Stop The World,记录下gc root直接引用的对象,因为不查找间接引用,速度很快。
  2. 并发标记:与用户线程并发运行,通过上一步获取的gc root直接引用对象,遍历查找所有引用对象,耗时很长,在gc整个过程中占了大部分时间,但不需要停顿用户线程。因为与用户线程并行,可能会有一些对了象状态发生改变,比如标记为非垃圾的对象成了垃圾,垃圾对象又被重新引用,CMS通过三色标记法来解决这个问题。
  3. 重新标记:Stop The World,因为并发标记阶段同时会有部分对象状态发生变化,这部分对象需要重新标记。这个阶段停顿时间会比初始标记长,但是远比并发标记短.主要用到了三色标记里的增量更新算法做重新标记。
  4. 并发清理 开启用户线程,同时GC线程清理未标记的区域。这个阶段如果有新增对象会标记为黑色不做任何处理。
  5. 并发重置 重置本次GC过程中的标记数据。

以上过程,只有初始标记重新标记会Stop The World,造成用户线程停顿,整个收集过程耗时不比Parallel Scavenge少,但是因为最耗时的并发标记阶段并没有Stop The World,造成用户线程停顿的时间相对较少。

CMS主要优点是并发收集低停顿,但有几个明显缺点:

  • 对CPU资源敏感(会和用户线程抢资源)
  • 无法处理浮动垃圾(在并发标记和并发清理过程中又产生的垃圾,只能等到下一次GC)
  • 标记-清除算法会产生大量内存碎片(可通过 -XX:UseCMSCompactAtFullCollection让jvm在gc后执行压缩整理)
  • 执行过程的不确定性,会存在上次垃圾回收没有完成,又触发垃圾回收的的情况,特别是在并发标记并发清理阶段,比如此时产生一个大对象,年轻代放不下,直接进入老年代,老年代剩余空间不足以放下这个对象,又会再次触发Full GC,也就是cocurrent mode failure,此时会进入Stop the world,降级使用serial old垃圾收集器。
CMS核心参数
  • -XX:+UseConcMarkSweepGC 启用CMS
  • -XX:ConcGCThreads 并发的GC线程数
  • -XX:+UseCMSCompactAtFullCollection Full GC后内存压缩,碎片整理
  • -XX:CMSFullGCsBeforeCompaction 间隔多少次Full GC后压缩一次,默认是0,即每次Full GC后都压缩一次。
  • -XX:CMSInitiatingOccupancyFraction 当老年代使用超过该比例时会触发GC,默认92,百分比。
  • -XX:+UseCMSInitiatingOccupancyOnly 只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction),如果不设定,只在第一次时使用设定值,后续自动调整。
  • -XX:+CMSScavengeBeforeRemark 在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代引用,降低CMS GC时标记阶段开销。
  • -XX:+CMSParallelInitialMarkEnabled 在初始标记阶段多线程执行,缩短STW
  • -XX:+CMSParallelRemarkEnabled 在重新标记阶段多线程执行,缩短STW
三色标记算法

CMS在并发标记过程中,因标记期间应用线程还在运行,对象间的引用可能发生变化,产生漏标或多标的情况。三色标记法就是用来解决这个问题,它将所有对象分为黑色、灰色、白色三种标记:

  • 黑色代表该对象已经被垃圾收集器访问过,并且这个对象的所有引用已经被扫描过(只扫直接引用)
  • 灰色代表该对象已经被垃圾收集器访问过,但该对象至少还有一个引用没有被扫描过。
  • 白色表示该对象没有被垃圾收集器访问过。
标记流程:
  1. 创建黑、灰、白三个集合
  2. 将所有对象放入白色集合
  3. 从GC root开始遍历所有对象(不递归,即不扫非直接引用),把遍历到的对象从白色集合放到灰色集合
  4. 遍历灰色集合,将灰色对象引用的对象从白色集合中移到灰色集合, 之后将灰色对象放到黑色集合中
  5. 重复步骤4,直到灰色中无任何对象
  6. 通过wirte-barrier(写屏障)检测对象是否有变化,重复上述步骤
  7. 收集所有白色对象
write-barrier 写屏障

写屏障原理类似于AOP,JVM在写入操作的前后加上拦截。

多标的处理

在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC root)被销毁,而这个GC root引用的对象已经被扫描过(标记为非垃圾对象),那么本轮GC不会回收这部分内存.这些本应该回收但没有被回收的内存,被称为浮动垃圾浮动垃圾不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收才能被清除。另外,针对并发标记,并发清除阶段产生的对象,通常的做法是直接全部当成黑色,本轮不做清理,这部分对象可能也会变成垃圾,也是浮动垃圾的一部分。

漏标的处理

漏标会导致被引用的对象当成垃圾,如果删除,会产生意料之外的结果,有两种解决方案:增量更新(Incremental Update)原始快照(Snapshot At The Beginning,SATB)

增量更新就是当黑色对象插入新的指向白色对象的引用关系时,将新插入的引用记录下来,等并发标记结束后,在重新标记阶段,再将这些记录过的引用关系中的黑色对象为根,重新扫描一遍。可以简化理解为,黑色对象一旦新插入了指向白色对象的引用,它就变成了灰色对象。

原始快照 就是当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用关系(并非只记录被引用的白色对象)记录下来,到重新标记阶段,再重新以保存的灰色对象为根,重新扫描,将扫描到的白色对象标记为黑色(目的是让这种对象在本轮gc中存活下来,待下一轮gc再重新扫描,这个对象也可能是浮动垃圾)。也可以简化理解为,被删除的引用的白色对象,变成为灰色对象。

无论是增量更新,还是原始快照,都是基于写屏障实现的。

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现方式不尽相同: 比如白色/黑色集合一般都不会出现(但是有其他体验颜色的地方)、灰色集体可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对没漏标的处理方案如下:

  • CMS: 写屏障+增量更新
  • G1,Shenandoah: 写屏障+SATB
  • ZGC: 读屏障

G1收集器(-XX:+UseG1GC)

G1(Garbage-First)更适合多处理器,大内存场景。以极高概率满足GC停顿时间要求的同时,还具有高吞吐量性能特征。

G1跟上面其他垃圾收集器不同,它同时支持年轻代、老年代垃圾收集,在物理上并没有分代,而是将Java堆划分为多个大小均等的独立区域(Region),JVM最多可以有2048个区域。默认Region的大小等于堆的大小除以2048,比如堆大小为4096M,Region大小则为2M,也可以用-XX:G1HeapRegionSize手动指定Region大小,但推荐默认的计算方式。

G1保留了年轻代、老年代的概念,但不再物理隔阂,他们都是Region的集合(可以不连续,如图),另外增加了大对象单独分区。

默认年轻代占堆的5%(可通过-XX:G1NewSizePercent调整),如果堆大小为4096M,那么年轻代初始占用200M左右内存,在系统运行过程中,JVM会不停给年轻代增加Region,但最多不超过60%(可通过-XX:G1MaxNewSizePercent调整)。年轻代的Eden和Survivor对应的Region也跟之前一样,团队8:1:1。

Region的角色会随着垃圾回收变化,一个Region之前可能是年轻代,但一次GC后,可能变成了空白区域,再次GC可能成了老年代。

G1垃圾收集器对于对象什么时候会转移到老年代跟其他收集器一致,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous,而不是让大对象直接进入老年代。大对象的判断是超过Region的50%,如按上面的例子,一个Region是2M,那么当一个对象超过1M时,就会被视为大对象,放到HUmongous区,而且如果对象太大,会跨多个Region.

Humongous区专门存放短期巨型对象,不用直接进入老年代,可以节约老年代空间,避免因老年代空间不够的GC开销。

Full GC除了收集年轻代、老年代,也会将Humongous一并回收.

G1收集流程:

  • 初始标记(initial mark,STW): 暂停所有用户线程,并记录下gc root 直接引用对象,速度很快
  • 并发标记(concurrent mark): 同CMS的并发标记
  • 最终标记(Remark, STW): 同CMS的重新标记
  • 筛选回收(Cleanup,STW): 筛选回收先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(默认200ms,可用-XX:MaxGCPauseMills指定)制定回收计划。比如老年代此时有1000个Region都满了,但因为预期停顿时间只有200ms,通过之前计算的回收成本计算得知,可能回收其中800个region刚好需要200ms,那么只会回收这800个region,尽量把gc 停顿时间控制在指定范围内。这个阶段其实也可以做到与用户线程并发执行,但是因为只回收一部分region,时间是可控的,而且停顿用户线程将大幅提高收集效率。不管年轻代还是老年代,回收算法主要是复制算法,将一个Region中存活的对象复制到另一个region中,因此不会像CMS那样因为产生碎片需要整理。(CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂没有实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看作G1的升级版本)

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值大的Region(这也是它的名字Garbage-First的由来。回收价值根据region内的存活对象计算,存活越多,复制成本越高,获得的空闲空间越少,价值也就越低)。比如一个Region花200ms能回收10M垃圾,另一个花50ms能回收20M垃圾,在回收时间有限的情况下,G1当然会优先选择后面的region进行回收。这种使用region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内尽可能高的收集效率。

G1被视为JDK 1.7以上版本的一个重要进化特征。它具有以下特点:

  • 并行与并发: G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(或核心),缩短STW时间.部分其他收集器原本需要STW的GC动作,G1仍然能够通过并发的方式让Java程序执行。
  • 分代收集: 虽然G1可以不需要与其他收集器配合就能独立管理整个堆,但还是保留了分代的概念
  • 空间整合: 与CMS的标记-清理算法不同,G1从整体来看是标记-整理算法实现的收集器;从局部看是基于复制算法实现的。
  • 可预测的停顿时间: 这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定一个时间片段。

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同的应用场景中取得关注吞吐量和关注延迟之间的平衡。不过,这里的期望值必须符合实际,不能异想天开,毕竟G1需要冻结用户线程来复制对象,这个停顿时间再怎么低也有个限度。默认停顿目标为200ms,一般来说,回收阶段占到几十到一百甚至接近200ms都很正常,但如果我们把停顿时间调得非常低,譬如设置为20ms,很可能出现的结果就是由于停顿目标时间太短,导致每次只能回收很少一部分垃圾,收集器的收集速度比不上垃圾对象产生的速度,导致垃圾慢慢堆积,最终占满堆,产生Full GC,反面降低性能,所以通常把期望停顿时间设置在一两百毫秒或两三百毫秒比较合理。

G1收集器GC分类

YoungGC
YoungGC并不是说Eden区放满了就会触发,G1会计算下现在Eden回收大概需要多少时间,如果回收时间远小于-XX:MaxGCPauseMills设定值,那么增加年轻代的region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMills设定的值,那么就会触发YoungGC。

MixedGC
MixedGC不是Full GC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupanyPercent)设定的值触发,回收所有年轻代和部分老年代Region(根据期望GC停顿时间和old区垃圾收集优先顺序确定)以及大对象区,正常情况G1的垃圾收集先做MixedGC,主要使用复制算法,需要把各种region中存活的对象拷贝到别的region里,拷贝过程发现没有足够的空region能够承载拷贝对象就会触发一次Full GC.

Full GC
停止用户线程,然后采用单线程进行标记、清理和压缩整理,空闲出一部分region供下一次MixedGC使用,这个过程非常耗时.(Shenandoah优化成多线程)

G1参数
  • -XX:+UseG1GC 使用G1收集器
  • -XX:ParallelGCThread 指定GC工作线程数量
  • -XX:G1HeapRegionSize: 指定分区大小(1MB~32MB,必须是2的N次幂),默认将堆划分成2048个分区
  • -XX:MaxGCPauseMillis: 目标暂停时间 默认200ms
  • -XX:G1NewSizePercent: 新生代内存初始空间,默认整堆的5%
  • -XX:G1MaxNewSizePercent: 新生代内存最大空间,默认整堆的60%
  • -XX:TargetSurvivorRatio: Survivor区填充容量(默认50%),Survivor区里一批对象(年龄1+年龄2+…+年龄N的多个年龄对象)总和超过了Survivor区域的50%,则把年龄N及以上的对象放入老年代。
  • -XX:MaxTenuringThreshold: 最大年龄阈值(默认15)
  • -XX:InitiatingHeapOccupanyPercent: 老年代占用空间达到整堆的阈值(默认45%),则执行MixedGC,比如我们之前说的堆默认有2048个region,如果有1000个region都是老年代的region,则可能触发MixedGC.
  • -XX:G1MixedGCLiveThresholdPercent Region中存活对象低于这个值(默认85%)时才回收该region,如果超过这个值,存活对象过多,回收意义不大。
  • -XX:G1MixedGCCountTarget 在一次回收过程中指定做几次回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停一会,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间太长。
  • -XX:G1HeapWastePercent gc过程中空出来的region是否充足的阈值(默认5%)。 在MixedGC中,对Region回收都是基于复制算法进行的,都是要把回收的Region里的存活对象放入其他Region,然后这个Region的垃圾对象全部清理掉,这样的话在回收过程中就会不断空出新的Region,一旦空闲出来的Region达到堆内存的5%,立即停止MixedGC,意味着本次回收结束。
G1优化建议

假设参数 -XX:MaxGCPauseMills设置过大,系统运行很久,年轻代可能占用堆内存达到60%,才触发年轻代GC,那么存活下来的对象可能会有很多,导致Survivor区占用超过50%,触发动态年龄判断机制,一些对象快速进入老年代。这里的核心在于调节-XX:MaxGCPauseMills 参数,在保证年轻代GC不太频繁同时,还得考虑每次GC过后有多少存活对象,避免存活对象太多快速进入老年代,触发MixedGC