• 【JVM详解&JVM优化】JVM垃圾回收机制


    简介:

    上篇文章介绍了JVM的内存模型,本期捋一捋JVM的垃圾回收机制相关概念。

    上篇文章链接:【JVM详解&JVM优化】JVM内存模型-CSDN博客


    一、垃圾标记算法

    ①. 引用计数法(Reference Counting)

            引用计数算法是一种高效直观的垃圾标识技术,它通过给每个对象分配一个计数器来标识对象是否为垃圾,当有其他对象引用该对象时它的计数器就会加1,当引用失效计数器就会减1,对象若有两个地方引用,它的引用计数器值为2,当计数器为0时该对象就没有被其他对象引用了,因此就可以回收该对象占用的空间了。

            这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死。

            若有对象的引用计数都不为0,永远无法被回收,如果环上的对象有引用其他对象,那么其他对象也永远无法被回收,就会导致内存泄漏。所以java并没有采用这种垃圾判定算法。

    ②. 可达性分析(Reachability Analysis)

            了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Roots的有虚拟机栈中本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。

            可达性分析,顾名思义就是看对象是否可达;通过一系列GC Roots对象作为起点向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当GC Roots到一个对象没有任何引用链相连时,说明此对象是不可达的,代表该对象可进行回收。 


    二、垃圾回收算法

    常用的垃圾回收算法有三种:标记-清除算法、复制算法、标记-整理算法,分代回收。

    ①. 标记-清除(Mark-Sweep)

    标记-清除算法分为两个阶段:

    1. 标记:从GC Roots开始,递归遍历所有可达的对象,并将它们标记是存活的。
    2. 清除:遍历堆内存中所有对象,对于没有被标记为存活对象,释放它占用的内存空间。

    缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。

    ②. 复制算法

    把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。

    缺点:实际可使用的内存空间缩小为原来的一半

    ③. 标记整理算法

            先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存,如下图。

    ④. 分代收集算法

            把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和To Survivor。

            一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此新生代采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;

            老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。

            Java会把堆分成年轻代和老年代(默认比例1:2),年轻代会分为Eden区和2个survivor区(默认比例8:1:1)

            创建一个对象首先会尝试在栈上分配,分配不下才会进入eden区域,但在多线程同时创建对象时,会存在同时竞争空间的问题,这里java提供TLAB(线程本地分配)机制减少了多线程竞争eden资源的情况。当经历过young Gc时,eden区中还存活的对象就会进入survivor区(s1),与其一同进入s1区的还有s0区中存活下来的对象,这三个区都属于年轻代,当年轻代中对象存活年龄超过一定阈值就会进入到老年代,除此之外还有一些其他情况也会进入老年代。

    Minor GC和Full GC

    1.Stop-The-World

    在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。

    2.Minor GC

    新生代的回收称为Minor GC,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短

    3.Full GC

    而Full GC一般是老年代的回收,并伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。

    所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。

    4.CG的流程
    1. 大多数情况下,新的对象都分配在Eden区,当Eden区没有空间进行分配时,将进行一次Minor GC,清理Eden区中的无用对象。清理后,Eden和From Survivor中的存活对象如果小于To Survivor的可用空间则进入To Survivor,否则直接进入老年代);Eden和From Survivor中还存活且能够进入To Survivor的对象年龄增加1岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1),当存活对象的年龄到达一定程度(默认15岁)后进入老年代,可以通过-XX:MaxTenuringThreshold来设置年龄的值。

    2. 当进行了Minor GC后,Eden还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。

    3. 解释:Minor GC之前,如果预测老年代内存不够,就进行Full GC老年代,否则就Minor GC新生代

    4. 大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行Full GC。

    5. 当在java代码里直接调用System.gc()时,会建议JVM进行Full GC,但一般情况下都会触发Full GC,一般不建议使用,尽量让虚拟机自己管理GC的策略。


    三、常见垃圾收集器

    1.垃圾回收器分类

    现在常见的垃圾收集器有如下几种

    • 新生代收集器:Serial、ParNew、Parallel Scavenge

    • 老年代收集器:Serial Old、CMS、Parallel Old

    • 堆内存垃圾收集器:G1

    每种垃圾收集器之间有连线,表示他们可以搭配使用。

    2.新生代:Serial

    Serial是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。

    如下是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。

    (桌面应用)单核服务器。可以用-XX:+UserSerialGC来选择Serial作为新生代收集器。

    3.新生代:ParNew

    ParNew就是一个Serial的多线程版本,其它与Serial并无区别。ParNew在单核CPU环境并不会比Serial收集器达到更好的效果,它默认开启的收集线程数和CPU数量一致,可以通过-XX:ParallelGCThreads来设置垃圾收集的线程数。

    如下是ParNew收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。

    适用场景:多核服务器;与CMS收集器搭配使用。当使用-XX:+UserConcMarkSweepGC来选择CMS作为老年代收集器时,新生代收集器默认就是ParNew,也可以用-XX:+UseParNewGC来指定使用ParNew作为新生代收集器。

    4.新生代:Parallel Scavenge

    Parallel Scavenge也是一款用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量

    可以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimeRatio来精确控制吞吐量。

    如下是Parallel收集器和Parallel Old收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old收集器以多线程,采用标记整理算法进行垃圾收集工作。

    适用场景:注重吞吐量,高效利用CPU,需要高效运算且不需要太多交互。可以使用-XX:+UseParallelGC来选择Parallel Scavenge作为新生代收集器,jdk7、jdk8默认使用Parallel Scavenge作为新生代收集器。

    5.老年代:Serial Old

    Serial Old收集器是Serial的老年代版本,同样是一个单线程收集器,采用标记-整理算法。

    如下图是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图:

    适用场景:Client模式(桌面应用);单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备预案。

    6.老年代:CMS

    CMS收集器是一种以最短回收停顿时间为目标的收集器,以“最短用户线程停顿时间”著称。整个垃圾收集过程分为4个步骤

    • 初始标记:标记一下GC Roots能直接关联到的对象,速度较快

    • 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长

    • 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短

    • 并发清除:用标记-清除算法清除垃圾对象,耗时较长

    整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS收集器垃圾收集可以看做是和用户线程并发执行的。

    CMS收集器也存在一些缺点:

    • 对CPU资源敏感:默认分配的垃圾收集线程数为(CPU数+3)/4,随着CPU数量下降,占用CPU资源越多,吞吐量越小

    • 无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当CMS运行时,预留的内存空间无法满足用户线程的需要,就会出现“Concurrent Mode Failure”的错误,这时将会启动后备预案,临时用Serial Old来重新进行老年代的垃圾收集。

    • 因为CMS是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过-XX:UserCMSCompactAtFullCollection开启碎片整理(默认开启),在CMS进行Full GC之前,会进行内存碎片的整理。还可以用-XX:CMSFullGCsBeforeCompaction设置执行多少次不压缩(不进行碎片整理)的Full GC之后,跟着来一次带压缩(碎片整理)的Full GC。

    适用场景:

            重视服务器响应速度,要求系统停顿时间最短。可以使用-XX:+UserConMarkSweepGC来选择CMS作为老年代收集器。

    7.老年代:Parallel Old

    Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力

    适用场景:与Parallel Scavenge收集器搭配使用;注重吞吐量。jdk7、jdk8默认使用该收集器作为老年代收集器使用 -XX:+UseParallelOldGC来指定使用Paralle Old收集器。

    8.堆收集:G1 收集器

            G1垃圾回收器在jdk11及其以后的版本中都被当作默认的垃圾回收器,这也标志着并行垃圾回收器取得了里程碑式的成功。它基于Region的内部分布形式开创了局部收集的先河;在G1中逻辑上遵循了分代设计思想,但在物理布局上和之前的垃圾回收器有明显的差异,G1不再固定年轻代、老年代的大小,而是把连续的内存划分为多个大小相等的独立区域(Region),每个Region可以根据需要划归为Eden区、Survivor区、Old区、Humongous区(超过Region大小的百分之50就会存放在这里,如果超过百分之百,则放入连续的多个Humongous区);

            通过  -XX:G1HeapRegionSize 可以设置每个Region的大小,它的取值范围1-32Mb,必须为2的N次幂。

    如下图:

    新生代回收流程如下:

    1. 挑出一些空闲区域作为伊甸园区,新创建的对象会存储到伊甸园区(大小会收到新生代区域大小限制)

    2. 当伊甸园不够时:需要垃圾回收时,挑出一个空闲区域作为幸存区,用标记复制算法,复制存活对象到幸存区,然后释放伊甸园区域的内存,这个过程是需要暂停用户线程的。

    3. 直到下一次,伊甸园区域内存不够,然后再次把伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代。然后释放伊甸园和之前幸存区的内存。

    如下图所示,G1收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和CMS收集器前几步的收集过程很相似:

    老年代垃圾回收流程如下:

    1. 初始标记:(与年轻代收集一起活动)标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行

    2. 并发标记:从GC Root开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行

    3. 最终标记(重新标记):修正在并发标记阶段引用户程序执行而产生变动的标记记录 , 为了处理漏标的对象,会停止用户线程进行标记

    4. 筛选回收(混合回收):挑选回收价值较高的老年代,连同伊甸园,幸存区进行一起回收,筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是Garbage First的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程

    适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用-XX:+UseG1GC使用G1收集器,jdk9默认使用G1收集器。

    9.CMS和G1对比

            G1和CMS都是关注停顿时间的垃圾回收器。在早期通常都会拿来进行对比,但目前在高版本jdk中CMS已经被移除了,同时默认使用G1垃圾回收器。相比CMS,G1可以指定最大停顿时间、Region内存布局、按收益动态回收region、算法上也采用标记-整理利于长期运行;但由于要维护记忆集付出的成本要比cms高。

            对于CMS和G1在JDK11以前发生full gc都是串行收集这样整个回收时间就会变得非常长,如果频繁发生full gc,那它们的性能还不如ps+po的组合,而在JDK11开始对G1的full gc进行改进,支持了并行收集,乍一看其实就让从单线程执行标记-整理,变成多线程,每个线程分配一部分region进行标记-整理,这其中涉及到了很多细节,例如:每个线程标记-整理完整后,最后一个region是不满的,并且当前没有可用region,就会把每个线程最后一个不满的region再进行一次压缩以便可以释放出完整的region空间。除此之外在jdk11中还优化了很多细节。


    各种垃圾回收器的搭配组合如图所示:


    三、结尾

            整篇文章介绍了垃圾标识算法以及他们各自适用的场景;对象在内存中是如何分配以及流转的以及传统垃圾回收器介绍。

    🔥如果文章对你有帮助的话,欢迎💗关注、👍点赞、⭐收藏、✍️评论,支持一下小老弟,蟹蟹大咖们~ 

  • 相关阅读:
    神经网络优化算法---学习记录
    【自然语言处理】【检索】GENER:自回归实体检索
    Postman内置动态参数和自定义的动态参数以及断言方式
    [华为认证]路由表和FIB表
    一些好玩的小游戏
    五、程序员指南:数据平面开发套件
    PostgreSQL基本运维
    【pandas小技巧】--修改列的名称
    Pgzero飞机大战
    如何纯注解整合Spring SpringMVC Mybatis
  • 原文地址:https://blog.csdn.net/longshehui/article/details/143427317