iOS - 老生常谈内存管理(二):从 MRC 说起

2020-07-18 17:39:28 浏览数 (1)

前言

MRC全称Manual Reference Counting,也称为MRRmanual retain-release),手动引用计数内存管理,即开发者需要手动控制对象的引用计数来管理对象的内存。

  在MRC年代,我们经常需要写retainreleaseautorelease等方法来手动管理对象内存,然而这些方法在ARC是禁止调用的,调用会引起编译报错。

  下面我们从MRC说起,聊聊iOS内存管理。

简介

关于内存管理

  应用程序内存管理是在程序运行时分配内存,使用它并在使用完后释放它的过程。编写良好的程序将使用尽可能少的内存。在 Objective-C 中,它也可以看作是在许多数据和代码之间分配有限内存资源所有权的一种方式。掌握内存管理知识,我们就可以很好地管理对象生命周期并在不再需要它们时释放它们,从而管理应用程序的内存。

  虽然通常在单个对象级别上考虑内存管理,但实际上我们的目标是管理对象图,要保证在内存中只保留需要用到的对象,确保没有发生内存泄漏。

  下图是苹果官方文档给出的 “内存管理对象图”,很好地展示了一个对象 “创建——持有——释放——销毁” 的过程。

Objective-C 在iOS中提供了两种内存管理方法:

  1. MRC,也是本篇文章要讲解的内容,我们通过跟踪自己持有的对象来显式管理内存。这是使用一个称为 “引用计数” 的模型来实现的,由 Foundation 框架的 NSObject 类与运行时环境一起提供。
  2. ARC,系统使用与MRC相同的引用计数系统,但是它会在编译时为我们插入适当的内存管理方法调用。使用ARC,我们通常就不需要了解本文章中描述的MRC的内存管理实现,尽管在某些情况下它可能会有所帮助。但是,作为一名合格的iOS开发者,掌握这些知识是很有必要的。

良好的做法可防止与内存相关的问题

  • 不正确的内存管理导致的问题主要有两种: ① 释放或覆盖仍在使用的数据 这会导致内存损坏,并且通常会导致应用程序崩溃,甚至损坏用户数据。 ② 不释放不再使用的数据会导致内存泄漏 内存泄漏是指没有释放已分配的不再被使用的内存。内存泄漏会导致应用程序不断增加内存使用量,进而可能导致系统性能下降或应用程序被终止。
  • 但是,从引用计数的角度考虑内存管理通常会适得其反,因为你会倾向于根据实现细节而不是实际目标来考虑内存管理。相反,你应该从对象所有权和对象图的角度考虑内存管理。
  • Cocoa 使用简单的命名约定来指示你何时持有由方法返回的对象。(请参阅 《内存管理策略》 章节)
  • 尽管内存管理基本策略很简单,但是你可以采取一些措施来简化内存管理,并帮助确保程序保持可靠和健壮,同时最大程度地减少其资源需求。(请参阅 《实用内存管理》 章节)
  • 自动释放池块提供了一种机制,你可以通过该机制向对象发送 “延迟”release消息。这在需要放弃对象所有权但又希望避免立即释放对象的情况下很有用(例如从方法返回对象时)。在某些情况下,你可能会使用自己的自动释放池块。(请参阅 《使用 Autorelease Pool Blocks》 章节)

使用分析工具调试内存问题

为了在编译时发现代码问题,可以使用 Xcode 内置的 Clang Static Analyzer。

如果仍然出现内存管理问题,则可以使用其他工具和技术来识别和诊断问题。

  • 《Technical Note TN2239, iOS Debugging Magic》 中描述了许多工具和技术,尤其是使用NSZombie(僵尸对象)来帮助查找过度释放的对象。
  • 您可以使用 Instruments 来跟踪引用计数事件并查找内存泄漏。请参阅 《Instruments Help》。

内存管理策略

NSObject 协议中定义的内存管理方法与遵守这些方法命名约定的自定义方法的组合提供了用于引用计数环境中的内存管理的基本模型。NSObject 类还定义了一个dealloc方法,该方法在对象被销毁时自动调用。

基本内存管理规则

  在MRC下,我们要严格遵守引用计数内存管理规则。

  内存管理模型基于对象所有权。任何对象都可以拥有一个或多个所有者。只要一个对象至少拥有一个所有者,它就会继续存在。如果对象没有所有者,则运行时系统会自动销毁它。为了确保你清楚自己何时拥有和不拥有对象的所有权,Cocoa 设置了以下策略:

四条规则
  • 创建并持有对象 使用 alloc/new/copy/mutableCopy 等方法(或者以这些方法名开头的方法)创建的对象我们直接持有,其RC(引用计数,以下使用统一使用RC)初始值为 1,我们直接使用即可,在不需要使用的时候调用一下release方法进行释放。
