Reference Counting Brief Analysis

 

Begin

这篇文不像动画那么有趣。记录的是我在学习Reference Counting中遇到的坑。倘若你之前只是听说过但是没有具体的实践过,我想你在阅读本文后会有很大的收获。

Manual Reference Counting Introduce

笔者学习iOS开发一年有余,对于我的iOS入门书籍,是Objective-C Programming: The Big Nerd Ranch Guide,在23.4部分,对Referrence Counting会有介绍。我们对MRC最初的认识就是,retain计数加一,release计数减一,autorelease计数将来会减一,retainCount可以返回引用计数。当引用计数减到0时,系统将会自动调用对象的dealloc方法,你可以把它类比成C#中的dispose方法,而原先的开发人员可以在dealloc中释放或清理资源。

也许第一遍看这些知识你正处于一个入门开发学习状态而没有进行试验,当我们深入去学习这门语言之后,你会发现很多问题。接下来,我们一一提出并解决。

alloc/retain/release/dealloc是如何实现的?

由于Cocoa framework的闭源,我们只能通过其互换框架GNUstep来了解其原理。首先我们通过alloc方法入手。

+ (id) alloc {
    return [self allocWithZone: NSDefaultMallocZone()];
}

+ (id) allocWithZone: (NSZone *)z {
    return NSAllocateObject(self, 0, z);
}

alloc方法会调用NSAllocateObject函数。具体是做什么的呢?我们往后看。

struct obj_layout {
    NSUInteger retained;
};

inline id NSAllocateObject(Class aClass, NSUInteger extraBytes, NSZone *zone) {
    int size = /* 计算容纳对象所需要的内存大小 */
    // 分配内存空间
    id new = NSZoneMalloc(zone, size);
    // 空间数据置0
    memset(new, 0, size);
    new = (id)&((struct obj_layout *) new)[1];
}
retained全部置0【对象存储区域】alloc返回指针struct obj_layoutalloc返回对象的内存图

NSAllocateObject函数通过调用NSZoneMalloc函数来分配存放对象所需的内存空间,之后将该内存空间置0,最后返回作为对象而使用的指针。那么NSZone又是什么?之前我想让大家移至CocoaDev,但是现在作者不在维护了。这里所说的NSZone我做了一下翻译:

从大体上来说,NSZone是Apple分配和释放内存的一种方式,它不是一个对象,而是使用C语言中的结构体来存储关于对象的内存管理信息。基本上,不需开发者去管理它,Cocoa Application使用一个默认的NSZone来对应用的对象进行管理。但是当默认的NSZone里面管理了大量数据的时候,你会想要一个自己控制的NSZone。中重视和,大量对象的释放可能会导致严重的内存碎片化,Cocoa本身有做过优化,每次alloc时会尝试着填满内存空隙,但如此开销会很大。于是,为了优化效率,你可以自己创建NSZone,当你有大量的alloc请求时,就全部转移到指定的NSZone,便可减少大量的时间开销。而且,使用NSZone还可以一次性的将你创建在NSZone的东西全部清除,避免逐个dealloc

熟悉C或者C++的读者,读过以后可以立马反应到,这其实就是一个官方封装的内存池。无论是优化内存碎片化还是对象统一释放,都是内存池的显著特点。总的来说,当你需要大量创建对象的时候,使用NSZone能提高效率的。在Cocoabuilder中,有一篇叫what’s an NSZone?的帖子中,Timothy J. Wood写道:由于历史原因,现在已经不能创建一个真正的NSZone,而是在Main Zone中创建一个Child Zone,这样不会使存储单元过度碎片化。发表日期是2002年,也就是说,Cocoa很早之前就已经注意到内存碎片的危险,而改善了Zone方法。

再来说一下retainrelease。我们也从GNUstep源码入手:

- (id) retain {
    NSIncrementExtraRefCount(self);
    return self;
}

inline void NSIncrementExtraRefCount(id anObject) {
    // 判断计数最大值
    if (((struct obj_layout *)anObject)[-1].retained == UINT_MAX - 1) {
        [NSException raise: NSInternalInconsistencyException 
                    format: @"NSIncrementExtraRefCount() asked to increment too far"];
    }
    ((struct obj_layout *)anObject)[-1].retained++;
}

大概扫一遍代码,其实我们只是对计数的上线做了一个判断。UINT_MAX - 1这是个什么东西呢。直接敲到Xcode中发现这个值得18446744073709551615,转换成二进制1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111,也就是我们常说的\(2^{64} - 1\)。这个值也就是-1在内存当中的补码存储形式。记住这个值,我们后面还会遇到。

- (void) release {
    if (NSDecrementExtraRefCountWasZero(self)) {
        [self dealloc];
    }
}

bool NSDerementExtraRefCountWasZero(id anObject) {
    if (((strcut obj_layout *)anObject)[-1].retained == 0) {
        return YES;
    } else {
        ((struct obj_layout *) anObject)[-1].retained --;
        return NO;
    }
}

