【问题标题】:Compacting a WeakReference Dictionary压缩弱引用字典
【发布时间】:2010-01-12 08:23:51
【问题描述】:

我有一个带有 Id 属性的类 Foo。我的目标是没有两个 Foo 实例同时具有相同的 Id

所以我创建了一个工厂方法 CreateFoo,它使用缓存来为相同的 Id 返回相同的实例。

static Foo CreateFoo(int id) {
    Foo foo;
    if (!cache.TryGetValue(id, out foo)) {
        foo = new Foo(id);
        foo.Initialize(...);
        cache.Put(id, foo);
    }
    return foo;
}

缓存实现为 Dictionary,基于 @JaredParBuilding a WeakReference Hashtable:

class WeakDictionary<TKey, TValue> where TValue : class {
    private readonly Dictionary<TKey, WeakReference> items;
    public WeakDictionary() {
        this.items = new Dictionary<TKey, WeakReference>();
    }
    public void Put(TKey key, TValue value) {
        this.items[key] = new WeakReference(value);
    }
    public bool TryGetValue(TKey key, out TValue value) {
        WeakReference weakRef;
        if (!this.items.TryGetValue(key, out weakRef)) {
            value = null;
            return false;
        } else {
            value = (TValue)weakRef.Target;
            return (value != null);
        }
    }
}

问题是 WeakReference 在其目标被垃圾回收后仍保留在字典中。这意味着需要一些策略来手动“垃圾收集”失效的 WeakReference,正如 @Pascal CuoqWhat happens to a WeakReference after GC of WeakReference.Target 中所解释的那样。


我的问题是:压缩弱引用字典的最佳策略是什么?

我看到的选项是:

  1. 不要从字典中删除弱引用。 IMO 这很糟糕,因为缓存会在我的应用程序的整个生命周期中使用,并且很多死的 WeakReferences 会随着时间的推移而累积。

  2. 在每个 PutTryGetValue 上遍历整个字典,并删除无效的 WeakReference。这在某种程度上违背了字典的目的,因为这两个操作都变成了 O(n)

  3. 在后台线程中定期遍历整个字典。鉴于我不知道 CreateFoo 的使用模式,什么是一个好的间隔?

  4. 将每个插入的 KeyValuePair 附加到一个双端链表。每次调用 PutTryGetValue 都会检查列表的头部。如果 WeakReference 是活动的,则将该对移动到列表的末尾。如果它已死,则从列表中删除该对并从字典中删除 WeakReference。

  5. 实现一个自定义哈希表,其细微差别是,当存储桶已满时,会先从存储桶中删除失效的 WeakReference,然后再照常进行操作。

还有其他策略吗?

最好的策略可能是具有摊销时间复杂度的算法。有这样的策略吗?

【问题讨论】:

  • C#/.NET 弱引用的严重缺陷之一是缺少来自 GC 的通信,例如 Java 将 ReferenceQueue 与弱引用关联起来。
  • dtb,当您对选项进行编号而不是项目符号时,讨论会变得更容易。

标签: c# .net-4.0 weak-references


【解决方案1】:

如果可以将托管对象切换为字典的键,那么可以使用.Net 4.0的ConditionalWeakTable(命名空间System.Runtime.CompilerServices)。

根据 Richter 先生的说法,ConditionalWeakTable 由垃圾收集器通知对象收集,而不是使用轮询线程。

    static ConditionalWeakTable<TabItem, TIDExec> tidByTab = new ConditionalWeakTable<TabItem, TIDExec>();

    void Window_Loaded(object sender, RoutedEventArgs e)
    {
        ...
        dataGrid.SelectionChanged += (_sender, _e) =>
        {
            var cs = dataGrid.SelectedItem as ClientSession;

            this.tabControl.Items.Clear();

            foreach (var tid in cs.GetThreadIDs())
            {
                tid.tabItem = new TabItem() { Header = ... };
                tid.tabItem.AddHandler(UIElement.MouseDownEvent,
                    new MouseButtonEventHandler((__sender, __e) =>
                    {
                        tabControl_SelectionChanged(tid.tabItem);
                    }), true);
                tidByTab.Add(tid.tabItem, tid);
                this.tabControl.Items.Add(tid.tabItem);
            }
        };
    }

    void tabControl_SelectionChanged(TabItem tabItem)
    {
        this.tabControl.SelectedItem = tabItem;
        if (tidByTab.TryGetValue(tabControl.SelectedItem as TabItem, out tidExec))
        {
            tidExec.EnsureBlocksLoaded();
            ShowStmt(tidExec.CurrentStmt);
        }
        else
            throw new Exception("huh?");
    }