代码语言:txt复制
    id obj = [NSObject alloc] init]; // 创建并持有对象,RC = 1
    /*
     * 使用该对象,RC = 1
     */
    [obj release]; // 在不需要使用的时候调用 release,RC = 0,对象被销毁

  如果我们通过自定义方法 创建并持有对象,则方法名应该以 alloc/new/copy/mutableCopy 开头,且应该遵循驼峰命名法规则,返回的对象也应该由这些方法创建,如:

代码语言:txt复制
- (id)allocObject
{
    id obj = [NSObject alloc] init];
    retain obj;
}

  可以通过retainCount方法查看对象的引用计数值。

代码语言:txt复制
    NSLog(@"%ld", [obj retainCount]);
  • 可以使用 retain 持有对象 我们可以使用retain对一个对象进行持有。使用上述方法以外的方法创建的对象,我们并不持有,其RC初始值也为 1。但是需要注意的是,如果要使用(持有)该对象,需要先进行retain,否则可能会导致程序Crash。原因是这些方法内部是给对象调用了autorelease方法,所以这些对象会被加入到自动释放池中。

  ① 情况一:iOS 程序中不手动指定@autoreleasepool

  当RunLoop迭代结束时,会自动给自动释放池中的对象调用release方法。所以如果我们使用前不进行retain,如果RunLoop迭代结束,对象调用release方法其RC值就会变成 0,该对象就会被销毁。如果我们这时候访问已经被销毁的对象,程序就会Crash

代码语言:txt复制
    /* 正确的用法 */

    id obj = [NSMutableArray array]; // 创建对象但并不持有,对象加入自动释放池,RC = 1

    [obj retain]; // 使用之前进行 retain,对对象进行持有,RC = 2
    /*
     * 使用该对象,RC = 2
     */
    [obj release]; // 在不需要使用的时候调用 release,RC = 1
    /*
     * RunLoop 可能在某一时刻迭代结束,给自动释放池中的对象调用 release,RC = 0,对象被销毁
     * 如果这时候 RunLoop 还未迭代结束,该对象还可以被访问,不过这是非常危险的,容易导致 Crash
     */

  ② 情况二:手动指定@autoreleasepool

  这种情况就更加明显了,如果@autoreleasepool作用域结束,就会自动给autorelease对象调用release方法。如果这时候我们再访问该对象,程序就会崩溃EXC_BAD_ACCESS

代码语言:txt复制
    /* 错误的用法 */

    id obj;
    @autoreleasepool {
        obj = [NSMutableArray array]; // 创建对象但并不持有,对象加入自动释放池,RC = 1
    } // @autoreleasepool 作用域结束,对象 release,RC = 0,对象被销毁
    NSLog(@"%@",obj); // EXC_BAD_ACCESS
代码语言:txt复制
    /* 正确的用法 */

    id obj;
    @autoreleasepool {
        obj = [NSMutableArray array]; // 创建对象但并不持有,对象加入自动释放池,RC = 1
        [obj retain]; // RC = 2
    } // @autoreleasepool 作用域结束,对象 release,RC = 1
    NSLog(@"%@",obj); // 正常访问
    /*
     * 使用该对象,RC = 1
     */
    [obj release]; // 在不需要使用的时候调用 release,RC = 0,对象被销毁

  如果我们通过自定义方法 创建但并不持有对象,则方法名就不应该以 alloc/new/copy/mutableCopy 开头,且返回对象前应该要先通过autorelease方法将该对象加入自动释放池。如:

代码语言:txt复制
- (id)object
{
    id obj = [NSObject alloc] init];
    [obj autorelease];
    retain obj;
}

  这样调用方在使用该方法创建对象的时候,他就会知道他不持有该对象,于是他会在使用该对象前进行retain,并在不需要该对象时进行release

备注releaseautorelease的区别:

  • 调用release,对象的RC会立即 -1;
  • 调用autorelease,对象的RC不会立即 -1,而是将对象添加进自动释放池,它会在一个恰当的时刻自动给对象调用release,所以autorelease相当于延迟了对象的释放。
  • 不再需要自己持有的对象时释放 在不需要使用(持有)对象的时候,需要调用一下release或者autorelease方法进行释放(或者称为 “放弃对象使用权”),使其RC-1,防止内存泄漏。当对象的RC为 0 时,就会调用dealloc方法销毁对象。
  • 不能释放非自己持有的对象 从以上我们可以得知,持有对象有两种方式,一是通过 alloc/new/copy/mutableCopy 等方法创建对象,二是通过retain方法。如果自己是持有者,那么在不需要该对象的时候需要调用一下release方法进行释放。但是,如果自己不是持有者,就不能对对象进行release,否则会发生程序崩溃EXC_BAD_ACCESS,如下两种情况:
