出色的 “清洁工具” —— 理解 IBM Java 垃圾收集器,第 2 部分: 垃圾收集

http://www.ibm.com/developerworks/cn/java/i-garbage2/

简介: 本文是关于 IBM Java 垃圾收集器(GC)系列文章(共 3 部分)的第 2 篇,IBM Java 垃圾收集器是用于 IBM Java 开发工具和运行时环境的一个存储管理器。本系列的第 1 部分讨论 对象分配,下一篇文章将讨论 verbosegc 和其他命令行参数。在本文中,Sam Borman 回顾了垃圾收集的工作原理,并描述了 GC 的主要三个阶段:标记、清理和压缩。他还讨论了并发标记和并行按位(bitwise)清理。本文简要讨论了引用对象、堆扩展和堆收缩。本文中的信息来自 Java 1.3.1 发行版,但是可以反映 Java 1.2.2 发行版。

查看本系列更多内容

发布日期: 2009 年 11 月 23 日 (最初发布 2002 年 8 月 01 日) 
级别: 中级  其他语言版本: 英文 
访问情况 : 2526 次浏览 
评论: 0 (查看 | 添加评论 - 登录)

平均分 1 星 共 1 个评分 平均分 (1个评分)
为本文评分

概述

当在堆锁分配过程中发生分配失败,或者 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 时间。另外,在写屏障方面也存在一定的开销。

有一个新的参数可用于启用并发标记:

-Xgcpolicy:<optthruput | optavgpause>
通过将  gcpolicy 设置为  optthruput 可以禁用并发标记。没有暂停时间问题(这种问题表现为不稳定的应用程序响应时间)的用户应该通过这个选项获得最佳吞吐率。 optthruput 是默认值。

可以通过将 gcpolicy 设置为 optavgpause 来以默认值启用并发标记。如果因为常规的垃圾收集而导致应用程序响应时间出现问题,那么可以通过运行 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 

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 变得不可获得时,可以将引用对象添加到该队列中。SoftReferenceWeakReference 和 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 弱引用

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 部分,对象分配


参考资料

关于作者

Sam Borman 于 1984 年加入 IBM,在此之前他在英国、新西兰和法国的另外七家公司担任程序员和系统程序员。他担任 CICS 领域的开发人员,后来担任开发经理。1990 年,他回到了技术领域从事 CICSPlex/SM 的研究,后来又从事 DirectTalk 的研究。1999 年,他加入了 Java 技术中心(Java Technology Centre),在那里他负责 “垃圾收集”。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值