手记

JVM(四)----HotSpot的垃圾收集器与内存分配回收策略

概览

这个虚拟机中包含的所有收集器如下图:


image.png

Young generation是年轻代,Tenured generation是老年代。如果两个收集器之间有连线,则说明它们可以搭配使用。

到目前为止,还没有什么最好的收集器,更加没有万能收集器。所以我们选择的知识对具体应用最合适的收集器或者收集器搭配。

评价特定应用下,一款收集器的好坏,主要有两个指标:停顿时间和吞吐量。停顿时间是指进行垃圾收集时,用户线程的暂停时间,也就是之前课程所说的“Stop The World”,一般来说,用户交互较为频繁的B/S应用更为重视停顿时间的长短,停顿时间越短,用户等待时间就越少,体验就越佳。

吞吐量是指用于执行用户线程的时间占总应用时间的比率,对于无需和用户进行交互的纯后台应用来说,停顿时间没那么重要,更看重的是吞吐量的大小,吞吐量越大,说明执行用户线程的时间更长,处理速度就越高。

Serial 收集器

Serial 收集器是最基本、历史最悠久的收集器。这是一个单线程的收集器,有以下两个特点:

  • 只使用一个CPU或一条收集线程去完成垃圾收集;

  • 进行垃圾收集时,必须暂停其他所有工作线程,也就是前面课程提到过的“Stop The World”;

这个单线程的收集器,是HotSpot运行在Client模式下的默认新生代收集器,主要是由于它有以下的优势:
对于单个CPU的环境来说,单线程的Serial 收集器不需要进行线程切换,减少了切换时的时间开销,因此在单CPU的环境下可以获得最高的收集效率;对于大多数客户端桌面应用来说,分配给虚拟机的内存一般不大,新生代的垃圾一般在几十兆到一两百兆之间,停顿时间完全可以控制在几十毫秒到一百多毫秒以内,这点是可以接受的。因此,Serial 收集器对于运行在客户端、单CPU环境的虚拟机来说,是一个很好的选择。


Serial/Serial Old收集器运行示意图

ParNew 收集器

ParNew 收集器其实就是Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其他都和Serial 收集器一致。

ParNew 收集器是虚拟机运行在Server模式下的首选新生代收集器,除了因为它是多线程收集器之外,还因为它是除了Serial 收集器,唯一一个能和CMS收集器配合的收集器。

ParNew 收集器在单CPU的环境中绝对不会有比Serial 收集器更好的收集效率,甚至由于线程切换的开销,它在通过超线程技术实现的两个CPU的环境中都不能百分百保证可以超越Serial 收集器。

当然,随着CPU数量的增加,ParNew 收集器的多线程优势会越发明显,它默认开启的收集线程数和CPU的数量相同,可以使用-XX:ParalletGCThreads参数来限制垃圾收集的线程数。

ParNew/Serial Old收集器运行示意图

Parallel Scavenge 收集器

Parallel Scavenge 收集器,和ParNew 收集器一样,是新生代收集器、同样采用复制算法、同样是多线程收集,那么,它有什么特别之处呢?
Parallel Scavenge 收集器最大的特点是它的关注点在于获得一个可以控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。即吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集的时间)。

它提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills和直接设置吞吐量大小的-XX:GCTimeRatio。

MaxGCPauseMills的值越小,系统就会将新生代的大小调的越小,以加快垃圾收集的速度,但是这样也会增加了垃圾收集的频率,自然吞吐量就下去了。
GCTimeRatio是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。可以精确地控制吞吐量。

实际使用时,经常会使用这两个参数中的一个,再配合另一个参数,-XX:UseAdaptiveSizePolicy. UseAdaptiveSizePolicy是一个开关参数,这个参数打开之后,就不需要手工去指定新生代的大小(-Xmn)、Eden和Survivor的比例(-XX:SurvivorRatio),虚拟机会根据当前系统的运行情况,动态调整这些参数,以实现你所设置的最大垃圾收集时间或者最大吞吐量的目标。这种有点人工智能、又有点傻瓜式的调节方式,叫做GC的自适应调节策略(GC Ergonomics)。

Serial Old收集器

Serial Old 收集器是Serial 收集器的老年代版本,同样是单线程收集器,同样是为了给Client模式的虚拟机使用,使用“标记-整理”算法。

