垃圾回收概述(上)

Demon.Lee 2023年11月05日 637次浏览

本来是梳理一下 Java 21 的新特性,但写到 ZGC 时,发现自己对 GC 的整个大脉络已经比较模糊了,于是便有了这篇文章。

回收算法

当堆中一个对象不再被其他对象引用时,就说明该对象可以被回收了。为此,演化出了两种经典的算法:引用计数和可达性分析。

引用计数算法

每个对象在初始化时,都会维护一个引用计数器(比如在对象的头上定义一个字段)。每当对象被引用时,引用计数器就会加 1,相反,引用失效时则减 1。如果引用计数器为 0,说明该对象可以被回收。目前 Python 和 Swift 中的 GC 使用的便是引用计数法。

对象在赋值时,会被虚拟机拦截,此时会计算被引用的次数。所以,该算法的优点也就很明显:1)当某个对象的引用次数为 0 时,马上可以被感知,于是该对象立即就被释放了;2)这个过程无需额外的 GC 线程参与,没有 STW,因而其暂停时间为 0。

与优势相比,该算法的弊端也很明显:

  • 每次对象的赋值操作,都要进行额外的计算,这在多线程的场景下,可能需要加锁,开销大;
  • 存在循环依赖问题,需要其他方案配合解决,比如三色标记算法;
  • 存在链式回收的情况,此时耗时可能会比较长。链式回收指的是,多个对象以链表的形式挂在一起,并且引用计数都是 1,当链表头被回收时,整个链表都要被回收。
class A:
    def __init__(self, b, value):
        self.b = b
        self.v = value

class B:
    def __init__(self, a, value):
        self.a = a
        self.v = value

a = A(None, 'aaa')
a.b = B(a,'bbb')
...
...

Python 与引用计数算法

如果把引用计数法回收对象的过程当作同步调用的话,那么可达性分析算法就是异步处理。

可达性分析算法

目前来说,引用计数算法已经不再是工业和学术界研究的重点,而可达性分析算法则依然活跃,JVM 主流的垃圾回收器采用的就是它。

可达性分析算法会选取一些入口,从这些入口出发,收集能被它们访问的对象,并将其加入集合,而没被探测到的对象则是需要回收的垃圾对象(或者反过来)。该过程又被称为标记(Mark),而这些入口也有一个名字,叫做 GC Roots。GC Roots 可以简单理解为从堆外到堆内的引用,包括但不限于:Java 方法栈帧中的局部变量引用,静态变量(全局变量),JNI handles,synchronized 等。

随着系统的不断扩展,各个对象之间的引用关系会变得十分复杂,最终就演变成了一张图。而从图中找到存活的对象,主要有两种方式:深度优先遍历(DFS)和广度优先遍历(BFS)。当我们找到这些存活的对象后,接下来要做的就是如何回收了,主流的方式有三种:

1、清除(sweep):标记出存活对象后,会将垃圾对象占用的内存标记为空闲,并将其记录到一个空闲列表(free list)中。当需要新建对象时,就从这个 free list 中找到一块大小合适的内存,并将其划分给新建的对象。很显然,这个空闲列表中的一个个内存块是不连续的,因而就会出现内存碎片。比如,我需要一个 512 KB 的空间,但目前拥有的三个空闲内存块分别是 256 KB,128KB,128 KB,这就无法满足。sweep 方式的另一个缺点是内存分配效率低,一个连续大内存块可以通过指针加法快速完成分配,但列表不行,需要先遍历,然后比较才能找到合适的空闲内存块。

2、整理(compact):又被称为压缩。简单来说,就是将存活对象全部移动到堆空间的起始位置,那么剩余的就是连续可用的空闲空间。显然,上面 sweep 方式中提到的缺点没有了,而付出的代价则是对象移动的开销。

3、复制(copy):最后一种则是将堆内存分成两份,一部分叫 from 区域,另一部分叫 to 区域,分配对象都在 from 区域中做,那 to 区域呢?当 from 区域内存满了之后,就将已经标记存活的对象复制到 to 区域,然后对调两个区域的名称,即 from 变成 to,to 变成 from。接着,继续在新的 from 区域重复前面的分配过程,循环往复。可以看到,这种方式的缺点也很明显,就是内存空间的利用率不高,总有一部分内存空间不能被用于对象分配。

JVM 中的 Scavenge 算法就是一种改良后的 copy 算法,它将 from 区域称为 Eden,to 区域则被划分为 S0 和 S1 两部分,S 代表 Survivor。新建对象时,从 Eden 区中分配,当 Eden 区域不够了,则发起 GC,把存活对象复制到 S0 区域,此时 Eden 区域就又可以分配新对象了,待到下一轮 GC 时,对调 S0 和 S1,同时回收 Eden 和 S1 两个区域,将存活对象复制到 S0。

