【进阶之路】攻克JVM——JVM的垃圾收集器(三)

986 阅读19分钟

导言

大家好,我是南橘,从接触java到现在也有差不多两年时间了,两年时间,从一名连java有几种数据结构都不懂超级小白,到现在懂了一点点的进阶小白,学到了不少的东西。知识越分享越值钱,我这段时间总结(包括从别的大佬那边学习,引用)了一些平常学习和面试中的重点(自我认为),希望给大家带来一些帮助

这是之前的两篇,没看过的可以一起看一下

JVM的文章的思路来自于猿人谷大佬,大佬技术非常好,写的文章也很硬,吃起来非常满足。(^_^)

有需要的同学可以加我的公众号,以后的最新的文章第一时间都在里面,也可以找我要思维导图

上篇文章讲了JVM的垃圾回收机制,大体上了解了什么样的对象会被回收,什么情况下会被回收。这篇文章我们就着重的介绍几种具体的垃圾收集器。

前文也有提过,根据不同分代的特点,我们所使用的垃圾收集器也有不同。根据之前的分析,适用于新生代的垃圾收集器需要选择效率更高,回收速度更快的。而对于老年代来说,因为回收的次数较少,应该避免使用标记-复制算法之类的应用于新生代的算法。

一、串行收集器:Serial

串行收集器是针对新生代的垃圾收集器,基于标记-复制算法。它采用单线程方式进行收集,且在GC线程工作时,系统不允许应用线程打扰。 因此,进行垃圾回收的时候,应用程序进入暂停状态,即Stop-the-world。对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率。

二、并行收集器:ParNew

并行收集器也是针对新生代的垃圾收集器,基于标记-复制算法,可以看成是Serial的多线程版本。它充分利用了多处理器的优势,采用多个GC线程并行收集,在多处理器环境下工作的并行收集器能够极大地缩短Stop-the-world时间。除了多线程外,其余的行为、特点和Serial收集器一样。

同时,目前只有ParNew能与CMS收集器配合工作,因为CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作。同时因为因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现,所以对于CMS来说,ParNew是必不可少的。

三、吞吐量优先收集器:Parallel Scavenge

首先提示一下:吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间 垃圾收集时间)。如虚拟机总运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

Parallel Scanvenge收集器是针对新生代的垃圾收集器,标记-复制算法,和ParNew类似,但更注重吞吐率。它在ParNew的基础上提供了一组参数,用于配置期望的收集时间或吞吐量,然后以此为目标进行收集。

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

  • 1、-XX:MaxGCPauseMillis

    控制最大垃圾收集停顿时间,大于0的毫秒数,MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降; 因为可能导致垃圾收集发生得更频繁;

  • 2、-XX:GCTimeRatio"

    设置垃圾收集时间占总时间的比率,0<n<100的整数,GCTimeRatio相当于设置吞吐量大小;

四、CMS垃圾收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS收集器仅作用于老年代的收集,是基于标记-清除算法的,它的过程分为4个步骤:

  • 1、初始标记(CMS initial mark)

初始标记阶段主要做两件事:一是遍历GC Root可直达的老年代对象;二是遍历新生代直达的老年代对象。这里的直达是指直接关联到GC Root的一级对象。初始标记阶段是完全STW的,引用程序会暂停。通过-XX:+CMSParallelInitialMarkEnabled参数可以开启该阶段的并行标记,使用多个线程进行标记,减少暂停时间。

  • 2、并发标记(CMS concurrent mark)

并发标记阶段是与应用程序一起执行的,这个阶段主要做两件事:

  • 1、对初始标记中标记的存活对象进行追踪,标记这些对象为可达对象,例如A->B,A在初始标记被识别,而B就是在并发标记阶段被识别。

  • 2、将在并发阶段新生代晋升到老年代的对象、直接在老年代分配的对象以及老年代引用关系发生变化的对象所在的card标记为dirty,避免在重新标记阶段扫描整个老年代。

