概述
在之前的博客中,已经对垃圾回收算法的基本知识进行了整理,本篇我们主要介绍在 oracle JVM
中,垃圾回收的具体实现:垃圾回收器(Garbage Collector
)。在介绍具体的垃圾回收器之前,先介绍一些具体的实现细节知识,方便后面对垃圾回收器的介绍。
垃圾回收器实现细节
根节点枚举
根节点枚举主要是用来获取可达性分析算法中GC Roots集合,要实现这个需要解决下面俩个问题
- 如何快速的找到所有的GC Roots
- 如何解决在查找过程中对象变化导致GC Roots变化的问题
首先看第一个问题的解决方案:
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在Java应 用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。
由于目前主流Java虚拟机使用的都是准确式垃圾收集(保证回收错误,相对应的是精准式垃圾回收,根节点完全准确),所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
对于第二个问题的解决办法是,停止所有的用户线程。迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有 用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CM S、G1、 ZGC等收集器,枚举根节点时也是必须要停顿的。
安全点和安全区域
在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safe point)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而 长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括 执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断 (Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最 近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他 需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式, 把轮询操作精简至只有一条汇编指令的程度。
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了, 但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集 过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的 场景便是用户线程处于Sleep 状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走 到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆表与卡表
讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建 立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器 , 典型的如 G1 、 ZGC 和 Shenandoah收集器 , 都 会面临相同的问题,因此我们有 必 要 进 一 步 理清记忆集的原理和实现方式,以便在后续章节里介绍几款最新的收集器相关知识时能更好地理解。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。因此不是具体的实现。既然是记录集,首先要确定的是记录的精度问题,目前有下面三种可选
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,也是最常用的方式,具体的结构如下。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃 圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它 们加入GC Roots中一并扫描。
写屏障
上面已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态。其实类似于AOP思想,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影 响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓 存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对 象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免 伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启 卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损 耗,是否打开要根据应用实际运行情况来进行测试权衡。
三色标记法
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象 是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析, 这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于GC Roots相比 起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。
可从GC Roots再继续往下遍历对象 图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。
要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等 比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时 间的话,那收益也将会是系统性的。
因此引入三色标记(Tri-color Marking)[1]作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用 关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活, 这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理 掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此 发生错误,下面表演示了这样的致命错误具体是如何产生的。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此只要解决上面的俩个问题,就能够使得三色标记算法正确的标记对象。由此产生了来个方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB ) 。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
在了解 垃圾回收器 之前,首先得了解一下垃圾回收器的几个名词。
吞吐量CPU
用于运行用户代码的时间与 CPU
总消耗时间的比值。比如说虚拟机总运行了 100
分钟,执行用户代码 时间 99
分钟,垃圾回收 时间 1
分钟,那么吞吐量就是 99%
。
1 | 吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾回收时间) |
停顿时间
指垃圾回收器运行时,应用程序的暂停时间。对于独占回收器(也就是只有回收器运行)而言,停顿时间可能会比较长。使用并发回收器时,由于垃圾回收器和应用程序交替运行,程序的停顿时间会变短,但是,由于其效率很可能不如独占垃圾回收器,故系统的吞吐量可能会较低。
并发与并行
串行(Serial):单线程进行垃圾回收工作时,用户线程处于 等待状态,也就是垃圾回收线程和用户线程交替执行。
并发(Concurrent):这里指用户线程与垃圾回收线程交替执行。
并行(Parallel):这里指用户线程和多条垃圾回收线程分别在不同 CPU
上同时工作。
垃圾回收器
在 JVM
中,具体实现有 Serial
、ParNew
、Parallel Scavenge
、CMS
、Serial Old(MSC)
、Parallel Old
、G1
等。在下图中,你可以看到不同垃圾回收器适合于不同的内存区域,如果两个垃圾回收器之间存在连线,那么表示两者可以配合使用。
下面按照先新生代后老年代的顺序介绍每个垃圾回收器。
新生代回收器
Serial(-XX:+UseSerialGC)Serial
回收器是最基本的新生代垃圾回收器,是单线程的垃圾回收器。这里单线程的意思不仅仅在于它只使用一个cpu或者一个线程来进行垃圾回收,更重要的在于他在进行垃圾回收时必须暂停其他所有线程的运行,等到回收完成时才允许其他线程继续运行,由于这个暂停的存在,所以他很不适用于交互式应用中去,类如web就不适合。他的劣势也正是它的优势,由于垃圾清理时,Serial
回收器不存在线程间的切换,因此,特别是在单CPU
的环境下,它的垃圾清除效率比较高。对于Client
运行模式的程序,选择 Serial
回收器是一个不错的选择。
主要流程如下:Serial
新生代回收器采用的是标记-复制算法
ParNew(-XX:+UseParNewGC)ParNew
回收器是在Serial
回收器的基础上演化而来的,属于Serial
回收器的多线程版本,同样运行在新生代区域。在实现上,两者共用很多代码。在不同运行环境下,根据CPU
核数,开启相应的线程数,从而达到最优的垃圾回收效果。对于那些Server
模式的应用程序,如果考虑采用CMS
作为老年代回收器时,新生代采用ParNew
回收器是一个不错的选择。
回收的整个流程如下图:ParNew
新生代回收器采用的是标记-复制算法。
Parallel Scavenge(-XX:+UseParallelGC)Parallel Scavenge
回收器运行在新生代区域,属于多线程的回收器。但不同的是,ParNew
回收器是通过控制垃圾回收的线程数来进行参数调整,而 Parallel Scavenge
回收器更关心的是程序运行的吞吐量。即一段时间内,用户代码运行时间占总运行时间的百分比。
Parallel Scavenge
新生代回收器采用的是标记-复制算法。
和 ParNew 回收一样,Parallel Scavenge 回收器也是运行在 新生代区域,属于 多线程 的回收器。但不同的是,ParNew 回收器是通过控制 垃圾回收 的 线程数 来进行参数调整,而 Parallel Scavenge 回收器更关心的是 程序运行的吞吐量。即一段时间内,用户代码 运行时间占 总运行时间 的百分比。
对上面做一个小的总结:
- Serial和ParNew追求的是垃圾回收的时间越短越好,越短越适合于与用户进行交互的程序,良好的响应速度更能提升用户体验。
- Parallel Scavenge 则是追求高吞吐量,而高吞吐量则可以高效利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要交互的任务。
老年代的垃圾回收器
Serial Old(-XX:+UseSerialGC)Serial Old
回收器是Serial
回收器的老年代版本,属于单线程回收器,它使用标记-整理算法。对于Server
模式下的虚拟机,在JDK1.5
及其以前,它常与Parallel Scavenge
回收器配合使用,达到较好的 吞吐量,另外它也是 CMS
回收器在Concurrent Mode Failure
时的后备方案。Serial
回收器和 Serial Old
回收器的工作过程如下:Serial Old
老年代回收器采用的是标记 - 整理算法。
Parallel Old(-XX:+UseParallelOldGC)Parallel Old
回收器是Parallel Scavenge
回收器的 老年版本,属于多线程回收器,采用标记-整理算法。Parallel Old
回收器和 Parallel Scavenge
回收器同样考虑了吞吐量优先这一指标,非常适合那些注重吞吐量和CPU
资源敏感的场合。Parallel Old
老年代回收器采用的是标记 - 整理算法。
CMS(-XX:+UseConcMarkSweepGC)CMS(Concurrent Mark Sweep)
回收器以获取最短回收停顿时间为前提的回收器,属于多线程回收器,采用标记-清除算法(这个是和上面不一样的地点)。
相比之前的回收器,CMS
回收器的运作过程比较复杂,分为四步:
初始标记(CMS initial mark):仅仅是标记
GC Roots
能直接关联的对象。这个阶段速度很快,需要Stop the World
。并发标记(CMS concurrent mark):进行的是
GC root Tracing
,从GC Roots
开始对堆内的对象进行可达性分析,找出 存活对象。重新标记(CMS remark):这个阶段为了修正并发期间由于用户进行运作导致的标记变动的那一部分对象的标记记录。这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,也需要
Stop The World
。并发清除(CMS concurrent sweep):这个阶段会进行对象回收,采用的是标记-清除算法
初始标记(CMS initial mark
和重新标记(CMS remark
会导致用户线程 卡顿,Stop the World
现象发生,不过相对来说这俩个阶段时间比较短。在整个过程中,CMS
回收器的内存回收基本上和 用户线程并发执行,因此不需要停顿用户线程,所以综合起来,CMS垃圾回收算法相对来说停顿的时间比较少,非常适合交互任务的执行,能给用户带来良好的体验。
由于 CMS
回收器 并发收集、停顿低,因此有些地方成为并发低停顿回收器(Concurrent Low Pause Sweep Collector
)。
CMS
回收器的缺点:
CMS回收器对CPU资源非常依赖:
CMS
回收器过分依赖于多线程环境,默认情况下,开启的线程数为(CPU 的数量 + 3)/ 4
,当CPU
数量少于4
个时,CMS
对用户查询的影响将会很大,因为他们要分出一半的运算能力去执行回收器线程;CMS回收器无法清除浮动垃圾:由于
CMS
回收器清除已标记的垃圾时(处于最后一个阶段),用户线程还在运行,因此会有新的垃圾产生。但是这部分垃圾未被标记,只有在下一次GC
才能清除,因此被成为浮动垃圾。由于内存回收和用户线程是同时进行的,内存在被回收的同时,也在被分配。当老年代中的内存使用超过一定的比例时,系统将会进行垃圾回收;当剩余内存不能满足程序运行要求时,系统将会出现Concurrent Mode Failure
,临时采用Serial Old
垃圾回收器来重新进行老年代的垃圾收集,这样就会造成停顿的时间增长,从而致使CMS的性能降低。垃圾收集结束后残余大量空间碎片:
CMS
回收器采用的标记清除算法,本身存在垃圾回收器收集结束后残余大量空间碎片的缺点。CMS
需要配合适当的内存整理策略,在一定程度上可以解决这个问题。
G1垃圾回收器
G1
是JDK 1.7
中正式投入使用的用于取代CMS
的垃圾收集器器,它既可以用在新生代又可以用在老年代,是一款面向服务端应用的垃圾收集器。
在G1之前的其他收集器进行的收集范围都是整个新生代或者老年代。使用G1收集器时,java堆的内存布局就与其他收集器有很大差别,他将整个java堆划分成多个大小相等的区域(region),虽然还保留有新生代和老年代的概念,但是新生代和老年代不在是物理上的各留,他们都是一部分region(不需要连续)的集合。G1
的分区示例如下图所示:
G1收集器之所以能建立可预测的停顿时间模型,是因为他可以有计划地避免在整个java堆中进行全区域的垃圾收集。G1跟踪各个Reqion里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限时间内可以获取竟可能高的手机效率。
还有一点需要注意的是,虽然划分了多个Region进行隔离,但是Region之间不可能是孤立的。一个对象分配在某个Region中,他并非只能被本Region中的其他对象引用,而是可以与整个java堆中任意的对象发生引用关系。那么在做可达性确定对象是否存活的时候,岂不是还得扫描整个java堆才能保证准确性?这个问题并非在G1中才有,只是在G1中更加突出而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集比老年代的收集要频繁写,那么回收新生代的对象时也面临相同的问题,如果回收新生代时不得不扫描老年代的话,那么Minor GC的效率可能下降不少。
在G1收集器中,Region之间的对象引用以及其他是机器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembed set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembed set,虚拟机发现程序在堆Reference类型的数据进行写操作时,会产生一个write barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代的对象是否一样了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用的对象所属的Region的Remembered set注解中。当进行内存回收时,在GC Roots的枚举范围中加入Remembered set即可保证不对全堆扫面也不会有遗漏。
这种使用Region
划分内存空间以及有优先级的区域回收方式,保证G1
回收器在有限的时间内可以获得尽可能 高的回收效率。G1
和 CMS
运作过程有很多相似之处,整个过程也分为 4
个步骤:
- 初始标记(CMS initial mark):仅仅是标记
GC Roots
内直接关联的对象。这个阶段速度很快,需要Stop the World
。 - 并发标记(CMS concurrent mark):进行的是
GC Tracing root
,从GC Roots
开始对堆进行可达性分析,找出存活对象。这个阶段耗时比较长,但可以与用户线程并发运行。 - 最终标记(CMS remark):这个阶段为了修正 并发期间由于用户进行运作导致的标记变动的那一部分对象的标记记录。这个阶段的停顿时间 一般会比初始标记阶段稍长一些,但远比并发标记的时间短,也需要
Stop The World
。 - 筛选回收:负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
具体的运行步骤如下图:
与其它 GC
回收相比,G1
具备如下 4
个特点:
-并行与并发:使用多个CPU
来缩短Stop-the-World
的停顿时间,有的回收器需要停顿Java
线程执行的GC
动作,G1
回收器仍然可以通过并发的方式 让Java
程序继续执行。
-分代回收:与其他回收器一样,分代概念在G1
中依然得以保留。虽然 G1
可以不需要其他回收器配合就能独立管理整个GC堆,但它能够采用不同的策略去处理新创建的对象和已经存活一段时间、熬过多次GC的对象,以获取更好的回收效果。新生代和老年代不再是物理隔离,是多个大小相等的独立 Region
。
-空间整合:与CMS
的标记—清理算法不同,G1
从整体来看是基于标记—整理算法实现的回收器。从局部(两个 Region
之间)上来看是基于标记-复制算法实现的。
但无论如何,这两种算法都意味着G1
运作期间不会产生内存空间碎片,回收后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发再一次的GC。
-可预测的停顿:这是G1
相对于CMS
的另一大优势,降低停顿时间是G1
和CMS
共同的关注点。G1
除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M
毫秒的时间片段内进行回收。(后台维护的优先列表,优先回收 价值大 的 Region
)。
相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动 态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。
与CMS 的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存 空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大 对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
不过,G1相对于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比CMS要高。
就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且 堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作 要比CM S消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现 为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
以上的优缺点对比仅仅是针对G1和CM S两款垃圾收集器单独某方面的实现细节的定性分析,通常 我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CM S的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不 同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也 会让对比结果继续向G1倾斜。
参考
- 周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社
- JVM(九)内存分配策略
- JVM系列(六) - JVM垃圾回收器
- JVM系列(五) - JVM垃圾回收算法
- 聊聊JVM的年轻代