JVM-GC 垃圾回收算法 - 标记清除法、复制算法、标记压缩法、分代算法

本贴最后更新于 1720 天前,其中的信息可能已经水流花落

🌹🌹 如果您觉得我的文章对您有帮助的话,记得在 GitHub 上 star 一波哈 🌹🌹

🌹🌹GitHub_awesome-it-blog 🌹🌹


GC 的出现解放了程序员需要手动回收内存的苦恼,但我们也是要了解 GC 的,知己知彼,百战不殆嘛。

常见的 GC 回收算法主要包括引用计数算法、可达性分析法、标记清除算法、复制算法、标记压缩算法、分代算法以及分区算法。

其中,引用计数法和可达性分析法用于判定一个对象是否可以回收,其他的算法为具体执行 GC 时的算法。

今天来聊聊标记清除算法、复制算法、标记压缩算法、分代算法,主要介绍分代算法。

引用计数法和可达性分析法请移步:

1 标记清除算法

标记清除法是现在 GC 算法的基础,目前似乎没有哪个 GC 还在使用这种算法了。因为这种算法会产生大量的内存碎片。

标记清除算法的执行过程分为两个阶段:标记阶段、清除阶段。

  • 标记阶段会通过可达性分析将不可达的对象标记出来。
  • 清除阶段会将标记阶段标记的垃圾对象清除。

标记阶段如图所示:

1.png

Java 堆中,黄色对象为不可达对象,在标记阶段被标记。

下面执行回收算法,执行后如图:

2.png

从上图可以清晰的看出此算法的缺陷,回收后会产生大量不连续的内存空间,即内存碎片。由于 Java 在分配内存时通常是按连续内存分配,那么当碎片空间不足以分配给新的对象时,就造成了内存浪费。

2 复制算法

复制算法会将内存空间分为两块,每次只使用其中一块内存。复制算法同样使用可达性分析法标记除垃圾对象,当 GC 执行时,会将非垃圾对象复制到另一块内存空间中,并且保证内存上的连续性,然后直接清空之前使用的内存空间。然后如此往复。

我们姑且将这两块内存区域称为 from 区和 to 区。

如下图所示,r1 和 r2 作为 GC Root 对象,经过可达性分析后,标记除黄色对象为垃圾对象。

3.png

复制过程如下,GC 会将五个存活对象复制到 to 区,并且保证在 to 区内存空间上的连续性。

4.png

最后,将 from 区中的垃圾对象清除。

5.png

综上述,该算法在存货对象少,垃圾对象多的情况下,非常高效。其好处是不会产生内存碎片,但坏处也是显而易见的,就是直接损失了一半的可用内存。

3 标记压缩算法

标记压缩算法可以解决标记清除算法的内存碎片问题。

其算法可以看作三步:

  • 标记垃圾对象
  • 清除垃圾对象
  • 内存碎片整理

其过程如下:

首先标记除垃圾对象(黄色)

6.png

清除垃圾对象

7.png

内存碎片整理

9.png

4 分代算法

分代算法基于复制算法和标记压缩算法。

首先,标记清除算法、复制算法、标记压缩算法都有各自的缺点,如果单独用其中某一算法来做 GC,会有很大的问题。

例如,标记清除算法会产生大量的内存碎片,复制算法会损失一半的内存,标记压缩算法的碎片整理会造成较大的消耗。

其次,复制算法和标记压缩算法都有各自适合的使用场景。

复制算法适用于每次回收时,存活对象少的场景,这样就会减少复制量。

标记压缩算法适用于回收时,存活对象多的场景,这样就会减少内存碎片的产生,碎片整理的代价就会小很多。

分代算法将内存区域分为两部分:新生代和老年代。

根据新生代和老年代中对象的不同特点,使用不同的 GC 算法。

新生代对象的特点是:创建出来没多久就可以被回收(例如虚拟机栈中创建的对象,方法出栈就会销毁)。也就是说,每次回收时,大部分是垃圾对象,所以新生代适用于复制算法。

老年代的特点是:经过多次 GC,依然存活。也就是说,每次 GC 时,大部分是存活对象,所以老年代适用于标记压缩算法。

新生代分为 eden 区、from 区、to 区,老年代是一整块内存空间,如下所示:

10.png

分代算法执行过程

