[iOS翻译]NSMapTable和弱引用归零

1,622 阅读8分钟

原文地址:cocoamine.net/blog/2013/1…

原文作者:cocoamine.net

发布时间:2013年12月13日

苹果自己的话说,NSMapTable是一个以NSDictionary为模型的可变集合,它提供了不同的选项(参见Mike Ash, NSHipsterobjc.io的概述)。主要的选项是让键和/或值以一种 "弱 "的方式持有,当其中一个对象被回收时,条目被删除。这种行为依赖于零化弱引用,它首先被引入到垃圾收集中,然后从Mountain Lion 10.8开始适应ARC。

归零弱引用的神奇之处在于,当它们所指向的对象被删除时,它们会自动转为零。例如,一个由变量foo持有的归零弱引用的值是0x60000001bf10,指向一个NSString的实例。但是当这个对象被取消分配的时候,foo的值就变成了0x000000000000。

将弱引用清零与NSMapTable结合使用有可能通过提供自动移除未使用的条目来简化对象管理。不幸的是,NSMapTable并没有实现它的所有承诺。

NSMapTable的承诺

让我们想象一个假设的FooNotificationCenter类,它提供了以下的API。

@interface FooNotificationCenter : NSObject
- (void)addObserver:(id)obs selector:(SEL)sel name:(NSString *)name;
- (void)postNotificationName:(NSString *)name;
@end

使用两个NSMapTable实例,FooNotificationCenter的一个非常简单的实现可以是这样的,其中表保留了选择器和名称,但只弱化了观察者。

image.png

- (id)init
{
    self = [super init];
    if (self != nil)
    {
        _selectorTable = [NSMapTable weakToStrongObjectsMapTable];
        _nameTable = [NSMapTable strongToWeakObjectsMapTable];
    }
    return self;
}

- (void)addObserver:(id)observer
           selector:(SEL)selector
               name:(NSString *)name
{
  NSString *selString = NSStringFromSelector(selector);
  [_selectorTable addObject:selString forKey:observer];
  [_nameTable addObject:observer forKey:name];
}

- (void)postNotificationName:(NSString *)name
{
  id observer = [_nameTable objectForKey:name];
  if (observer == nil)
      return;
  SEL selector = [_selectorTable objectForKey:observer];
  if (selector)
      [observer performSelector:selector withObject:nil];
}

// This highly-flawed code is released under the least friendly
// open-source license possible, to ensure you don't use it.

当观察者被解配时,NSMapTables中相应的引用将被清零,条目将自动 "消失",名称和选择器对象将被解配。观察者不需要明确地从我们的 FooNotificationCenter 中删除自己。它将在观察者被删除时自动发生...至少在理论上是这样。

image.png

探测NSMapTable

我最近为NSMapTable中的意外行为所困扰,决定仔细研究一下。我得出了一个不幸的结论:NSMapTable在处理弱引用清零方面有未记录的限制(至少在ARC下是这样的,尽管我期望在手动内存管理方面也有类似的结果)。

为了看看NSMapTable的运行情况,我写了一个简单的测试程序来锻炼NSMapTable,发布在github上。该应用程序管理着一个NSMapTable的实例。下面是它的样子(如果你运行这个应用程序,使用工具提示来获得更多关于界面元素的信息)。

image.png

当点击 "添加条目 "按钮时,NSMapTable被送入所要求的键和对象的数量,这些对象是用特殊的PARMarker类新鲜创建的。PARMarker是一个简单的NSObject子类,它记录了对init和dealloc的调用。条目(一个PARMarker用于键,一个用于对象)的添加是在一个自动释放池中完成的,所以当池子死亡时,没有被NSMapTable实例保留的标记会被取消分配。代码非常简单。