代码语言:txt复制
    id obj = [[NSObject alloc] init]; // 创建并持有对象,RC = 1
    [obj release]; // 如果自己是持有者,在不需要使用的时候调用 release,RC = 0
    /*
     * 此时对象已被销毁,不应该再对其进行访问
     */
    [obj release]; // EXC_BAD_ACCESS,这时候自己已经不是持有者,再 release 就会 Crash
    /*
     * 再次 release 已经销毁的对象(过度释放),或是访问已经销毁的对象都会导致崩溃
     */
代码语言:txt复制
    id obj = [NSMutableArray array]; // 创建对象,但并不持有对象,RC = 1
    [obj release]; // EXC_BAD_ACCESS 虽然对象的 RC = 1,但是这里并不持有对象,所以导致 Crash

  还有一种情况,这是不容易发现问题的情况。下面程序运行居然不会崩溃?这是为什么呢?这里要介绍两个概念,野指针僵尸对象

  • 野指针: 在 C 中是指没有进行初始化的指针,该指针指向一个随机的空间,它的值是个垃圾值;在 OC 中是指指向的对象已经被回收了的指针(网上很多都是这样解释,但我认为它应该叫 “悬垂指针” 才对)。
  • 僵尸对象: 指已经被销毁的对象。

  如下这种情况,当我们通过野指针去访问僵尸对象的时候,可能会有问题,也可能没有问题。对象所占内存在“解除分配(deallocated)”之后,只是放回可用内存池。如果僵尸对象所占内存还没有分配给别人,这时候访问没有问题,如果已经分配给了别人,再次访问就会崩溃。

代码语言:txt复制
    Person *person = [[Person alloc] init]; // 创建并持有对象,RC = 1
    [person release]; // 如果自己是持有者,在不需要使用的时候调用 release,RC = 0
    [person release]; // 这时候 person 指针为野指针,对象为僵尸对象
    [person release]; // 可能这时候僵尸对象所占的空间还没有分配给别人,所以可以正常访问

  以上几个例子都可以用一句话总结:不能释放非自己持有的对象。

以上就是内存管理基本的四条规则,你对照上篇文章中讲的《办公室里的照明问题》,是不是就比较好理解了,你细品,你细细的品!

一个简单的例子

Person 对象是使用alloc方法创建的,因此在不需要该对象时发送一条release消息。

代码语言:txt复制
{
    Person *aPerson = [[Person alloc] init];
    // ...
    NSString *name = aPerson.fullName;
    // ...
    [aPerson release];
}
使用 autorelease 发送延迟 release

当你需要发送延迟release消息时,可以使用autorelease,通常用在从方法返回对象时。例如,你可以像这样实现 fullName 方法:

代码语言:txt复制
- (NSString *)fullName {
    NSString *string = [[[NSString alloc] initWithFormat:@"%@ %@",
                                          self.firstName, self.lastName] autorelease];
    return string;
}

根据内存管理规则,你通过alloc方法创建并持有对象,要在不需要该对象时发送一条release消息。但是如果你在方法中使用release,则return之前就会销毁 NSString 对象,该方法将返回无效对象。使用autorelease,就会延迟release,在 NSString 对象被释放之前返回。

你还可以像这样实现 fullName 方法:

代码语言:txt复制
- (NSString *)fullName {
    NSString *string = [NSString stringWithFormat:@"%@ %@",
                                 self.firstName, self.lastName];
    return string;
}

根据内存管理规则,你不持有 NSString 对象,因此你不用担心它的释放,直接return即可。stringWithFormat 方法内部会给 NSString 对象调用autorelease方法。

相比之下,以下实现是错误的:

代码语言:txt复制
- (NSString *)fullName {
    NSString *string = [[NSString alloc] initWithFormat:@"%@ %@",
                                         self.firstName, self.lastName];
    return string;
}

在 fullName 方法内部我们通过alloc方法创建对象并持有,然而并没有释放对象。而该方法名不以 alloc/new/copy/mutableCopy 等开头。在调用方看来,通过该方法获得的对象并不持有,因此他会进行retain并在他不需要该对象时release,在他看来这样使用该对象没有内存问题。然而这时候该对象的引用计数为 1,并没有销毁,就发生了内存泄漏。

你不持有通过引用返回的对象

Cocoa 中的一些方法指定通过引用返回对象(它们采用ClassName **id *类型的参数)。常见的就是使用NSError对象,该对象包含有关错误的信息(如果发生错误),如initWithContentsOfURL:options:error:NSData)和initWithContentsOfFile:encoding:error:NSString)方法等。

在这些情况下,也遵从内存管理规则。当你调用这些方法时,你不会创建该NSError对象,因此你不持有该对象,也无需释放它,如以下示例所示:

代码语言:txt复制
    NSString *fileName = <#Get a file name#>;
    NSError *error;
    NSString *string = [[NSString alloc] initWithContentsOfFile:fileName
                            encoding:NSUTF8StringEncoding error:&error];
    if (string == nil) {
        // Deal with error...
    }
    // ...
    [string release];

实现 dealloc 以放弃对象的所有权

NSObject 类定义了一个dealloc方法,该方法会在一个对象没有所有者(RC=0)并且它的内存被回收时由系统自动调用 —— 在 Cocoa 术语中称为freeddeallocated

dealloc方法的作用是销毁对象自身的内存,并释放它持有的任何资源,包括任何实例变量的所有权。

以下举了一个在 Person 类中实现 dealloc方法的示例:

代码语言:txt复制
@interface Person : NSObject
@property (retain) NSString *firstName;
@property (retain) NSString *lastName;
@property (assign, readonly) NSString *fullName;
@end
 
@implementation Person
// ...
- (void)dealloc
    [_firstName release];
    [_lastName release];
    [super dealloc];
}

注意:

  • 切勿直接调用另一个对象dealloc的方法;
  • 你必须在实现结束时调用[super dealloc]
  • 你不应该将系统资源的管理与对象生命周期联系在一起,请参阅《不要使用 dealloc 管理稀缺资源》章节;
  • 当应用程序终止时,可能不会向对象发送dealloc消息。因为进程的内存在退出时会自动清除,所以让操作系统清理资源比调用所有对象的dealloc方法更有效。

Core Foundation 使用相似但不同的规则

Core Foundation 对象有类似的内存管理规则(请参阅 《 Core Foundation 内存管理编程指南》)。但是,Cocoa 和 Core Foundation 的命名约定不同。特别是 Core Foundation 的创建对象的规则(请参阅 《The Create Rule》)不适用于返回 Objective-C 对象的方法。例如以下的代码片段,你不负责放弃 myInstance 的所有权。因为在 Cocoa 中使用 alloc/new/copy/mutableCopy 等方法(或者以这些方法名开头的方法)创建的对象,我们才需要对其进行释放。

代码语言:txt复制
    MyClass * myInstance = [MyClass createInstance];

实用内存管理

尽管内存管理基本策略很简单,但是你可以采取一些措施来简化内存管理,并帮助确保程序保持可靠和健壮,同时最大程度地减少其资源需求。

使用访问器方法让内存管理更轻松

如果类中有对象类型的属性,则你必须确保在使用过程中该属性赋值的对象不被释放。因此,在赋值对象时,你必须持有对象的所有权,让其引用计数加 1。还必须要把当前持有的旧对象的引用计数减 1。

有时它可能看起来很乏味或繁琐,但如果你始终使用访问器方法,那么内存管理出现问题的可能性会大大降低。如果你在整个代码中对实例变量使用retainrelease,这肯定是错误的做法。

以下在 Counter 类中定义了一个NSNumber对象属性。

代码语言:txt复制
@interface Counter : NSObject
@property (nonatomic, retain) NSNumber *count;
@end;

@property会自动生成settergetter方法的声明,通常,你应该使用@synthesize让编译器合成方法。但如果我们了解访问器方法的实现是有益的。

@synthesize会自动生成settergetter方法的实现以及下划线实例变量,详细的解释将在下一篇ARC文章中讲到。

getter方法只需要返回合成的实例变量,所以不用进行retainrelease

代码语言:txt复制
- (NSNumber *)count {
    return _count;
}

setter方法中,如果其他所有人都遵循相同的规则,那么其他人很可能随时让新对象 newCount 的引用计数减 1,从而导致 newCount 被销毁,所以你必须对其retain使其引用计数加 1。你还必须对旧对象release以放弃对它的持有。所以,先对新对象进行retain,再对旧对象进行release,然后再进行赋值操作。(在Objective-C中允许给nil发送消息,且这样会直接返回不做任何事情。所以就算是第一次调用,_count 变量为nil,对其进行 release也没事。可以参阅《深入浅出 Runtime(三):消息机制》)

注意: 你必须先对新对象进行retain,再对旧对象进行release。顺序颠倒的话,如果新旧对象是同一对象,则可能会发生意外导致对象dealloc

代码语言:txt复制
- (void)setCount:(NSNumber *)newCount {
    [newCount retain];
    [_count release];
    // Make the new assignment.
    _count = newCount;
}

以上是苹果官方的做法,该做法在性能上略有不足,如果新旧对象是同一个对象,就存在不必要的方法调用。

更好的做法如下:先判断新旧对象是否是同一个对象,如果是的话就什么都不做;如果新旧对象不是同一个对象,则对旧对象进行release,对新对象进行retain并赋值给合成的实例变量。

代码语言:txt复制
- (void)setCount:(NSNumber *)newCount {
    if (_count != newCount) {
        [_count release];
        _count = [newCount retain];
    }
}
使用访问器方法设置属性值

