垃圾回收器总结(一)

上一篇我们介绍了如果要使用自动内存管理以及垃圾回收,应该如何做,有哪些难点以及解决方法,接下可以说说在HotSpot虚拟机中,使用过的经典的垃圾回收器:

单线程垃圾回收器

Serial / Serial Old 收集器

Serial收集器是最初的垃圾回收器,与其配套的是Serial Old垃圾回收器,其运行方式也和其名字一样,是一个单线程垃圾回收器,新生代使用了标记-复制算法,老年代使用标记-整理算法。整个回收过程,都需要STW

ParNew / CMS 收集器

ParNewSerial的多线程版本,其回收过程和Serial非常类型,不过回收过程使用多线程就行回收。不过并没有与之配套的ParOld垃圾回收器,而是CMSCMS作为一款优秀的并发垃圾回收器,默认使用ParNew与之配合。不过在JDK9之后,HotSpot官方删除了ParNewCMS与其他垃圾回收器的搭配,比如ParNew+Serial OldSerial+CMS (一般没有人这样使用)。对于CMS垃圾回收器,后续会详细介绍

ParNew使用的标记-复制算法,CMS使用的标记-清除算法。

Parallel Scavenge / Parallel Old 收集器

Parallel Scavenge / Parallel Old 是一款注重吞吐量的垃圾回收器,Parallel Svavenge用于回收新生代,使用标记-复制算法,Parallel Old用于回收老年代,使用标记-整理算法,PSJDK8的默认垃圾收集器

Paralleel Scavenge系列和CMS的区别在于注重点不同:

  • PS主要以吞吐量为重,如果运行的是那种大型的科学计算,或者是后台日志分析,则可以使用PS垃圾回收器。
  • CMS则是以减少停顿时间为主,对于互联网网站,或者作为用户APP服务器这种直接与用户交互的时候,可以使用CMS垃圾回收器。

G1 收集器

G1收集器是一款包干了新生代和老年代回收的收集器,因为其新生代和老年代的大小,数量,位置都是动态变化的,G1通过建立优先级列表,每次都进行部分垃圾回收,实现了 “可预测停顿时间模型”。

G1收集器,在总体上看,是基于标记-整理算法,但是从局部(两个Region)来看,是基于标记-复制算法。


CMS

下面详细介绍下CMSG1的回收过程,CMS虽然后续会被逐渐标记为Deprecated(JDK14已经无法使用),但是目前作为小型应用服务器还是有比较好的效果,并且其回收过程是直接借鉴的。

CMS(ConcurrentMarkSweep)

-XX:+UseConcMarkSweepGC表示老年代使用CMS回收器,新生代默认使用ParNewCMS是一款以减少停顿时间为主的垃圾回收器,从名字就能看出来:并发,标记清除回收器。CMS的回收过程如下:

  • 初始标记(STW initial mark)
  • 并发标记(Concurrent marking)
  • 并发预清理(Concurrent precleaning)
  • 重新标记(STW remark)
  • 并发清理(Concurrent sweeping)
  • 并发重置(Concurrent reset)

初始标记: 初始标记便是标记GC Roots的过程, 这个过程需要Stop The Word,不过时间比较短。

