五千字带你了解 JVM 的常见垃圾回收器

83 阅读23分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

大家好,这里是追風者,今天我们来聊一聊 JVM 常见的一些垃圾回收器。

对象死亡判定

何为对象死亡?对象实例全部在堆中存储,如何判定对象死亡?

当没有任何引用指向该对象时,可以判定为对象死亡了。判定死亡的方式有两种,一为引用计数算法,二为可达性分析算法。

引用计数算法:对象中包含一个被引用计数器,被引用就加一,引用失效就减一。但该方法还有局限性,存在循环依赖时,对象就永远无法判定为死亡。

可达性分析算法:通过 GC Roots 的根对象作为起始点的集合,从这些节点开始通过引用关系进行搜索,构成引用链,未在引用链中的节点就是死亡的节点。Java 中也是采用该方式来判定对象死亡的。

Java 技术体系中,可以成为 GC Roots 的对象包括:

  • 在虚拟机栈中引用的对象。
  • 在方法区中类静态属性引用的对象。
  • 在方法区中常量引用的对象。
  • 在本地方法栈中 JNI 引用的对象。
  • Java 虚拟机内部的引用,如基本类型对应的 Class 对象等。
  • 所有被同步锁持有的对象。
  • 反应 Java 虚拟机内部情况的 JMXBean等。

方法区的回收

  1. 常量的回收。当程序中没有任何字符串引用常量池中的常量时,并且虚拟机中也没有任何引用该常量的地方了,那么这个常量就可以被清理出常量池。

  2. 类型的回收。判断类不再被使用,允许被回收,有三个条件:      - 该类的所有实例都已经被回收,Java 堆中不存在任何该类及其子类的实例。

     - 加载该类的类加载器已经被回收。

     - 该类对应的 java.lang.Class 对象没有在任何地方被引用。

垃圾收集算法

潮流的垃圾回收器采用的都是追踪式垃圾收集,即采用 GC Roots 方式来构建引用链进行垃圾回收。

分代收集

垃圾收集采用分代方式进行垃圾收集,主要有 Minor GC(新生代收集)、Major GC(老年代收集) 和 Full GC(混合收集)。

标记-清除算法

首先标记所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象(反之亦可)。

缺点:执行效率不稳定。内存空间碎片化。

标记-复制算法

此算法采用的是 “半区复制” 的模式。即将可用内存分为两个一样大小的块,每次只是用一个块。当这一块内存用完后,将存活的对象复制到另一块,然后将原来的内存块整块清除即可。

缺点:每次能够利用的内存减少为一半。

针对这种缺点,又提出了 Appel 式回收,将新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存都使用 Eden 和一块 Survivor 空间。垃圾收集时,将 Eden 和已经用过的 Survivor 中仍然存活的对象复制到另一块 Survivor 中,然后直接清理掉 Eden 和 Survivor。 Eden 与 Survivor 的大小比为 8:1。当 Survivor 空间不足以容纳一次 Minor GC 存活下来的对象时,就会借用老年代的内存区域进行内存担保。

标记-整理算法

标记-整理算法,顾名思义,先标记后整理,将存活的对象移动到内存空间的一端,然后直接清除边界外的内存即可。

算法细节

用户线程停止——根节点枚举

在进行 GC Roots 集合查找引用链时,固定可以作为 GC Roots 的节点主要在全局性的引用中。所有收集器在 根节点枚举 时都需要暂停用户线程。HotSpot 使用一组 OopMap 的数据结构来记录引用位置。当类加载完毕时,HotSpot 会把对象内引用位置记录下来。

安全点与安全区域

HotSpot 在安全点处生成 OopMap,当进行垃圾回收时,将用户线程停止在最近的安全点处。

安全点的选取是以 “是否具有让程序长时间执行的特征” 这一标准选取的(指令序列复用,如方法调用、循环跳转、异常跳转等)。这些重复读高的指令才可以产生安全点。

如何让用户线程在垃圾收集时去不都跑到最近的安全点呢?

