第五章 内存管理—第29条:理解引用计数

在Objective-C这种面向对象语言里,内存管理是个重要概念。要想用一门语言写出内存使用效率高而且又没有bug的代码,就得掌握其内存模型的种种细节。
一旦理解了这些规则,你就会发现,其实Objective-C的内存管理没那么复杂,而且有了"自动引用计数"(Automatic Reference Counting, ARC)之后,就变得更为简单了。ARC几乎把所有内存管理事宜都交由编译器来决定,开发者只需专注于业务逻辑。

Objective-C语言使用引用计数来管理内存,也就是说,每个对象都有个可以递增或递减的计数器。如果想使某个对象继续存活,那就递增其引用计数;用完了之后,就递减其计数。计数变为0,就表示没人关注此对象了,于是,就可以把它销毁。上面这几句话只是个概述,要想写出优秀的Objective-C代码,必须完全理解此问题才行,即便打算用ARC来编码(参见第30条)也是如此。
从Mac OS X 10.8开始,"垃圾收集器"(garbage collector)已经正式废弃了,以Objective-C代码编写Mac OS X程序时不应再使用它,而iOS则从未支持过垃圾收集。因此,掌握引用计数机制对于学好Objective-C来说十分重要。Mac OS X程序已经不能再依赖垃圾收集器了,而iOS系统不支持此功能,将来也不会支持。
已经用过ARC的人可能会知道: 所有与引用计数有关的方法都无法编译,然而现在先暂时忘掉这件事。那些方法确实无法用在ARC中,不过本条就是要从Objective-C的角度讲解引用计数,而ARC实际上也是一种引用计数机制,所以,还是要谈谈这些在开启ARC功能时不能直接调用的方法。

引用计数工作原理
在引用计数架构下,对象有个计数器,用以表示当前有多少个事物想令此对象继续存活下去。这在Objective-C中叫做"保留计数"(retain count),不过也可以叫"引用计数"(reference count)。NSObject协议声明了下面三个方法用于操作计数器,以递增或递减其值:

  • Retain 递增保留计数。
  • release 递减保留计数。
  • autorelease 待稍后清理"自动释放池"(autorelease pool)时,再递减保留计数。
    查看保留计数的方法叫做retainCount,此方法不太有用,即便在调试时也如此,所以笔者(与苹果公司)并不推荐大家使用这个方法。更多内容请参阅第36条。
    对象创建出来时,其保留计数至少为1.若想令其继续存活,则调用retain方法。要是某部分代码不再使用此对象,不想令其继续存活,那就调用release或autorelease方法。最终当保留计数归零时,对象就回收了(deallocated),也就是说,系统会将其占用的内存标记为"可重用"(reuse)。此时,所有指向该对象的引用也都变得无效了。
    下图演示了对象自创建出来之后历经一次"保留"及两次"释放"操作的过程。
屏幕快照 2017-05-04 08.49.11.png

应用程序在其生命期中会创建很多对象,这些对象都相互联系着。例如,表示个人信息的对象会引用另一个表示人名的字符串对象,而且可能还会引用其他个人信息对象,比如在存放朋友的set中就是如此,于是,这些相互关联的对象就构成了一张"对象图"(object graph)。对象如果持有指向其他对象的强引用(strong reference),那么前者就"拥有"(own)后者。也就是说,对象想令其所引用的那些对象继续存活,就可将其"保留"。等用完了之后,再释放。
在下图所示的对象图中,ObjectB与ObjectC都引用了

屏幕快照 2017-05-04 08.56.17.png

ObjectA。若ObjectB与ObjectC都不再使用ObjectA,则其保留计数降为0,于是便可摧毁了。还有其他对象想令ObjectB与ObjectC继续存活,而应用程序里又有另外一些对象想令那些对象继续存活。如果按"引用树"回溯,那么最终会发现一个"根对象"(root object)。在Mac OS X应用程序中,此对象就是NSApplication对象;而在iOS应用程序中,则是UIApplication对象。两者都是应用程序启动时创建的单例。
下面这段代码有助于理解这些方法的用法:

NSMutableArray *array = [[NSMutableArray alloc] init];

NSNumber *number = [[NSNumber alloc] initWithInt:1337];
[array addObject:number];
[number release];

// do something with `array’

[array release];

如前所述,由于代码中直接调用了release方法,所以在ARC下无法编译。在Objective-C中,调用alloc方法所返回的对象由调用者所拥有。也就是说,调用者已通过alloc方法表达了想令该对象继续存活下去的意愿。不过请注意,这并不是说对象此时的保留计数必定是1。在alloc或"initWithInt:"方法的实现代码中,也许还有其他对象也保留了此对象,所以,其保留计数可能会大于1.能够肯定的是: 保留计数至少为1.保留计数这个概念就应该这样来理解才对。绝不应该说保留计数一定是某个值,只能说你所执行的操作是递增了该计数还是递减了该计数。
创建完数组后,把number对象加入其中。调用数组的"addObject:"方法时,数组也会在number上调用retain方法,以期继续保留此对象。这是,保留计数至少为2。接下来,代码不再需要number对象了,于是将其释放。现在的保留计数至少为1。这样就不能照常使用number变量了。调用release之后,已经无法保证所指的对象仍然存活。当然,根据本例中的代码,我们显然知道number对象在调用了release之后仍然存活,因为数组还在引用着它。然而绝不应该假设此对象一定存活,也就是说,不要像下面这样编写代码:

NSNumber *number = [[NSNumber alloc] initWithInt:1337];
[array addObject:number];
[number release];
NSLog(@"number = %@", number);

