vlambda博客
学习文章列表

梳理七大垃圾回收器的优缺点

    在上篇我们已经梳理了JVM的垃圾回收机制及各区域使用的回收算法,这章我们就来看看基于这些方法论实现的垃圾回收器。肯定之前大家也了解过,知道不同的垃圾回收器有不同的特性,并没有一个万能或最好的垃圾回收器,只能根据不同的业务场景来选最合适的垃圾回收器。这篇我们先梳理常用垃圾回收器的优缺点,供大家参考。

(一) Stop The World(STW)

    为什么JVM要Stop the World?可不可以回收时不STW,原因是这样,上篇我们已经说了主流的虚拟机都采用可达性分析算法,来判断某个对象该不该回收,而我们也知道可达性分析算法是从GC Roots集合开始找引用链,这个过程它会枚举根节点,然后从根节点标记存活的对象,但在根节点枚举以及整理内存碎片的时候,都会Stop the world,此时JVM会直接暂停应用程序的所有用户线程,然后进行垃圾回收。因为垃圾回收时如果不暂停所有用户线程,那这些线程将会继续创建对象或更新对象引用,就会导致这些对象可能无法跟踪和回收、根节点不断变化等比较复杂的问题,因此垃圾回收过程必须暂停所有的用户线程,进入STW状态,回收完之后,再恢复应用程序的所有用户线程。

    所有的垃圾回收器都无法避免STW,只能尽量缩短用户线程的停顿时间。系统停顿期间,无法处理任何请求,所有用户请求都会出现短暂的卡顿。如果因为内存分配不合理或垃圾回收器使用不合理,导致频繁的垃圾回收,而每次停顿时间过长,这会让用户体验极差。JVM最重要的一个优化就是通过合理的内存分配,使用合理的垃圾回收器,使得垃圾回收频率最小、停顿时间最短,避免影响系统正常运行。

(1) 安全点 (safe point)

    用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。安全点可以理解成在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停。

    安全点位置的选取基本上是以“是否具有让程序长时间执行的特征''为标准进行选定的,"长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

    JVM采用主动中断的方式,在垃圾回收发生时让所有线程都跑到最近的安全点。主动式中断的思想是当垃圾回收需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

3.安全区域 (safe region)

    安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾回收过程的安全点。但是,程序“不执行”的时候,线程就无法响应虚拟机的中断请求,如用户线程处于Sleep状态或者Blocked状态,这个时候就没法再走到安全的地方去中断挂起自己。这就需要安全区域来解决了。

    安全区域是指能够保证在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾回收都是安全的。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾回收时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了需要暂停用户线程的阶段,如果完成了,那线程就继续执行;否则就必须一直等待,直到收到可以离开安全区域的信号为止。

    简单来说,JVM虚拟机就是通过安全点,安全区域来保证暂停用户程序的所有线程,以及恢复用户程序的所有线程的。

(二) 垃圾回收器

(1)Serial垃圾回收器

    Serial 垃圾回收器是一个单线程回收器,它进行垃圾回收时,必须暂停其他所有用户线程,直到它回收结束。Serial主要用于新生代垃圾回收,采用复制算法实现。

    服务端程序几乎不会使用Serial回收器,因为服务端程序一般会分配比较大的内存,可能几个G,如果使用Serial回收器,由于是单线程,标记、清理阶段就会花费很长时间,就会导致系统较长时间的停顿

    Serial 一般用在客户端程序或占用内存较小的微服务,因为客户端程序一般分配的内存都比较小,可能几十兆或一两百兆,回收时的停顿时间是完全可以接受的,而且Serial是所有回收器额外消耗内存最小的,也没有线程切换的开销,非常简单高效。

(2)Serial Old 垃圾回收

    Serial Old是Serial的老年代版本,它同样是一个单线程回收器,主要用于客户端程序。Serial Old用于老年代垃圾回收,采用标记整理算法实现。

    Serial Old也可以用在服务端程序,主要有两种用途:一种是与Parallel Scavenge回收器搭配使用,另一种就是作为CMS回收器发生在失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

(3)ParNew 垃圾回收  

    ParNew 回收器实质上是Serial 回收器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都与Serial回收完全一致,控制参数、回收算法、对象分配规则等都是一致的。除了Serial回收器外,目前只有ParNew回收器能与CMS回收器配合工作,ParNew是激活CMS后的默认新生代垃圾回收器

    ParNew 默认开启的回收线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾回收的线程数。

梳理七大垃圾回收器的优缺点

