理解内存回收——JDK 默认 GC 如何工作 (QFY译版)

Understanding Garbage Collectors - How the default garbage collectors work
by Christine H. Flood
November 21, 2019
原文: https://blogs.oracle.com/javamagazine/understanding-garbage-collectors

垃圾回收(或GC)是一种自动回收无需再次使用内存对象的方法。与手动分配及销毁对象的其他语言不同,Java采用GC所以程序员不需要手工检查每个对象是否还需要。幕后无所不知的GC管家悄悄地丢弃不再有用的对象,并整理剩下的东西。This decluttering leads to an efficient program.

什么是GC?

JVM将程序数据组织成对象。对象在称为堆的托管地址空间中包含字段(数据)。( Objects contain fields (data) in a managed address space called a heap.)想象一下下面的Java类,它表示一个简单的二叉树节点。

class TreeNode {
    public TreeNode left, right;
    public int data;
    TreeNode(TreeNode l,  TreeNode r, int d) {
        left = l; right = r; data = d;
    }
    public void setLeft(TreeNode l) { left = l;}
    public void setRight(TreeNode r) {right = r;}
}

现在想象一下在这个类上执行以下操作。

TreeNode left = new TreeNode(null, null, 13);
TreeNode right = new TreeNode(null, null, 19);
TreeNode root = new TreeNode(left, right, 17);

在这里,我创建了一个根为17、左子节点为13、右子节点为19的二叉树(参见图1)。

Figure 1. A three-node tree

图1包含三个节点的树

假设我随后替换了右子节点,使子节点19成为未连接的垃圾:

root.setRight(new TreeNode(null, null, 21));

结果如图2所示。

The same tree with one subnode replaced

图2 替换了一个子节点的同一棵树

可以想象,在构造和操作数据结构的过程中,堆将开始像图3所示。

A heap with many unused data items in it

图3堆中有许多未使用的数据项

压缩数据意味着更改内存中的地址。Java程序希望在特定地址找到一个对象。如果垃圾回收器移动对象,Java程序需要知道新的位置。最简单的方法是停止所有Java线程,压缩所有对象,更新对旧地址的所有引用指向新地址,并恢复Java程序。但是,这种方法可能导致Java线程较长时间停止等待(称为GC暂停时间)。

当Java程序员的应用程序没有运行时,他们并不高兴。有两种降低GC暂停时间的主流策略。GC文献将它们称为并发算法(在程序运行同时工作)和并行算法(将Java线程停止后使用更多线程以更快地完成工作)。JDK 8中的默认垃圾回收器(可以手工在命令行指定 -XX:+UseParallelGC )采用并行策略。它使用多个GC线程来获得不错的吞吐量。

并行垃圾收集器

并行垃圾收集器根据对象存活的GC周期数将对象分为新生代和老年代两个区域。新生代对象最初是在新生区域中分配的,压缩步骤使它们保持在该区域中,直到它们历经多次新生代回收仍然存活。如果他们活得够久,就会被提升到老年代。理论上,与其停下来对整个堆回收(这将花费太长时间),不如只回收堆中可能包含临时对象的部分。最终,也将有必要回收旧对象。
为了回收较年轻对象,垃圾回收器需要知道老年代中的哪些对象引用了新生代中的对象。 需要更新旧对象去引用新对象的新位置。JVM通过维护称为card table的摘要数据结构来实现这一点。每当一个引用被写到一个旧的生成对象中时,card表就会被标记,这样在下一个年轻代GC周期中,JVM就可以扫描这张卡来寻找从旧到新的引用。在这些引用已知的情况下,并行垃圾收集器能够识别要剔除哪些对象以及要更新哪些引用。它使用多个GC线程在程序暂停时更快地完成工作。

G1垃圾回收器

JDK垃圾回收器G1同时使用并行和并发线程。它使用并发线程在Java程序运行时扫描活动对象。它使用并行线程快速复制对象,并保持较短的暂停时间。
G1将堆分成许多区域。在程序运行期间,一块区域可以是在老年代也可以是新生代。每次GC暂停时新生代都会进行回收,但是G1会灵活地回收老年代,因为它预期回收时间不应超过用户指定的暂停时间。这种灵活性允许G1将旧对象回收工作集中在堆中垃圾最多的区域。它还允许G1根据用户指定的暂停时间来调整回收暂停时间。
如图4所示,G1随意将对象压缩到新的区域中。