假设我们要重置以上count属性的值。有以下两种方法:

代码语言:txt复制
- (void)reset {
    NSNumber *zero = [[NSNumber alloc] initWithInteger:0];
    [self setCount:zero];
    [zero release];
}
代码语言:txt复制
- (void)reset {
    NSNumber *zero = [NSNumber numberWithInteger:0];
    [self setCount:zero];
}

对于简单的情况,我们还可以像下面这样直接操作_count变量,但这样做迟早会发生错误(例如,当你忘记retainrelease,或者实例变量的内存管理语义(即属性关键字)发生更改时)。

代码语言:txt复制
- (void)reset {
    NSNumber *zero = [[NSNumber alloc] initWithInteger:0];
    [_count release];
    _count = zero;
}

另外请注意,如果使用KVO,则以这种方式更改变量不会触发KVO监听方法。关于KVO我做了比较全面的总结,可以参阅《iOS - 关于 KVO 的一些总结》。

不要在初始化方法和 dealloc 中使用访问器方法

你不应该在初始化方法和dealloc中使用访问器方法来设置实例变量,而是应该直接操作实例变量。

例如,我们要在初始化 Counter 对象时,初始化它的count属性。正确的做法如下:

代码语言:txt复制
- init {
    self = [super init];
    if (self) {
        _count = [[NSNumber alloc] initWithInteger:0];
    }
    return self;
}
代码语言:txt复制
- initWithCount:(NSNumber *)startingCount {
    self = [super init];
    if (self) {
        _count = [startingCount copy];
    }
    return self;
}

由于 Counter 类具有实例变量,因此还必须实现dealloc方法。在该方法中通过向它们发送release消息来放弃任何实例变量的所有权,并在最后调用super的实现:

代码语言:txt复制
- (void)dealloc {
    [_count release];
    [super dealloc];
}

以上是苹果官方的做法。推荐做法如下,在release之后再对 _count 赋值nil

备注: 先解释一下nilrelease的作用:nil是将一个对象的指针置为空,只是切断了指针和内存中对象的联系,并没有释放对象内存;而release才是真正释放对象内存的操作。 之所以在release之后再对 _count 赋值nil,是为了防止 _count 在被销毁之后再次被访问而导致Crash

代码语言:txt复制
- (void)dealloc {
    [_count release];
    _count = nil;
    [super dealloc];
}

我们也可以在dealloc通过self.count = nil;一步到位,因为通常它相当于[_count release];_count = nil;两步操作。但是苹果说了,不建议我们在dealloc中使用访问器方法。

代码语言:txt复制
- (void)dealloc {
    self.count = nil;
    [super dealloc];
}

Why?为什么初始化方法中需要self = [super init]

  • 先大概解释一下selfsuperself是对象指针,指向当前消息接收者。super是编译器指令,使用super调用方法是从当前消息接收者类的父类中开始查找方法的实现,但消息接收者还是子类。有关selfsuper的详细解释可以参阅《深入浅出 Runtime(四):super 的本质》。
  • 调用[super init],是子类去调用父类的init方法,先完成父类的初始化工作。要注意调用过程中,父类的init方法中的self还是子类。
  • 执行self = [super init],如果父类初始化成功,接下来就进行子类的初始化;如果父类初始化失败,则[super init]会返回nil并赋值给self,接下来if (self)语句的内容将不被执行,子类的init方法也返回nil。这样做可以防止因为父类初始化失败而返回了一个不可用的对象。如果你不是这样做,你可能你会得到一个不可用的对象,并且它的行为是不可预测的,最终可能会导致你的程序发生Crash

Why?为什么不要在初始化方法和 dealloc 中使用访问器方法?

  • 在初始化方法和dealloc中,对象的存在与否还不确定,它可能还未初始化完毕,所以给对象发消息可能不会成功,或者导致一些问题的发生。
    • 进一步解释,假如我们在init中使用setter方法初始化实例变量。在init中,我们会调用self = [super init]对父类的东西先进行初始化,即子类先调用父类的init方法(注意: 调用的父类的init方法中的self还是子类对象)。如果父类的init中使用setter方法初始化实例变量,且子类重写了该setter方法,那么在初始化父类的时候就会调用子类的setter方法。而此时只是在进行父类的初始化,子类初始化还未完成,所以可能会发生错误。
    • 在销毁子类对象时,首先是调用子类的dealloc,最后调用[super dealloc](这与init相反)。如果在父类的dealloc中调用了setter方法且该方法被子类重写,就会调用到子类的setter方法,但此时子类已经被销毁,所以这也可能会发生错误。
    • 《Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法》书中的第 31 条 —— 在 dealloc 方法中只释放引用并解除监听 一文中也提到:在 dealloc 里不要调用属性的存取方法,因为有人可能会覆写这些方法,并于其中做一些无法在回收阶段安全执行的操作。此外,属性可能正处于 “键值观测”(Key-Value Observation,KVO)机制的监控之下,该属性的观察者(observer)可能会在属性值改变时 “保留” 或使用这个即将回收的对象。这种做法会令运行期系统的状态完全失调,从而导致一些莫名其妙的错误。
    • 综上,错误的原因由继承和子类重写访问器方法引起。在初始化方法和 dealloc 中使用访问器方法的话,如果存在继承且子类重写了访问器方法,且在方法中做了一些其它操作,就很有可能发生错误。虽然一般情况下我们可能不会同时满足以上条件而导致错误,但是为了避免错误的发生,我们还是规范编写代码比较好。
  • 性能下降。特别是,如果属性是atomic的。
  • 可能产生副作用。如使用KVO的话会触发KVO等。