因为并发标记阶段与程序同时执行,因此会出现之前A->B->C变成A->C的情况,这种情况下C对象时无法在并发标记阶段被标记的。在标记阶段会使用三色标记算法。 三色标记法把 GC 中的对象划分成三种情况: 白色:还没有搜索过的对象(白色对象会被当成垃圾对象) 灰色:正在搜索的对象 黑色:搜索完成的对象(不会当成垃圾对象,不会被 GC)

(从大佬那边拿一张图) 这就是三色标记法。 我们能看出来,三色标记法是有很大的漏洞的,所以,采用了写入屏障的方法。就是说,如果A已经被标记了(已经是黑色的了),那么用户线程改动 A->C的时候,会自动把 C变成灰色,这样,以后就可以搜索 C了。

但是,如果在并发过程中线程又把已经失效的对象生效了,该怎么办?这时候,就要靠重新标记了。

  • 3、重新标记(CMS remark)

初始标记、重新标记这两个步骤需要Stop-the-world。重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。例如这个阶段用户线程产生了新的对象,这个对象是白色的,总不能被GC掉吧。这个阶段就是为了让这些对象重新标记。这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

  • 4、并发清除(CMS concurrent sweep)

采用标记-清除算法进行清除

CMS收集器优点

    并发收集、低停顿。

CMS收集器缺点

CMS收集器对CPU资源非常敏感。
CMS收集器无法处理浮动垃圾(Floating Garbage)。  
(浮动垃圾:被标记为不可回收后又突然不用了,不会有很大问题,可以在下次GC中回收)
CMS收集器是基于标记-清除算法,该算法的缺点都有。

五、G1垃圾收集器

G1是一款面向服务端应用的垃圾收集器。G1具备如下特点:

  • 1、重新定义了堆空间:打破了原有的分代模型,将堆划分为一个个区域,每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。

  • 2、并行于并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。

  • 3、分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的收集效果。

  • 4、空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器;从局部上来看是基于标记-复制算法实现的。

  • 5、可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,

G1和CMS的特征对比:

特征G1CMS
并发和分代
最大化释放堆内存
低延时
吞吐量
压实
可预测性
新生代和老年代的物理隔离

分区

G1重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域

  • G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域
  • G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。在堆的结构设计时,G1打破了以往将收集范围固定在新生代或老年代的模式,G1将堆分成许多相同大小的区域单元,每个单元称为Region。Region是一块地址连续的内存空间。
  • G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。Region的大小是一致的,数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region

(这张图不少文章里都有,我也就厚颜无耻的借来用了)

卡片

G1垃圾收集器在每个分区内部又分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中。

JVM中分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

G1同样可以指定堆的大小。

当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低

目标参数**-XX:GCTimeRatio**即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。

Full GC后,堆尺寸计算结果也会调整堆空间。

分代

G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

G1中新对象始终分配在Eden里面,经过一次垃圾回收的对象就被移动到Survisor区了,经过数次(15次)垃圾回收之后还活着的对象会被移到Old区。

整个年轻代内存会在初始空间**-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)**、需要扩缩容的大小以及分区的记忆集合(RSet)计算得到。

这里有个问题,为什么JVM的分代年龄为什么是15?(十五次后从年轻代进入老年代)

我们在前文中提过 (包罗万象——JAVA中的锁),HotSpot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针),而Mark Word中默认存储对象的HashCode,分代年龄和锁标志位信息。在HotSpot中,有4bit用于存储对象分代年龄

明白了吗,对象的分代年龄占4位,也就是0000,最大值为1111也就是最大为15

本地分配缓冲

JVM的第一章中,我们提过TLAB。应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间。

而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间。对于从Eden/Survivor空间晋升到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。

巨型对象

有时候,对象太大的话,就不能再TLAB中进行分配(比如超过一个分区)。

分配巨型对象时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动(没有意义啊)。

在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。

已记忆集合 Remember Set (RSet)

G1垃圾收集器为了避免出现STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。

但是并非所有的引用都需要记录在RSet中,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,不需要在RSet中记录。只有老年代的分区可能会有RSet记录。同时如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系,引用源自本分区的对象,就不用记录在RSet中。