可以看到,改良后的 copy 算法,对象分配效率高,内存空间利用率也有一定的改进,至于回收的效率,则需要看具体的代码。如果存活对象多,那回收的效率就低,如果存活的对象少,那回收的效率就高。JVM 中使用 -XX:SurvivorRatio 来配置 Eden 和 Survivor 两部分的占比。默认情况下,该值为 8,即 Eden 区域占总空间的 80%,S0 和 S1 则各占 10%,即 8:1:1。

分代回收

我们日常生活中,经常会听到一个二八法则:

帕累托法则(英语:Pareto principle,或称80/20法则、关键少数法则、八二法则(二八法则)、巴莱多定律)指出,约仅有20%的因素影响80%的结果。也就是说:所有变因中,最重要的仅有20%,虽然剩余的80%占了多数,影响的幅度却远低于“关键的少数”。

那在计算机程序中,是否也存在类似的情况?即程序运行过程中,大部分对象的生命周期都很短,只有少数对象存活时间长。实践证明,该规律也适用于软件开发。于是,计算机科学家们便提出了分代回收的思想。

在 JVM 中,堆空间会被划分为新生代和老年代,所有对象的分配和回收都在新生代维护,比如上面介绍的 Scavenge 算法。那老年代在什么时候起作用?当一些对象在垃圾回收多次后依然存在,则将其移入老年代。仔细思考一下,就会发现,此处有几个问题需要回答:1)如何知道对象的年龄;2)如何维护跨代引用;3)老年代如何回收垃圾对象。

对象年龄

我们在对象头上增加一个 age 字段,专门用来存储其活过几次垃圾回收。每次垃圾回收,若对象还活着,则年龄加一。默认情况下该配置(-XX:MaxTenuringThreshold)为 15,到达该阈值后,则晋升(promote)到老年代。当然,这不是唯一选择,如果单个 Survivor 区已经被占用了 50%,那么高龄的对象(但没有达到阈值)也会晋升。

跨代引用

在上图的基础上,当对象 C 晋升到老年代之后,如果 A 引用了 C,而 C 又引用了 B,就产生了跨代引用。显然,当老年代的 C 或新生代的 B 进行 GC 时,就得考虑这种跨代关系。

先说新生代的对象 B,如果没有考虑老年代的跨代引用,B 可能会被无辜杀掉,这显然不行。为此,在对新生代进行回收时,需要增加新的 GC Roots,即老年代到新生代的引用。难道要对老年代进行一次全量扫描?

可以进行全量扫描,但这种做法的成本太高,分代也失去了它的意义。因为分代的目标就是用不同回收算法来处理存活年龄不同的对象。新生代的大多数对象存活时间短(即活跃对象少,垃圾对象多),使用 copy 算法,回收效率高;而老年代大多数对象存活时间长,每次回收对象的数量少(即活跃对象多,垃圾对象少),使用 sweep 算法则是首选。

为了解决老年代全量扫描的问题,我们必须将那些引用了新生代的对象标记并存储起来,这个集合又被称为记录集(Remember Set, RS)。这个工作是在对象引用发生变化时处理的(比如对对象的属性进行赋值),该手段即所谓的写屏障(write barrier)。当被引用对象在新生代,引用者在老年代,就会触发记录操作。当然,记录对象到 RS 之前,先要判断 RS 中是否已经记录过了,以避免无效的重复操作。

在进行 Minor GC 时,这个 RS 中的对象将会加入 GC Roots。随着需要记录的对象越来越多,RS 也越来越大,每次对老年代做 GC,都需要维护 RS,不仅复杂而且效率也不高。为此,科学家们又引入一项叫卡表(Card Table)的技术。卡表借助于位图(BitMap)的思想,把老年代堆空间划分成一张张卡,每张卡占用 1 byte,其映射的堆空间为 512 bytes,即压缩比为 512:1。当这 512 bytes 中存在指向新生代的引用时(比如上面的对象 C 引用了 对象 B),那么就将该张卡片置为 1。

这里说的是老年代引用了新生代,那肯定会存在新生代引用老年代的情况,也要记录一个 RS 吗?答案是不需要的。这是因为新生代的 GC 比较频繁,当老年代回收时,就顺道也把新生代也全量扫描一次,直接找出对应的引用类即可。

老年代回收

因为老年代主要考虑 sweep 算法,当使用上面的方式将活跃对象标记出来后,剩下的就是垃圾对象了。回收的方式说起来也比较简单,就是从头到尾扫描整个老年代空间,将未标记的对象加入空闲内存链表即可。这个与 glibc 中的 malloc 函数很像,malloc 管理内存时,会先分配一块较大的内存,然后将其拆分成一个个小块用于分配。为了分配与回收方便,前辈们便用一个链表将这些空闲内存块串起来,这个链表就是 free list。

Hotspot VM 与 malloc 在回收细节上也有一些不同。当一块空闲内存分配出去后,如果剩余空间大于 24 bytes,Hotspot 会将其作为新的空闲块,再次加入到空闲链表中;如果剩余空间小于 24 bytes,则填充一些无效值而直接作为内存碎片。而 malloc 的做法是,将一块大的内存块拆分成不同组合的小内存块(比如 4 bytes,8 bytes,16 bytes,32 bytes 等),并将它们维护到不同的空闲链表中(这里不展开)。