Before and after a G1 run
图4 G1运行前后。
区域1和区域2被压缩成区域4。区域4也会被分配进新对象。区域3没有动,因为会需要太多的复制工作(70%)但是可回收的空间太少(30%)

G1知道每个区域中有多少数据存活,以及复制存活数据所需的大概时间。如果用户对最小暂停时间敏感,G1可以选择只回收几个区域。如果用户不担心暂停时间或者已经声明了相当大的暂停时间目标,G1可能会选择回收更多的区域。

G1必须维护一个卡表(card table)数据结构,这样它可以只回收新生代。它还必须记录老区间的引用。这种数据结构称为进入记忆集( remembered set )。

指定小暂停时间的缺点是 G1可能无法跟上程序分配速率,这样最终会变成完全暂停程序然后GC的模式。这意味着扫描和复制工作都是在Java线程暂停时完成的。注意,如果部分GC回收时间超过希望暂停时间,那么完全GC时间定然超过分配时间。 (原文: Note that if the GC can’t meet the pause-time goal with partial collections, then a full GC is guaranteed to exceed the allocated time. )

总之,G1是一个很好的总体收集器,它可以平衡吞吐量和暂停时间限制。

Shenandoah 垃圾回收器

Shenandoah 垃圾回收器是一个OpenJDK项目,它成为OpenJDK 12发行版的一部分,并被移植到JDK 8和11。它使用与G1相同的基于区域的堆布局,并使用相同的并发扫描线程来计算每个区域中的存活数据量。不同的是它处理压缩的方式。

Shenandoah 并发压缩数据。因此,Shenandoah不需要限制回收区域的数量来降低应用程序暂停时间。 相应的它选择回收所有最有成效的区域——就是那些存活对象很少的区域,换言之有很多死亡对象很多的区域。引入暂停的唯一步骤是与扫描开始和结束时执行的某些记帐任务相关联的步骤。

Shenandoah并发复制的难点在于对象的地址在执行复制工作的GC线程和访问堆的Java线程之间需要一致。此地址可能存储在多个位置,并且必须同时更新地址。像计算机科学中最棘手的问题一样,解决方法是增加一个中间层。

对象为间接指针分配了额外的空间。当Java线程访问对象时,它们首先读取间接指针,以查看对象是否已移动。当垃圾收集器移动对象时,它会更新间接指针以指向新位置。新对象被分配一个指向自身的间接指针。只有在GC期间复制对象时,间接指针才会指向其他地方。

此间接指针是有代价的。读取指针并找到对象的当前位置在空间和时间上都要付出代价。这些费用比你想象的要少。从空间上看,Shenandoah不需要用于其他回收器(如card table和into membered set)的堆外数据结构。从时间上看,有各种策略可以消除读瓶颈。优化过的JIT编译器可以意识到程序正在访问一个不可变的字段,例如数组大小。在这些情况下,读取对象的旧副本或新副本都是正确的,因此不需要间接读取。此外,如果Java程序从同一对象中读取多个字段,JIT可能会认识到这一点,并避免再次通过间接指针读取。

如果Java程序写入Shenandoah正在复制的对象,就会发生竞跑。这可以通过让Java线程与GC线程协作来解决。如果Java线程将要写入待复制的对象,Java线程将首先将该对象复制到其自己的分配区域,检查是否首次复制该对象后再执行写入操作。如果GC线程先复制了对象,那么Java线程可以释放其分配并使用GC副本。

Shenandoah消除了复制存活对象时必须暂停,因此提供了更短的暂停时间。

结语

自从本文首次发布以来,JDK中的GC以新的方式发展。本期的其他文章详细介绍了其中的一些更改,根据本文中的GC机制可以更好地理解这些更改。 —Ed.

数码
沪ICP备19006215号-4