G1通过维护RSet,得到准确的分区引用信息,而RSet的维护主要来源两个方面:写栅栏(Write Barrier)并发优化线程(Concurrence Refinement Threads)

收集集合 (CSet)

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区

在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中

还是问大佬借一张图给大家看看什么是CSet

G1主要有两种GC模式,Young GC和Mix GC,两种都是需要Stop The World(STW)的

Young GC(新生代垃圾收集)

流程

  • 1、当eden中数据满了,触发Young GC(Young GC 是并行、stop-the-world的)
  • 2、将 eden region 中存活的对象拷贝到survivor,或者直接晋升到Old Region中;将Survivor Regin中存活的对象拷贝到新的Survivor或者晋升old region。
  • 3、为了下一次Young GC,根据扩容大小和分区记忆集合重新调整Eden区和Survivor区大小

Mix GC(混合垃圾收集)

Mix GC中包括了Young GC 和 Old GC 流程

  • 1、初始标记:(stop-the-world)伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。
  • 2、扫描根引用区:因为先进行了一次 Young GC,所以当前年轻代只有 Survivor 区有存活对象,它被称为根引用区。扫描 Survivor 到老年代的引用,该阶段必须在下一次 Young GC 发生前结束。

这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。

  • 3、并发标记:寻找整个堆的存活对象,该阶段可以被 Young GC 中断。

这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程

  • 4、重新标记:stop-the-world,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

这个阶段会回收完全空闲的区块

  • 5、清理:在这个最后阶段,G1 GC 执行统计和 RSet 梳理的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。清理阶段真正回收的内存很少。

Full GC

除了Young GCOld GC,当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:

  • 1、晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。
  • 2、疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。
  • 3、大对象分配失败:分配巨型对象时在老年代无法找到足够的连续分区时候会触发Full GC,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象。

G1垃圾收集器还有不少的参数设置,这里就一起介绍给大家

-XX:+UseG1GC   使用 G1 收集器

-XX:MaxGCPauseMillis=200   指定目标停顿时间,默认值 200 毫秒。
在设置XX:MaxGCPauseMillis 值的时候,不要指定为平均时间,而应该指定为满足 90% 的停顿在这个时间之内。
记住,停顿时间目标是我们的目标,不是每次都一定能满足的。

-XX:InitiatingHeapOccupancyPercent=45  
整堆使用达到这个比例后,触发并发 GC 周期,默认 45%。
如果要降低晋升失败的话,通常可以调整这个数值,使得并发周期提前进行

-XX:NewRatio=n  老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代
不要设置年轻代为固定大小,否则:
                G1 不再需要满足我们的停顿时间目标
                不能再按需扩容或缩容年轻代大小

-XX:SurvivorRatio=n  Eden/Survivor,默认值 8,这个和其他分代收集器是一样的

-XX:MaxTenuringThreshold =n  从年轻代晋升到老年代的年龄阈值,也是和其他分代收集器一样的

-XX:G1HeapRegionSize=n    
每一个 region 的大小,默认值为根据堆大小计算出来,取值 1MB~32MB,这个我们通常指定整堆大小就好了。

-XX:ConcGCThreads=n  并发标记阶段的垃圾收集线程数
增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到:
ConcGCThreads=(ParallelGCThreads + 2) / 4^3

-XX:ParallelGCThreads=n    并行收集时候的垃圾收集线程数

六、总结

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

通过学习JVM的垃圾收集器,不是让我们去真的自己写这些,而是让我们在工作中可以根据不同的情况合理的分配对象的大小、设置合适的参数。通过JVM参数的设置,再加上代码的review,我们每个人都可以写出优质的代码。

结语

JVM最后一章也写完了。写的过程中复习了很多东西,看完这个感觉面试、调优什么的都会有一点点进步。如果大家觉得还看得过去的话,动动小手点个赞什么的就是对我最大的支持了see you!

同时需要思维导图的话,可以联系我,毕竟知识越分享越香!