(4)Parallel Scavenge 垃圾回收  

    Parallel Scavenge是新生代回收器,采用复制算法实现,也是能够并行回收的多线程回收器。Parallel Scavenge 主要关注可控制的吞吐量,其他回收器的关注点是尽可能地缩短垃圾回收时的停顿时间。吞吐量就是处理器用于运行程序代码的时间与处理器总消耗时间的比值,总消耗时间等于运行程序代码的时间加上垃圾回收的时间。

    Parallel Scavenge 提供了两个参数用于精确控制吞吐量:

  • -XX:MaxGCPauseMillis: 控制最大垃圾回收停顿时间,参数值是一个大于0的毫秒数,回收器将尽力保证垃圾回收花费的时间不超过这个值。

  • -XX:GCTimeRatio:直接设置吞吐量大小,参数值是一个大于0小于100的整数,默认值是99,即允许最大1%的垃圾收集时间。

    Parallel Scavenge还有一个参数 -XX:+UseAdaptiveSizePolicy,当设置这个参数之后,就不需要人工指定新生代的大小,Eden与Survivor区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

梳理七大垃圾回收器的优缺点

(5)Parallel Old 垃圾回收

    Parallel Old是Parallel Scavenge的老年代版本,支持多线程并发回收,采用标记-整理算法实现。在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑Parallel Scanvenge + Parallel Old这个组合。

梳理七大垃圾回收器的优缺点

(6)CMS(Concurrent Mark Sweep) 垃圾回收

    CMS(Concurrent Mark Sweep )是一种以获取最短回收停顿时间为目标的回收器。CMS用于老年代垃圾回收,采用标记-清除算法实现。

1)CMS回收过程:

CMS垃圾回收总体分为四个步骤:

(1)初始标记(会STW):初始标记需要Stop The World,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

(2)并发标记:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象引用链的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾回收线程一起并发运行。

(3)重新标记(会STW):重新标记需要Stop The World,重新标记阶段是为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

(4)并发清除:清除阶段是清理删除掉标记阶段的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。

梳理七大垃圾回收器的优缺点

    最耗时的并发标记和并发清除阶段是和用户线程并发进行的,总体上来说,CMS回收过程是与用户线程一起并发执行的,是一款并发低停顿的回收器。

2 ) 触发CMS的条件:

    CMS GC 在实现上分成 foreground collector 和 background collector。

(1) foreground collector

    foreground collector 触发条件比较简单,一般是遇到对象分配空间不够,就会直接触发GC,来立即进行空间回收。采用的算法是mark sweep,不压缩。

(2)background collector

    background collector 是通过CMS后台线程不断的去扫描,过程中主要判断是否符合background collector 的触发条件,一旦有符合的情况,就会进行一次background 的collect。每次扫描过程中,先等CMSWaitDuration时间(默认2秒),然后再判断是否满足background collector的触发条件。

background collector 的触发条件:

  • 并行Full GC ,如调用了System.gc()

  • 未配置UseCMSInitiatingOccupancyOnly时,会根据统计数据动态判断是否需要进行一次CMS GC。如果预测CMS GC完成所需要的时间大于预计的老年代将要填满的时间,则进行GC,这些判断是需要基于历史的CMS GC统计指标,第一次CMS GC时,统计数据还没有形成,是无效的,这时会根据Old Gen的使用占比来判断是否进行GC。

  • 未配置UseCMSInitiatingOccupancyOnly时,判断CMS的使用率大于CMSBootstrapOccupancy(默认50%)时触发Old GC。

  • 老年代内存使用率阈值超过CMSInitiatingOccupancyFraction(默认为92%)时触发Old GC,CMSInitiatingOccupancyFraction默认值为-1,没有配置时默认阈值为92%。

  • 未配置UseCMSInitiatingOccupancyOnly时,因为分配对象时内存不足导致的扩容等触发GC。

CMS参数设置:

    在没有配置UseCMSInitiatingOccupancyOnly参数的情况下,会多出很多种触发可能,一般在生产环境会配置UseCMSInitiatingOccupancyOnly参数,配了之后就不用设置CMSBootstrapOccupancy参数了。

    CMSInitiatingOccpancyFraction(occupancy 占用率的意思,fraction分数的意思。设置得太高将会很容易导致频繁的并发失败,性能反而降低;太低又可能频繁触发CMS background collector,一般在生产环境中应根据实际应用情况来权衡设置。

-XX:+UseConcMarkSweepGC-XX:CMSInitiatingOccupancyFraction=92-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSBootstrapOccupancy=92-XX:CMSWaitDuration=2000

3)CMS的问题:

(1)并发回收导致CPU资源紧张:

    在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程是:(CPU核数 + 3)/4,当核数不足四个时,CMS对用户程序的影响可能变得很大。

(2)无法清理浮动垃圾:

    在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随着有新的垃圾对象产生,但这一部分垃圾对象是出现在标记过程结束后,CMS无法在当次收集中集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。

