【HotSpot、G1】垃圾回收算法和垃圾收集器

大鱼仙人

共 7417字,需浏览 15分钟

 ·

2021-05-23 15:16



前言

上一篇我们说了如何判断一个对象是否存活,这一篇呢,就是接着前面几篇文章来的,我们知道堆中分为年轻代和老年代,有着不同的特点;每个区域有着不同的特点,也就有了多种垃圾回收算法,每种算法也是根据内存情况进行不同程度的优化

就像上一篇提到的打扫屋子,接下来就是需要找到打扫屋子的最合适的方法,比如屋子的哪些东西归为一类,哪些可以扔掉,哪些可以摆放到一起

JVM的算法有很多,大鱼这里只说比较常见的四种:

  • 标记-清楚算法

  • 复制算法

  • 标记-整理算法

  • 分代收集算法

说完了算法,就会介绍下JVM的流行的各种收集器,收集器就是垃圾回收算法的实现咯,就是理论和实现的关系,一起来学习吧

我知道你们都是最爱学习的仔,没错吧(暗示关注,因为大鱼会持续给大家更新技术博文



四种算法


四种算法分别是标记清除、复制、标记整理、分代收集四种算法

标记-清除算法

”标记-清楚“算法,顾名思义,就是分为标记和清楚两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象

这是属于最基础的垃圾回收算法,后续的多种算法都是基于它的改进

但是它有两个主要的缺点的嘞

一个是效率问题,就是标记和清楚的效率都比较低

二是空间问题,标记清楚之后会产生大量的内存碎片,空间碎片多了就会导致空间的利用率不高,你想啊,清楚之后出现很多比较小的空间,当程序在以后的运行过程中需要分配比较大的对象的时候,无法找到足够的连续空间时,便会提前触发另一次的GC动作

复制算法

为了解决效率问题,便出现了复制收集算法,它将可用的内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了的时候,就把活着的对象复制到另一块上,然后将已经使用过的内存空间全部清理掉

这样便使得每次都是对其中一块进行内存回收,内存分配时就不需要考虑内存碎片这样的复杂情况;只需要移动堆顶指针,按照顺序分配内存即可,实现很简单,运行起来也比较高效;不过这种算法的代价是将内存缩小为原来的一半,持续复制生存期比较长的对象会导致效率降低

现在很多商业虚拟机都使用这种手机算法来回收新生代,新生代的98%对象都是朝生夕死的,所以呢,并不需要按照1:1的比例来划分内存空间,而是把内存分成一块较大的Eden区域和两块较小的Survivor空间

每次使用其中的Eden区域和一块Survivor区域,最后复制的时候将Eden和Survivor区域还存活的对象复制到另一块Survivor区域,最后清理掉Eden区域和Survivor区域,只剩下刚刚复制过的Survivor区域,继续使用Eden和这块Survivor区域

这样呢,每次也就只会浪费10%的内存,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor区域不够用的时候,需要依赖其它内存来进行分配担保(一般是老年代担保);复制算法解决了内存碎片的问题,提升了效率

标记-整理算法

上面说的那种适用于新生代,因为新生代大多数都是朝生夕死的,所以也就能很好的利用那种回收算法;但是呢,对于老年代,这种方法就不适用了,因为老年代的大多数对象都是很能活的,所以呢,复制来复制去的很浪费,很不合适

根据老年代的特点,于是就衍生出了标记-整理这种算法,标记过程其实和标记-清楚的算法类似,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

切记:是标记完之后,先清理,再将存活的对象移动到一端

标记-整理算法相较于标记-清楚算法增加了移动,所以成本更高,但是却解决了内存碎片的问题,对于老年代来说,是很合适的回收算法

分代收集算法

分代收集算法,听名字就知道了,肯定就是根据年轻代还是老年代来采用不同的垃圾回收算法了,这样就可以根据各个年代的特点采用最合适的收集算法了

在新生代中,每次垃圾收集时都有大量的对象死去,只有少量会存活,也就是选用复制算法,只需要付出少量存活对象的复制的成本即可;而对于老年代来说,回收率较低,每次存活的对象较多,就必须使用标记-清楚或者标记-整理算法来进行回收咯

就像治理一个大企业,或者治理一群人一样,有的时候不能千篇一律的去采用同样的对待方法,合适的人去用合适的对待方法,这样才能恰到好处,功效最大化利用,我觉得我有当boss的潜力,哈哈哈

快来关注我吧,等我哪天成了boss,我招聘高管的时候,要是我的粉丝,我就优先给你offer(这么大的诱惑,快点动动小手点个关注吧



HotSpot算法实现

JVM虚拟机总的来说是一种标准规范,虚拟机有很多的实现版本,主要作用就是运行Java的类文件的,而HotSpot是虚拟机的一种实现,也是目前使用最广泛的虚拟机

HotSpot,顾名思义,它是基于热点代码探测的,有JIT即时编译功能,能够提供高质量的本地代码,二者区别是一个是标准,一个是实现方式

枚举根节点

从可达性分析中,我们可以根据GC Roots节点找引用链这个操作为例子,可作为GC Roots的节点主要在全局性引用于执行上下文中,现在的很多引用其实属于很重量级了,仅仅方法区可能就会达到数百兆,如果要逐个检查里面的引用,这样必然会消耗很多时间

枚举根节点的过程是必须停顿的,对时间的敏感也体现在GC的停顿上,因为这个分析工作必须在一个能够确保一致性的快照中进行

这里的一致性指的是在整个分析期间整个执行系统看起来像被冻结在某个时间点,不可以出现在过程中对象引用还在不断变化的情况,这样的话,准确性就无法得到保证了

现在的虚拟机都是使用准确实GC,所以当执行系统停顿下来之后,并不需要一个不漏的去检查完所有执行上下文和全局的引用位置,虚拟机应该是知道哪些地方存放着这些对象引用

安全点

HotSpot虚拟机是使用一组OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据全都计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用

有了OopMap,HotSpot可以快速且准确地完成GC Roots的枚举,但是呢,可能导致引用关系变化,或者OopMaori内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,需要大量的额外空间,GC成本也会变高

哎啊,你这绕来绕去,到底咋办呢

 

刚刚还说生成OopMap,现在又说成本太高了,那到底最后如何解决这个问题的呢

是这样的,HotSpot并没有为每个指令都生成OopMap,虚拟机在特定的位置记录这些信息,这些位置被称为安全点

在程序的执行期间并非在所有位置都可以停下来GC,而是只有在达到安全点的时候才能够暂停,所以安全点的选择既不能太少让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负荷

GC发生时如何让所有线程都跑到最近的安全点再停顿下来呢

这里呢,有两种方案可以选择,抢先试中断和主动式中断

抢先试中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就会恢复线程,让它继续跑到安全点上

其实现在几乎没有虚拟机实现采用抢先式来响应GC事件了,我个人猜测是因为这种方法过于暴力,可控制性比较低

主动式中断,则是当GC需要中断线程时,不直接对线程进行操作,仅仅简单地设置一个标志,各个线程的执行会主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地点和安全点是重合的

看似安全点已经解决了停顿位置的问题,但是实际情况却不是,因为上面两种方法考虑的都是程序的执行期间,如果程序不执行的时候呢

所谓的程序不执行就是没有分配CPU时间,比如线程处于Sleep和Blocked状态时,这样线程无法响应JVM的中短请求了,导致无法走到安全点去中断挂起

安全区域

安全区域,大家应该也能想出来了,指的是在一段代码之中,引用关系不会发生变化,在这个区域的任意地方GC都是安全的,也可以把安全区域看成是扩展的安全点

在线程执行到安全区域中的代码时,首先标识自己已经进入了安全区域,这样在这段时间里JVM要发起GC时,就不需要管标识自己为安全区域状态的线程了

在线程要离开安全区域时,它要检查系统是否已经完成了根节点效率,如果完成了,则线程继续执行,否则则必须等待直到收到可以安全离开安全区域的信号为止



垃圾收集器

上面看了垃圾回收算法了,属于方法论,垃圾收集器就是方法的具体实现,收集器其实有很多种,因为Java虚拟机规范对垃圾收集器应该如何实现也并没有任何规定,所以呢,百花齐放

Serial收集器:复制算法

这个垃圾收集器属于元老级别的收集器了,采用的复制算法,单线程收集器,只会使用一个CPU或者一个收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集的时候,必须暂停其它所以的工作线程,直到收集结束为止

这个过程也就是大家都熟知的Stop The World,停止世界

这个工作是虚拟机在后台自动发起和自动完成的,用户不可见,但是会把用户正常工作线程全部停掉,是有感知的,对于很多应用是难以接受的

到现在为止,它依然是虚拟机运行组Client模式下的默认新生代收集器,也有着优于其他收集器的地方:简单而高效,单线程,没有线程切换交互的开销,专心做垃圾收集工作,收集几十兆甚至一两百兆的新生代内存,停顿时间完全可以控制在几十毫秒以内,这点停顿也是可以接受的

Serial Old收集器:标记整理算法

Serial收集器的老年代版本,也同样是一个单线程收集器,使用的标记-整理算法,主要意义是给Client模式下的虚拟机使用

在Server模式下,主要有两大用途:一种是在JDK1.5之前和Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备预案

ParNew收集器:复制算法

新生代的收集器,是多线程版本的Serial收集器,在多核CPU环境下有着比Serial更好的表现;除了多线程工作之外,其余和Serial收集器很相似

它却是很多运行在Server模式下的虚拟机的新生代收集器,比较重要的原因就是除了Serial收集器,目前只有它可以和CMS收集器配合工作(CMS下面介绍)

ParNew Scavenge收集器:复制算法

一个新生代收集器,使用的是复制算法,属于并行的多线程收集器,看上面和上面的ParNew收集器一样啊,特别之处在哪里呢

ParNew Scanvenge收集器最大的特点是它的关注点与其它收集器不同,CMS等收集器的关注点是尽可能的缩短垃圾收集时的用户线程的停顿时间,而Parallel Scavenge收集器的目的则是达到一个可控制的吞吐量

吞吐量:CPU用于运行用户代码的时间和CPU总消耗时间的比值

停顿时间短更适合给用户带来更好的交互体验,而高吞吐量则可以高效率的利用CPU时间,尽快的完成运算任务,主要适合在后台运算而不需要太多交互的任务

该收集器提供了两个精确控制吞吐量的参数:分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数

Parallel Old收集器:标记-整理算法

Parallel Scavenge收集器的老年代版本,是一个多线程的收集器

也正是由于它的出现,Parallel Scavenge也正式有了合适的搭档,正式产生了吞吐量优先的的应用组合,在注重吞吐量和CPU的资源敏感场合,都可以优先考虑这一对搭档

CMS:标记-清楚算法

CMS收集器是以获取最短回收停顿时间为目标,也就是能提供最好的客户体验为准,在一些注重服务的响应速度的应用上很符合,基于标记-清楚算法

CMS回收过程较为复杂,分为四个步骤:

  • 初始标记

  • 并发标记

  • 重新标记

  • 并发清除

其中,前两个步骤初始标记、并发标记是需要Stop The World的,初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快,并发标记是进行GC Roots的Tracing阶段

重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致产生的标记变动,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记时间短

整个过程最耗时的并发标记和并发清楚都是可以与用户线程一起工作的,所以,总体上来看,CMS收集器的内存回收过程和用户线程一起并发执行的

等下,大鱼,我记得标记-清楚算法你上面说了会有产生大量空间碎片的缺点啊,这样的话,不利于接下来给大对象分配,可能出现很多不连续的小空间,这咋办

为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection的开关参数,默认是开启的,用于在CMS收集器顶不住要进行FullGC的时候开启碎片的合并整理过程

不过内存碎片整理的过程是无法并发的,空间碎片问题解决了,但是停顿时间不得不变长;虚拟机提供者还设计了一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC,再来一次压缩的(默认值是0,表示每次进行Full GC都会进行内存碎片整理)

最重要的G1收集器

G1收集器是当今收集器技术发展最前沿的成果之一,所以这个是最需要熟悉的,与其它的收集器相比,G1收集器具备以下优点

  • 并行与并发:高效利用多核CPU来缩短Stop The World的时间

  • 分代收集:采用不同方式来处理堆中的不同特点的对象

  • 空间整合:和CMS的标记-清理是不同的,G1从整体上来看是基于标记-整理算法,从局部Region之间来看是基于复制算法实现的,这两种算法都不会有内存碎片产生

  • 可预测的停顿:这是特点之一,G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段之内,消耗在垃圾收集上的时间不得超过N毫秒

别的收集器的范围都是整个新生代或者是老年代,而G1不再是针对于一部分,而是针对整个堆的收集器,有很大区别;它将Java堆划分为多个大小相等的独立的Region区域,虽然还保留有新生代和老年代的概念,但是新生代和老年代不再是物理隔离的,它们都是属于一部分Region的集合

那它为什么可以建立可预测的停顿时间模型呢?

是这样的,它可以有计划的避免对整个堆进行垃圾回收,G1跟踪各个Region里面的垃圾堆积的价值大小,也就是回收获得的空间大小以及回收所需的时间的经验值,在后台维护一个优先列表,每次根据允许的收集时间,来优先回收价值最大的Region,保证了G1收集器在有限时间内可以获取尽可能高的收集效率

好好说说G1的化整为零的思路

你可能觉得把内存划分为多个Region来管理,理解起来似乎很容易,但是其中的实现细节,却比较麻烦,我们来看一个细节

把Java堆分为多个Region之后,垃圾收集器就能以Region为单位来进行管理了?实际上是Region不可能是独立的,一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以和整个Java堆任意的对象发生引用关系

那这样的话,在做可达性分析的时候,岂不是还是得扫描整个Java堆才能保证准确性?

其实这个问题并非G1才有,而是G1更加突出,在以前的分代收集中,新生代比老年代小,新生代收集频繁,那回收新生代对象时同样有相同的问题,如果回收新生代的时候不得不扫描老年代,那Minor GC的效率肯定下降不少

如何来避免全堆扫描呢

在G1收集器中,Region之间的对象引用和其它收集器新生代与老年代之间的对象引用都是通过Remembered Set来避免全堆扫描的,G1中每个Region都有一个与之对应的Remembered Set,在对Reference类型对象进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中

这样,当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set就可以保证不对全堆进行扫描也不会有遗漏了

G1收集器的运作步骤

  • 初始标记:标记GC Roots能直接关联的对象,需要停顿线程,时间较短

  • 并发标记:从GC Root在堆中进行可达性分析,找出存活的对象,耗时较长,但可与用户线程并发执行

  • 最终标记:修正在并发标记期间因用户线程运作而导致的标记产生变动的那一部分,把这个记录在Remembered Set Logs里面,并且合并到Remembered Set中

  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望来制定回收计划


 


求赞



 

好了,以上就是全部内容了,我是小鱼仙,你们的学习成长小伙伴

                             

我希望有一天能够靠写字养活自己,现在还在磨练,这个时间可能会有很多年,感谢你们做我最初的读者和传播者。请大家相信,只要给我一份爱,我终究会还你们一页情的。

再次感谢大家能够读到这里,我后面会持续的更新技术文章以及一些记录生活的灵魂文章,如果觉得不错的,觉得大鱼同学有点东西的话,求点赞、关注、分享三连

哦,对了!后续的更新文章我都会及时放到这里,欢迎大家点击观看,都是干货文章啊,建议收藏,以后随时翻阅查看

https://github.com/DayuMM2021/Java

 

推荐阅读


● 面试官问我:你确定用了BigDecimal后,计算结果一定精确?

   ● 这个GitHub地址,真香

● 消息队列入门

   ● 搞懂什么是RocketMQ





浏览 28
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报