不过,有些情况我们必须破例。比如:

  • 待初始化的实例变量声明在父类中,而我们又无法在子类中访问此实例变量的话,那么我们在初始化方法中只能通过setter来对实例变量赋值。

使用弱引用来避免 Retain Cycles

retain对象会创建对该对象的强引用(即引用计数 1)。一个对象在release它的所有强引用之后(即引用计数 =0)才会dealloc。如果两个对象相互retain强引用,或者多个对象,每个对象都强引用下一个对象直到回到第一个,就会出现 “Retain Cycles(循环引用)” 问题。循环引用会导致它们中的任何对象都无法dealloc,就产生了内存泄漏。

举个例子,Document 对象中有一个属性 Page 对象,每个 Page 对象都有一个属性,用于存储它所在的 Document。如果 Document 对象具有对 Page 对象的强引用,并且 Page 对象具有对 Document 对象的强引用,则它们都不能被销毁。

Retain Cycles” 问题的解决方案是使用弱引用。弱引用是非持有关系,对象do not retain它引用的对象。

MRC中,这里的 “弱引用” 是指do not retain,而不是ARC中的weak

但是,为了保持对象图完好无损,必须在某处有强引用(如果只有弱引用,则 Page 对象和 Paragraph 对象可能没有任何所有者,因此将被销毁)。因此,Cocoa 建立了一个约定,即父对象应该对其子对象保持强引用(retain),而子对象应该对父对象保持弱引用(do not retain)。

因此,Document 对象具有对其 Page 对象的强引用,但 Page 对象对 Document 对象是弱引用,如下图所示:

Cocoa 中弱引用的示例包括但不限于 table data sources、outline view items、notification observers 以及其他 targets 和 delegates。

当你向只持有弱引用的对象发送消息时,需要小心。如果在对象销毁后向其发送消息就会Crash。你必须定义好什么时候对象是有效的。在大多数情况下,弱引用对象知道其它对象对它的弱引用,就像循环引用的情况一样,你要负责在弱引用对象销毁时通知其它对象。例如,当你向通知中心注册对象时,通知中心会存储对该对象的弱引用,并在发布相应的通知时向其发送消息。在对象要销毁时,你需要在通知中心注销它,以防止通知中心向已销毁的对象发送消息。同样,当 delegate 对象销毁时,你需要向委托对象发送setDelegate: nil消息来删除 delegate 引用。这些消息通常在对象的 dealloc 方法中发送。

避免导致你正在使用的对象被销毁

Cocoa 的所有权策略指定,对象作为方法参数传入,其在调用的方法的整个范围内保持有效,也可以作为方法的返回值返回,而不必担心它被释放。对于应用程序来说,对象的 getter 方法返回缓存的实例变量或计算值并不重要。重要的是对象在你需要的时间内保持有效。

此规则偶尔会有例外情况,主要分为两类。

  1. 从一个基本集合类中删除对象时。
代码语言:txt复制
    heisenObject = [array objectAtIndex:n];
    [array removeObjectAtIndex:n];
    // heisenObject could now be invalid.

当一个对象从一个基本集合类中移除时,它将被发送一条release(而不是autorelease)消息。如果集合是移除对象的唯一所有者,则移除的对象(示例中的 heisenObject)将立即被销毁。

  1. 当 “父对象” 被销毁时。
代码语言:txt复制
    id parent = <#create a parent object#>;
    // ...
    heisenObject = [parent child] ;
    [parent release]; // Or, for example: self.parent = nil;
    // heisenObject could now be invalid.

在某些情况下,你通过父对象获得子对象,然后直接或间接release父对象。如果release父对象导致它被销毁,并且父对象是子对象的唯一所有者,则子对象(示例中的 heisenObject)将同时被销毁(假设在父对象的dealloc方法中,子对象被发送一个release而不是一个autorelease消息)。

为了防止这些情况发生,在得到 heisenObject 时retain它,并在完成后release它。例如:

代码语言:txt复制
    heisenObject = [[array objectAtIndex:n] retain];
    [array removeObjectAtIndex:n];
    // Use heisenObject...
    [heisenObject release];

不要使用 dealloc 来管理稀缺资源

你通常不应该在dealloc方法中管理稀缺资源,如文件描述符,网络连接和缓冲区或缓存等。特别是,你不应该设计类,以便在你想让系统调用dealloc时就调用它。由于bug或应用程序崩溃,dealloc的调用可能会被延迟或未调用。

相反,如果你有一个类的实例管理稀缺的资源,你应该在你不再需要这些资源时让该实例释放这些资源。然后,你通常会release该实例,紧接着它dealloc。如果该实例的dealloc没有被及时调用或者未调用,你也不会遇到稀缺资源不被及时释放或者未释放的问题,因为此前你已经释放了资源。

如果你尝试在dealloc上进行资源管理,则可能会出现问题。例如:

  1. 依赖对象图的释放机制。 对象图的释放机制本质上是无序的。尽管通常你希望可以按照特定的顺序释放,但是会让程序变得很脆弱。如果对象被autorelease而不是release,则释放顺序可能会改变,这可能会导致意外的结果。
  2. 不回收稀缺资源。 内存泄漏是应该被修复的bug,但它们通常不会立即致命。然而,如果在你希望释放稀缺资源时没有释放,则可能会遇到更严重的问题。例如,如果你的应用程序用完了文件描述符,则用户可能无法保存数据。
  3. 释放资源的操作被错误的线程执行。 如果一个对象在一个意外的时间调用了autorelease,它将在它碰巧进入的任何一个线程的自动释放池块中被释放。对于只能从一个线程触及的资源来说,这很容易致命。

集合持有它们包含的对象

将对象添加到集合(例如arraydictionaryset)时,集合将获得对象的所有权。当从集合中移除对象或集合本身被销毁时,集合将放弃对象的所有权。因此,例如,如果要创建一个存储numbers的数组,可以执行以下任一操作:

代码语言:txt复制
    NSMutableArray *array = <#Get a mutable array#>;
    NSUInteger i;
    // ...
    for (i = 0; i < 10; i  ) {
        NSNumber *convenienceNumber = [NSNumber numberWithInteger:i];
        [array addObject:convenienceNumber];
    }

在这种情况下,NSNumber对象不是通过alloc等创建,因此无需调用release。也不需要对NSNumber对象进行retain,因为数组会这样做。

代码语言:txt复制
    NSMutableArray *array = <#Get a mutable array#>;
    NSUInteger i;
    // ...
    for (i = 0; i < 10; i  ) {
        NSNumber *allocedNumber = [[NSNumber alloc] initWithInteger:i];
        [array addObject:allocedNumber];
        [allocedNumber release];
    }

在这种情况下,你就需要对NSNumber对象进行release。数组会在addObject:时对NSNumber对象进行retain,因此在数组中它不会被销毁。

要理解这一点,可以站在实现集合类的人的角度。你要确保在集合中它们不会被销毁,所以你在它们添加进集合时给它们发送一个retain消息。如果删除了它们,则必须给它们发送一个release消息。在集合的dealloc方法中,应该向集合中所有剩余的对象发送一条release消息。

所有权策略是通过使用 Retain Counts 实现的

所有权策略通过引用计数实现的,引用计数也称为“retain count”。每个对象都有一个retain count

  • 创建对象时,其retain count为 1。
  • 向对象发送retain消息时,其retain count将 1。
  • 向对象发送release消息时,其retain count将 -1。
  • 向对象发送autorelease消息时,其retain count在当前自动释放池块结束时 -1。
  • 如果对象的retain count减少到 0,它将dealloc

重要提示: 不应该显式询问对象的retain count是多少。结果往往会产生误导,因为你可能不知道哪些系统框架对象retain了你关注的对象。在调试内存管理问题时,你只需要遵守内存管理规则就行了。 备注: 关于这些方法的具体实现,请参阅《iOS - 老生常谈内存管理(四):源码分析内存管理方法》

使用 Autorelease Pool Blocks

自动释放池块提供了一种机制,让你可以放弃对象的所有权,但避免立即释放它(例如从方法返回对象时)。通常,你不需要创建自己的自动释放池块,但在某些情况下,你必须这样做或者这样做是有益的。

关于 Autorelease Pool Blocks

Autorelease Pool Blocks 使用@autoreleasepool标记,示例如下:

代码语言:txt复制
    @autoreleasepool {
        // Code that creates autoreleased objects.
    }

@autoreleasepool的末尾,在块中接收到autorelease消息的对象将被发送一条release消息。对象在块内每接收一次autorelease消息,就会被发送一条release消息。

与任何其他代码块一样,@autoreleasepool可以嵌套,但是你通常不会这样做。