这里重要的是,唯一引用 TabItem 对象的是 tabControls.Items 集合,以及 ConditionalWeakTable 的键。 ConditionalWeakTable 的 key 不算。因此,当我们从 tabControl 中清除所有项目时,可以对这些 TabItems 进行垃圾收集(因为不再引用它们,因此 ConditionalWeakTable 的键也不计算在内)。当它们被垃圾收集时,ConditionalWeakTable 会收到通知,并删除具有该键值的条目。所以我庞大的 TIDExec 对象也被垃圾收集了(没有引用它们,除了 ConditionalWeakTable 的值)。

【讨论】:

    【解决方案2】:

    您的选项 3(线程)的一大缺点是在所有 Put/TryGetvalue 操作上都需要同步。如果你使用它,你的时间间隔不是毫秒,而是每 N 个 TryGet 操作。

    选项 2,扫描字典,会产生严重的开销。您可以通过仅扫描 1000 个动作中的 1 个和/或观察 GC 的运行频率来改进。

    但我会认真考虑选项 1:什么都不做。您可能有“很多”死条目,但另一方面它们非常小(并且可以回收)。可能不是服务器应用程序的选项,但对于客户端应用程序,我会尝试衡量我们正在谈论的每小时有多少条目(kByte)。

    经过一番讨论:

    这样的[n摊销]策略 存在吗?

    我猜不会。您的问题是 GC 的微型版本。您将不得不不时扫描整个事物。所以只有选项 2) 和 3) 提供了真正的解决方案。而且它们都很昂贵,但可以通过一些启发式(大量)优化。选项 2) 仍然会给你偶尔的最坏情况。

    【讨论】:

    • 我又添加了两个选项。你怎么看那些? ...问题是我正在构建一个可以在服务器和客户端应用程序中使用的库。在这两种情况下,我的课程很可能会在应用程序的整个生命周期中使用。设计一个在每次方法调用时获取内存并且从不释放它的库感觉是错误的,即使每次调用只是几个字节。
    • 您对图书馆角度的看法是正确的。两种新选择都可能有效,但在最坏情况下的行为非常糟糕。我投票支持 nr 2,定期全面扫描。
    • 为什么你认为选项 5 有一个非常糟糕的最坏情况行为?哈希表实现总是需要扫描桶中的空白点,那么为什么不在这里检查死的 WeakReferences 呢?
    • 它不会扫描所有个桶。这与检查一些随机条目大致相同。根本问题是与 GC 清理的内容和您可以轻松访问的内容没有关联。
    【解决方案3】:

    方法#5 很有趣,但它的缺点是很难知道哈希表的实际利用率是多少,因此很难知道何时应该扩展哈希表。如果每当哈希表“看起来”应该扩展时,首先进行全表扫描以删除死条目,则可以克服该困难。如果表中超过一半的条目已失效,请不要费心扩展它。这种方法应该产生摊销的 O(1) 行为,因为在添加与删除的条目一样多的条目之前不会进行全表扫描。

    一个更简单的方法是,在最后一次清除表后,计算有多少项目是活着的,并且每个最近存活的元素也会产生 O(1) 的摊销时间和 O(1) 空间,并且从那时起添加了多少元素。每当后一个计数超过第一个时,执行一次全表扫描和清除。扫描和清除所需的时间将与清除之间添加的元素数量成正比,因此保留了分摊的 O(1) 时间,并且集合中的总元素数量不会超过最近观察到的元素数量的两倍是活着的,所以死元素的数量不能超过最近活跃的元素数量的两倍。

    【讨论】:

    • 这对我最近很有帮助,因为我试图解决同样的问题。如果我可以投票两次,我会的。谢谢!
    【解决方案4】:

    我遇到了同样的问题,并像这样解决了它(WeakDictionary 是我试图清理的类):

    internal class CleanerRef
    {
        ~CleanerRef()
        {
            if (handle.IsAllocated)
                handle.Free();
        }
    
        public CleanerRef(WeakDictionaryCleaner cleaner, WeakDictionary dictionary)
        {
            handle = GCHandle.Alloc(cleaner, GCHandleType.WeakTrackResurrection);
            Dictionary = dictionary;
        }
    
        public bool IsAlive
        {
            get {return handle.IsAllocated && handle.Target != null;}
        }
    
        public object Target
        {
            get {return IsAlive ? handle.Target : null;}
        }
    
        GCHandle handle;
        public WeakDictionary Dictionary;
    }
    
    
    internal class WeakDictionaryCleaner
    {
        public WeakDictionaryCleaner(WeakDictionary dict)
        {
            refs.Add(new CleanerRef(this, dict));
        }
    
        ~WeakDictionaryCleaner()
        {
            foreach(var cleanerRef in refs)
            {
                if (cleanerRef.Target == this)
                {
                    cleanerRef.Dictionary.ClearGcedEntries();
                    refs.Remove(cleanerRef);
                    break;
                }
            }
        }
        private static readonly List<CleanerRef> refs = new List<CleanerRef>();
    }
    

    这两个类试图实现的是“挂钩”GC。您可以通过在构造弱集合期间创建 WeakDictionaryCleaner 的实例来激活此机制:

    new WeakDictionaryCleaner(weakDictionary);
    

    请注意,我没有创建对新实例的任何引用,因此 GC 将在下一个循环中处理它。在 ClearGcedEntries() 方法中,我再次创建了一个新实例,这样每个 GC 循环都会有一个清理器来完成,进而执行集合压缩。 您可以使 CleanerRef.Dictionary 也成为弱引用,这样它就不会将字典保存在内存中。

    希望对你有帮助

    【讨论】:

      【解决方案5】:

      我想这是放置它的正确位置,尽管它可能看起来像死灵法术。以防有人像我一样偶然发现这个问题。 .net 中缺少专用的身份映射有点令人惊讶,我觉得它最自然的工作方式如最后一个选项中所述:当表已满且容量即将翻倍时,它会检查是否存在足够多的死条目可以回收以供进一步使用,因此不需要增长。

      static IdentityMap<int, Entity> Cache = new IdentityMap<int, Entity>(e => e.ID);
      ...
      var entity = Cache.Get(id, () => LoadEntity(id));
      

      该类仅公开一个公共方法Getkey 和可选的value 参数,如果实体不在缓存中,则延迟加载和缓存它。

      using System;
      class IdentityMap<TKey, TValue>
          where TKey : IEquatable<TKey>
          where TValue : class
      {
          Func<TValue, TKey> key_selector;
          WeakReference<TValue>[] references;
          int[] buckets;
          int[] bucket_indexes;
          int tail_index;
          int entries_count;
          int capacity;
      
          public IdentityMap(Func<TValue, TKey> key_selector, int capacity = 10) {
              this.key_selector = key_selector;
              Init(capacity);
          }
          void Init(int capacity) {
              this.bucket_indexes = new int[capacity];
              this.buckets = new int[capacity];
              this.references = new WeakReference<TValue>[capacity];
              for (int i = 0; i < capacity; i++) {
                  bucket_indexes[i] = -1;
                  buckets[i] = i - 1;
              }
              this.tail_index = capacity - 1;
              this.entries_count = 0;
              this.capacity = capacity;
          }
      
          public TValue Get(TKey key, Func<TValue> value = null) {
              int bucket_index = Math.Abs(key.GetHashCode() % this.capacity);
              var ret = WalkBucket(bucket_index, true, key);
              if (ret == null && value != null) Add(bucket_index, ret = value());
              return ret;
          }
      
          void Add(int bucket_index, TValue value) {
              if (this.entries_count == this.capacity) {
                  for (int i = 0; i < capacity; i++) WalkBucket(i, false, default(TKey));
                  if (this.entries_count * 2 > this.capacity) {
                      var old_references = references;
                      Init(this.capacity * 2);
                      foreach (var old_reference in old_references) {
                          TValue old_value;
                          if (old_reference.TryGetTarget(out old_value)) {
                              int hash = key_selector(value).GetHashCode();
                              Add(Math.Abs(hash % this.capacity), old_value);
                          }
                      }
                  }
              }
              int new_index = this.tail_index;
              this.tail_index = buckets[this.tail_index];
              this.entries_count += 1;
              buckets[new_index] = bucket_indexes[bucket_index];
              if (references[new_index] != null) references[new_index].SetTarget(value);
              else references[new_index] = new WeakReference<TValue>(value);
              bucket_indexes[bucket_index] = new_index;
          }
      
          TValue WalkBucket(int bucket_index, bool is_searching, TKey key) {
              int curr_index = bucket_indexes[bucket_index];
              int prev_index = -1;
              while (curr_index != -1) {
                  TValue value;
                  int next_index = buckets[curr_index];
                  if (references[curr_index].TryGetTarget(out value)) {
                      if (is_searching && key_selector(value).Equals(key)) return value;
                      prev_index = curr_index;
                  } else {
                      if (prev_index != -1) buckets[prev_index] = next_index;
                      else bucket_indexes[bucket_index] = next_index;
      
                      buckets[curr_index] = this.tail_index;
                      this.tail_index = curr_index;
                      this.entries_count -= 1;
                  }
                  curr_index = next_index;
              }
              return null;
          }
      }
      

      【讨论】:

      • 感谢分享 - 这对我帮助很大!
      【解决方案6】:

      您可以删除TryGetValue 中的“无效”WeakReference

      [编辑]我的错误,这些解决方案实际上只做你建议的,因为Put 方法无论如何都会用新对象交换旧对象。无视就好。

      public bool TryGetValue(TKey key, out TValue value) {
          WeakReference weakRef;
          if (!this.items.TryGetValue(key, out weakRef)) {
              value = null;
              return false;
          } else {
              value = (TValue)weakRef.Target;
              if (value == null)
                  this.items.Remove(key);
              return (value != null);
          }
      }
      

      或者,您可以在需要时立即在字典中创建一个新实例:

      public TValue GetOrCreate(TKey key, Func<Tkey, TValue> ctor) {
      
          WeakReference weakRef;
          if (!this.items.TryGetValue(key, out weakRef) {
              Tvalue result = ctor(key);
              this.Put(key, result);
              return result;
          } 
      
          value = (TValue)weakRef.Target;
          if (value == null)
          {
              Tvalue result = ctor(key);
              this.Put(key, result);
              return result;
          }
      
          return value;
      }
      

      然后你会像这样使用它:

      static Foo CreateFoo(int id)
      {
          return cache.GetOrCreate(id, id => new Foo(id));
      }
      

      [编辑]

      根据windbg,WeakReference实例单独占用16个字节。对于 100,000 个收集的对象,这不会是一个如此严重的负担,因此您可以轻松地让它们存活。

      如果这是一个服务器应用程序,并且您认为您可以从收集中受益,我会考虑使用后台线程,但也可以实现一个简单的算法来增加等待时间每当您收集相对较小的对象的数量。

      【讨论】:

      • 在这种情况下,CreateFoo 会立即重新插入,所以很遗憾,这并没有改善。
      • 您可以在删除弱引用时进行扫描以压缩字典。
      • 不仅有死掉的WeakReference占空间,还有Dictionary的内部结构。因此,即使活动 Foo 对象的数量保持不变,哈希表也需要不断增长以容纳所有失效的 WeakReference。
      • 正确,我没有考虑到这一点。对于每个数组成员,这将是额外的 4 个字节(在 32 位系统上)(当然,平均而言,可能会有一些小的恒定开销)。
      • 一种改进,它不会增长。
      【解决方案7】:

      一点特化:当目标类知道弱字典引用及其TKey 值时,您可以从 finalyzer 调用中删除它的条目。

      public class Entry<TKey>
      {
          TKey key;
          Dictionary<TKey, WeakReference> weakDictionary;
      
          public Entry(Dictionary<TKey, WeakReference> weakDictionary, TKey key)
          {
              this.key = key;
              this.weakDictionary = weakDictionary;
          }
      
          ~Entry()
          {
              weakDictionary.Remove(key);
          }
      }
      

      当缓存对象是Entry&lt;TKey&gt; 的子类时,没有空的WeakReference 泄漏 因为 finalyzer 在其实例被垃圾回收后被调用。

      【讨论】:

      • 终结器不应访问托管对象。可能会同时收集字典和条目,并通过访问已经完成的字典导致非常不可预测的行为。此外,在不同线程上同时调用 AddRemove 还会导致线程问题。
      • @NickWhaley 谢谢,我没有完全意识到你指出的潜在问题。所以我看到这种“解决方法”可能在特殊情况下对我们无法控制的东西起作用,比如终结器线程的选择或应用程序退出时对象的销毁顺序,这可能最终会从终结器中抛出异常。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2014-07-24
      • 2021-11-15
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多