(3) 并发失败(Concurrent Mode Failure):

    由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他垃圾回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了92%的空间后就会触发CMS垃圾回收,这个值可以通过-XX:CMSInitiatingOccupancyFraction参数来设置。

    这里有一个隐患:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用Serial Old来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。

(4)内存碎片问题:

    CMS是一款基于"标记-清除"算法实现的回收器,这意味着回收结束时就会有内存碎片产生。内存碎片过多时,将会给大家分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

 为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会很长,还有另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前就会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理。

(七)G1(Garbage First) 垃圾回收

    G1(Garbage First) 回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用程序应用的垃圾回收器。G1设计初衷就是替换CMS,成为一种全功能收集器。G1在JDK9之后成为服务端模式下的默认垃圾回收器,取代了Parallel Scavenge加Parallel Old的默认组合,而CMS被声明为不推荐使用的垃圾回收器。G1从整体来看是基于标记-整理算法实现的回收器,但从局部(两个Region之间)上看又是基于标记复制算法实现的。

1)可预期的回收停顿时间

    G1可以指定垃圾回收的停顿时间,通过-XX:MaxGCPauseMillis参数指定,默认为200毫秒。这个值不宜设置过低,否则会导致每次回收只占堆内存很小的一部分,回收器的回收速度逐渐赶不上对象分配的速度,导致垃圾慢慢堆积,最终占满堆内存导致Full GC反而降低性能。

    G1之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次回收到内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾回收。G1会去跟踪各个Region的垃圾回收价值,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的回收停顿时间,优先处理回收价值收益最大的那些Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1回收器在有限的时间内得到尽可能高的回收效率。

    由于Region数量比传统回收器的分代数量明显要多得多,因此G1回收器要比其他的传统垃圾回收器有着更高的内存占用负担。G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持回收器工作。

2) G1内存布局

    G1不再是固定大小以及固定数量的分代区域划分,而是把堆区划分为多个大小相等的Region,每个Region的大小默认情况下是堆内存大小除以2048,因为JVM最多可以有2048个Region,而且每个Region的大小必须是2的N次幂。每个Region的大小也可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。

    G1也有新生代和老年代的概念,不过是逻辑上的区分,每一个Region都可以根据需要,作为新生代的Eden空间、Survivor空间,或者老年代空间。新生代默认占堆内存的5%,但最多不超过60%,这个默认值可以使用-XX:G1NewSizePercent参数设置,最大值可以通过-XX:G1MaxNewSizePercent参数设置。新生代Region的数量并不是固定的,随着使用和垃圾回收动态的变化,同样的,G1新生代也有Eden区和Survivor区的划分,也可以通过-XX:SurvivorRatio设置其比例,默认为8。

    Region中还有一类特殊的Humongous,专门用来存储大对象,而不是直接进入老年代的Region。G1认为一个对象只要大小超过了一个Region容量的一半就判定为大对象,而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来看待。

4)G1回收过程

G1回收器的运作过程大致可分为四个步骤:

    (1)初始标记(会STW):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象,这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

(2)并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成后,还要重新处理在并发时有引用变动的对象。

(3)最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。

(4)清理阶段(会STW):更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作设计存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

5) G1新生代回收

    根据G1的内存布局举个例子,例如:设置堆内存4G,就是4096M,除以2048个Region,每个Region就是2M;新生代初期占5%,就是约100个Region,此时Eden区占80个Region,两个Survivor区各占10个Region;不过随着对象在新生代分配,属于新生代的Region会不断增加,Eden和Surivivor对应的Region也会不断增加。直到新生代占用60%,也就是约1200个Region,就会触发新生代的GC,这个时候就会采用复制算法将Eden对应Region存活的对象复制到from survivor对应的Region。只不过这里会根据用户期望的停顿时间来选取部分最有价值回收的Region进行回收。

6)G1混合回收

    G1有一个参数,-XX:InitiatingHeapOccupancyPercent,它的默认值是45%,就是如果老年代占堆内存45%的Region的时候,此时就会触发一次新生代+老年代的混合回收。

    混合回收阶段,因为我们设定了最大停顿时间,所以G1会从新生代、老年代、大对象里挑选一些Region,保证指定的时间内回收尽可能多的垃圾。所以G1可能一次无法将所有Region回收完,它就会执行多次混合回收,先停止程序,执行一次混合回收回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region,可以通过参数-XX:G1MixedGCCountTarget设置一次回收的过程中,最后一个阶段最多执行几次混合回收,默认值是8次。通过这种反复回收的方式,避免系统长时间的停顿。

    G1还有一个参数 -XX:G1HeapWastePercent,默认值是5%,就是在混合回收时,Region回收后,就会不断的有新的Region空出来,一旦空闲出来的Region数量超过堆内存的5%,就会立即停止混合回收,即本次混合回收就结束了。

    G1还有一个参数 -XX:G1MixedGCLiveThresholdPercent,默认值为85%,意思是回收Region的时候,必须存活对象低于Region大小的85%时才可以进行回收,一个Region存活对象超过85%,就不必回收它了,因为复制大部分存活对象到别的Region,这个成本是比较高的。

