注:一直都想要尝试介绍一下JVM的垃圾收集器,但是不知道从何开始,纠结了好久,还是尝试总结一波。本文将介绍JVM发展至今所有的垃圾收器。
前言
如果说垃圾收集器是内存回收的具体实现,那么内存收集算法就是内存回收方法论。在开始具体实现相关介绍之前,我们先来看一下内存收集算法。
注:由于垃圾收集算法底层实现细节较多,本文不分析hotspot具体实现,具体源码分析会在后续的博客陆续给出,请大家持续关注。
-
标记-清除算法
标记-清除算法可谓是垃圾收集最基础的算法,它分为标记和清除两个阶段:标记:标记出需要收集的对象;
清除:在标记出所有需要回收的对象之后,回收这些对象的内存空间。
之所以说它是垃圾收集最基础的算法,是因为其他的垃圾收集算法都是在它的基础针对它的不足做的改进。它主要有两大不足:
效率问题:标记和清除过程的效率都不是很高;
内存碎片:由于需要被回收的对象分散在堆的各个地方,所以在标记清除回收掉内存后,会产生大量不连续的内存碎片,内存碎片过多可能会导致需要给较大对象分配内存空间时无法找到足够的内存而触发GC。
标记-清除算法回收前后内存的状态如下:
注:黑色表示可回收的对象,绿色表示存活的对象,白色表示未使用的内存。
-
复制算法
复制算法的提出主要是为了解决效率问题,该算法将内存分为两块,每次只使用一块,当这块内存使用完毕,就将还存活的对象复制到另一块内存上,然后将已使用过的内存空间一次性清除掉。这样,每次回收就从原来的全内存回收变为对一半内存进行回收,同时,内存回收后也不会出现内存碎片,这样,内存分配只需要顺序分配即可,简单,高效。复制算法回收前后内存的状态如下:注:黑色表示可回收的对象,绿色表示存活的对象,白色表示未使用的内存,浅蓝色表示保留区域
JVM的新生代就采用该算法进行垃圾收集,由于young区的对象大多都是朝生夕死的对象,所以内存划分并不需要遵守1:1的比例。JVM将yong区划分为一块较大的eden区和两块较小的survivor区(from区和to区),每次使用eden区和其中的一块survivor区(from区)。每次gc时,将eden区和from区中还存活的对象一次性复制到to区,清理掉eden区和from区。JVM默认的eden区和survivor区的大小比例为8:1,换句话说,yong区每次可用的内存空间为yong区容量的90%,只有10%的内存会被浪费掉。
-
标记-整理算法
在对象存活率比较高的时候,在用复制算法做垃圾收集的时候就会进行比较多的内存复制操作,这样效率就会比较低。当然,如果不想浪费内存空间,还需要有额外的空间进行分配担保,以应对对象都存活的极端情况。所以,在old区复制算法就不适用了。根据old区的特点,标记-整理算法被提出。它的过程与标记-清除算法一致,只是清除不再是直接对需要回收的对象直接做内存回收,而是先将所有存活的对象移动到一端,再清除掉端边界以外的内存。标记-整理算法回收前后内存的状态如下:注:黑色表示可回收的对象,绿色表示存活的对象,白色表示未使用的内存。
分代收集
JVM都采用分代收集算法来完成垃圾收集,其实该算法并没有什么新的内容,只是将heap进行分区,针对分区的特点,采用不同的垃圾收集算法。一般,heap分为young区和old区:young区的对象都是朝生夕死,只有很少的对象存活,选用复制算法更为合适;而old区对象存活率较高,并且没有额外的空间让其分配担保,使用标记-整理算法更为合适。
啰嗦完垃圾收集算法之后,我们接下来看看垃圾收集算法的具体实现--垃圾收集器的相关内容。
垃圾收集器
JVM的规范并没有规定垃圾收集器的实现方式,所以不同版本的JVM提供的垃圾收集器可能都是有差别的,当然,JVM也会提供参数以供用户自己选择和组合垃圾收集器。本文将会介绍6种作用于不用分区的收集器,它们几乎涵盖了JDK发展至今所有的主流垃圾收集器。
young区:Serial、ParNew、Parallel Scavenge
old区:CMS、Serial Old、Parallel Old
young区 + old区:G1
Serial收集器
Serial收集器应该是最基础的一款垃圾收集器,在JDK 1.3之前是JVM young区垃圾收集的唯一选择。Serial收集器是单线程的串行进行垃圾收集的收集器,而且,它在进行垃圾收集是必须要暂停所有的工作线程(STW),直到垃圾收集结束。
注:STW(Stop The World),由JVM自动发起和自动完成,在用户不知情的情况下把所有用户线程全部停掉。这样会导致很不好的用户体验,但是JVM的开发者们其实也很无奈,如果不暂停所有的用户线程,在边收集垃圾的过程中还不断有新的垃圾产生,循环往复,垃圾还能收集完么?
从JDK 1.3开始,JVM的开发锅锅们一直都在努力为消除/减少因内存回收而导致的STW,从Serial到Parallel再到CMS,G1,STW的时间在不断缩短,但是,STW还是仍然存在,并没有被完全消除。
看到这里,大家可能会觉得那Serial收集器是不是完全没用,但是其实到现在,它还是JVM运行在client模式下默认young区垃圾收集器。对于其他垃圾收集器,特别是单个CPU的时候,它有一个很明显的优点:简单高效,Serial收集器由于没有线程切换的相关开销,它只需要关注于垃圾收集,很自然也就可以获得最高的单线程收集效率。
同样,JVM提供以下参数控制垃圾收集:
-XX:SurvivorRatio
:设置eden区和survivor区的大小比例,如不通过该参数设置,默认比例为8:1;-XX:PretenureSizeThreshold
:设置对象可直接在old区进行内存分配的大小阈值,一旦对象超过该阈值,可直接在old区为其分配内存;-XX:-HandlePromotionFailure
:设置是否允许担保失败,如果允许,检查old区的最大可用连续空间是否大于晋升到old区对象的平均大小,如果大于,再进行一次Monitor GC,如果小于或者不允许担保失败,则进行一次Full GC。
ParNew收集器
ParNew收集器是Serial收集器的多线程版本,它和Serial收集器的唯一区别就是一个是多线程,一个是单线程,在hotspot的具体实现中,它们也共用了很多代码。
虽然ParNew比起Serial收集器并没有多少创新,但是它确实很多服务首选的young区收集器,其中有一个很重要的原因就是除Serial收集器之外,只有它能与CMS收集器配合使用。当然,JVM提供参数-XX:+UseParNewGC
来设置选择ParNew收集器。
ParNew收集器在单CPU的环境中的效果其实没有Serial垃圾收集器好,但是,随着CPU数量的增多,它对于在垃圾收集时的资源利用有很大的好处。ParNew的默认开启线程数跟CPU的核数相同,同时,JVM提供参数-XX:ParallelGCThreads
参数来设置垃圾收集线程数。
Parallel Scavenge收集器
Parallel Scavenge也是一款基于复制算法的young区垃圾收集器,从命名就可以知道,它的垃圾收集也是并行的多线程收集器,那么它跟ParNew收集器有什么区别呢?其实对于ParNew和CMS它们的相关优化的重点都是为了尽可能的缩短STW时间,但是Parallel Scavenge则是为了达到一个可控制的吞吐量,提高了吞吐量,也就可以更高效率的利用CPU。
注:吞吐量 = 运行时间 / (运行时间 + 垃圾收集时间)
JVM提供以下参数用于控制吞吐量:
-XX:MaxGCPauseMillis
:设置垃圾收集花费时间最大值,单位ms;-XX:GCTimeRatio
:设置垃圾收集时间占总时间的比率,大家估计不能理解意思,举个例子吧,比如我将此参数设置为19,那允许最大的垃圾收集时间占总时间的比率 = 1 / (1 + 19) = 5%。如果不通过该参数明确设置,默认垃圾收集时间占总时间比率为99,也就是允许最大的垃圾收集时间占总时间的比率为1%。
同时,JVM还提供另一个参数-XX:UseAdaptiveSizePolicy
用于控制是否要使用动态调整策略,如果使用,就不再需要指定young区的大小,eden区和survivor区的大小比例,晋升老年代对象年龄等参数,JVM会根据当前系统的运行情况性能监控信息动态调整这些参数(GC自适应调节策略)。
在使用Parallel Scavenge收集器的时候可以配合自适应调节策略,把内存管理和优化交由JVM,只需要设置好heap的最大值,再为JVM设定吞吐量相关目标,剩下的事情就可以完全放心的交由JVM去完成啦。so,自适应策略也是另一个与ParNew收集器的重要区别。
Serial Old收集器
Serial Old是Serial收集器的老年代收集器版本,它采用标记-整理算法,是一个单线程的垃圾收集器。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge的老年代收集器版本,它同样采用标记-整理算法,比起Serial Old,它是一个多线程的垃圾收集器。该收集器是在JDK 1.6之后才提出的,在此之前,Parallel Scavenge只能与Parallel Old搭配使用,由于Serial Old性能较低,所以就算使用Parallel Scavenge也并不能在整体上提升吞吐量。Parallel Old提出后该问题就迎刃而解了,在注重吞吐量的场合,可以考虑使用Parallel Scavenge + Parallel Old组合。
CMS(Concurrent Mark Sweep)收集器
CMS收集器是JDK 1.5提出的第一个真正意义上的并发收集器,它第一次实现了用户线程和GC线程并行执行。它在最大程度上减少了STW时间。
CMS收集器是基于标记-清除算法实现的,整个垃圾收集过程主要分为以下四个部分:
初始标记:标记GC Roots可以直接关联到的对象,速度非常快;
并发标记:进行GC Roots tracing,耗时较多;
重新标记:重新进行一次GC Roots tracing,重新标记也是为了修正并发标记期间因用户程序继续执行导致标记产生变化的那一部分对象,它的时间会比较长,但是比并发标记短很多;
并发清除:回收需要回收的对象内存。
整个垃圾收集过程可以和用户程序并发执行,最大程度上减少了STW时间,但是过程1和过程3仍然需要STW,所以CMS并没有完全清除STW,有些博客有些错误的理论觉得CMS没有STW,其实并不是如此。
CMS虽然是一款很好的垃圾收集器,但是它还是有一些很明显的缺点:
CMS是基于标记-清除算法的垃圾收集器,如果大家对该算法还有印象的话,应该知道该算法执行结束后会产生很多的内存碎片,当空间碎片过多时往往会出现old区还有很多空间就提前触发GC。为了解决这个问题,CMS提供参数
-XX:+UseCMSCompactFullCollection
,用于控制进行Full GC时开启内存碎片整理压缩,此开关默认开启。内存整理后,空间碎片的问题没有了,但是由于内存整理无法并发完成,STW的时间也随之边长,为此,CMS提供另一个参数-XX:+UseCMSFullGCsBeforeCompaction
设置执行多次Full GC后再压缩内存,默认值为0,表示每次执行Full GC后都进行内存压缩;CMS收集器对CPU资源敏感,在并发阶段,它虽然不会导致用户现程停顿,但是会占用一部分线程资源导致总吞吐量降低。默认CMS启动的垃圾回收线程数 = (CPU核数 + 3) / 4,当CPU核数不小于4时,垃圾回收的线程数需要占用不少于25%的CPU资源,而且随着CPU核数增加,垃圾回收占用的资源数随之减少,但是如果CPU核数少于4时,垃圾回收占用的资源就会非常大,对用户程序的影响也会很大。当然,为了解决资源占用情况,JVM推出了i-CMS(Incremental Concurrent Mark Sweep,增量式并发收集器),它采用抢占模式,GC线程、用户线程交替执行,减少了GC线程独占资源的情况,虽然会增加GC的时间,但是会减少对用户线程的影响。但是,实践证明i-CMS的效果很一般,所以很多人可能都不知道这个收集器;
CMS垃圾收集器无法处理浮动垃圾,可能会出现
Concurrent Mode Failure
导致另一次Full GC。由于CMS的垃圾收集线程与用户线程是并行执行的,old区需要预留一部分内存给用户线程,所以CMS垃圾收集器并不会等到old区满了才触发垃圾收集。CMS提供参数-XX:CMSInitiatingOccupancyFraction
设置old区使用阈值,超过该阈值时触发Full GC。如果预留内存无法满足用户程序需要,就会出现Concurrent Mode Failure
,一旦出现该失败,JVM将会启动GC后备预案:临时启动Serial Old收集器进行old区垃圾回收,此时STW时间会很长。所以大家在设置参数-XX:CMSInitiatingOccupancyFraction
一定要视情况而定,不要随意设置。
注:什么叫浮动垃圾(Floating Garbage)?
由于CMS垃圾收集线程与用户线程并行执行,所以在回收过程中仍然会有新的垃圾产生,如果这部分垃圾是产生在标记之后,在本次GC它们是不会被清理掉的,需要等下次GC才能被收集,这一部分垃圾就被称之为浮动垃圾。
到这里为止,6种垃圾收集器介绍告一段落,G1收集器我会单独给出博文介绍,同时给出一些性能数据分析,请大家持续关注~~~