GC 侦听器、软引用和真正弱集合
使用监听器拦截垃圾回收器,
引言
据说 .NET/C# 不提供拦截/挂钩/干扰垃圾收集器的方法。事实上也确实没有,而且这是有充分理由的。同时,C# 也没有提供 Java 世界中所谓的“软引用”:不是在 GC 周期结束后立即被丢弃的弱引用。这些事实使得创建真正弱集合(那些当元素不再使用时会自动移除和回收的集合)变得困难。
本文首先介绍了 GCListener
类,这是一个 abstract
基类,用于需要获知垃圾回收发生或在回收后满足给定过期标准的对象。该类是 SoftReference
类的父类,SoftReference
是 WeakReference
的替代品,它允许以编程方式设置过期标准,从而有效地延长其存在时间。最后,利用这些软引用,本文还包含了一些 SoftXXX
集合(如 SoftList
、SoftDictionary
等),它们的元素被视为软引用,因此当它们在给定时间内或经过一定数量的垃圾回收周期后不再使用时,它们会被自动移除和回收。
GCListener
.NET 的执行环境在认为有必要时会触发一次垃圾收集器周期。有很多关于 GC 如何工作、其分代机制、以及开发 GC 友好型类需要考虑的事项的文章和教程,因此我不会重复这些信息,只介绍后续所需的部分。
需要牢记的一点是,这些 GC 周期是在任意时刻触发的,而不仅仅是在内存压力大的时候。实际上,当您执行编译为“Release”模式的代码时,GC 可能会非常激进:为了优化其操作,第 0 代对象会被非常频繁地回收。
现在,看起来似乎没有办法让执行引擎通知我们垃圾回收即将发生(除了第 2 代),但确实有一种方法可以在垃圾回收发生后得到通知——至少在非常具体的情况下是这样。好消息是,您很可能已经在日常工作中使用了它:我指的是 C# 的终结器。您一定知道,它们会在实现终结器的对象的垃圾回收之后自动调用。但是,在对象已被销毁并且无法再做任何事情时,收到销毁的通知有什么意义呢?
GCListener
所做的是使用一个代理对象,这是一个临时的对象,当它被终结时,会通知“拥有”它的侦听器。侦听器处理完代理的通知后,会创建一个另一个临时的代理,这个循环会一直重复,直到主对象本身被终结。为了让我们能够对这些通知做些有用的事情,任何继承自 GCListener
的类都需要重写 OnGCNotification()
方法。
在继续解释之前,请允许我先解决您可能有一些担忧
- 是的,这种方法会带来一点点性能损失(您可以使用我随下载提供的性能测试来感受)。但至少在我使用的场景中,这个类带来的开销很小。首先,临时对象非常轻量级,它们基本上只携带一个指向拥有实例的引用。其次,这些临时对象被快速有效地回收——GC 正是为此而设计的!
OnGCNotification()
方法仅在这些临时代理对象被终结时调用,因此,如前所述,是在垃圾回收发生在该实例上之后——但可能还没有发生在其他侦听器上——并且是从高优先级的终结器线程调用的。在实现继承自GCListener
的类(我想如果您使用本文后面介绍的其他类,您很少会这样做)时,这两点都必须考虑在内。
- 最后,为了让我们的生活更轻松一点,对通知方法的调用受到一个轻量级锁定模型的保护:
GCListener
尝试锁定其SyncRoot
对象属性,但如果它已经被其他线程锁定,那么通知将被简单地丢弃(直到下一个周期)。
这个 SyncRoot
属性是 virtual
的,因此您可以在派生类中使用任何您想要的类型作为锁定对象,只要它是一个引用类型(一个类)。例如,本文后面介绍的软集合会重写此属性以指向它们自己的内部结构。
GCListener
实现了另一项改进,即过期标准机制。默认的无参构造函数只是将侦听器设置为在每次垃圾回收周期发生时调用通知方法。由于在很多情况下这可能效率不高,该类提供了两个属性:GCCycles
和 GCTicks
,允许我们指定希望何时接收这些通知。
GCCycles
允许我们指定一个数字,在经过这么多次垃圾回收周期后,将触发通知。您可能需要回忆一下,垃圾回收发生的时间由 GC 控制,因此通知发生的具体时间是完全不确定的。如果此属性的值为零,则不使用此标准。
GCTicks
允许我们指定在触发通知之前必须经过的最少时间刻数。请注意,此属性仅在发生垃圾回收时进行检查,因此,再次强调,满足此标准的具体时间也是不可预测的。如果此属性的值为零,则不使用此标准。
下面我们将看到一些使用这些属性的示例。
最后,为了完整起见,该类提供了 GCCurrentPulses
属性,用于告知自实例化以来此实例发生了多少次 GC。它在内部使用,但我发现将其设为 public 有助于调试。
SoftReference
WeakReference
有什么问题?嗯,实际上没什么问题,只要您将其用于其设计目的。但我们都遇到过这样的场景:我们希望我们的弱引用能比下一次垃圾回收活得更久。Java 的 SoftReference
实际上不会立即被回收,而是直到出现某种压力才会被回收——但在 C# 中我们没有同样的便利。
所以我借用了名称,但并未简单复制 Java 的功能:我希望我们新的 C# SoftReference
至少在给定的时间刻或给定的垃圾回收周期(或两者)内保持对底层对象的强引用,并且之后,如果它没有被使用,则让 GC 完成它的工作来回收对象。但是,如果在此期间,对象的引用因任何原因被回收,它将再次抓取该引用以开始另一个过期标准周期。
让我们看下面的例子
var span = TimeSpan.FromSeconds(5);
var obj = new MyObject(...);
var soft = new SoftReference(obj) { GCTicks = span.Ticks };
obj = null;
在这里,我们获取了一个对象的软引用,而我们不再保留对其的强引用。但在这种情况下,我们希望它的引用在至少 5 秒内有效,尽管在此期间可能发生多少次垃圾回收。我们可以通过以下方式轻松测试这一点
var start = DateTime.Now; do
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
while ((DateTime.Now.Ticks - start.Ticks) < span.Ticks);
Assert.IsTrue(soft.IsAlive);
请注意,如果使用 WeakReference
,上面的代码将永远无法通过测试,因为在几次垃圾回收后,其 Target
属性将变为 null
,而其IsAlive属性将变为 false
。但由于我们使用了 SoftReference
,我们实现了我们的目标。
一旦满足了过期标准,SoftReference
就会“忘记”它之前维护的对底层对象的强引用,从此以后,它将仅依赖于对该对象的内部弱引用。如果此时再次发生垃圾回收,对象很可能被回收,并且这个弱引用将失效。但是,如果在此期间使用了 Target
属性,那么强引用将被“刷新”,并重新开始一个新的完整周期。
如果我们要测试这个事实,针对多个周期
var obj = new MyObject();
var soft = new SoftReference(obj) { GCCycles = ... }; // Use your favorite number
var max = ...; // Your test value
for(int i = 0; i < max; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.IsTrue(soft.IsAlive);
var target = soft.Target;
Assert.IsNotNull(target);
}
显然,所有这些逻辑都由 SoftReference
类本身处理,因此,就实际用途而言,您可以将其用作 WeakReference
的替代品,但具有更长且可自定义的生命周期。
如果您好奇,或者如果您需要在一些相当复杂的场景中使用它们,该类还提供了以下属性:RawTarget
,它提供对内部强引用的访问(如果 SoftReference
实例此时仅使用弱引用,则可能为 null
);WeakTarget
,它返回强引用或弱引用,以不为 null
的那个为准,但不会刷新强引用(Target
会);以及 WeakReference
,顾名思义,它允许您访问该实例维护的实际弱引用。
Soft Collections
讨论弱集合、弱字典、弱缓存等的文章可能和 Steve Balmer 在他著名演讲中说“developer”的次数一样多。或者甚至更多。无论如何,那些试图使用 WeakReference
实现此功能的尝试,嗯,(顾名思义)是弱的,并且充满细微差别,而少数尝试使用新的 ConditionalWeakTable
的尝试发现,尽管它很棒,但实际上不能用于我们所需的目的。
但是,不要再痛苦了,下载源代码(或从 GitHub 获取)并开始享受以下类。
SoftList
此类提供了一个 IList<T>
实现,其元素在内部被存储为 SoftReference
s,因此当它们不再被使用并且满足过期标准(如果设置了)时,它们会自动被回收并从列表中移除。
出于简单性,元素的过期标准在添加到集合时从所属列表设置的过期标准复制而来。
var list = new SoftList<MyObject>() { GCCycles = 5 };
list.Add(new MyObject());
在此示例中,在 5 次垃圾回收脉冲后,添加到集合中的对象将过期,因为如前所述,它从所属列表复制了过期标准。当发生这种情况时(并且当它的弱引用不再有效时),它将被回收,列表会自动将其移除。
是的,您可以像期望的那样使用这个新列表,好处是您无需手动移除无效元素。例如:
var obj = list[0];
此示例按给定索引返回元素。实际上,它做得更多,因为通过以这种方式获取值,它刷新了其引用,因此,确实,至少又延长了一个过期周期。
注意事项
由于 GC 回收给定对象的具体时间是不可预测的,因此从对象被回收到它被移除之间可能会有很小的延迟——后者发生在列表本身处理其自身的通知方法时。在这些罕见的情况下,如果您按索引访问元素,可能会检索到 null
,而根据设计,null
被视为无效值。是的,所有 SoftXXX
集合都不接受 null
作为有效值。
SoftDictionary
此类提供了一个 IDictionary<TKey, TValue>
实现,其键和值在内部被存储为 SoftReference
s。如果该对的任何成员被回收,无论是键还是值,该条目都将被视为无效,并自动从集合中移除。
就像之前一样,键和值的过期标准从所属字典设置的过期标准复制而来。
一个重要的考虑因素是,内部条目是使用键的哈希码维护的,而不是键本身。SoftDictionary
类提供了构造函数,允许您指定您喜欢的自定义 IEqualityComparer<TKey>
比较器,其 GetHashCode()
方法用于,嗯,获取键的哈希码。
var comparer = new MyFavoriteComparer<TKey>();
var dict = new SoftDictionary<TKey, TValue>(comparer);
有趣的是,我不经常使用这个构造函数,但在我需要的时候,它确实很有帮助。
此类作为 IDictionary
实现,为您提供了所有预期的方法和属性。因此,您可以使用它的 Add
和 Remove
方法,它的 TryGetValue
方法,等等。例如,让我们看看它是如何实现 ContainsKey
方法的:
public bool ContainsKey(TKey key)
{
if (key == null) throw new ArgumentNullException("key");
lock (SyncRoot)
{
var hash = _KeysComparer.GetHashCode(key);
Entry entry = null; if (_Dict.TryGetValue(hash, out entry))
{
if (!entry.SoftKey.IsAlive) return false;
var target = (TKey)entry.SoftKey.Target; if (target == null) return false;
if (!_KeysComparer.Equals(key, target)) return false;
return true;
}
return false;
}
}
所以我们可以看到,如果找不到键,那么什么都不会刷新,但如果找到了,那么它就像在概念上“使用”过一样被刷新。
最后,还有其他构造函数重载,允许您为值指定比较器。在这种情况下,将使用其 Equals(TValue a, TValue b)
方法来比较值,而不是依赖于 object
的默认 ReferenceEquals
方法。
SoftDictionary 变体
SoftKeyDictionary<TKey, TValue>
,仅当键被视为软引用时使用(或者当值的类型不是类时)。
SoftValueDictionary<TKey, TValue>
,仅当值被视为软引用时使用(或者当键的类型不是类时)。我曾使用此类,例如,当键是int
类型时。
SoftBucketDictionary
最后,我个人最喜欢的是 SoftBucketDictionary<TKey, TValue>
类,我用它作为内存缓存。它基本上允许您将任意数量的值关联到一个键,其中键和值都在内部被存储为软引用。
出于技术原因,它不实现 IDictionary
接口,但提供了类似的功能。它最重要的属性和方法如下:
Keys
和Values
属性允许枚举集合中的有效元素。实际上,后者会枚举其中所有桶中的有效值。KeysCount
和ValuesCount
属性给出集合中当前有效元素各自的数量。
FindList(TKey key)
方法返回维护与给定键关联的值的SoftList<TValue>
实例。
Remove(TKey key, TValue value)
方法将给定的值从与给定键关联的桶中移除,而不是从其他任何桶中移除(以防它被添加到其他桶中)。如果您想移除给定值的所有出现,请使用RemoveAll(TValue value)
方法。
Add(TKey key, TValue value)
方法将给定的值添加到与给定键关联的桶中,如果需要,会先创建该桶。
SoftBucketDictionary 变体
是的,正如您可能预期的那样,该库还包括 SoftKeyBucketDictionary<TKey, TValue>
和 SoftValueBucketDictionary<TKey, TValue>
类,它们的功能与其对应的 SoftDictionary
类相似。
结语与杂项
- 您可能知道,
WeakReference
本质上是GCHandle
结构的一个面向对象的包装器。我强烈建议您查看它们的源代码,因为它们是了解 .NET 框架内部机制的宝贵信息来源。
- 目前没有 NuGet 包,我也没有打算提供一个单独的包。原因是本文介绍的所有类都属于
Kerosene.Tools
库的下一个版本,该库确实有一个 NuGet 包。如果您查看源代码,您会注意到“Tools”文件夹,其中包含从主库借用的最少支持类。
历史
- 版本 1.0.0 - 16 年 4 月:初始版本