7)回收失败

(1)并发回收失败

(2) 混合回收失败

    混合收阶段,新生代和老年代都是基于复制算法进行回收,复制的过程中如果没有空闲的Region了,就会触发失败。一旦失败,就会停止程序,然后采用单线程标记、清理和内存碎片整理,然后空闲出来一批Region。这个过程是很慢的,因此需要尽量调优避免混合回收失败的发生。

(三)垃圾回收器总结对比

垃圾回收器类型
单线程/多线程
使用的算法
特点
设置参数
适用区域
客户端/服务端
Serial
单线程
复制
简单高效、停顿时间长
-XX:+UseSerialGC(新生代:serial 老年代:serial old)
新生代
客户端
ParNew
多线程
复制
降低了停顿时间,但增加了线程上下文奇幻的消耗

-XX:+UseParNewGC(新生代:parnew ,老年代:serial old。JDK9生效)

-XX:ParallelGCThreads=4(设置并发线程数,默认等于CPU核心数)

新生代
服务端
Parallel Scavenge
多线程
复制
追求高吞吐量,高效利用CPU,可以控制最大垃圾回收停顿时间

-XX:+UseParallelGC(新生代:Parallel scavenge 老年代:Parallel Old)

-XX:ParallelGCThreads=4(设置并发线程数,默认等于CPU核心数)

-XX:MaxGCPauseMillis=200(设置最大GC停顿时间)

-XX:GCTimeRatio=99(设置吞吐量)

-XX:+UseAdaptiveSizePolicy(启用自适应调解策略,默认开启)

新生代
服务端
Serial Old
单线程
标记整理 简单高效,停顿时间长,主要用于客户端程序,或者与其他回收器使用

老年代
客户端、服务端
Parallel Old
多线程
标记整理
追求高吞吐量
-XX:+UseParallelOldGC(新生代:Parallel Scavenge, 老年代:Parallel Old)
老年代
服务端
CMS
多线程
标记清除
可以与用户线程同时进行,高并发,低停顿,追求最短回收停顿时间,CPU占用比较高,响应时间快,停顿时间短,需要Serial Old来避免并发失败的风险

-XX:+UseConcMarkSweepGC(新生代:ParNew ,老年代:CMS Serial Old备用 )

-XX:+CMSInitiatingOccupancyFraction=92(老年代使用空间超过这个比例后触发CMS回收)

-XX:+UseCMSInitiatingOccupancyOnly(让JVM使用设定的阈值)

-XX:CMSWaitDuration=2000(CMS gc 间隔时间,默认2000毫秒)

-XX:+UseCMSCompactAtFullCollection(在必须Full gc时整理内存碎片,默认开启)

-XX:CMSFullGCsBeforeCompaction=0(多少次Full gc后整理内存碎片)

-XX:CMSBootstrapOccupancy=50(默认50%)

老年代
服务端
G1
多线程
标记整理+复制
可以与用户线程同时进行,基于Region的内存布局形式,高并发、低停顿、可控的回收停顿时间。

-XX:+UseG1GC(新生代:G1,老年代:G1)

-XX:MaxGCPauseMillis=200(设置最大GC停顿时间,默认200毫秒)

-XX:G1HeapRegionSize=1(设置每个Region的大小,1M~32M,且为2的N次幂)

-XX:InitiatingHeapOccupancyPercent(老年代超过这个比例后触发Mixed GC 默认45%)

-XX:G1HeapWastePercent(空闲Region比例,超过这个阈值后停止MixedGC 默认为5%)

-XX:G1MixedGCLiveThresholdPercent(Region存活对象小于这个比例才会被回收,默认85%)

-XX:G1NewSizePercent(新生代占用初始的堆内存比例,默认5%)

-XX:G1MaxNewSizePercent(新生代占用最大的堆内存比例,默认60%)

-XX:ParallelGCThreads=4(设置并发线程数,默认等于CPU核心数)

新生代、老年代
服务端

垃圾回收器的配合使用

(四)GC性能衡量指标

    一个垃圾收集器在不同场景下表现出的性能也不一样,我们可以借助下面指标来衡量GC的性能。

1)吞吐量

    吞吐量是指应用程序所花费的时间和系统总运行时间的比值,系统总运行时间=应用程序耗时+GC耗时。如果系统运行了100分钟,GC耗时1分钟,则系统吞吐量为99%。GC的吞吐量一般不能低于95%。

2)停顿时间

    指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

3)垃圾回收频率

    通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。