> 文章列表 > JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

一、垃圾收集算法

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

1. 分代收集算法

目前大多数垃圾收集器都是采用的分代收集算法,该算法其实算是一种思想:根据对象存活周期的不同而将内存分为年轻代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法。比如在年轻代中,每次收集都会有绝大多数对象死去(没有被GC root所引用),可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集;而老年代中的对象存活几率比较高,并且没有额外的空间对其进行分配担保,所以选择标记清除算法或者标记整理算法进行垃圾回收;需要注意,通常来讲标记清除算法或标记整理算法会比复制算法慢上许多!

2. 复制算法

复制算法简单来说就是将内存分为大小相同的两块区域,每次使用其中一块区域,当这一块内存区域使用完后,就将还存活的对象复制到另一块内存区域中去,然后再把之前使用的那块内存区域一次性清理掉,这样就使得每次的内存回收都是对内存区域的一半进行回收。总的来说算是空间换时间,并且复制算法适合应用在年轻代 (年轻代中survivor区的 s1:s2 正好是1:1)

  • 优点:垃圾回收效率高、没有内存碎片
  • 缺点:占用内存空间
    JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

3. 标记清除算法

该算法总的来说分为标记清除两个阶段:标记存活的对象,统一回收所有未被标记的对象(一般选择这种);也可以反过来标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

  • 优点:节省内存空间(相较于复制算法)、效率高于标记整理算法
  • 缺点:如果标记的对象太多效率不高、标记清除后会产生大量不连续的内存碎片
    JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

4. 标记整理算法

标记整理算法是根据老年代的特点出的一种垃圾回收算法,分为标记整理两个阶段;标记过程中仍然与标记清理算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。算是一种用时间换空间的算法

  • 优点:节省内存空间(相较于复制算法)、避免产生内存碎片
  • 缺点:垃圾回收效率最低(相比于复制和标记清理算法)
    JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

5. 三种垃圾回收算法特点比较:

复制算法 标记清除算法 标记整理算法
速度 最快 中等 最慢
空间开销 通常需要活对象的2倍大小(不堆积碎片) 少(但会堆积碎片) 少(不堆积碎片)
是否移动对象
  • 为什么复制算法要快于标记清除算法和标记整理算法?
    个人理解:单线程情况下,复制只需要一次遍历即可,可以理解为一边标记一边复制一边更新引用,即标记遍历,正因为一次遍历,在朝生夕灭的情况下比标记清除还快。复制后对象在内存中的顺序按照引用关系排列,与标记的顺序一致。而对于标记整理算法而言多了一次整理堆空间碎片的过程,所以更加耗时。

二、垃圾收集器

垃圾收集器之间组合关系

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)
JVM的垃圾回收工作正是靠着这些垃圾回收器实现的,每一种垃圾回收器都有各自的特点,没有完全完美的垃圾回收器,我们需要根据不同的应用场景来选择合适的垃圾收集器。

1. Serial收集器

Serial(串行)收集器是比较早期的垃圾收集器,是一款单线程的垃圾收集器,其垃圾收集过程中只会使用一个线程去完成收集工作,并且在这个过程中必须暂停其它所有的工作线程(也就是"Stop The World"),直到垃圾收集完成后,工作线程才会继续运行。其年轻代采用复制算法,老年代采用标记整理算法

收集器设置参数:-XX:+UseSerialGC -XX:+UseSerialOldGC

  • 优点:简单而高效(与其他收集器的单线程相比),Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率;不会产生浮动垃圾
  • 缺点:较长时间的Stop The World可能会带来不好得用户体验

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

补充:Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的候选方案。

2. Parallel Scavenge收集器

Parallel收集器其实就是Serial收集器的多线程版本,不同之处就是使用了多线程进行垃圾收集,其余行为如收集算法和回收策略和Serial收集器相似;默认的收集线程数跟cpu核数相同,也可以通过参数-XX:ParallelGCThreads设置收集线程数;吞吐量(高效利用cpu)是Parallel收集器的关注点,即:CPU中用于运行用户代码的时间与CPU总消耗时间的比值。其年轻代采用复制算法,老年代采用标记整理算法。

收集器设置参数:-XX:+UseParallelGC -XX:+UseParallelOldGC

  • 优点:不会产生浮动垃圾、利用多线程提高了垃圾收集的吞吐量
  • 缺点:垃圾收集过程中会暂停用户线程Stop The World,带来不好的用户体验

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程标记整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的年轻代和老年代收集器)。

3. ParNew收集器

ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。新生代采用复制算法,老年代采用标记整理算法。它是许多运行Server模式下虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作。

收集器设置参数:-XX:+UseParNewGC
JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

4. CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,实现了让垃圾收集线程与用户线程(基本上)同时工作。