即便上述代码在本例中可以正常执行,也仍然不是个好办法。如果调用release之后,基于某些原因,其保留计数降至0,那么number对象所占内存也许会回收,这样的话,再调用NSLog可能就将使程序崩溃了。笔者在这里只说"可能",而没说"一定",因为对象所占的内存在"解除分配"(deallocated)之后,只是放回"可用内存池"(avaiable pool)。如果执行NSLog时尚未覆写对象内存,那么该对象仍然有效,这时程序不会崩溃。由此可见: 因过早释放对象而导致的bug很难调试。
为避免不经意间使用了无效对象,一般调用完release之后都会清空指针。这就能保证不会出现可能指向无效对象的指针,这种指针通常称为"悬挂指针"(dangling pointer)。比方说,可以这样编写代码来防止此情况发生:

NSNumber *number = [[NSNumber alloc] initWithInt:1337];
[array addObject:number];
[number release];
number = nil;

属性存取方法中的内存管理
如前所述,对象图由互相关联的对象所构成。刚才那个例子中的数组通过在其元素上调用retain方法来保留那些对象。不光是数组,其他对象也可以保留别的对象,这一般通过访问"属性"(参见第6条)来实现,而访问属性时,会用到相关实例变量的获取方法及设置方法。若属性为"strong关系"(strong relationship),则设置的属性值会保留。比方说,有个名叫foo的属性由名叫_foo的实例变量所实现,那么,该属性的设置方法会是这样:

- (void)setFoo:(id)foo {
    [foo retain];
    [_foo release];
    _foo = foo;
}

此方法将保留新值并释放旧值,然后更新实例变量,令其指向新值。顺序很重要。假如还未保留新值就先把旧值释放了,而且两个值又指向同一个对象,那么,先执行的release操作就可能导致系统将此对象永久回收。而后续的retain操作则无法令这个已经彻底回收的对象复生,于是实例变量就成了悬挂指针。

自动释放池
在Objective-C的引用计数架构中,自动释放池是一项重要特性。调用release会立刻递减对象的保留计数(而且还有可能令系统回收此对象),然而有时候可以不调用它,改为调用autorelease,此方法会在稍后递减计数,通常是在下一次"事件循环"(event loop)时递减,不过也可能执行得更早些(参见第34条)。
此特性很有用,尤其是在方法中返回对象中返回对象时更应该用它。在这种情况下,我们并不总是想令方法调用者手工保留其值。比方说,有下面这个方法:

- (NSString*)stringValue {
    NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
    return str;
}

此时返回的str对象其保留计数比期望值要多1(+1 retain count),因为调用alloc会令保留计数加1,而又没有与之对应的释放操作。保留计数多1,就意味着调用者要负责处理多出来的这一次保留操作。必须设法将其抵消。这并不是说保留计数本身就一定是1,它可能大于1,不过那取决于"initWithFormat:"方法内的实现细节。你要考虑的是如何将多出来的这一次保留操作抵消掉。
但是,不能在方法内释放str,否则还没等方法返回,系统就把该对象回收了。这里应该用autorelease,它会在稍后释放对象,从而给调用者留下了足够长的时间,使其可以在需要时先保留返回值。换句话说,此方法可以保证对象在跨越"方法调用边界"(method call boundary)后一定存活。实际上,释放操作会在清空最外层的自动释放池(参见第34条)时执行,除非你有自己的自动释放池,否则这个时机指的就是当前线程的下一次事件循环。改写stringValue方法,使用autorelease来释放对象:

- (NSString*)stringValue {
    NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
    return [str autorelease];
}

修改之后,stringValue方法把NSString对象返回给调用者时,此对象必然存活。所以我们能够像下面这样使用它:

NSString *str = [self stringValue];
NSLog(@"The string is: %@", str);

由于返回的str对象将于稍后自动释放,所以多出来的那一次保留操作到时自然就会抵消,无须再执行内存管理操作。因为自动释放池中的释放操作要等到下一次事件时才会执行,所以NSLog语句在使用str对象前不需要手工执行保留操作。但是,假如要持有此对象的话(比如将其设置给实例变量),那就需要保留,并于稍后释放:

_instanceVariable = [[self stringValue] retain];
// …
[_instanceVariable release];

由此可见,autorelease能延长对象生命期,使其在跨越方法调用边界后依然可以存活一段时间。

保留环
使用引用计数机制时,经常要注意的一个问题就是"保留环"(retain cycle),也就是呈环状相互引用的多个对象。这将导致内存泄漏,因为循环中的对象其保留计数不会降为0.对于循环中的每个对象来说,至少还有另外一个对象引用着它。下图里的每个对象都引用了另外两个对象之中的一个。在这个循环里,所有对象的保留计数都是1。

屏幕快照 2017-05-04 09.30.36.png

在垃圾收集环境中,通常将这种情况认定为"孤岛"(island of isolation)。此时,垃圾收集器会把三个对象全都回收走。而在Objective-C的引用计数架构中,则享受不到这一便利。通常采用"弱引用"(weak reference, 参见第33条)来解决此问题,或是从外界命令循环中的某个对象不再保留另外一个对象。这两种办法都能打破保留环,从而避免内存泄漏。

要点

  • 引用计数机制通过可以递增递减的计数器来管理内存。对象创建好之后,其保留计数至少为1.若保留计数为正,则对象继续存活。当保留计数降为0时,对象就被销毁了。
  • 在对象生命期中,其余对象通过引用来保留或释放此对象。保留与释放操作分别会递增及递减保留计数。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容