release类比于上面的retain也就好理解多了,这里我们只要有一个下限判断,如果计数等于0的时候,调用dealloc实例方法,废弃对象。

- (void) dealloc {
    NSDeallocateobject(self);
}

inline void NSDeallocateObject(id anObject) {
    struct obj_layout *o = &((strcut obj_layout *)anObject)[-1];
    free(o);
}

上述代码仅废弃了由alloc分配的内存块。最后简单看一下retainCount的实现

- (NSUInteger) retainCount {
    return NSExtraRefCount(self) + 1;
}

inline NSUInteger NSExtraRefCount(id anObject) {
    return ((struct obj_layout *)anObject)[-1].retained;
}
struct obj_layoutanObject对象指针访问头部通过对象访问对象内存头部

如上示意图,指向struct obj_layout的指针减去struct obj_layout大小的地址,即可找到访问对象内存头部。因为分配时全部置0,由NSExtraRefCount(self) + 1得出,retainCount为1,可以推测出,retain方法使retained属性加1,而release减1。

在苹果的官方实现中,__CFDoExternRefoperation函数中可以发现苹果采用了一个引用计数表,结构类似于散列,来管理内存块以及引用计数。这也是官方代码和GNUstep的一点区别。(官方代码可以从这里查看。)

更多的了解苹果源码可以参考书籍Pro multithreading and memory management for iOS and OS X

When to use -retainCount

When to use -retainCount

这个子标题的答案是什么呢?最后揭晓。

初学Reference Counting,我们会使用retainCount来进行一些实验。例如这个代码:

NSString *string = @"Some";
NSLog(@"retainCount: %lu", [string retainCount]);

NSNumber *number1 = @1;
NSLog(@"retainCount: %lu", [number1 retainCount]);

NSNumber *number2 = @3.14;
NSLog(@"retainCount: %lu", [number2 retainCount]);

输出结果为如下:

2016-06-24 10:18:57.797 text[43373:4020361] retainCount: 18446744073709551615
2016-06-24 10:18:57.798 text[43373:4020361] retainCount: 9223372036854775807
2016-06-24 10:18:57.798 text[43373:4020361] retainCount: 1

第一次看到这些值的时候,都会大声惊叹:“Why?”其实,第一个值是之前见过的\(2^{64} - 1\),第二个值是\(2^{63} - 1\)。为什么会这样,原因是因为我们的编译器将其实现为Singleton Object(也就是我们常说的单例对象)。编译时编译器会把其对象所表示的数据放到应用程序的二进制文件中,这样的话运行程序就可以直接使用,无需再创建NSString对象。这是一种编译优化手段,我们称之为编译器常量(Compile-time Constant)。而对于NSNumber来说,它使用了一种标签指针(Tagged Pointer)机制来标注特定类型的数值,这不依赖与NSNumber对象,而是把与数值有关的全部消息都放在标签指针中,并对它执行响应操作。而这些标签指针系统会在消息派发阶段(objc_msgSend)来检测标签指针,从而获得数据信息。NSNumber这种优化只在某些场合使用,比如例中的浮点数对象就没有优化,所以其保留计数为1。

我们来验证一下NSString的单例存储:

NSString *a = @"gua";
NSString *b = @"gua";
NSLog(@"%p", a);
NSLog(@"%p", b);
2016-06-24 10:37:39.511 text[43524:4042012] 0x106ddb050
2016-06-24 10:37:39.512 text[43524:4042012] 0x106ddb050

结果发现我们一旦有一个NSString对象,并且它的值为@”gua”的时候,其指针指向的地址单元永远是一致的。倘若不是单例对象,则不可能出现相同地址。可能你会纠结于那么不同的单例对象为何引用计数还会不相同?其实这里我们无需关注这个问题,我们只要肯定的是,单例对象的引用计数始终不会改变。其实这也反应这么一种思想:我们不应该总是依赖保留计数的具体值来编码

其实retainCount这个属性很早就被苹果公司放弃使用了。两个原因:

  • retainCount没有考虑后续的自动释放操作,只是不停地通过释放操作来降低保留计数,直至是对象为系统回收。假如此对象在自动释放池里,那么稍后系统清空是还要继续释放,导致crash。
  • retainCount有回收不确定性。retainCount可能永远不得0,因为有时系统会优化对象的释放行为,在保留计数是1的时候,就回收了。只有当系统不打算优化时,计数值才会递减至0。

苹果的官方文档也对其没有价值有了很详尽的说明。在官方引入ARC时,毫不犹豫的将其放弃,我们就更不应该使用了。

综上,回答标题问题:When to use -retainCount? The answer is Never!

End

这篇博文只是简单的给出了引用计数是如何工作的,以及避免使用retainCount的原因,后续我希望进一步探究iOS内存管理的其他细节问题,希望读者与笔者一样,共同探究问题,挖掘本质。倘若文章有错误,望指出继而共勉。


lots-of-problems-about-nsstring-reference-count