- (IBAction)addEntriesToMapTable:(id)sender
{
    @autoreleasepool
    {
        NSUInteger addCount = _entriesToAddField.integerValue;
        for (NSUInteger i = 0; i < addCount; i++)
        {
            PARMarker *key = [[PARMarker alloc] init];
            PARMarker *object = [[PARMarker alloc] init];
            [self.mapTable setObject:object forKey:key];
        }
    }
    [self refreshUI];
}

让我们来试试这个应用程序。我们将首先在一个强到不能再强的NSMapTable中添加10个条目。添加后,NSMapTable的count属性值为10,有10个键和10个对象。有20个标记被创建(10个键和10个值)。没有一个标记被取消分配:它们都被NSMapTable实例所保留。到目前为止,一切都很好。

image.png

从弱到强

现在,让我们来找找麻烦。为此,我们将使用一个具有弱到强引用的NSMapTable实例,并添加1个条目。因为键在被添加到表中后就被自动释放了,所以在我们的NSMapTable实例中根本就不应该有条目。下面是我们得到的结果。

image.png

让我们从好消息开始。地图表的'key count'和'Object count'都是0。这些数字是keyEnumerator.allObjects.count和objectEnumerator.allObjects.count所返回的值。这表明,当键被自动释放时,添加的条目被从NSMapTable中移除。相应的对象已经从NSMapTable中消失了,只要键被deallocated。或者它真的消失了吗?

问题的第一个迹象是NSMapTable的count属性,它返回1。不知为什么,NSMapTable仍然认为它有1个条目。更麻烦的是,对应于该条目的对象本应消失,但实际上仍然活着 在2个初始的FDMarker实例中,只有一个被删除了(键),而另一个仍然活着(对象)。这是非常麻烦的,因为这基本上意味着这个对象不能再被访问了,而且是 "泄漏 "的。

从技术上讲,这不是泄漏,因为当NSMapTable被解配时,它被正确地释放。但是如果NSMapTable不再被使用,而只是保持活力,那么这个对象也将永远保持活力(这里没有类似垃圾收集器的活动)。不过有一些操作可以触发 "清除",如果NSMapTable被使用,该对象最终会被释放。例如,对removeAllObjects的调用将触发这样的清除。

此外,如果我们不断地添加更多的条目,该对象最终会被释放和去分配。例如,如果我们再添加8个条目,1个1个地添加,现在仍然活着的标记数量是6个,这意味着9个添加的对象中,有3个被清除了。但是我们仍然有6个对象悬空。

image.png

这完全违背了[NSMapTable weakToStrongObjectsMapTable]的目的。经过大量的谷歌搜索,令我非常惊讶的是,我最终意识到这种行为(有点)被记录下来了,作为Moutain Lion发行说明的一部分,其方式与NSMapTable官方文档的承诺直接矛盾。

然而,目前不推荐使用弱到强的NSMapTable,因为被清零的弱键的强值不会被清除(和释放),直到/除非地图表自己调整大小。

我希望这一点在NSMapTable文档中有所记载(要像我一样报告这个问题,请使用Xcode文档窗口中的 "提供反馈 "按钮,或者直接使用这个链接)。

由强变弱

NSMapTable文档或OS X发布说明中没有包括任何关于强到弱的NSMapTables的警告,但这并不意味着我们不应该寻找更多的麻烦。让我们使用一个强到弱的NSMapTable,并添加1000个条目。同样,一旦条目被添加,我们的代码就会退出一个自动释放池,但是这一次,对象(而不是键)被去掉并清零。这应该会导致所有的NSMapTable条目被移除,然后是键的去分配。让我们来看看。

image.png

肯定会有更多的麻烦。在NSMapTable最初保留的1000个键中,只有489个被取消了分配,511个仍然活着。

让我们继续挖掘,通过再按64次 "添加条目 "按钮来添加更多条目。在以1000个增量添加了65000个键/值对之后,我们发现有32255个对象仍然活着,并且被地图表所帮助。这意味着65,000个对象中的32,255个仍然存活,而所有的键都被取消了分配(和清零)。

image.png

