【JVM 垃圾收集器】— 垃圾收集算法和实现

注:本文是垃圾收集器读书笔记,内容基本来自《深入理解Java虚拟机(第2版)》

上一篇介绍了 GC 时哪些对象需要回收,本篇主要将主要介绍怎么回收,也就是垃圾收集算法。

垃圾收集算法

标记-清除算法

最基础的收集算法,分为“标记”和“清除”两个阶段:首先标记(也就是两次标记的过程)出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。不足之处:

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

复制算法

复制算法的思路是:将可用内存按照容量划分为大小相等的两块,每次只用其中一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点就是浪费了一半的内存。

标记-整理算法

老年代存放的都是存活率较高的对象,如果老年代采用复制算法,将要进行比较多的复制操作,效率会降低,所以老年代一般不使用复制算法。于是,“标记-整理(Mark Compact)算法”应运而生。标记整理算法的标记过程和标记清除算法一样,但后续操作不是直接对可回收对象进行回收,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

分代收集算法(Generation Collection)只是根据对象存活周期的不同将内存划分为几块。目前虚拟机将堆内存分为新生代和老年代,新生代又分为一块内存较大的 Eden 空间和两块内存较小的 Survivor 空间。HotSpot 虚拟机默认 Eden 区和 Survivor 区的内存比例为 8 :1,也就是新生代中可用内存空间为整个新生代的 90%。当回收时,将 Eden 区和 Survivor 区还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用的 Survivor 空间。当 Survivor 空间不够时,需要老年代进行分配担保。整体结构如下(图片来自网络)

image

在新生代中,每次垃圾收集时都会有大批的对象死去,所以采用复制算法。而老年代中对象存活率高,没有额外的空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

HotSpot 算法实现

枚举根节点

在可达性分析中,可作为 GC Roots 的节点主要为全局性引用(常量或类变量)和执行上下文(栈帧中本地变量表)。如果每次查找根节点的时候都来遍历方法区,那时间上的消耗必然不可忽略。

STW

可达性分析要求在分析期间这个执行系统看起来就像冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,如果不满足这点的话,分析结果的准确性就得不到保证。所以 GC 进行时必须停顿所有的 Java 执行线程,也就是 Stop The World(STW)。

OopMap

当执行系统停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局性引用位置,虚拟机有办法直接知道哪些地方存放着对象引用。在 HotSpot 实现中,使用一组称为 OopMap 的数据结构来达到这个目的:在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC 在扫描时就可以直接得知这些信息了。

安全点

借助 OopMap,虚拟机可以快速且准确的完成 GC Roots 枚举,但有另外一个问题:可能导致引用关系变化、或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的成本将会变的很高。

HotSpot 并没有为每条指令生成 OopMap,只是在“特定的位置”记录了这些信息,这些特定位置就称为安全点(safepoint),即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。安全点的选定既不能太少以致于让 GC 等待时间过长,也不能过于频繁以至于过分增大运行时的负荷。所以,安全点选定基本上是以程序“是否具有让程序长时间执行的特征”为标准选定的。“长时间执行”的最明显的特征就是指令序列复用,比如方法调用、循环跳转、异常跳转等。

还有个问题就是如何在 GC 发生时让所有的线程(不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。现在虚拟机基本采用的是主动式中断的思想:

在 GC 发生时,需要中断线程的时候,不直接对线程直接操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合,另外再加上创建对象需要分配内存的地方。

安全区域

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入的 GC 的 Safepoint。但如果线程处于 Sleep 状态或者 Blocked 状态,这时候线程就无法响应 JVM 的中断请求,“走”到安全的地方中断挂起,JVM 显然也不可能等待线程唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域只是在一段代码片段之中,对象引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。

在线程执行到安全区域的时候,就标记自己已经进入到安全区域了。当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开安全区域的信号为止。

总结

垃圾收集算法

  • “标记-清除”算法:最原始的算法,清除后产生大量的内存碎片,不利于后续的对象分配。
  • “复制”算法:没有内存碎片产生,但浪费了一半的内存,用于新生代垃圾收集。
  • “标记-整理的”算法:综合以上两种算法,无明显缺点。
  • “分代收集”算法:根据内存空间对象生命周期不同,采用不同的垃圾收集算法。

算法实现

  • 借助 OopMap,JVM 可以快速且准确的完成 GC Roots 枚举。
  • 程序只有执行到安全点才能停顿下来 GC。
  • 安全点的选定标准是“程序长时间执行”,例如方法调用、循环跳转、异常跳转等。
  • GC 时需要 STW,主动式中断所有线程
  • 安全区域是引用关系不会发生变化的代码片段
  • 对于处于 Sleep 或者 Blocked 状态线程,标识自己已进入安全区域。

参考

深入理解Java虚拟机(第2版)

posted @ 2022-06-06 10:45  Tailife  阅读(43)  评论(0编辑  收藏  举报