两种方式:

  1. 抢先式中断:在垃圾收集发生时,系统首先将全部用户线程中断(注意是系统,而非 Java 语言级别的中断),如果发现又用户线程中断的地方不在安全点上,就恢复线程执行直到跑到安全点重新中断。

  2. 主动式中断:当垃圾收集需要中断线程时,设置一个标志位,各个线程执行过程时会不断轮循到标志位,当标志位为真时,线程自己在最近的安全点主动中断即可。

安全区域:指能确保不影响引用关系的某一段代码片段。当用户线程执行到安全区域代码时,会标记自己进入了安全区域,再次期间进行垃圾收集不需要管这些线程。

记忆集与卡表

记忆集是用于记录从非收集区域指向收集区域的指针的集合的结构。

收集器只需要通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可。

记忆集有三种可选精度:

  1. 字长精度。每个记录精确到一个机器字长,该字包含跨代指针。

  2. 对象精度。每个记录精确到一个对象,该对象包含跨代指针。

  3. 卡精度。每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

第三种卡精度就是用一种称为 “卡表” 的方式去实现记忆集。

写屏障

HotSpot 通过写屏障来维护卡表状态的。在引用对象赋值后插入写后屏障来维护卡表状态。

三色标记问题

为了减少垃圾收集时用户线程的停顿,可达性分析阶段一般都可以与用户线程并发执行。

三色标记问题:

白色表示对象未被垃圾收集器访问过。

灰色对象表示部分引用被垃圾收集器访问过。

黑色对象标识所有引用都被垃圾收集器访问过,且不会再次访问。

当且仅当下面两个条件同时满足时,会产生 “对象消失” 问题:

  1. 赋值器插入了一条或多条从黑色到白色对象的新引用。

  2. 赋值器删除了所有从灰色对象到白色对象的直接或间接引用。

解决方案:

  1. 增量更新:破坏了第一个条件,当黑色对象有新的指向白色对象的引用关系时,将该引用记录下来,等并发扫描后,再将这些黑色对象作为根节点重新扫描。

  2. 原始快照:破坏第二个条件,当灰色要删除指向白色对象的引用关系时,就将该删除的引用记录下来,并发扫描后,将灰色对象作为根节点重新扫描。

经典垃圾回收器

Serial 收集器

Serial 收集器是一个单线程收集器。采用标记-复制算法。

它在进行垃圾回收时,必须暂停其他所有工作线程,直到收集工作结束(即将用户线程不可控的停止)。

优点:简单高效,它是所有收集器中额外内存消耗最小的。并且无线程交互的开销,单线程收集效率最高。

ParNew 收集器

ParNew 收集器是一个多线程并行收集收集器(多个线程进行收集)。采用标记-复制算法。

它是多线程版本的 Serial 收集器,只能与 CMS 收集器搭配使用。单线程情况下,不比 Serial 收集器好。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是并行收集的收集器。采用标记-复制算法。

它的目标是达到一个可控制的吞吐量。提供两个参数来控制吞吐量。

-XX:MaxGCPausMillis 参数,控制最大垃圾收集停顿时间。

控制垃圾收集停顿时间是以牺牲新生代大小来实现的,新生代越小,垃圾回收时间越短。但这也会使得垃圾回收频率增大。

-XX:GCTimeRadio 设置吞吐量大小。

-XX:+UseAdaptiveSizePolicy 参数激活后,无需人工指定新生代大小、Eden 区与 Survivor 区的比例等细节参数。 虚拟机慧根据当前系统的运行情况动态调节这些参数。

Serial Old 收集器

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

Parallel Old 收集器

Parallel Old 收集器采用标记-整理算法,是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。

在注重吞吐量或处理器资源稀缺的场合,可以采用 Parallel Scavenge + Parallel Old。

CMS 收集器

CMS (Concurrent Mark Sweep)收集器目标是获取最短回收停顿时间。采用标记-清除算法进行垃圾回收。只能回收老年代和元空间。