哎呀,让我们再增加1000个条目。当达到132000个键/值对时,"活 "对象的数量下降到487个。看起来内部发生了一些事情(调整大小?),NSMapTable清除了大部分保留的键。

image.png

正如Michael Tsai所指出的,强到弱的NSMapTable的预期行为并没有很好的记录,而且还需要解释。他期望键应该被保留,直到用removeObjectForKey:明确地移除(尽管在这种情况下,我期望keyEnumerator仍然包括这些键)。我个人希望有一个与弱到强的NSMapTable对称的行为,即对象的归零将从表中删除条目并释放键。根据我所看到的,后者可能是NSMapTable应该做的。不过,实际发生的情况比这两种可能性都要糟糕:键被保留一段时间,最终被释放。

还有一个小信息:尽管未释放的键没有被列在keyEnumerator中,你仍然可以用这些键调用removeObjectForKey:,这实际上会清除相应的键。你可以在测试程序中使用底部的弹出菜单中的 "移除实时键 "动作来尝试一下。

结论

不管人们如何解释NSMapTable的文档,它在实践中并不像人们希望的那样神奇。无论是弱到强还是强到弱的NSMapTables都有处理弱引用归零的问题。这种行为取决于表的类型、它当前的大小和它过去的活动。在这两种情况下,行为都可能是确定的,但它取决于NSMapTable的内部实现。这是不可能预测的,应该被认为是未定义的。

在NSMapTable中,有两个明显的后果,那就是用归零的弱引用泄露条目。

  • 内存泄漏。这只有在地图表被用于大型对象和/或大量对象时才有意义,因为内部清除似乎在更大的尺寸下不那么频繁,而且允许保持活力的对象数量也变多。即使对象数量较少,也会形成意外的保留循环,弱引用应该被打破,如果地图表使用不多,可能会保持很长时间的停留。
  • 性能问题。如果强引用对象有任何重要的活动(例如,响应通知,写到文件,等等......),它们不仅会参与到内存占用中,而且保持活动的时间也会比预期的长很多。例如,它们可能为通知、文件系统事件等注册,运行不再需要的代码(甚至可能以不可预测的方式破坏你的应用程序状态)。

唯一真正的解决方法是在内部使用NSMapTables的类中设置明确删除条目的API。这些类的API客户端应该在它们的dealloc方法中添加显式调用。对于FooNotificationCenter来说,这将意味着增加一个方法,它需要平衡addObserver:selector:name:的任何使用。

@interface FooNotificationCenter (Removal)
// use this or risk leaking the observer for a while 
- (void)removeObserver:(id)obs;
@end

这是一种耻辱。类似于ARC将内存管理从开发者手中拿走,并减少了相关的bug数量,NSMapTable和弱引用归零的结合应该进一步减少清理更复杂的对象关系所需的代码,并进一步减少现有应用程序的内存消耗和资源使用。

正如Michal Tsai所想的那样,"NSMapTable是一个重要的构建模块,所以为什么苹果还没有把它弄好,这是一个谜"。我也认为他的解释是正确的:如果没有弱分配通知,被清零的引用的所有者是不知道这个变化的。引用的归零是通过在正确的内存地址上写一堆0来完成的,这些0对应于引用被删除对象的变量。这个过程必须尽可能的高效,并且是线程安全的。任何用于跟踪对象图形或维护回调表的额外工作,都需要在每个弱引用的去分配时发生,这可能对性能产生重大影响。苹果公司准备好让这种情况发生了吗?

不过,NSMapTable的实现不一定要依赖于Objective C的弱引用归零的一般实现。仅仅为了改进NSMapTable而改变Objective C的运行时间,可能是过犹不及。相反,NSMapTable的正确实现似乎可以使用另一种弱引用归零的方法来完成,比如MAZeroingWeakRef。我们只能等待苹果公司修复NSMapTable,或者实现我们自己的版本(假设它可以可靠地完成)。


www.deepl.com 翻译