前言:大家好,我是TwosJel,一名21级的本科生(*^▽^*),最近二刷了《深入理解Java虚拟机》,因此想写一篇关于垃圾回收的随笔,于是便有了这篇文章❥(^_-)。
个人主页:TwosJel
个人介绍:目前大二在读,即将大三惹,希望能和大家一起进步!
欢迎大家:欢迎大家阅读我的博文,大家点点关注,一键三连,谢谢大家!
目录
2.3 Parallel Scavenge垃圾回收器(新生代)
1、前置知识
前置知识主要是为了大家更好的理解我们接下来讲解的垃圾回收器。篇幅有点长,希望大家耐心看完,这样能更深入理解整个过程。
温馨提示:这里前置知识有点多,大家如果感到不适,可以先看垃圾回收器,等遇到不懂的前置知识可以跳到相应部分看!
1.1 分代收集理论
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。(不了解垃圾回收算法的UU可以看这:垃圾回收算法)
- 新生代使用: 标记-复制算法
- 老年代使用: 标记 - 清除 或者 标记 - 整理 算法
这个理论实际上是由于“有些对象基本是“朝生夕灭”的,而有些对象能存活很久。因此我们将对象分为了两块区域:“新生代”和“老年代”。新生代大部分都是“朝生夕灭”的,而老年代则大部分能存活很久”。
1.2 如何判断对象已死?
对于对象是否已死,即是否能被回收,有两种比较常见的方法来判断:“引用计数器算法”和“可达性分析算法”。
1.2.1 引用计数器算法
引用计数器算法实际上就是给每个对象添加一个引用计数器,当这个对象被引用的时候,其对应的引用计数器就+1。
此时判断一个对象是否能够回收就很简单了,我们只需判断其引用计数器是否为0,是的话说明没人引用它,那么可以回收;否则说明有人还在引用它,不可回收。
优点:
引用计数器法的优点很显然:简单。因为我们只需要去判断一个计数器是否为0即可,并不会很复杂。
缺点:
有点基础的小伙伴应该也能看出这种方法的一个弊端:“循环引用问题”。如果两个对象相互引用(如下图)那么就算这两个对象已经不会再使用了,明明是可以回收的,但是由于他们的引用计数器永远不会为0,所以无法回收,因此会存在内存问题。
1.2.2 可达性分析算法
可达性分析算法是指从根节点开始扫描,若能扫描到该节点,则该节点存活。扫描结束后,清除没被扫描到的节点。
可达性分析算法(如下图所示,图来自于@pdai)
那么,这里肯定就有一个问题了:“哪些对象算根节点?”
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
- 被同步锁(synchronize关键字)持有的对象
当然,除了上述固定的这些GC Roots对象外,可能还会有一些其他的对象“临时插入”进来与这些固定的GC Roots共同组成完整的GC Roots集合。
“可达性分析算法”是目前主流虚拟机都在使用的算法,它很好的解决了“引用计数器算法”无法解决“循环引用”的问题。
1.3 Stop The World
Stop The World翻译成中文的意思就是:“世界暂停”。在GC过程中的意思就是除了垃圾回收线程,其他的正常线程(即用户线程)停止运行。
至于什么时候会发生Stop The World,以及发生的原因,我们接下来会解释。
1.4 再谈根节点之根节点枚举
上文中我们提到“可达性分析算法”是从根节点开始的,因此如何快速的定位、选取到这些对象是个值得思考的问题。大家肯定会有个疑惑,为什么我们要提如何高效的枚举根节点呢?直接遍历整个方法区不就完事了吗?
是的,确实直接遍历能达到我们的目的,但是这种做法是很低效的!这种就跟我们平时写算法一样,虽然对于同一个题目,“暴力”和“最优解”算法都能解决我们的需求,并且对于“小量”数据来说,二者是差别不大的。
但是!就拿大家平时刷力扣来看,虽然暴力能过一些简单、数量小的例子,但是一旦数据量大的时候,这个时候就会超时了。因此使用一个高效的方法解决“大量数据”的问题是很重要的!
随着时代的发展,我们的Java程序越做越大,对于一些大型项目来说,单方法区可能就有上百兆了,里面的类、常量肯定就更加“数不胜数”了,因此若要逐个遍历,那代价肯定是很大的!而且!最重要的一点是:“枚举根节点的时候会发生Stop The World”!至于为什么会发生Stop The World大家思考一下就可以理解,如果我们在枚举根节点的过程中,正常线程又在运行,那么对象引用肯定也是时时刻刻在变化的,这个时候我们就很难去把握哪些是根节点,哪些不是了。
所以对于根节点枚举来说,目前主流的Java虚拟机在这个过程都是需要“Stop The World”,那么回到我们最初的话题——“如何有效获取对象引用在方法区的位置,从而遍历,获取根节点”。
目前我们主流的Java虚拟机其实都是“准确式垃圾收集”,也就是说虚拟机是有办法直接获取对象在方法区中的位置,而不需要去扫描整个方法区,可以通过某些方法直接获取对象引用的位置。通常虚拟机是借助一个名为“OopMap”的数据结构来达成这个目的的,通常在类加载动作完成的时候,这个“OopMap”就已经构造完成了。
上文我们提到虚拟机通过一个名为“OopMap”的数据结构来达成快速枚举根节点的效果,但是随之而来又有一个问题——对象引用是时时刻刻变化的,可能某一条指令都会导致“OopMap”变化很多,但如果我们为每一条指令都构建一个“OopMap”,那就会占用大量的内存!因为我们自己平时随便写的小程序都可能几百上千行代码,指令肯定就更多了,更别说那些大公司了。
所以我们当前又需要思考一个新的问题——“在哪些位置构建OopMap”。
1.5 安全点
在上文我们提到,在OopMap的帮助下,我们能很快枚举出所有GC Roots,但是如果我们给所有指令都配备一个OopMap,那么所占用的内存是极大的。
实际上,虚拟机也并没有给每条指令都配备一个OopMap,而是在一些特定的位置才配备“OopMap”,这些特定的位置便是“安全点”。
有了安全点的设定,我们可以猜到当程序执行到安全点才能进行垃圾回收(因为根节点枚举这一步需要借助OopMap,而OopMap仅有安全点才有)。因此在安全点的选取上我们不能过于频繁(过于频繁会占用大量内存),也不能过于少(因为过于少,程序当想要垃圾回收时,不能及时走到安全点)。
这里对于安全点的选取,《深入理解Java虚拟机》是这样说的:
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,比如:“方法调用”、“循环跳转”、“异常跳转”都属于指令序列的复用,所以只有具有这些功能的指令才会产生“安全点”。
(题外话:这里笔者强烈建议大家好好读一下《深入理解Java虚拟机》,这本经典书真的值得反复阅读,每次阅读笔者都有新的感悟!)
那么,我们又要思考一个问题了——“如果发生垃圾回收时,一些线程没走到安全点怎么办?”
对于这个情况,目前有两种解决方法:“抢先式中断”和“主动式中断”。
1.5.1 抢先式中断
抢先式中断是指:当程序发生垃圾回收时,先停止所有用户线程,如果某些用户线程没有走到安全点上,那么再恢复这个用户线程的运行,直到其走到安全点上。
然而,现实上目前主流的虚拟机基本没有使用这种方法的。而是使用另一种方法:“主动式中断”。
1.5.2 主动式中断
主动式中断是指:我们不需要对直接的操作用户线程,而是设置一些“标志位”,用户线程不断地去轮询这些标志位,当发生垃圾回收时,轮询的标志位会以“某种方式”告知用户线程发生了垃圾回收,于是用户线程会走到当前位置最近的安全点挂起自己。
这些“标志位”通常设置的地方是安全点和一些需要申请内存的地方。
(这里的某种方式,HotSpot是使用“内存陷阱”的方式来实现的。由于我们程序中需要大量“轮询”过程,因此如果轮询操作不够简洁、高效的话,会导致性能明显下降,HotSpot对于这块的处理相当巧妙,仅用了一行汇编指令就解决了。具体本文不做过多介绍,大家感兴趣的可以去自行查阅相关资料。)
1.6 安全区
上述我们详细的讲解了“安全点”,安全点好像已经解决了我们借助OopMap完成GC Roots的枚举时所遇到的困难,但是大家有没有想过,我们上述提到:“如果发生垃圾回收时,用户线程没走到安全点,会先走到安全点再挂起。”似乎这句话看起来毫无问题,但是大家有没有想过:“如果用户线程执行不了了呢?比如处于blocked状态、sleep状态等,那应该怎么办呢?”因此就需要引入“安全区”来解决这个问题。
(这里安全区可不是大家吃鸡那种安全区!!!)
安全区实际上就是指当程序步入一些代码块后,在这一部分代码块都不会发生对象引用的改变,这一部分代码区变称为:“安全区”。
因此,当发生垃圾回收时,用户正处于安全区中,此时并不需要暂停(我们一开始讲GC Roots的时候就提到,暂停的原因是因为用户线程会引起对象引用的改变,而安全区中并不会,所以不用暂停),用户线程可以继续运行,这里会发生两种情况:
①用户线程准备运行出安全区时,垃圾回收已经完成:那么肯定就没啥影响,可以继续运行。
②用户线程准备运行出安全区时,垃圾回收还没完成:那么此时如果用户线程继续运行,势必会引起对象引用的改变,因此不能继续运行。此时该用户线程也会停止,等待垃圾回收的完成才可继续运行。
1.7 记忆集与卡表
我们说分代收集理论的时候,提到虚拟机会分别收集两个区域,并且基本都是使用“可达性分析算法”来决定对象是否可回收的。这里大家又需要思考一个问题:“比如虚拟机在针对新生代进行回收时,如何解决跨代引用的问题”。
1.7.1 跨代引用
跨代引用实际上是指不同区域(比如老年代与新生代之间,还有我们后面提到G1垃圾回收器的分区之间)之间互相引用。
那么跨代引用会发生什么问题呢?由于我们经常会针对某个区域进行垃圾回收,如果不存在跨代引用的话,我们只需按在这个区域的GC Roots来往下走,即可找出这个区域中,哪些对象要回收,哪些对象不用回收。
但是!由于跨代引用的存在,如果我们只遍历这个区域的GC Roots的话,可能会发生以下情况:当我们针对新生代进行垃圾回收时,此时某个对象虽然在新生代里面没有有效对象(即能从GC Roots根据可达性分析算法走到的对象)引用它,但是在老年代存在这个对象的引用,因此按理来说是不应该回收这个对象的,而如果按我们上述过程说的仅遍历回收区域的GC Roots,这个对象是会被回收的!(如下图所示)
此时如果我们仅遍历新生代的GCRoots,可能就会发生“误回收”。
讲到这,肯定会有哥们儿提到:“哎呀,那我直接遍历所有GC Roots不就得了吗,哪来芥末多事情”。
对!没错!你说得对!确实遍历所有GC Roots可以解决上述问题,但是!这不是就回到我们上文提到的“暴力算法”与“高效算法”的话题了嘛?
这里我们需要提到一个假说:
跨代引用假说:实际上跨代引用相对于同代引用来说,占比非常非常非常小!
因此,辣么多本不用遍历的GC Root,如果遍历,那不是白费时间了嘛!所以虚拟机又引入了一个名为“记忆集”的东西来解决跨代引用问题。
1.7.2 记忆集
记忆集实际上就是一种用于记录非回收区指向回收区域的指针集合的抽象数据结构。
用人话来说记录哪些非回收区域存在回收区域的对象引用。有了这种数据结构,我们解决跨代引用问题时,并不需要去遍历非回收区所有的GC Roots,而是仅需遍历回收区+记忆集中存储的那些区域即可。
这里记忆集的实现根据记录精度主要划分成三种:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),表明该字段包含了跨代指针。
- 对象精度:每个记录精确到一个对象,表明该对象里的字段含有跨代指针。
- 卡表精度:每个记录精确到一块内存区域(比如我们将老年代划分成下图这种一块一块的),表明该区域包含了跨代指针。
其中,卡表精度便是我们所提到的卡表:意思是新生代中记录着哪些老年代块存在跨代引用,那么到时候我们仅需针对这些块进行GC Roots的遍历即可,而不是遍历整个老年代。(如下图)
(这里卡表的具体实现实际上是通过“字节数组”来实现的,本文不做过多介绍,大家感兴趣的可以自行查阅相关资料)
这里大家肯定会感兴趣:我们程序是如何维护这个卡表的呢?实际上,程序是通过“写屏障”来维护卡表的,在每条指令执行后加入一个“写屏障”来维护卡表。(对于读、写屏障不了解的同学可以先去了解再来看这句话更有助于理解)。
1.8 并发可达性分析
我们在说根节点枚举的时候,说了在根节点枚举阶段会发生“Stop The World”以及发生的原因:
如果不STW,那么在运行过程中对象引用时时刻刻在变换,枚举根节点就不太方便。但是如果STW的话会引起性能下降(除了垃圾回收线程正常运行外,其他用户线程均会暂停)。
但是平时我们在运行Java程序的时候,并没有感觉到明显的暂停,这是由于虽然根节点枚举需要STW,但是在OopMap的帮助下,这个暂停时间是极其短暂的;我们知道:虚拟机是通过“可达性分析算法”来判断对象是否可以回收的,在需要判断的对象中,非GC Root对象相对GC Root对象来说肯定是多得多的(这里我们通过上述讲“可达性分析算法”的图来观察一下):
由上图可见:“非GC Root对象相对GC Root对象来说肯定是多得多”。因此如果我们通过“可达性分析算法”来标记这些对象时,也进行STW,那么暂停的时间肯定就会感觉很明显了。但是!为什么我们平时写Java程序没有感觉到明显的暂停呢?这是由于我们在标记非GC Root对象时,并不会发生STW(即用户线程和垃圾回收线程是并发的)。
这里我们需要对垃圾回收这块的“并发”和“并行”做个讲解:
垃圾回收的并发是指用户线程和垃圾回收线程之间的关系,意思是用户线程和垃圾回收线程并发运行,并不会因为垃圾回收线程开始进行GC而导致用户线程暂停。
垃圾回收的并行是指多个垃圾回收线程之间的关系,意思是多个垃圾回收线程协同工作, 共同进行“垃圾回收”。通常这时默认用户线程是阻塞的状态。
可见,垃圾回收的“并发”和“并行”这两个概念和我们平时操作系统中的概念不大一样,大家需要会注意一下。
看到这,我们知道在标记非GC Root对象时,并不会发生STW,此时又有一个问题需要大家思考一下——“既然不STW,也就是说用户线程和垃圾回收线程并发执行,那么在运行过程中对象引用不断改变会引起哪些问题呢?”
这里其实会引起“对象消失”和“垃圾逃逸”两个问题。(哈哈哈这是我自己取得名字,不知道能不能这样称,大家往下看,明白什么意思就好哈哈哈)
1.8.1 三色标记法
在给大家演示“对象消失”和“垃圾逃逸”两个问题前,我们先了解一下“三色标记法”。
三色标记法:主流的垃圾收集器基本上都是基于可达性分析算法来判定对象是否存活的。根据对象是否被垃圾收集器扫描过而用白、灰、黑三种颜色来标记对象的状态的一种方法。
其中三者的意思如下:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始阶段,所有的对象都是白色的,若在分析结束之后对象仍然为白色,则表示这些对象为不可达对象,对这些对象进行回收。
- 灰色:表示对象已经被垃圾收集器访问过,但是这个对象至少存在一个引用(属性)还没有被扫描过。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经被扫描过。黑色表示这个对象扫描之后依然存活,是可达性对象,如果有其他对象引用指向了黑色对象,无须重新扫描,黑色对象不可能不经过灰色对象直接指向某个白色对象。
我们用三个图来讲解三色标记法的流程:
初始阶段(黑方框是GC Roots):
扫描一段时间后:
扫描结束:
相信看完上述过程,大家肯定对“三色标记法”有了初步的了解,接下来我们说在并发标记情况下出现的两种问题。
1.8.2 垃圾逃逸
垃圾逃逸是指本该被标记为白色(被回收)的对象,结果却标成了黑色(存活)。
我们依旧拿上面那个图来说:
比如当我们运行到下图这一步时:
此时,有一个用户线程断开了3号对象和6号对象直接的引用关系,那么在结束扫描时情况如下:
可见,此时并没有其他对象引用6号对象,它理论上应该被标为白色(被回收),可是它却是黑色(存活)。
这样会带来什么影响呢?其实也没很大的影响,就是6号对象能逃过本轮的垃圾回收,当它在下轮垃圾回收被扫描时,还没有人引用它,它就会被回收了。因此这种“垃圾逃逸”的问题其实并没有很大的影响。而接下来我们讲的这种“对象消失”问题则会引起程序异常!
1.8.3 对象消失
对象消失是指本该标记为黑色(存活)的对象却被标记成了白色(被回收)。
这种情况我们一样用图来讲解:
比如运行到下面这步时:
此时一个用户线程将3号对象对6号对象的引用断开,然后将2号对象赋予了一个6号对象的引用:
此时在扫描结束后,三色图如下:
可见,此时在标记结束后,6号对象将会被回收!但是2号对象却还有其引用,那此时如果2号对象通过引用对“已经被回收”的六号对象进行操作,那不就完犊子了吗!
可见,这种情况远远比我们上文说的“垃圾逃逸”那种情况危险的多!
那么应该如何防止这种“对象消失”问题的出现呢?这里其实在1994年时,Wilson在理论上证明了当且仅当如下两个条件同时满足时,才会出现“对象消失”的问题:
- 用户线程删除了所有灰色对象到该白色对象的引用。
- 用户线程新增了一个或多个黑色对象对该白色对象的引用。
我们可以发现上文笔者将“同时满足”这四个字加粗了!对!没错,就是你想那样!也就是说我们仅需破坏其中任意一个条件即可防止出现“对象消失”的问题!
这里通过破坏不同条件而产生了两种解决方案:“增量更新”和“原始快照”。
1.8.4 增量更新
增量更新是指当并发标记过程中,用户线程对某个黑色对象新增了对象引用,那么我们便将这个黑色对象记录下来,当并发标记结束后,我们会发生STW来单独处理这些记录的对象。
换句话说就是当某个黑色对象新增了对象引用后,那么它会变回灰色,等扫描结束后,发生STW,然后从这些灰色对象重新扫描一遍。
(这里如果不发生STW,那么可能我们单独处理这些对象的时候,又有其他对象发生了同样的情况,那么便会无限套娃!)
看懂上述描述就很容易理解“增量更新”是通过破坏条件②(用户线程新增了一个或多个黑色对象对该白色对象的引用)来防止“对象消失”问题的。
1.8.5 原始快照
原始快照是指当用户线程删除了某个灰色对象的白色对象的引用时,那么将这个要删除的引用记录下来,当初步扫描结束后,发生STW,单独处理被记录的对象。
简单来说就是当扫描发生的那一瞬间,整个引用图就被记录下来了,按原引用图来扫描,初步扫描结束后,发生STW,然后从那些删除引用的灰色对象开始重新扫描一遍。
(这里发生STW的原因和上述一样,我们就不做过多描述)
看懂上述描述就很容易理解“增量更新”是通过破坏条件①(用户线程删除了所有灰色对象到该白色对象的引用)来防止“对象消失”问题的。
可见,在并发(标记)可达性分析这一阶段,其实可以理解成两步:①初始标记(不会发生STW) ②重新标记(会发生STW,要不然会套娃)。
---------------------------------------------------------手动分界线---------------------------------------------------------
至此,我们垃圾回收的所有前置知识都已经讲解完毕!这里前置知识有点多,如果大家看完还不太理解不用太担心,可以先看下文常见的垃圾回收器,等遇到我们前置知识时再回来看也行!
2、垃圾回收大致过程
这里我们基于上述讲的前置知识来说一下垃圾回收的大致过程:
①一开始程序正在运行,有多个用户线程正在执行程序。
②此时JVM开始执行垃圾回收(以下过程可以根据垃圾回收器内部实现,分为多垃圾回收线程和单垃圾回收线程),因此需要所有用户线程走到安全点上挂起。
③进行根节点(GC Roots)枚举。
④此时枚举完GC Roots,需要根据“可达性分析算法”配合“三色标记法”往下走,标记其他非GC Roots对象。这里根据垃圾回收器实现机制可以分为两种:
- 并发标记(上文详细讲解了),此时垃圾回收线程和用户线程同时运行,不会发生STW,垃圾回收线程初步标记完所有对象后,需要重新标记(解决并发标记带来的问题),此时会发生STW(防止套娃)。
- 非并发标记,即垃圾回收线程进行垃圾回收时,会发生STW,用户线程均会在安全点上挂起(如果在安全区中则会继续运行,原因我们上文详细讲解了)。
⑤此时已经对所有需要清除与存活的对象进行了标记区分,可以开始内存回收,即垃圾清除。这里使用的垃圾回收算法取决于垃圾回收器使用什么算法。
⑤垃圾回收结束。
注意!我们之前说并发时,是这样解释的:
并发是指用户线程和垃圾回收线程之间的关系,意思是用户线程和垃圾回收线程并发运行,并不会因为垃圾回收线程开始进行GC而导致用户线程暂停。
其实这个并发运行的概念是“相对”来说的, 因为在无论是并发还是非并发,在枚举GC Roots时均需要发生STW,只不过时间很短而已。因此我们说的那些并发回收是指在标记非GC Roots这个“比较久”的过程中,用户线程是否能一起运行。
大家一定要先看完这个再去看垃圾回收器啊!!!因为垃圾回收器画的图都直接一步带过了上述过程,大家看完上述过程才能更好理解内部发生了什么。
3、经典垃圾回收器
上图(以下垃圾回收器部分图来自@pdai)是我们HotSpot中的七个垃圾回收器(这里ZGC等新型垃圾回收器我们就不进行讲解了,大家有兴趣的可以自行查阅其他文章)。
上半部分是指用于回收新生代的垃圾回收器(Serial,ParNew,Parallel Scavenge等),这部分垃圾回收器均采用“标记-复制算法”。
下半部分是指用于回收老年代的垃圾回收器(CMS、Serial Old、Parallel Old等),这部分垃圾回收器采用的是“标记-清除算法”与“标记-整理算法”,这里具体使用哪个取决于垃圾回收器的关注点,如果关注的是“低延迟”,则采用“标记-清除算法”;如果关注的是整体的吞吐量则采用“标记-整理算法”。
(如果对上述三个算法使用场景以及原因不理解的小伙伴,可以看看我之前发的关于“垃圾回收算法”的文章——垃圾回收算法)
其中G1垃圾回收器很特殊,由于它并不采用我们传统的分代收集理论,因此我们后面单独讲它,大家先往下看其他六个。
可以注意到,上图新生代和老年代的垃圾回收器如果有连线,则说明二者之间能搭配使用,否则不能搭配使用。
这里有些名词大家先理解再往下看:
- 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程进行垃圾回收。单线程可以说是串行,多线程则是并行。
2.1 Serial垃圾回收器(新生代)
Serial翻译成中文的意思就是“串行”的意思,从它命名我们可以看出它是单个GC线程执行的垃圾回收器。Serial垃圾回收器采用的是“标记-复制算法”
Serial工作流程如图所示。(这里我们先不关注老年代那边,因为我们单独讲解“Serial”,它是新生代的垃圾回收器,所以大家看前半部分即可)
由上图可见,Serial垃圾回收器在垃圾执行的整个过程中都是STW的,也就是非并发执行垃圾回收,并且是单个GC 线程来执行。
大家对着我们上述说的回收过程模板那一套来看,就是单GC线程执行垃圾回收,过程④走非并发标记那个流程。
2.2 ParNew垃圾回收器(新生代)
它是 Serial 收集器的多线程版本。与Serial收集器回收过程除了是多GC线程外,其他一模一样,我们就不做过多讲解了。ParNew垃圾回收器采用的是“标记-复制算法”
ParNew工作流程如图所示。(这里我们先不关注老年代那边,因为我们单独讲解“Serial”,它是新生代的垃圾回收器,所以大家看前半部分即可)
2.3 Parallel Scavenge垃圾回收器(新生代)
Parallel Scavenge垃圾回收器和ParNew垃圾回收器几乎一模一样,是多线程垃圾回收器。回收过程几乎一模一样,我们也不做过多讲解了。Parallel Scavenge垃圾回收器采用的是“标记-复制算法”
这里大家肯定很疑惑,明明和ParNew几乎一模一样,那这个Parallel Scavenge垃圾回收器存在的意义是什么呢?这里我们讲一下它的不同之处:
其它垃圾回收器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
那么“低延迟”和“高吞吐量”区别是什么?
停顿时间越短(低延迟)就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
这里我们设置最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数后,虚拟机会尽量在最大垃圾收集停顿时间内尽可能的提高“吞吐量”。当然,大家别异想天开:“哎呀,那我把这个停顿时间设置的很小,那我不就吞吐量又高、延迟又低了嘛!”
“鱼和熊掌不可兼得”,Parallel Scavenge收集器保证在我们设置的最大垃圾收集停顿时间内是通过“减少总空间大小”来实现的,大家想想,如果我们设置的最大垃圾收集停顿时间如果过小,那可能我们的总空间就很小,那么发生垃圾回收的频率肯定更高,从而吞吐量相对我们设置一个合理的值来说会更低。(哈哈哈,这里可以知道“哪有什么岁月静好,不过是有人替你负重前行”,停顿时间可是通过剥削空间大小来进行的)
那么大家肯定有个疑惑,怎么才是合理的呢?这里其实设计者们已经帮我们想好这个问题了,可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
2.4 Serial Old垃圾回收器(老年代)
实际上就是Serial垃圾回收器的老年代版本。区别就是Serial垃圾回收器用于新生代的垃圾回收,Serial Old垃圾回收器用于老年代的垃圾回收。Serial Old垃圾回收器采用的是“标记-整理算法”
是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用(这里Concurrent Mode Failure是什么我们后面讲到CMS垃圾回收器的时候会说,大家先往下看)。
2.5 Parallel Old垃圾回收器(老年代)
实际上就是Parallel Scavenge垃圾回收器的老年代版本。区别就是Parallel Scavenge垃圾回收器用于新生代的垃圾回收,Parallel Old垃圾回收器用于老年代的垃圾回收。Parallel Old垃圾回收器采用的是“标记-整理算法”
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
2.6 CMS垃圾回收器(老年代)
CMS(Concurrent Mark Sweep),翻译成中文就是“并发标记清除”,大家从中文就可以看出,CMS垃圾回收器采用的是多GC线程、并发标记、“标记-清除算法”来进行垃圾回收。
分为以下四个流程:
- 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除: 不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
可见,CMS垃圾回收器是一个关注“低延迟”的垃圾回收器,但是这里它有以下缺点:
- 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
2.7 G1垃圾回收器
G1垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。
在上文所有提到的垃圾回收器中,我们能发现一个特点:“它们都是基于分代收集理论实现的”,也就是说每次收集都必须对“整个新生代”或“整个老年代”进行垃圾回收,其实这句话隐藏了一些问题:
- 如果我们不想对整个区域做回收,那么此时就无法实现了。
- 如果我们想打造一个“低延迟”的垃圾回收器,如果我们还是基于“分代收集理论”的话,这始终是会有瓶颈的,只能通过“减小回收区域大小”才能达到更低的延迟,但是“减小回收区域大小”又会导致GC更加频繁,降低了吞吐量。
- 并且如果我们想根据一些“停顿预测模型”,预测哪些区域能回收的垃圾比较多(回收价值比较高),那么想优先回收这些区域也很难完成。
根据我们上述提出以“分代收集理论”打造的垃圾回收器肯定无法完成以上三个需求,那么我们如何才能解决我们上述提到的问题呢?
“只要思想不滑坡,方法总比困难多”。
既然“分代收集理论”无法解决,那我们就换个思路,不用传统的“分代收集理论”,而是尝试思考另一种将垃圾“分区”的方法不就行了吗?
在G1收集器出现之前,其他所有收集器(包括CMS在内),垃圾收集的范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),要么就是整个Java堆(Full GC)。直到G1收集器的出现,才打破了这个“牢笼”。G1收集器跳出了上述“牢笼”,它可以面向堆内存任何部分来进行垃圾回收,也就是说G1收集器将堆内存分成了一块一块的Region(分区),回收的标准也不再是根据“新生代”或“老年代”,而是“基于一些停顿预测模型,分析出哪块区域垃圾最多,回收价值最高,那么就回收哪块区域”。(G1垃圾回收器将Java堆分成如下图所示)
可见,通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
遇到那种大对象,一个Region很难存储下甚至一个Region都放不去怎么办?
Region中还有一类特殊的“Humongous”区域,专门用来存储大对象,G1收集器认为一个对象超过了一个Region存储空间的一半,就被认为是大对象,此时这类对象会被存入“Humongous”区域。
而对于哪些超过一个Region存储空间的“超级大对象”,通常会使用N个连续的“Humongous”区域来存储它们。
G1收集器使用的是什么算法?
G1收集器从整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“标记-复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
这里整体和局部我们举个例子:回收两个Region,清除完这两个Region中的垃圾后,会将这两个Region存活的对象复制到另一个空Region中(从这三个Region局部来看是“标记-复制算法”),但如果我们从Java堆来看,将整个Java堆看成多块Region,就是两块整理到一块去了(“标记-整理算法”)。
G1收集器垃圾回收过程如下图:
G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记:实际上就是根节点(GC Roots)枚举,因此这个过程如上图所示是会发生“Stop The World”的。
- 并发标记:就是垃圾回收线程从GC Roots开始往下标记与其关联的对象时,用户线程不用阻塞,可以继续干自己的事情。(我们上文说了,这里就不详细介绍了)
- 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,防止出现“对象消失”问题(上文也详细介绍过,我们就不说了)。这阶段需要停顿线程,但是多个垃圾回收线程并行执行。
- 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
这里实际上G1还有很多问题我们没提到,比如①将Java堆分成多个Region后,Region之间的“跨Region引用”问题如何解决?②并发标记过程中用户线程对对象引用的改变如何解决?③如何建立“可预测停顿模型”?我们下文会粗略的介绍一下这几个问题(如果想详细了解的小伙伴可以全《深入理解Java虚拟机》,这本书真的真的真的很好!)
Region之间的“跨Region引用”问题
大家回忆一下,我们之前讲跨代引用问题是如何解决的?对,是采用一个“记忆集”来记录老年代中哪些块存在跨代引用。
那如今G1收集器不再有“新生代”和“老年代”了,而是分成好多个Region,那应该如何解决呢?
其实这里也是通过“记忆集”来解决的。“分代收集理论”中我们主要解决的是“老年代”对“新生代”的跨代引用,因此在“新生代”记录了一个“记忆集”来存储“老年代”的存在跨代引用的区域。
那么同理,此时我们要存储其它Region对当前Region的跨代引用,那么我们不就在每个“Region”中维护一个“记忆集”来存储哪些“Region”存在对当前Region的跨代引用不就行了吗!
并发标记过程中用户线程对对象引用改变如何解决?
这里实际上我们上文“前置知识”已经讲过了,是通过“增量更新”或“原始快照”解决的。其中CMS收集器使用的是“增量更新”,而G1收集器使用的是“原始快照”。
如何建立“可预测停顿模型”?
G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集中的赃卡数量等可测量的步骤所需的成本,然后根据这些值预测哪些Region组成的“回收集”能获取最大的“收益”。
2.8 CMS和G1对比
由于CMS和G1很像,因此面试中常常会将二者进行对比,下面我们来讲解一下二者的区别。
①理论基础不同:
- CMS是基于分代收集理论的,将Java堆分成了“新生代和老年代”。
- G1则是将Java堆分成了一个个Region。
②回收使用算法不同:
- CMS为了最求低延迟,使用的是“标记-清除算法”,当内存碎片过多时,使用一次“标记-整理算法”提高“吞吐量”。
- G1收集器从整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“标记-复制”算法实现的。
因此从内存碎片上来看,G1不会在运行期间产生内存碎片,而CMS会。G1这种不产生内存碎片的特点更有利于程序的长时间稳定运行,在大对象分配时不容易因为无法分配足够的空间而导致“Full GC”的发生。
③处理并发标记使用方法不同:
- CMS收集器使用的是“增量更新”算法。
- G1收集器使用的是“原始快照”算法。
“原始快照”算法相较于“增量更新”算法来说,在“并发标记”和“重新标记”阶段消耗时间更少。因此在“最终标记”阶段,G1能避免CMS那种“长时间”等待的情况。
④从维护记忆集来看:
虽然二者均维护记忆集,但是G1要维护多个记忆集,因此“额外负载”更大一些。维护时,CMS是直接通过“写后屏障”来同步维护的,而由于G1维护多个记忆集复杂的多,因此除了使用“写后屏障”,为了实现“原始快照”算法,还是用了“写前屏障”来维护,由于更加繁琐,因此是通过存入一个类似“消息队列”的结构中异步进行维护。
呼!终于完结了!垃圾回收这块其实还有很多细节我们没说(实在太多了细节,要说的话十万字都说不完),大家感兴趣可以自行查阅资料或者仔细阅读《深入理解Java虚拟机》!
好的!本文到此就结束啦!很高兴你看到这里!加油!一起冲冲冲!