标记清除的过程:

  • 初始标记:标记 GC Roots 能关联的对象,需要停止用户线程。

  • 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图的过程。不需要停止用户线程且消耗时间长,可以与垃圾收集线程并发执行。

  • 重新标记:修正并发标记阶段期间,因用户线程继续运行导致变动那一部分对象的标记记录。需要停止用户线程。

  • 并发清除:清理删除标记阶段判断死亡的对象。不需要停止用户线程。

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

优点:并发收集,低停顿。

缺点:

  1. 并发处理,对处理器资源敏感,降低吞吐量。

  2. CMS 无法处理浮动垃圾(CMS 在并发标记和并发清理时,用户线程还会产生新的垃圾,无法当次处理,就会出现浮动垃圾),有可能造成完全 “Stop the world” 的 Full GC 的情况产生。所以 CMS 不会等待老年代全部填满再进行垃圾回收,预留一些空间供并发收集时的程序运行。-XX:CMSInitatingOccu-pancyFraction 参数 CMS 收集触发百分比。
    如果 CMS 运行期间预留空间不足时,就会并发失败,这时候就需要采用备案方法:冻结当前用户线程,临时采用 Serial Old 收集器来重新进行老年代的垃圾回收,停顿时间长。

  3. 由于 CMS 收集器采用标记-清除算法,会导致大量的空间碎片,下次分配对象没有足够连续空间分配时,不得不提前触发 Full GC。

G1 收集器

G1 收集器在局部上是标记-复制算法,整体上是标记-整理算法。开创了收集器面向局部收集的设计思路和基于 Region 的内存形式。G1 收集器的目标是建立 “停顿时间模型” 的收集器。即可以执行一个时间长度,让收集器在该时间段内可以完成收集。

Region 的内存形式

Region 不再以固定大小以及固定数量的粉黛区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 可以根据需要扮演新生代的 Eden 和 Survivor 空间,或老年代空间。

Humongous 区域。它专门用于储存大对象。当大小超过一个 Region 一半的对象即可判定为大对象。而超过整个 Region 容量的大对象,被放在几个连续的 Humongous Region 中。G1 大多数行为把 Humongous Region 作为老年代看待。

G1 实现 “停顿时间模型” 的基础就是以 Region 作为最小的回收单元,每次收集的内存空间都是 Region 的整数倍,可以有计划的避免对整个 Java 堆进行全区垃圾收集。

“时间停顿模型” 实现思想

G1 跟踪各个 Region 里的垃圾堆积的价值大小,即回收所获得的空间大小以及回收所需时间的这种经验值,在后台维护一个优先级列表,每次根据用户设置的允许停顿时间优先处理回收价值收益最大那些的 Region。

G1 的细节问题处理

  1. 跨 Region 引用对象。每个 Region 都维护自己的记忆集,这些记忆集会记录别的 Region 指向自己的指针,标记这些指针分别在哪些卡页范围内。这时候的卡表记录所有的跨 Region 引用。G1 的记录集本质上是一种哈希表,Key 为其他 Region 的起始地址,value 为卡表的索引。

  2. 由于并发标记阶段,收集线程与用户线程并发运行,很容易出现用户线程改变对象的引用关系,从而导致标记结果出现错误。此问题 CMS 采用增量更新算法解决的,而 G1 通过 原始快照(SATB 灰色删除对白色对象的引用记录下来,可达性分析结束后重新遍历灰色节点)算法解决的。

  3. 垃圾收集过程中,新建对象的内存分配问题。G1 为每一个 Region 设计了两个名为 TAMS 的指针,把 Region 中的一部分空间划分出来用于新对象分配,并发回收时,新分配的对象地址必须在这两个指针上。G1 默认这两个地址上的对象被隐藏标记过的,即默认存活,不纳入回收范围。如果内存回收速度赶不上内存分配的速度,G1 就会冻结用户线程,进行 Full GC。

  4. 建立可靠的停顿预测模型。G1 收集器的停顿预测模型是以衰减均值为理论实现的,在垃圾回收过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集中的脏卡数量等各个可测量的步骤花费的陈本,并分析出平均值、标准偏差等统计信息。衰减均值指它会比普通的平均值更容易收到新数据的影响