首先简述一下新生代 GC 的整个过程(老年代 GC 会在下面介绍):新创建的对象总是在 eden 区中出生,当 eden 区满时,会触发 Minor GC,此时会将 eden 区中的存活对象复制到 from 和 to 中一个没有被使用的空间中,假设是 to 区(正在被使用的 from 区中的存活对象也会被复制到 to 区中)。

有几种情况,对象会晋升到老年代:

  • 超大对象会直接进入到老年代(受虚拟机参数-XX:PretenureSizeThreshold 参数影响,默认值 0,即不开启,单位为 Byte,例如:3145728=3M,那么超过 3M 的对象,会直接晋升老年代)
  • 如果 to 区已满,多出来的对象也会直接晋升老年代
  • 复制 15 次(15 岁)后,依然存活的对象,也会进入老年代

此时 eden 区和 from 区都是垃圾对象,可以直接清除。

PS:为什么复制 15 次(15 岁)后,被判定为高龄对象,晋升到老年代呢?
因为每个对象的年龄是存在对象头中的,对象头用 4bit 存储了这个年龄数,而 4bit 最大可以表示十进制的 15,所以是 15 岁。

下面从 JVM 启动开始,描述 GC 的过程。

JVM 刚启动并初始化完成后,几块内存空间分配完毕,此时状态如上图所示。

  • 新创建的对象总是会出生在 eden 区

11.png

  • 当 eden 区满的时候,会触发一次 Minor GC,此时会从 from 和 to 区中找一个没有使用的空间,将 eden 区中还存活的对象复制过去(第一次 from 和 to 都是空的,使用 from 区),被复制的对象的年龄会 +1,并清除 eden 区中的垃圾对象。

12.png

  • 程序继续运行,又在 eden 区产生了新的对象,并产生了一个超大对象,并产生了一个复制后 to 区放不下的对象

13.png

  • 当 eden 区再次被填满时,会再一次触发 Minor GC,这次 GC 会将 eden 区和 from 区中存活的对象复制到 to 区,并且对象年龄 +1,超大对象会直接晋升到老年代,to 区放不下的对象也会直接晋升老年代。

14.png

  • 程序继续运行,假设经过 15 次复制,某一对象依然存活,那么他将直接进入老年代。

15.png

  • 老年代 Full GC

在进行 Minor GC 之前,JVM 还有一步操作,他会检查新生代所有对象使用的总内存是否小于老年代最大剩余连续内存,如果上述条件成立,那么这次 Minor GC 一定是安全的,因为即使所有新生代对象都进入老年代,老年代也不会内存溢出。如果上述条件不成立,JVM 会查看参数 HandlePromotionFailure[1]是否开启(JDK1.6 以后默认开启),如果没开启,说明 Minor GC 后可能会存在老年代内存溢出的风险,会进行一次 Full GC,如果开启,JVM 还会检查历次晋升老年代对象的平均大小是否小于老年代最大连续内存空间,如果成立,会尝试直接进行 Minor GC,如果不成立,老年代执行 Full GC。

16.png

eden 区和 from 区的存活对象会复制到 to 区,超大对象和 to 区容纳不下的对象会直接晋升老年代。当 eden 区满时,触发 Minor GC,此时判断老年代剩余连续内存已经小于新生代所有对象占用内存总和,假设 HandlePromotionFailure 参数开启,JVM 还会继续判断老年代剩余连续内存是否大于历次晋升老年代对象的平均大小,如图所示,目前老年代还剩 2 个空间,如果之前平均每次晋升三个对象到老年代,剩余空间小于平均值,会触发 Full GC。

  • 老年代回收-标记:

17.png

  • 老年代回收-清除:

18.png

  • 老年代回收-碎片整理:

19.png

Minor GC 存在的问题

Minor GC 的问题在于,新生代的对象可能被老年代引用,而这种情况可达性分析是分析不到的,但这种情况的新生代对象是不应该被回收的。

HotSpot 虚拟机提供了一个解决方案:卡表。

这种方法会将老年代内存平均分为很多的卡片,每个卡片都包含一部分对象,然后维护一个卡表,卡表是一个数组,每个元素指向一个卡片,并标识出这个卡片中有没有指向新生代的对象,如果有标识为 1。这样一来,Minor GC 只需要扫描卡表中标识为 1 的卡片即可,大大提升了效率。

卡表如下图所示:

20.png

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 2 关注
  • GC
    17 引用 • 45 回帖
  • 算法
    388 引用 • 254 回帖 • 22 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...