收集器设置参数:-XX:+UseConcMarkSweepGC

从其名字不难看出,CMS收集器是一种基于标记清除算法实现的收集器,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记:暂停所有的应用线程,并记录下GC Root直接引用的对象,这个阶段速度很快。
  • 并发标记:并发标记阶段就是从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程比较耗时但是不需要暂停用户线程,可以与垃圾收集器线程一起并发运行;需要注意的是这个阶段因为用户线程也在运行,可能会导致已经标记过的对象状态发生改变。
  • 重新标记:这个阶段就是为了修正上一并发标记阶段因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录(主要是漏标),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,但是比并发标记阶段的时间短。其主要用到了三色标记里的增量更新算法做重新标记。
  • 并发清理:开启用户线程继续运行,同时GC线程也开始对未标记的区域做清理。这个阶段如果有新增对象会被直接标记为黑色不做任何处理(具体看后面的三色标记算法)。
  • 并发重置:重置本次GC过程中的标记数据。

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

  • 优点:并发收集、低停顿(STW时间会比较短)
  • 缺点:
    • (1)对CPU资源敏感(由于是与应用线程并发运行,会和服务抢资源);
    • (2)无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理);
    • (3)它使用的回收算法是标记清除算法会导致收集结束时会有大量的内存碎片,当然这个可以通过设置参数-XX:+UseCMSCompactAtFullCollection让jvm在执行完标记清除后再做整理;
    • (4)执行过程中的不确定性,会存在上一次垃圾收集还没执行完,然后又触发gc,特别是并发标记和并发清理阶段容易出现一边回收引用线程一边运行,也许还没回收完再次触发Full gc,这会引起“concurrent mode failure” ,从而进入STW改用Serial old垃圾收集器来处理回收任务。

CMS的相关核心参数:

-XX:+UseConcMarkSweepGC:启用cms;
-XX:ConcGCThreads:并发的GC线程数;
-XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片);
-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次;
-XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比);
-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整;
-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段;
-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW;
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

三、垃圾收集底层原理

1. 三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生,漏标的问题主要引入了三色标记算法来解决。

三色标记算法是把GC Root可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色:被标记成黑色的对象表示已经被垃圾收集器访问过,并且该对象的所有引用都已经扫描过,它是安全存活的;如果有其它对象引用指向了黑色对象则无须重新扫描。黑色对象只能指向灰色对象,不能直接指向白色对象。
  • 灰色:被标记成灰色的对象表示已经被垃圾收集器访问过,但是该对象上还有没有被扫描过的引用。
  • 白色:表示对象还没有被垃圾收集器访问过;在可达性分析开始的阶段,所有对象都是白色的,若在可达性分析结束后对象仍然是白色,则表示不可达(即垃圾对象)。

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

例如下图代码

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

多标—浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前又被扫描过(被标记为存活对象),那么本轮GC不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。 浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除

另外,针对并发标记 (还有并发清理) 开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。

漏标—读写屏障

漏标会导致被引用的对象被当成垃圾误删除,这是严重问题,必须解决,有两种解决方案: 增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)

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

原始快照:当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

写屏障

所谓的写屏障,说简单点就是在在赋值操作前后,加入一些处理(类似Spring中的AOP概念)

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

  • 写屏障实现SATB(原始快照)
    当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
    JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

  • 写屏障实现增量更新
    当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
    JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

读屏障

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)
读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

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

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

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

此外,读写屏障还有其他功能,比如写屏障可以用于记录跨代 / 区引用的变化,读屏障可以用于支持移动对象的并发执行等;功能之外,还有性能的考虑。

为什么G1收集器使用SATB,而CMS收集器使用增量更新?
       个人理解:其实增量更新相比于原始快照最大不同在于在重新标记阶段SATB不需要再次深度扫描被删除的引用对象,而新增更新会对新增引用的根对象做深度扫描,同时G1因为很多对象都位于不同的Region中,而CMS就一块老年代区域,因此重新深度扫描对象对于G1收集器这种结构明显代价会更昂贵,故G1选择SATB不深度扫描对象只是简单标记,等到下一轮GC在深度扫描。

2. 记忆集与卡表

在年轻代做GC Root可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低。为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GC Root扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC收集器, 都会面临相同的问题。垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,不需要了解跨代引用指针的全部细节

Hotspot使用一种叫做卡表(Cardtable) 的方式实现记忆集,也是目前最常用的一种方式。

JVM垃圾收集算法与垃圾收集器(Serial、ParNew、CMS) —JVM系列(四)

卡表是使用一个字节数组实现:CARD_TABLE[],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”;HotSpot使用的卡页是29大小,即512字节。

一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0;GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。

卡表的维护:
       卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1,Hotspot使用写屏障维护卡表状态。


参考文献:

《深入理解Java虚拟机》