回收过程

  1. 初始标记:标记 GC Roots 能直接关联到达对象,并修改 TAMS 指针值,让下一阶段用户并发运行时,能够正确在可用的 Region 中分配新对象。需要暂停用户线程。

  2. 并发标记:从 CG Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。该阶段可以与用户线程并发执行。

  3. 最终标记:处理并发标记阶段遗留的少量 SATB 记录。需要停顿用户线程。

  4. 筛选回收:负责更新 Region 统计数据,对各个 Region 的回收价值和成本进行排序,根据用户期望停顿事件制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的 Region 中存活的对象复制到空的 Region 中,最后清理掉旧 Region 的全部空间。此阶段设计存活对象的移动,用户线程需要暂停,但可以多线程进行回收。

CMS 与 G1 对比

相同点:

  • 全部都关注停顿时间的控制。

  • 都采用卡表来处理跨代引用。

    • CMS 的卡表只有一份,只记录老年代对新生代的引用。

    • G1 的卡表实现复杂,维护一个大卡表,然后每个 Region 都维护一个记忆集,哈希结构,key 是指向当前 Region 的 Region 的起始地址,value 是卡表的索引。

  • 都采用了写屏障。

    • CMS 采用写后屏障维护卡表。

    • G1 除了使用写后屏障进行同样的卡表维护操作之外,为实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时指针变化情况。

不同点:

  • CMS 采用标记-清除算法实现。

  • G1 整体采用标记-整理算法实现,局部基于标记-复制算法实现。

  • G1 不会产生内存空间碎片,垃圾收集完成后能提供规整的可用内存。而 CMS 垃圾收集后会出选内存空间碎片。

  • G1 垃圾收集产生的内存占用和程序运行的额外负载,都比 CMS 高。

低实时延垃圾回收器

Shenandoah 收集器

Shenandoah 收集器的目标时实现一种能够在任何内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾回收器。

内存布局

Shenandoah 收集器依然采用 Region 块和 Humongous Region 进行管理,但它已经不使用分代收集理论进行收集,并且放弃了记忆集而采用了 “ 连接矩阵” 来记录跨块指针。

连接矩阵:全局数据结构,记录跨 Region 的引用关系,降低了处理跨代指针的记忆集维护所需消耗,也降低了伪共享问题发生的概率。

连接矩阵可以理解为二维表格,如果 Region 1 中引用了 Region 2 中的对象,则在表格 [1,2] 处打标记即可。

垃圾回收

垃圾回收分为以下阶段:

  1. 初始标记:标记 GC Roots 直接关联的对象。此阶段会停顿用户线程,停顿时间与堆大小无关,只与 GC Roots 数量有关。

  2. 并发标记:遍历对象图,标记处全部可达的对象。时间长度取决于堆中存活对象的数量以及对象图的结构复杂程度。此阶段可以与用户线程并发。

  3. 最终标记:处理剩余的 SATB 扫描,统计出回收价值最高的 Region,将这些 Region 构成一组回收集合。此阶段用户线程也会有一小段停顿。

  4. 并发清理:清理整个区域无任何存活对象的 Region。

  5. 并发回收:把回收集合里面的存活的对象先复制到一份到其它未被使用的 Region 中。该阶段与用户线程并发执行。该阶段通过读屏障搭配转发指针来解决用户线程并行对移动的对象进行读写访问。

  6. 初始引用更新:建立一个线程集合点,确保所有并发回收阶段的收集器线程都已经完成分配给它们的对象移动任务。此阶段会产生非常短暂的用户线程停顿。

  7. 并发引用更新:真正开始进行引用更新操作,时间长度取决于内存中设计的引用量多少。不需要沿着对象图进行搜索,只需要按照内存物理地址的顺序,线性搜索处引用类型,把旧址修改为新值。

  8. 最终引用更新:修正 GC Roots 中的引用。用户线程停顿的时间与 GC Roots 数量有关。

  9. 并发清理:整个回收集中所有的 Region 已无任何存活对象,将回收集中的 Region 内存空间清理即可。