当运行在Server模式下时,它主要有两大用途:

  • 在JDK 1.5以及之前的版本中,和Parallel Scavenge 收集器配合使用;

  • 作为CMS收集器的后备方案,在发生Concurrent Mode Failure时使用,后面会详细介绍;


    Serial/Serial Old收集器运行示意图

Parallel Old收集器

Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本,采用多线程收集和“标记-整理”算法。
在Parallel Old出现之前,如果新生代选择了Parallel Scavenge,老年代除了Serial Old就没有别的选择,而由于受到单线程的Serial Old在服务器端表现的拖累,使用Parallel Scavenge也未必可以获得吞吐量最大化的效果。
Parallel Old 收集器的出现让“吞吐量优先”收集器终于有了合适的应用组合。


image.png

CMS收集器

CMS(Concurrent Mark Sweep)收集器的定位是获取最短的"Stop The World"的时间,也就是最短停顿时间,在具有大量用户交互使用的B/S应用上,停顿时间越短,就越能给用户带来好的体验。那么CMS是如何做到最短停顿的呢?

并发:用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
并行:多条垃圾收集线程并行工作,但此时用户线程还是处于等待状态。

答案是并发。CMS实现了垃圾收集线程和用户线程的并发执行,从名字上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程有以下4个步骤:

1.初始标记(CMS initial mak)
2.并发标记(CMS concurrent mark)
3.重新标记(CMS remark)
4.并发清除(CMS concurrent sweep)

其中,初始标记和重新标记是仅有的两个需要“Stop The World”的阶段。其他两个阶段都不需要。
初始标记只是标记以下GC Roots能直接关联的对象,也就是下图中和GC Roots有直接连线的object1,因此速度很快;
并发标记就是进行GC Roots Tracing,对所有与GC Roots不可达的对象进行标记。
重新标记则是为了修正并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的时间一般比初始标记的长,但远比并发标记短;
并发清除,这个阶段JVM将启动多条线程将所有标记为unreachable的对象清除掉。
整个过程,耗时最长的并发标记和并发清除都可以与用户线程同时工作,因此能够实现最短的停顿时间。

CMS绝对是一款十分优秀的收集器,并发收集、低停顿,但是CMS还远不到完美,它有以下3个明显缺陷:

1、CMS会抢占CPU资源。并发阶段虽然不会导致用户线程暂停,但却需要CPU分出精力去执行多条垃圾收集线程,从而使得用户线程的执行速度下降。

2、CMS无法处理浮动垃圾(Floating Garbage),可能会出现“Concurrent Mode Failure”而导致另一次Full GC。并发清理的过程中,由于用户线程还在执行,因此就会继续产生对象和垃圾,这些新的垃圾没有被标记,CMS只能在下一次收集中处理它们。这也导致了CMS不能在老年代几乎完全被填满了再去进行收集,必须预留一部分空间提供给并发收集时程序运作使用。在JDK1.5默认设置下,老年代使用了68%(JDK1.6是92%)的空间后CMS的垃圾收集就会被激活,其实这是一个比较保守的设置,只要应用中老年代增长不是很快,可以适当地调高参数-XX:CMSInitialingOccupancyFraction来提高触发百分比,降低回收的频率来获得更好的性能。如果CMS在收集期间,内存无法满足程序的需要,就会出现“Concurrent Mode Failure”,这时JVM将启动Plan B,也就是临时调用单线程的Serial Old收集器来重新进行老年代的垃圾收集,这样的话,CMS原本降低停顿时间的目的不仅没完成,和直接使用Serial Old收集器相比,还增加了前面几个阶段的停顿时间。

3、CMS的“标记-清除”算法,会导致大量空间碎片的产生(为什么?)。碎片的积累会给分配大对象带来麻烦,往往会出现明明老年代还有很多空间剩余,但是却无法找到连续的空间分配对象的情况,这时候就不得不触发一次Full GC。为了解决这个问题。CMS提供了一个-XX:+UseCMSCompactAtFullCollection的开关参数(默认是开启的),用于在CMS收集器进行Full GC时对内存碎片进行合并整理,整理的过程是需要暂停用户线程的,这样碎片虽然没有了,但停顿时间又变长了。CMS的设计初衷可是降低停顿,于是又提供了一个参数-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩碎片的Full GC后,跟着来一次带压缩的Full GC(默认值为0,即每次都会)。

G1收集器

这个部分要终点关注!!!

G1(Garbage First)收集器是当代收集器技术发展的最前沿成果之一,是一款面向服务端的收集器,HotSpot团队甚至希望G1收集器在未来可以替换掉CMS收集器。