并发标记: 并发标记是从初始标记标记出来的对象出发,开始循环查找(Trace)的过程。这个过程比较长,不过它可以不用暂停用户线程,可以和用户线程并发。并发带来的好处是用户感受不到停顿,但是会占用CPU和产生并发失败的问题,占用CPU比较好理解。因为CMS需要和用户线程并发。并发失败,是因为由于用户线程此时正在运行,如果这个时候JVM预留的内存不能满足用户线程申请的内存,就会发生并发失败,并发失败的后果就是CMS立即暂停用户线程,然后使用单线程开始回收内存(类似Serial GC

并发预清理 : 并发预清理依然是并发进行的,这一步主要是为了减轻下一阶段:重新标记的工作,减少重新标记的停顿时间。主要做的工作有:处理跨代引用,处理并发标记过程老年代引用关系变化的对象(三色标记,增量更新)

可中断预清理: 这个阶段的目标跟“预清理”阶段相同,也是为了减轻重新标记阶段的工作量。为什么叫可中断预清理,原因在于”记忆集”,CMS为了节省新生代记忆集(RSET)的开销(因为新生代朝生夕死,变动很大,维护起来开销比较大),所以只实现了老年代指向新生代的RSET。这样做的代价便是每次进行Old GC的时候,就需要扫描整个新生代。如果新生代对象数量过多,则会导致remark阶段暂停时间过长,所以可中断预处理的主要目的就是为了等待一次minor gc,减少新生代对象数量。

和这个阶段有关的控制参数:

-XX:CMSScheduleRemarkEdenSizeThreshold: 默认2M,新生代使用大小低于这个值的话,不启动可中断预清理

-XX:CMSScheduleRemarkEdenPenetration:新生代使用率,默认50%,大于这个值就结束可中断预清理

可中断预处理会一直循环,直到时间超过CMSMaxAbortablePrecleanTime(默认5s),或者次数超过CMSMaxAbortablePrecleanLoops(默认为0),表示只以时间为准

可中断预处理只是用来等待minor gc,在某些情况下,如果没有等到minor gc,则可以通过设置CMSScavengeBeforeRemark表示每次进入remark阶段之前,都进行一次minor gc

重新标记: 这个阶段主要就是为了修正前面并发标记过程中,用户线程修改的引用关系。主要包括通过增量更新记录的并发过程中新插入的对象以及扫描整个新生代,查找被新生代引用的老年代对象。这个过程作为最后的纠正过程,需要Stop The Word,同时由于CMS没有实现新生代对老年代引用的RSet,因此这个过程需要扫描整个新生代。

前面说过CMS由于新生代朝生夕死,变化较大,所以为了节省开销没有实现新生代指向老年代的RSet,那么其他垃圾回收期为什么没有这个问题呢?原因在于只有CMS存在单独的Old GC,也就是只针对老年代的GC,其他GC大多数Old GC都是Full GC,也就是回收整个堆,因此便不存在这个问题,而对于G1这种Mixed GC的,是每个Region都单独存在一个RSet的。

并发清除CMS采用标记-清除算法回收老年代内存,这样带来的好处便是不用移动对象,效率高,并且方便实现并发回收,但是缺点也很明显,那就是存在内存碎片, 同时分配内存无法通过指针碰撞的方式进行计算,只能通过”分区空闲分配表”查询可分配的内存,会对分配内存带来一定的效率问题。

并发重置: CMS恢复内部初始值,为下一次内存回收做准备。


从GC日志上看CMS回收过程

CMS的回收过程大体上分析完毕,但是想要真正理解,还需要具体的实践。下面从CMS的日志开始对上面说到的回收过程进行分析。

/**
  初始标记:
  老年代已使用410547K(总共449900K)]
  当前堆已使用414147K,总容量652396K,
  标记使用时间:0.0004938 secs 
  */
[GC (CMS Initial Mark) [1 CMS-initial-mark: 410547K(449900K)] 414147K(652396K), 0.0004938 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

/**
  并发标记开始
*/
[CMS-concurrent-mark-start]
/**
  并发标记,已使用时间:0.001 secs
*/
[CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

/**
  并发预清理开始
*/
[CMS-concurrent-preclean-start]
/**
  并发预清理使用时间
*/
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
/**
  可中断并发预清理开始
*/
[CMS-concurrent-abortable-preclean-start]
/**
  Minor GC ,ParNew,GC原因:内存分配失败    回收前3600K,回收后:4K,回收使用时间:0.000655 secs
*/
[GC (Allocation Failure) [ParNew: 3600K->4K(202496K), 0.0006552 secs][CMS[CMS-concurrent-abortable-preclean: 0.000/0.024 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
 
/** 
  并发回收失败,回收前老年代使用410547K->回收后使用947K(总容量449900K)
  总容量已使用414147K->回收后使用947K,总共652396K
  元空间:回收前3729K->回收后3729K,总容量1056768K
  使用时间:0.0195286 secs
*/
 (concurrent mode failure): 410547K->947K(449900K), 0.0050302 secs] 414147K->947K(652396K), [Metaspace: 3729K->3729K(1056768K)], 0.0195286 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]  
/**
 由于并发失败,CMS刚刚已经被替换为Serial Old 进行Full GC,因此后面没有继续remark ,而是重新开始新的一轮回收。
*/
[GC (CMS Initial Mark) [1 CMS-initial-mark: 615347K(789192K)] 615347K(867912K), 0.0005200 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[CMS-concurrent-mark-start]
//省略重复的日志,我们接着可中断并发预清理后面,正常情况便是remark
/**
  重新标记:年轻代已使用1400K,容量为78720K
  多线程重新扫描,使用时间0.0005394 secs
  清理弱引用:weak refs processing
  卸载未使用的类:class unloading
  分别清理包含类级元数据和内部化字符串的符号表和字符串表:scrub symbol table,scrub string table
  这个阶段老年代使用量和老年代容量:615347K(789192K)
  这个阶段总的使用率和总的容量:616747K(867912K)
  使用时间: 0.0011634 secs
*/
[GC (CMS Final Remark) [YG occupancy: 1400 K (78720 K)][Rescan (parallel) , 0.0005394 secs][weak refs processing, 0.0000043 secs][class unloading, 0.0001943 secs][scrub symbol table, 0.0003117 secs][scrub string table, 0.0000867 secs][1 CMS-remark: 615347K(789192K)] 616747K(867912K), 0.0011634 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
/**
 并发清理开始
*/
[CMS-concurrent-sweep-start]
/**
 并发清理
*/
[CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
/**
 并发重置开始
*/
[CMS-concurrent-reset-start]
/**
 并发重置
*/
[CMS-concurrent-reset: 0.004/0.004 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

从日志上我们可以看出CMS的整个阶段和我们刚开始整理的差不多,也能看出来CMSFinal Remark阶段任务是比较繁重的。

上面的日志中出现了”并发失败”,这个现象其实非常容易复现,JDK 8默认的预留容量是92%,也就是说你要是不断地while循环申请大于8%的对象,同时设置-XX:PreteureSizeThreshold(大对象直接进入老年代参数)小于8%容量,基本就能复现并发失败的现象。

CMS 调优

明白了上面CMS的回收过程,我们就能简单的进行调优:

  • GC 日志中出现concurrent mode failure:前面我们已经解释了并发失败出现的原因,出现并发失败后,会启动Serial Old重新进行回收,会浪费很多的时间,因此我们需要避免这个问题,避免的方式也比较简单,减少-XX:CMSInitiatingOccupancyFraction的值,默认92%
  • GC日志中Remark时间过长: remark阶段是一个需要Stop The Word的过程,同时这个过程需要扫描整个新生代,如果发现现场日志中remark阶段相对整个过程比较长,同时可中断预清理没有等到Yong GC,那么可以设置:-XX:CMSScavengeBeforeRemark,强制在remark阶段之前进行Yong GC
  • GC日志中发现Full GC过于频繁,CMS作为唯一个可以只针对老年代回收的垃圾回收器,如果Full GC过于频繁,那说明出现了很多次CMS无法处理的情况,情况之一便是内存碎片,CMS

以上只是CMS简单的调优,还有一些例如内存碎片整理等等,由于这些参数在JDK9中已经被废弃,同时作用不是很大,感兴趣的同学可以后面了解。


说完了CMS后面将会对比介绍G1,同时将会简单的介绍JDK自带的一些调试工具。

关注我,带你了解不一样的读书笔记