代码语言:txt复制
    @autoreleasepool {
        // . . .
        @autoreleasepool {
            // . . .
        }
        . . .
    }

MRC下还可以使用NSAutoreleasePool创建自动释放池。不过建议使用@autoreleasepool,苹果说它比NSAutoreleasePool快大约六倍。

代码语言:txt复制
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    // Code benefitting from a local autorelease pool.
    [pool release]; // [pool drain]

Cocoa 总是希望代码在@autoreleasepool中执行,否则autorelease对象不会被release,导致内存泄漏。如果你在@autoreleasepool之外发送autorelease消息,Cocoa 会打印一个合适的错误消息。AppKit 和 UIKit 框架会在RunLoop每次事件循环迭代中创建并处理@autoreleasepool,因此,你通常不必自己创建@autoreleasepool,甚至不需要知道创建@autoreleasepool的代码怎么写。

但是,有三种情况可能会使用你自己的@autoreleasepool

  • ① 如果你编写的程序不是基于 UI 框架的,比如说命令行工具;
  • ② 如果你编写的循环中创建了大量的临时对象; 你可以在循环内使用@autoreleasepool在每次循环结束时销毁这些对象。这样可以减少应用程序的最大内存占用。
  • ③ 如果你创建了辅助线程。 一旦线程开始执行,就必须创建自己的@autoreleasepool;否则,你的应用程序将存在内存泄漏。(有关详细信息,请参阅《Autorelease Pool Blocks 和线程》章节。

关于@autoreleasepool的底层原理,可以参阅《iOS - 聊聊 autorelease 和 @autoreleasepool》。

使用 Local Autorelease Pool Blocks 来减少峰值内存占用量

许多程序创建autorelease的临时对象。这些对象将添加到程序的内存占用空间,直到块结束。在许多情况下,允许临时对象累积直到当前事件循环迭代结束时,而不会导致过多的开销。但是,在某些情况下,你可能会创建大量临时对象,这些对象会大大增加内存占用,并且你希望更快地销毁这些对象。在这时候,你就可以创建自己的@autoreleasepool。在块结束时,临时对象被release,这可以让它们尽快dealloc,从而减少程序的内存占用。

以下示例演示了如何在 for 循环中使用 local autorelease pool block。

代码语言:txt复制
    NSArray *urls = <# An array of file URLs #>;
    for (NSURL *url in urls) {
 
        @autoreleasepool {
            NSError *error;
            NSString *fileContents = [NSString stringWithContentsOfURL:url
                                             encoding:NSUTF8StringEncoding error:&error];
            /* Process the string, creating and autoreleasing more objects. */
        }
    }

for 循环一次处理一个文件。在@autoreleasepool内发送autorelease消息的任何对象(例如 fileContents)在块结束时release

@autoreleasepool之后,你应该将块中任何autorelease对象视为 “已销毁”。不要向该对象发送消息或将其返回给你的方法调用者。如果你需要某个autorelease的临时对象在@autoreleasepool结束之后依然可用,可以通过在块内对该对象发送retain消息,然后在块之后将对其发送autorelease,如下示例所示:

代码语言:txt复制
– (id)findMatchingObject:(id)anObject {
 
    id match;
    while (match == nil) {
        @autoreleasepool {
 
            /* Do a search that creates a lot of temporary objects. */
            match = [self expensiveSearchForObject:anObject];
 
            if (match != nil) {
                [match retain]; /* Keep match around. */
            }
        }
    }
 
    return [match autorelease];   /* Let match go and return it. */
}

@autoreleasepool中给match对象发送一条retain消息,并在@autoreleasepool之后给其发送一条autorelease消息,延长了match对象的生命周期,允许它在while循环外接收消息,并且可以返回给findMatchingObject:方法的调用方。

Autorelease Pool Blocks 和线程

Cocoa 应用程序中的每个线程都维护自己的 autorelease pool blocks 栈。如果你写的是一个仅基于 Foundation 的程序或者如果你使用子线程,则需要创建自己的@autoreleasepool

如果你的应用程序或线程长期存在并且可能会产生大量的autorelease对象,则应使用@autoreleasepool(如 AppKit 和 UIKit 就在主线程创建了@autoreleasepool);否则,autorelease对象会不断累积,导致你的内存占用量不断增加。如果你在子线程上没有进行 Cocoa 调用,则不需要使用@autoreleasepool

注意: 如果你使用pthreadPOSIX thread)而不是使用NSThread创建子线程,那么你就不能使用 Cocoa 除非 Cocoa 处于多线程模式。Cocoa 只有在detach它的第一个NSThread对象之后才会进入多线程模式。要想在pthread创建的子线程上使用 Cocoa,你的应用程序必须先detach至少一个可以立即退出的NSThread对象。你可以使用NSThread的类方法isMultiThreaded测试 Cocoa 是否处于多线程模式。

0 人点赞