• 【JVM】垃圾回收(GC)详解


    垃圾回收 (Garbage Collection)的区域:

    对于程序计数器、虚拟机栈、本地方法栈而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,当方法结束或者线程结束时,内存就自然跟着线程回收了。对于方法区,是类加载时使用的,类卸载时释放,但是类卸载操作是一个非常低频的操作。所以内存分配和回收主要关注的是堆这个区域。

    Java 堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。

    一. 死亡对象的判断算法

    1. 引用计数算法

    引用计数描述的算法为:

    • 给对象增加一个引用计数器;
    • 每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;
    • 任何时刻计数器为 0 的对象就是不能再被使用的,即对象已"死"。

    引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。

    缺点:

    1. 空间利用率低, 每个对象都得搭配一个计数器,如果对象本身比较大,多出 4 个字节不算啥,但是如果对象本身就很小,比如 4 个字节,再加上计数器,相当于浪费了一半的空间。
    2. 有循环引用问题。

    观察循环引用问题

    class Test {
        public Object instance = null;
        public static void testGC() {
            Test test1 = new Test();
            Test test2 = new Test();
            test1.instance = test2;
            test2.instance = test1;
            test1 = null;
            test2 = null;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    test1、test2 两个对象的引用计数器都不为 0, 但是其实 test1 和 test2 都是无法被外界代码访问到的。

    2. 可达性分析算法

    Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活。

    此算法的核心思想为 :

    • 通过一系列称为"GC Roots"的对象作为起始点,
    • 从这些节点开始向下搜索,搜索走过的路径称之为"引用链",
    • 当一个对象到 GC Roots 没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。

    以下图为例:

    在这里插入图片描述

    对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。

    在Java语言中,可作为GC Roots的对象包含下面几种:

    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;
    2. 方法区中类静态属性引用的对象;
    3. 方法区中常量引用的对象;
    4. 本地方法栈中 JNI(Native方法)引用的对象。

    优点:
    克服了基于引用计数的两个缺点:空间利用率低和循环引用问题。

    缺点:
    系统开销大,遍历一次可能比较慢。

    二. 垃圾回收算法

    将死亡对象标记出来之后就可以进行垃圾回收操作了。

    1. 标记-清除算法

    "标记-清除"算法是最基础的收集算法。
    算法分为"标记"和"清除"两个阶段 :

    • 首先标记出所有需要回收的对象;
    • 在标记完成后统一回收所有被标记的对象。

    "标记-清除"算法的不足主要有两个 :

    1. 效率问题 : 标记和清除这两个过程的效率都不高
    2. 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

    后续的收集算法都是基于这种思路并对其不足加以改进而已。

    2. 复制算法

    "复制"算法是为了解决 “标记-清理” 的内存碎片问题。

    • 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块;
    • 当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面;
    • 然后再把已经使用过的内存区域一次清理掉。

    这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

    复制算法的缺点:

    1. 空间利用率低。
    2. 若要保留的对象比较多,要释放的对象少,此时复制开销就很大。

    3. 标记-整理算法

    复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
    针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

    在这里插入图片描述

    注意:
    标记整理算法也涉及到对象的复制,相比于复制算法,解决了空间利用率低的问题,但是仍然没有解决复制搬运元素开销大的问题。

    虽然标记整理算法同样需要复制, 但是标记整理算法通常需要复制(移动)较少的对象,因为老年代的对象生命周期较长,一些对象在不同的垃圾回收周期中仍然存活,不需要频繁地移动。相比之下,复制算法在每次垃圾回收时都要复制大部分对象。

    4. 分代算法

    针对对象进行分类,(根据年龄划分)一个对象熬过一轮 GC 扫描成为涨了一岁,针对不同的对象,采用不同的方案。

    新生代中使用复制算法,老年代中使用标记整理算法。

    现在的商用虚拟机 (包括 HotSpot 都是采用复制算法来回收新生代)

    • 新生代中98%的对象都是"朝生夕死"的,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的 Eden (伊甸园)空间和两块较小的 Survivor (幸存者)空间,
    • 每次使用 Eden 和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。
    • 当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

    在这里插入图片描述

    当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保。
    HotSpot 默认 Eden 与 Survivor 的大小比例是8 : 1,也就是说 Eden:Survivor From : Survivor To = 8:1:1。
    所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。

    HotSpot实现的复制算法流程如下:

    1. 当 Eden 区满的时候,会触发第一次 Minor gc,把还活着的对象拷贝到 Survivor From 区;
      当 Eden 区再次触发 Minor gc 的时候,会扫描 Eden 区和 From 区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到 To 区域,并将 Eden 和 From 区域清空。
    2. 当后续 Eden 又发生 Minor gc 的时候,会对 Eden 和 To 区域进行垃圾回收,存活的对象复制到 From 区域,并将 Eden 和 To 区域清空。
    3. 部分对象会在 From 和 To 区域中复制来复制去,如此交换 15 次(由 JVM 参数 MaxTenuringThreshold 决定,这个参数默认是 15 ),最终如果还是存活,就存入到老年代。

    基本经验规律:一个对象越老继续存活的可能性越大,(要死早死了),所以老年代的扫描频率远远低于新生代,老年代中使用标记整理的方式进行回收。

    当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

    哪些对象会进入新生代?哪些对象会进入老年代?

    • 新生代:一般创建的对象都会进入新生代;
    • 老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代移动到老年代。(大对象拷贝开销比较大,不适合使用复制算法,直接进入老年代)

    面试题 : 请问了解 Minor GC 和 Major GC 么,这两种 GC 有什么不一样吗?

    1. Minor GC 又称为新生代 GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此 Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
    2. Major GC 又称为 老年代 GC 或者 Full GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的 Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行 Full GC 的策略选择过程), 所以又称为 Full GC。Minor GC通常比Full GC频繁发生,因为新生代中的对象生命周期较短。Major GC的速度一般会比 Minor GC 慢10倍以上。

    三. STW

    STW(Stop-The-World)是垃圾回收中的一种现象,它表示在某些情况下,应用程序的所有线程都会被暂停,以便执行垃圾回收操作。STW 是为了确保垃圾回收器可以安全地执行清理和整理内存的工作,而不会受到应用程序线程的干扰。

    1. 为什么要 STW

    为了确保垃圾回收操作的正确性和安全性。尽管 STW 会导致应用程序线程的暂停,但它是必要的,因为在垃圾回收过程中需要满足以下条件:

    • 一致性:在执行垃圾回收期间,垃圾回收器需要准确地知道哪些对象是活动的,哪些是垃圾的。如果在执行垃圾回收时,应用程序线程仍然可以创建、修改或引用对象,那么垃圾回收器将无法确定对象的状态,可能会导致对象被错误地清理或保留。

    • 引用更新:在垃圾回收期间,垃圾回收器需要更新对象之间的引用关系,以确保引用的正确性。如果应用程序线程在垃圾回收期间继续运行,可能会导致引用关系混乱,垃圾回收器无法正确地更新引用。

    • 内存整理:在垃圾回收中,可能会发生内存整理操作,例如对象的移动,以减少内存碎片或提高内存利用率。如果应用程序线程继续操作内存,可能会导致内存整理的不一致性,从而破坏程序的状态。

    • 垃圾对象清理:STW 允许垃圾回收器安全地删除不再被引用的垃圾对象,而不会涉及到应用程序线程对这些对象的操作。

    2. 什么情况下 STW

    minor GC (新生代垃圾回收)和 major GC (老年代垃圾回收)都会触发 STW。

    四. 垃圾收集器

    垃圾收集器就是内存回收的具体实现。
    垃圾收集器的作用:垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。

    以下这些收集器是 HotSpot 虚拟机随着不同版本推出的重要的垃圾收集器:

    在这里插入图片描述

    上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们之间可以搭配使用。
    所处的区域,表示它是属于新生代收集器还是老年代收集器。在讲具体的收集器之前我们先来明确三个概念:

    注意:这里的并行和并发只是指 GC 时的并行和并发。

    • 并行(Parallel) : 指多条垃圾收集线程并行工作,用户线程仍处于等待状态
    • 并发(Concurrent) : 指用户线程与垃圾收集线程同时执行(不一定并行,可能会交替执行),用户程序继续运行,而垃圾收集程序在另外一个CPU上。
    • 吞吐量:就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。(可以简单理解为单位时间内回收的垃圾数量)

    吞 吐 量 = 运 行 用 户 代 码 时 间 /(运 行 用 户 代 码 时 间 +垃 圾 收 集 时 间)

    例如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

    为什么会有这么多垃圾收集器?

    自从有了 Java 语言就有了垃圾收集器,这么多垃圾收集器其实是历史发展的产物。

    • 最早的垃圾收集器为 Serial,也就是串行执行的垃圾收集器,Serial Old 为串行的老年代收集器,
    • 而随着时间的发展,为了提升更高的性能,于是有了 Serial 多线程版的垃圾收集器 ParNew。
    • 后来人们想要更高吞吐量 的垃圾收集器,吞吐量是指单位时间内成功回收垃圾的数量,于是就有了吞吐量优先的垃圾收集器 Parallel Scavenge(吞吐量优先的新生代垃圾收集器)和 Parallel Old(吞吐量优先的老年代垃圾收集器)。
    • 随着技术的发展后来又有了 CMS(Concurrent Mark Sweep)垃圾收集器,CMS 可以兼顾吞吐量和以获取最短回收停顿时间为目标的收集器,在 JDK 1.8(包含)之前 BS 系统的主流垃圾收集器,
    • 而在 JDK1.8 之后,出现了第一个既不完全属于新生代也不完全属于老年代的垃圾收集器 G1(Garbage First),G1 提供了基本不需要停止程序就可以收集垃圾的技术。

    1. CMS收集器(老年代收集器,并发GC)

    • 特性:
      CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

    CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤:

    • 初始标记(CMS initial mark)
      初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要 (STW)“Stop The World”。
    • 并发标记(CMS concurrent mark)
      并发标记阶段就是进行GC Roots Tracing 的过程。
    • 重新标记(CMS remark)
      重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。
    • 并发清除(CMS concurrent sweep)
      并发清除阶段会清除对象。

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

    优点:
    CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。
    缺点:

    • CMS收集器对CPU资源非常敏感
      在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

    • CMS收集器无法处理浮动垃圾
      CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
      由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
      也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
      要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

    • CMS收集器会产生大量空间碎片
      CMS 是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。

    在这里插入图片描述

    2. G1收集器(唯一一款全区域的垃圾回收器)

    G1(Garbage First)垃圾回收器是用在 heap memory 很大的情况下,把heap划分为很多很多的 region 块,然后并行的对其进行垃圾回收。
    G1垃圾回收器在清除实例所占用的内存空间后,还会做内存压缩。
    G1垃圾回收器回收 region 的时候基本不会STW,而是基于 most garbage 优先回收(整体来看是基于"标记-整理"算法,局部(两个 region 之间)基于"复制"算法) 的策略来对 region 进行垃圾回收的。

    在这里插入图片描述

    一个region有可能属于Eden,Survivor 或者 Tenured 内存区域。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,T 表示属于 Tenured 内存区域。图中空白的表示未使用的内存空间。
    G1垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的H块。这种内存区域主要用于存储大对象-即大小超过一个region 大小的50%的对象。

    年轻代垃圾收集:
    在 G1 垃圾收集器中,年轻代的垃圾回收过程使用复制算法。把 Eden 区和 Survivor 区的对象复制到新的 Survivor 区域。
    如下图:

    在这里插入图片描述

    老年代垃收集

    对于老年代上的垃圾收集,G1垃圾收集器也分为4个阶段,基本跟 CMS 垃圾收集器一样,但略有不同:

    • 初始标记(Initial Mark)阶段
      同CMS垃圾收集器的 Initial Mark 阶段一样,G1 也需要暂停应用程序的执行,它会标记从根对象出发,在根对象的第一层孩子节点中标记所有可达的对象。
      但是 G1 的垃圾收集器的 Initial Mark 阶段是跟 minor gc 一同发生的。也就是说,在 G1 中,你不用像在 CMS 那样,单独暂停应用程序的执行来运行 Initial Mark 阶段,而是在 G1 触发 minor gc 的时候一并将年老代上的 Initial Mark 给做了。
    • 并发标记(Concurrent Mark)阶段
      在这个阶段 G1 做的事情跟 CMS 一样。但 G1 同时还多做了一件事情,就是如果在 Concurrent Mark 阶段中,发现哪些 Tenured region 中对象的存活率很小或者基本没有对象存活,那么 G1 就会在这个阶段将其回收掉,而不用等到后面的clean up 阶段。这也是 Garbage First 名字的由来。同时,在该阶段,G1 会计算每个 region的对象存活率,方便后面的 clean up 阶段使用 。
    • 最终标记( CMS 中的 Remark 阶段)
      在这个阶段 G1 做的事情跟 CMS 一样, 但是采用的算法不同,G1采用一种叫做 SATB(snapshot-at-the-begining) 的算法能够在 Remark 阶段更快的标记可达对象。
    • 筛选回收(Clean up/Copy)阶段
      在G1中,没有 CMS 中对应的 Sweep 阶段。相反 它有一个 Clean up/Copy 阶段,在这个阶段中,G1 会挑选出那些对象存活率低的 region 进行回收,这个阶段也是和 minor gc 一同发生的,如下图所示:

    在这里插入图片描述
    G1(Garbage-First)是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是未来可以替换掉JDK 1.5中发布的 CMS 收集器。 如果你的应用追求低停顿,G1 可以作为选择;如果你的应用追求吞吐量,G1并不带来特别明显的好处。

    总结:一个对象的一生

    一个对象的一生:我是一个普通的 Java 对象,我出生在 Eden 区,在 Eden 区我还看到和我长的很像的小兄弟,我们在 Eden 区中玩了挺长时间。有一天 Eden 区中的人实在是太多了,我就被迫去了 Survivor 区的 “From” 区(S0 区),自从去了 Survivor 区,我就开始漂了,有时候在 Survivor 的 “From” 区,有时候在 Survivor 的 “To” 区(S1 区),居无定所。直到我 18 岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次GC加一岁)然后被回收了。

    在这里插入图片描述

  • 相关阅读:
    心理健康数据集:mental_health_chatbot_dataset
    15.二分法
    面试官:你了解vue的diff算法吗?
    【是C++,不是C艹】 类与对象 | 认识面向对象 | 访问限定符 | 封装 | this指针
    华为无线ac+fit三层组网,每个ap发射不同的业务vlan
    小黑子—MyBatis:第一章
    npm 镜像源切换与设置
    Linux查询日志 打印日志
    队列的基本操作以及C语言实现
    字符函数和字符串函数(上)
  • 原文地址:https://blog.csdn.net/m0_61832361/article/details/133375405