G1收集器具有以下特点:

并行和并发:这一点和CMS是类似的,可以充分利用CPU的资源,来缩短“Stop The World”的时间,提高收集效率。

不产生空间碎片:和CMS的“标记-清除”算法不同,G1从整体上看是采用“标记-整理”,从局部又像是“复制”,但无论如何,它都不会像CMS一样产生大量碎片而导致分配大对象失败的情形。

可预测的停顿:这又是G1相对于CMS的一大优势,CMS和G1都追求最低停顿时间,但是G1可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

G1 总览
首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代,每个代的空间是确定的。

而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。


image.png

执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因。

在 G1 中,目标停顿时间非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停顿时间。

G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。

我们要知道 G1 不是一个实时收集器,它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。

注意:G1 有和应用程序一起运行的并发阶段,也有 stop-the-world 的并行阶段。但是,Full GC 的时候还是单线程运行的,所以我们应该尽量避免发生 Full GC,后面我们也会介绍什么时候会触发 Full GC。

G1 比 ParallelOld 和 CMS 会需要更多的内存消耗,那是因为有部分内存消耗于簿记(accounting)上,如以下两个数据结构:

  • Remembered Sets:每个区块都有一个 RSet,用于记录进入该区块的对象引用(如区块 A 中的对象引用了区块 B,区块 B 的 Rset 需要记录这个信息),它用于实现收集过程的并行化以及使得区块能进行独立收集。总体上 Remembered Sets 消耗的内存小于 5%。

  • Collection Sets:将要被回收的区块集合。GC 时,在这些区块中的对象会被复制到其他区块中,总体上 Collection Sets 消耗的内存小于 1%。

G1 工作模式
G1 收集器提供三种垃圾回收模式:young gc、mixed gc、full gc,在不同的条件下触发。

young gc
发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

mixed gc
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。
那么mixed gc什么时候被触发?

先回顾一下cms的触发机制,如果添加了以下参数:

-XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly

当老年代的使用率达到80%时,就会触发一次cms gc。相对的,mixed gc中也有一个阈值参数 -XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc.

mixed gc的执行过程有点类似cms,主要分为以下几个步骤:

  • 1.initial mark: 初始标记过程,整个过程STW,标记了从GC Root可达的对象

  • 2.concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息

  • 3.remark: 最终标记过程,整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象

  • 4.clean up: 垃圾清除过程,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中。该阶段会STW。清点和重置标记状态。这个阶段有点像mark-sweep中的sweep阶段,这个阶段并不会实际上去做垃圾的收集,只是去根据停顿模型来预测出CSet,等待evacuation阶段来回收。

补充:拷贝存活对象阶段

Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。Evacuation阶段从第一阶段选出来的Region中筛选出任意多个Region作为垃圾收集的目标,这些要收集的Region叫CSet,通过RSet实现。
筛选出CSet之后,G1将并行的将这些Region里的存活对象拷贝到其他Region中,这点类似于ParalledScavenge的拷贝过程,整个过程是完全暂停的。关于停顿时间的控制,就是通过选择CSet的数量来达到控制时间长短的目标。

full gc
如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc。

介绍一下几种会导致Full gc的情况:

1.concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。

这个时候说明:
堆需要增加了,
或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束
或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用就开始进行并发周期。

2.晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

说明混合垃圾回收需要更迅速完成垃圾收集,也就是说在混合回收阶段,每次年轻代的收集应该处理更多的老年代已标记区块。

3.疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。

最简单的就是增加堆大小

4.大对象分配失败,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象。

内存分配回收策略

1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时,JVM将进行一次Minor GC。
此处的Minor GC指的是对新生代的GC。而Full GC/Major GC指的是对老年代的GC。
利用参数-XX:+PrintGCDetails -XX:+PrintGCTimeStamps可以查看相关的GC日志。

2.大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。直接进入老年代区域而不是分配到新生代。

JVM参数-XX:PretenureSizeThreshold的意思就是将体积大于这个设置值的对象直接在老年代分配。
这样做是为了避免在Eden区及两个Survivor区之间发生大量的内存复制。

3.长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。 为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。 对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

4.对象年龄的动态判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代。
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

5.空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。
如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;
如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。



作者:小北觅
链接:https://www.jianshu.com/p/9a24abf6b0fb


0人推荐
随时随地看视频
慕课网APP