简介: 本文是关于 IBM Java 垃圾收集器(GC)系列文章(共 3 部分)的第 2 篇,IBM Java 垃圾收集器是用于 IBM Java 开发工具和运行时环境的一个存储管理器。本系列的第 1 部分讨论 对象分配,下一篇文章将讨论 verbosegc 和其他命令行参数。在本文中,Sam Borman 回顾了垃圾收集的工作原理,并描述了 GC 的主要三个阶段:标记、清理和压缩。他还讨论了并发标记和并行按位(bitwise)清理。本文简要讨论了引用对象、堆扩展和堆收缩。本文中的信息来自 Java 1.3.1 发行版,但是可以反映 Java 1.2.2 发行版。
当在堆锁分配过程中发生分配失败,或者 System.GC 被调用时,将执行垃圾收集(Garbage collection,GC)。发生分配失败或调用 System.GC 的线程接管控制并执行 GC。它首先获取 GC 所需的所有锁,然后挂起所有其他线程。然后,分三个阶段执行 GC:标记(mark)、清理(sweep)和可选的压缩(compaction)。我们称之为 stop-the-world(STW)收集,因为在垃圾收集期间所有应用程序线程都被停止。
GC 的标记阶段将标记所有活动的对象。由于不能逐个标记不可获得的(unreachable)对象,所以仅标记所有可获得的(reachable)对象,然后剩下的内容就是垃圾。标记所有可获得对象的过程称作跟踪(tracing)。
JVM 的活动状态由以下部分组成:为每个线程保存的寄存器、表示线程的栈集合、Java 类中的静态类,以及本地和全局 Java 本地接口(JNI)引用的集合。JVM 本身中的所有函数调用都会在 C 栈中产生一个帧。这个帧本身可以包含对象实例,这些对象实例可以赋给本地变量,也可以作为调用方传递的参数。跟踪例程平等地对待所有这些引用。实际上,我们将一个线程的栈看作一组 4 字节的字段(在 64 位架构中就是 8 字节),并在每个栈中从上到下进行扫描。我们假设栈中每 4 个字节为一行(在 64 位架构中为 8 字节)。对每个槽都要检查,看它是否指向堆中的一个对象。但是,这并不会使它一定成为一个对象的指针,因为它可能不幸只是某个浮点数或整数中的位组合。当对栈进行扫描时,必须持保守的态度;任何指向对象的东西都被当作一个对象,但是在 GC 期间不能去除有问题的对象。一个槽如果满足以下标准,则可以视作指向一个对象的指针:
- 它的粒度为 8 字节。
- 它在堆边界内。
- 启用 allocbit。
以这种方式引用的对象称作根(root),这些对象将启用 dosed 位,以表明不能移动它们。
现在可以准确地进行跟踪,这意味着我们可以发现根中对其他对象的引用。由于我们知道这些是真正的引用,因此可以在压缩阶段移动它们,因为我们可以解决这些引用。
跟踪过程使用一个持有 4K 条目的栈。通过设置相关的 markbit,所有被压入该栈的引用同时被标记。首先标记根并将其压入栈,然后开始从栈中弹出条目,并跟踪它们。
对于一般对象(非数组),可通过使用 mptr 访问类块(classblock)对其进行跟踪,这告诉我们在这个对象中的哪些位置可以发现对其他对象的引用。当发现每个引用时,如果它还没有被标记,则标记它,并将它压入栈。
对于数组对象,通过查看每个数组条目对其进行跟踪,如果数组条目没有被标记,则标记它并将它压入栈。附加的代码每次跟踪数组的一小部分,以尽量避免标记栈溢出。
跟踪过程反复进行,直到标记栈最终变为空栈。
由于标记栈有固定的大小,所以可能溢出。如果发生溢出,需要执行以下操作:
- 设置一个全局标志,以表明发生了标记栈溢出。
- 在不能被压入栈的对象中设置 NotYetScanned 位。
然后可以继续跟踪其他不能压入栈的对象,设置它们的 NotYetScanned 位。当所有跟踪完成时,从第一个对象开始,使用 size 字段导航到下一个对象,对堆进行遍历。将发现的设置了 NotYetScanned 位的所有对象进行标记,并将它们压入到标记栈中。然后关闭 NotYetScanned 位,像前面一样继续跟踪。可能再次发生标记栈溢出,在这种情况下,我们再次重复整个过程,直到所有可获得的对象都被标记。
并行标记是在 Java 1.3.0 中引入的。因为要执行按位清理和压缩回避(Compaction avoidance),大部分的 GC 时间花在标记对象上。这直接导致并行式 GC 标记的开发。并行标记不会降低单处理器的标记性能,并且使 8-way 机器的一般标记性能优于 4 处理器。
其基本思想是通过增加 helper 线程和可以在 helper 线程之间共享工作的工具来增强对象标记。最初的实现盗用一个应用程序线程来执行 GC。并行标记仍然需要一个应用程序线程的参与,这个应用程序线程被用作主协调代理。这个线程执行的任务几乎没有变化,包括负责扫描 C 栈以标记用于垃圾收集的根指针。具有 N 个处理器的平台还将有 N-1 个新的 helper 线程,这些 helper 线程与主线程一起完成 GC 的标记阶段。可以用 -Xgcthreadsn
参数覆盖默认的线程数量。如果该参数的值为 1,则没有 helper 线程。允许的值为 1 到 N 之间的值。
并行标记的目标是为每个 marker 线程提供一个本地栈和一个可共享队列,它们都包含已经被标记但还没有被扫描的对象的引用。线程使用它们的本地栈做主要的标记工作,只有在需要平衡工作时才同步可共享的队列。标记位是使用不需要附加锁的原子原语更新的。由于每个线程都有一个可以持有 4K 条目的标记栈,以及一个可以持有 2K 条目的标记队列,标记栈溢出的机会将减少。
并发标记是在 Java 1.3.1 中引入的,用于在堆变大时减少和稳定 GC 暂停时间。它在堆变满之前开始一个并发的标记阶段。在并发阶段,我们通过请求每个线程扫描自己的栈来扫描根。然后,使用这些根来并发地跟踪活动的对象。跟踪是通过一个低优先级的后台线程执行的,每个应用程序线程在进行堆锁分配时也会执行跟踪。
当在应用程序线程运行时并发地标记活动对象时,必须记录对已跟踪对象的任何更改。这可以通过使用一个写屏障(write barrier)来实现,每当更改一个对象中的引用时,都会激活写屏障。当对象引用发生更新时,需要写屏障来告诉我们,使我们知道必须重新扫描堆的一部分。它与 resettable 所需的写屏障是一样的。堆被分成 512 字节的区段,在 card 表中每个区段被分配一个字节。每当一个对象的引用被更新,与已经用新对象引用更新的对象的起始地址对应的卡片被标记为 0x01。这里使用一个字节,而不是一个位,原因有两点:第一,对一个字节执行一次写入要比执行位修改快;第二,我们想在将来使用其他的位。
当发生以下情况之一时,将开始 STW 收集:
- 分配失败
- System.GC 被请求
- 并发标记完成它可以做的所有标记
我们尝试开始并发标记阶段,以便在堆耗尽的同时完成标记。可以通过不断调优控制并发标记时间的参数来实现这一点。
在 STW 阶段,我们再次扫描所有的根,使用已标记的卡片查看需要跟踪什么,然后执行常规的清理。我们可以确保所有在并发阶段开始时不可获得的对象都被收集。但是我们不能保证在并发阶段变得不可获得的对象也被收集。
并发标记的优点是可以减少和稳定暂停时间,但是也需为之付出代价。应用程序线程必须 “付出代价”,即在它们请求堆锁分配时执行跟踪。这种代价的高低取决于有多少用于后台线程的空闲 CPU 时间。另外,在写屏障方面也存在一定的开销。
有一个新的参数可用于启用并发标记:
-
通过将
gcpolicy
设置为optthruput
可以禁用并发标记。没有暂停时间问题(这种问题表现为不稳定的应用程序响应时间)的用户应该通过这个选项获得最佳吞吐率。optthruput
是默认值。可以通过将
gcpolicy
设置为optavgpause
来以默认值启用并发标记。如果因为常规的垃圾收集而导致应用程序响应时间出现问题,那么可以通过运行optavgpause
选项牺牲部分吞吐率来缓解这些问题。
-Xgcpolicy:<optthruput | optavgpause>
经过标记阶段后,对于堆中的每个可获得对象,markbits 向量中包含一个位,并且它必须是 allocbits 向量的一个子集。清理阶段标识 allocbits 向量与 markbits 向量的交叉部分 —— 已经被分配但是不再引用的对象。在按位清理技术中,我们直接检查 markbits 向量,查找无价值的 “长期” 运行,它们可能表示空闲空间。一旦发现这样的长期运行,则在这个运行的开始位置查看对象的长度,以确定要释放的空闲空间。如果空闲空间的数量加上头部大小超过 512(在 1.3.0 和更早版本中为 384),则将这个空闲块放到空闲列表上(freelist)。不在空闲列表上的小块存储称作 “暗物质(dark matter)”,当与它们邻近的对象被释放,或者压缩堆的时候,这些存储块将被发现。这里不需要释放空闲块中的单个对象,因为我们知道整个块都是空闲的存储。实际上,当释放一个块时,我们并不知道其中的对象。在这个过程中,markbit 被复制到 allocbit,以便完成时 allocbit 正确地反映堆中分配的对象。
并行按位清理是在 Java 1.3.1 中引入的,它通过使用可用的处理器缩短清理时间,这一点类似于并行标记。我们使用与并行标记相同的 helper 线程,所以默认的 helper 线程数量是相同的,并且可以使用 -Xgcthreadsn
参数来更改。
32 * helper 线程的数量,或最大堆大小 / 16M,取两者中的较大值 |
helper 线程每次扫描一个区段,执行一个修改过的按位清理。对于每个区段,扫描的结果被存储起来,当所有区段都扫描完毕时,构建空闲列表。
从堆中清除掉所有垃圾之后,可以考虑压缩剩下的对象集合,以消除它们之间的空隙。压缩有些复杂,因为如果移动任何对象,都必须修改它当前使用的所有引用。如果其中一个引用来自一个栈,而且不能肯定它是一个对象引用(例如,它可能是一个浮点数),那么显然我们无法移动该对象。在代码中,位置被临时固定的对象被称作 “dosed”,在它们的头部字中设有 “dosed” 位。类似地,在某些 JNI 操作期间可以 “固化(pin)” 对象,这样可以取得相同的效果,但是这种固化是 “持久的”,直到 JNI 显式地取消对象的固化。而对于可移动的对象,则分两个阶段进行压缩:首先利用 mptr 的低 3 位的 0,然后使用其中一位来表示我们已经将它 “交换”。注意,这个已交换的位被应用到两个地方:
- size + 标志字段,在此它被称作
OLINK_IsSwapped
。 - mptr,在此它被称作
GC_FirstSwapped
。
不管何种情况,都设置最低有效位(x01)。
为了更好地理解压缩过程,可以将堆比作一个仓库,其中一部分放满了不同大小的家具。空闲空间就是家具之间的空隙。空闲列表只包含超出一定大小的空隙。压缩就是将所有东西朝一个方向推移,以弥合所有空隙。它从最靠墙的对象开始,将它推向墙边,然后将离墙第二近的对象推向第一个对象,接着将第三个对象推向第二个对象,依此类推。最后,所有家具在一端,所有空闲空间在另一端。虽然不能移动的 pinned 对象和 dosed 对象使压缩变得复杂,但总体原理是一致的。
压缩回避(Compaction avoidance)试图通过适当地放置对象来减少(在很多情况下甚至消除)对压缩的需要。压缩回避的一个关键方面是称为荒地预留(wilderness preservation)的概念。其思想是通过关注其他方面的分配活动,保留堆中某个区域的初始状态,在实践中,要避免压缩需求,需要将堆的主要部分与一个预留的 wilderness 部分分隔开来。在通常情况下,每当 wilderness 受到威胁时,就会触发非压缩 GC 事件。只有在必须满足一个较大的分配时,或者自上次 GC 以来发生了不充分的分配过程时,才会消耗(占用)wilderness。图 1 显示了堆中的 wilderness。
图 1. wilderness
wilderness 是在堆的活动部分的末端分配的。它的大小为堆的活动部分的 5%,最大为 3M。当堆锁分配失败时,如果自上次的 GC 之后发生过充分的分配过程,则执行一个 GC。充分分配的定义是自上次 GC 以来至少分配了 30% 的堆空间。这是默认设置,可以通过参数 -Xminf
更改。如果没有实现充分分配,则在可能的情况下立即由 wilderness 满足分配。如果这样做失败,则发生 “常规” 分配失败。如果收到对一个较大对象的分配请求,且在耗尽空闲列表之前无法满足这样的分配,则发生不充分的分配过程。在这种情况下,预留的 wilderness 就可以满足这样的请求,从而避免 GC 和压缩。
如果出现以下情况之一,且没有指定 -Xnocompactgc
,则会发生压缩:
-
-Xcompactgc
已经被指定。 - 在清理阶段之后,没有足够的空闲空间来满足分配请求。
- System.GC 被请求,且上一次的分配失败 GC 没有执行压缩。
- 之前可用空间中至少有一半已经被 TLH 分配消耗(确保准确的抽样),并且 TLH 平均大小小于 1000 字节。
- 活动堆中空闲空间少于 5%。
- 活动堆中空闲空间少于 128K。
在 Java 1.2.2 中,整个引用处理发生很大的改变。其目标是平等地对待所有的引用,并以相同的方式处理它们。因此,我们实际上在堆上创建两个单独的对象 —— 对象本身和一个单独的 “引用” 对象。可选地,引用对象可以与一个队列相关联,当 referent 变得不可获得时,可以将引用对象添加到该队列中。SoftReference
、WeakReference
和 PhantomReference
的实例由用户创建,是不可修改的。除了创建时所引用的对象之外,不能让它们引用其他的对象。与一个 finalizer 关联的对象在创建时被 “注册到” Finalizer
类。结果是创建了一个 FinalReference
对象,该对象与 Finalizer 队列相关联,并且引用要执行 finalize 的对象。
在 GC 期间,引用对象得到特殊的处理,因为在标记阶段没有跟踪 referent 字段。一旦标记完成,便按以下顺序处理引用:soft(软引用)、weak(弱引用)、final(强引用)和 phantom(虚引用)。SoftReference
对象的处理是专门化的,在此过程中,ST 组件可以决定,如果 referent 没有被标记(除了通过引用外不可获得),则应该清除这些引用。如果内存耗尽,则执行这种清除,这种清除是选择性的,它要根据最近的使用情况而定。“使用情况(usage)” 由上次调用的get
方法测定,这可能导致一些令人惊讶但是完全有效的结果。在处理一个引用对象时,它的 referent 被标记,以确保当为一个也有 SoftReference
的对象处理 FinalReference
时,FinalReference
将看到一个被标记的 referent,并因此不会将FinalReference
放入队列进行处理。结果,这些引用将在后续的 GC 周期中被放入队列中。
对未标记的对象的引用最初被放入 Reference
类的 ReferenceHandler 线程队列中,该线程将队列中取出对象,并查看它们的 “queue” 字段。如果一个对象与一个特定的队列相关联,则将它重新放入该队列,作进一步的处理。FinalReference
对象被重新放入队列,最终 Finalizer 线程运行 finalize
方法。
JNI 弱引用提供与 WeakReference
对象相同的功能,但是处理方式有很大的不同。一个 JNI 例程可以创建对一个对象的 JNI 弱引用,并在以后删除该引用。ST 组件将清除 referent 未被标记的所有弱引用,但是这没有对等的队列机制。注意,删除 JNI 弱引用失败时,将导致表发生内存泄漏和性能问题。对于 JNI 全局引用也是如此。JNI 弱引用的处理是在引用处理的最后进行的。其结果是,对于一个已经执行了 finalize 并且之前曾将一个虚引用放入队列并进行处理的对象,可以存在一个 JNI 弱引用。
堆扩展是在 GC 之后,当所有线程已经被重新启动,但 HEAP_LOCK 仍被占有时发生的。如果满足以下条件之一,堆的活动部分将被扩展到最大:
- GC 没有释放出足够的存储来满足分配请求。
- 空闲空间少于最小空闲空间(可以通过 -Xminf 参数设置,默认为 30%)。
- 超过 13% 的时间被花在 GC 中,如果按最小扩展量(-Xmine)扩展,产生的堆不会大于最大空闲空间百分比(-Xmaxf)。
扩展量按以下方法计算:
- 如果是因为空闲空间少于 -Xminf(默认为 30%)而进行扩展,则计算需要扩展多少才能获得 -Xminf 的空闲空间。如果结果大于最大扩展量(可以使用 -Xmaxe 参数设置,默认为 0,即没有最大扩展量),那么将它减少至 -Xmaxe。如果结果小于最小扩展量(可以用 -Xmine 参数设置,默认为 1M),则将它增加至 -Xmine。
- 如果扩展的原因是 GC 没有释放出足够的存储,并且花在 GC 上的时间不超过 13%,那么根据分配请求进行扩展。
- 如果是因为其他原因进行扩展,则计算需要扩展多少才能得到 17.5% 的空闲空间。和前面一样,我们根据 -Xmaxe 和 -Xmine 对计算结果加以调整。如果 GC 没有释放出足够的存储,则还需要确保扩展量至少能满足分配请求。
所有计算出的扩展量都要以 64K(在 32 位架构上)或 4M(64 位架构上)为界向上取整。
堆收缩是在 GC 之后,当所有线程仍然被挂起时发生的。如果符合以下条件之一,将不会 发生堆收缩:
- GC 没有释放出足够的存储空间满足分配请求。
- 最大空闲空间(可以通过 -Xmaxf 参数设置,默认为 60%)被设为 100%。
- 最近 3 次 GC 中已经扩展过堆。
- 这是一个 System.GC,并且在 GC 开始时空闲空间少于堆的活动部分的 -Xminf(默认为 30%)。
如果以上条件都不满足,并且有超过 -Xmaxf 的空闲空间,则计算要将堆收缩多少才能达到 -Xmaxf 的空闲空间,同时不低于初始值(-Xms)。这个数字要以 64K(32 位架构上)或 4M(64 位架构上)为界向下取整。
如果满足以下所有条件,则在收缩之前将发生压缩:
- 本次的 GC 周期中还没有执行过压缩。
- 堆的末端没有空闲的块,或者堆末端的空闲块的大小少于所需收缩量的 10%。
- 在上一次的 GC 周期中没有执行过收缩和压缩。
继续关注本系列的第 3 部分,这个部分将讨论 verbosegc 和其他命令行参数。如果错过了之前的内容,请阅读 第 1 部分,对象分配。
- 参与论坛讨论。
- Richard Jones 和 Rafael Lins 撰写的 Garbage Collection - Algorithms for Automatic Dynamic Memory Management(John Wiley & Son Ltd,1996,ISBN:0-471-94148-4)。
- 阅读本系列的 第 1 部分,对象分配(developerWorks,2002 年 8 月)和 第 3 部分,
verbosegc
和命令行参数(developerWorks,2002 年 9 月)。
- 在 developerWorks Java 技术专区 查找更多 Java 资源。