Hotspot 中经典的 CMS(Concurrent Mark Sweep,并发标记清除) 算法就是用于老年代垃圾回收的,既然是并发,是不是就不存在 STW(Stop The World)呢?答案是否定的。这里先解释一下 STW。简单来说就是垃圾回收时,GC 线程需要标记和清除垃圾对象,但业务线程可能会同时修改对象之间的引用关系,如此便可能会出现错乱,比如把一个活跃对象给误杀了。为此,在 GC 线程工作的某些步骤中,需要业务线程暂停工作,此时整个 Java 进程无法响应任何请求,就像“世界停止”了一般。

那么,CMS 中的并发是在哪个环节起作用的?下图是 CMS 回收的主要步骤:

从图上可以看到,CMS 对老年代空间的回收,划分了 7 个阶段,其中 S1 和 S5 是需要停止业务线程的,而其他阶段则不需要。需要注意的是,S1 中出现了并行,它和并发有不同的含义。并发指 GC 线程和业务线程同时工作,而并行则是指多个 GC 线程同时工作

S1 阶段之所以需要 STW,是因为老年代根对象(GC Roots)包含从栈上出发的引用,这些数据比较敏感,不易并发控制。

S2 - S4 都是在标记存活对象,为了实现并发标记,业内主要使用三色标记法(这里不展开),而 CMS 则在三色标记法的基础上做了一些改动:在标记过程中,如果业务线程同时又修改了对象之间的引用关系,比如 A 对象引用了 B 对象,那么写屏障在处理时,直接将 A 对象所在 Card 置位。当一轮标记结束后,可能会有一些 Card 被置位,此时垃圾回收器会发起新一轮的并发标记:扫描这些脏 Card 所对应的对象,处理结束后将脏 Card 清零。这就是预清理的来由。

有的文章将这三个阶段统一称为并发标记,这样一来,我们这里划分的 7 个阶段,也就变成了 4 个。

如果每一轮标记结束后,都有脏 Card 怎么办?那没办法,只能在到达一定次数后,启动 STW,把业务线程暂停,而后对标记阶段进行收尾。这就是重标记 S5 阶段的工作,此时就不允许 Card 再被置位了。

小结

最后,我们来总结一下 Java 9 之前的垃圾回收器,周志明老师在《深入理解Java虚拟机(第3版)》引用了一个图,笔者将其重绘在此:

可以看到,G1 是一个全局回收器,笔者将在下一篇文章中进行梳理,而其他的则都是分代回收器,即要么作用于新生代,要么作用于老年代。回收器之间两两连接,则表示它们之间可以组合使用,只不过这些组合随着 JDK 版本的升级会不断变化,甚至消失。笔者将上图使用表格重新整理了一遍,如下所示,供大家学习参考。

从图中可以看到,新生代 GC 主要使用标记-复制算法,而老年代则主要使用标记-整理或标记-清除算法。需要说明的是,标记-清除算法是最早出现的回收算法,算是最基础版本,而标记-复制和标记-整理都可以看做是对标记-清除算法的改进版。另外,与 -XX:ParallelGCThreads 相关的,还有一个 -XX:ConcGCThreads 配置项,二者之间的关系,可以参考这篇文章了解。

附 Java 7 及之后主要版本默认使用的 GC:

# 对于启动好的 Java 进程,也可以使用 jcmd <pid> VM.flgs 查看
$ java -XX:+PrintCommandLineFlags -version

// Java 7
java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:+PrintCommandLineFlags -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.7.0_80"
Java(TM) SE Runtime Environment (build 1.7.0_80-b15)
Java HotSpot(TM) 64-Bit Server VM (build 24.80-b11, mixed mode)

// Java 8
-XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
openjdk version "1.8.0_382"
OpenJDK Runtime Environment (Zulu 8.72.0.17-CA-macos-aarch64) (build 1.8.0_382-b05)
OpenJDK 64-Bit Server VM (Zulu 8.72.0.17-CA-macos-aarch64) (build 25.382-b05, mixed mode)

// Java 11
-XX:G1ConcRefinementThreads=9 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version "11.0.19" 2023-04-18 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.19+9-LTS-224)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.19+9-LTS-224, mixed mode)

// Java 17
-XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=9 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8589934592 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version "17.0.8" 2023-07-18 LTS
Java(TM) SE Runtime Environment (build 17.0.8+9-LTS-211)
Java HotSpot(TM) 64-Bit Server VM (build 17.0.8+9-LTS-211, mixed mode, sharing)

// Java 21
-XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=9 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8589934592 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedOops -XX:+UseG1GC
java version "21" 2023-09-19 LTS
Java(TM) SE Runtime Environment (build 21+35-LTS-2513)
Java HotSpot(TM) 64-Bit Server VM (build 21+35-LTS-2513, mixed mode, sharing)