引用更新:并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修改到复制后的新地址,这个操作就称为引用更新。

转发指针

采用转发指针来实现对象移动与用户产需并发执行。

在原有的对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向自己。

转发指针的好处是:当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可以将所有对该对象的访问转发到新的副本上。这样只要旧的对象的内存依然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上工作。

此转发指针必然会出现多线程竞争问题。如果发生并发写入,就必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。

Shenandoah 收集器通过 CAS 操作来保证并发时对象的访问正确性的。

通过读写屏障来更改访问转发指针的引用地址。

ZGC 收集器

ZGC 收集器目标是尽可能对吞吐量印象不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫米以内的低延迟。

ZGC 收集器是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目的的一款垃圾收集器。

内存布局

ZGC 的 Region 具有动态性,能够动态的创建和销毁,以及动态的区分容量大小。

Region 有三种容量:

  • 小型 Region:容量固定为 2MB,用于放置小于 256KB 的小对象。

  • 中型 Region:固定容量为 32MB,用于放置大于等于 256 KB 但是小于 4MB 的对象。

  • 大型 Region:容量不固定,可以动态的变化,但必须为 2MB 的整数倍,用于放置大于 4MB 的大对象。每个大型 Region 只会存放一个大对象。大型 Region 不会重分配。

并发整理算法的实现——染色指针

染色指针:将指针位数一部分用于定位内存,一部分用于标识信息。

通过染色指针,可以看到其引用对象的三色标记状态(黑、白、灰)、是否进入了重分配集、是否只能通过 finalize() 方法才能访问到。

染色指针的优势:

  • 可以使得一旦某个 Region 的存活对象被移走后,这个 Region 立即能释放和重用,而不必等待整个堆中所有指向该 Region 的引用都被修改后才清理。

  • 可以大幅度减少在垃圾收集过程中内存屏障的使用数量。 ZGC 堆对吞吐量的影响相对较低。

  • 可以作为一种可扩展的存储结构来记录更多与对象标记、重定位过程相关的数据。

内存多重映射:将多个不同的虚拟地址映射到同一物理内存地址。

ZGC 采用了多重映射将多个虚拟内存地址映射到同一物理内存地址上,将染色指针中的标志位看作段描述符,那么只要将多个地址段映射到同一物理内存地址空间,就能够实现多重映射了。

垃圾回收

  1. 并发标记:并发标记是遍历对象图做可达性分析的阶段,需要经历类似 Shenandoah 收集器的初始标记、最终标记的短暂停顿。停顿时做的事也类似。与 Shenandoah 不同的是, ZGC 的标记是在指针上进行的,而不是在对象上进行。标记阶段会更新染色指针的 Marked0 和 Marked1 标志位。

  2. 并发预备重分配:该阶段要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集。ZGC 的每次回收都会去扫描所有的 Region,用范围更大的扫描成本省去记忆集的维护成本。ZGC 重分配集只能决定里面的存活对象被重新复制到其他的 Region 中,里面的 Region 会被释放,而并不能说回收行为就只是针对这个集合里面的 Region 进行,因为标记过程是针对全堆的。

  3. 并发重分配:重分配就是要把重分配集中的对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转向关系。使用了染色指针, ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集中,如果用户并发访问了重分配集中的对象,这次访问将会被预置的内存屏障截获,并同时修正更新引用的值,使其直接指向新对象。ZGC 将这种行为称为指针的 “自愈” 功能。

  4. 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。虽然指针能够 “自愈”,重映射的目的是为了不变慢。ZGC 将并发重映射阶段的工作合并到了下一次垃圾回收循环中的并发标记阶段里去完成了。

ZGC 还采用了 NUMA-Aware 内存分配方式,ZGC 收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。

以上是对《深入理解 Java 虚拟机 第三版》这本书中垃圾回收器的一个概括总结,大体上对各种常见垃圾回收器的回收步骤都比较清晰了。

如果文章有任何错误欢迎各位斧正,编程心得就是需要不断的交流才会拓宽视野,感谢各位。