Matrixzk’s Blog

keep moving

OC内存管理那些事儿(2):内存管理实践

Nov 11th, 2014

虽然上篇文章中所介绍的基本概念简单明了,但仍有一些实用的套路可使内存管理更加简单,而且能帮忙确保在最大限度得减少资源需求的同时,程序依然能够可靠健壮得运行。

使用存取器方法让内存管理变得更加简单

如果类中有一个对象属性,就必须确保给该对象赋的任何值在使用的过程中都不会被释放掉。因此在给该对象赋值时就要显式得持有它。之后也必须确保释放所有当前所持有的对象。

这些有时看起来可能有些冗余或者形式化,但如果使用存取器方法(accessor method),在管理内存的过程中出错的几率将会大大降低。如果你的代码中的实例变量到处都是retainrelease操作,那几乎可以确定,你已踏上了一条不归路。

思考如下一个Counter类,它有一个count属性:

1
2
3
@interface Counter : NSObject
@property (nonatomic, retain) NSNumber *count;
@end;

这里的属性(property)声明了两个存取器方法(accessor method)。通常编译器能帮忙自动合成这两个方法,不过,看一下它们具体实现还是有好处的。

get访问器中,只是返回一个合成的实例变量,所以不需要retain或者release

1
2
3
- (NSNumber *)count {
    return _count;
}

set方法中,如果别人也按照相同的规则操作count,就不得不假设新的count随时可能被释放掉,所以此时应该持有它,即向它发送一个retain消息,以确保在使用它的过程中不会被释放掉。与此同时,应该给之前的count对象发送release消息以放弃对它的所有权(在 Objective-C 中时允许给nil发送消息的,所以即使_count还没被赋值,这里依然可以正常工作)。即使新旧两个值是同一个对象,也要在[newCount retain]之后给其发送release消息,毕竟你并不想无意中导致它被释放嘛。

1
2
3
4
5
6
- (void)setCount:(NSNumber *)newCount {
    [newCount retain];
    [_count release];
    // Make the new assignment.
    _count = newCount;
}

使用存取器方法给属性赋值

假设要实现一个方法来重置count的值,有若干方案。第一种实现是,使用alloc创建一个NSNumber实例,为平衡所有权随后release它。

1
2
3
4
5
- (void)reset {
    NSNumber *zero = [[NSNumber alloc] initWithInteger:0];
    [self setCount:zero];
    [zero release];
}

第二种实现方式,使用便利构造函数( convenience constructor )来创建NSNumber对象,因此此时也就不需要retain或者release操作:

1
2
3
4
- (void)reset {
    NSNumber *zero = [NSNumber numberWithInteger:0];
    [self setCount:zero];
}

注意,这两种方式都使用了set存取器。

接下来的实现方式在简单的场景下也可以正常工作,这里看似避开了存取器方法,但这样做有时几乎肯定会导致出错(比如,当忘记retainrelease时,或者改变了该实例变量的内存管理语义(semantics))。

1
2
3
4
5
- (void)reset {
    NSNumber *zero = [[NSNumber alloc] initWithInteger:0];
    [_count release];
    _count = zero;
}

注意,如果使用了 KVO (key-value observing,即键值观察),使用上述方式改变这个变量是不会引起 KVO 响应的。

不要在初始化( Initializer )方法和 dealloc 方法中使用存取器方法

初始化( Initializer )方法和dealloc方法,是唯一不该使用存取器方法来给实例变量赋值的地方。使用number对象初始化一个初始值为0的counter对象,可以如下实现init方法:

1
2
3
4
5
6
7
- (instancetype)init {
    self = [super init];
    if (self) {
        _count = [[NSNumber alloc] initWithInteger:0];
    }
    return self;
}

如果想通过一个count参数来初始化counter,可以如下实现一个initWithCount:方法:

1
2
3
4
5
6
7
- (instancetype)initWithCount:(NSNumber *)startingCount {
    self = [super init];
    if (self) {
        _count = [startingCount copy];
    }
    return self;
}

由于Counter类有一个对象实例变量,所以它必须实现dealloc方法,在这个方法中给其所有实例变量发送一个release消息来放弃对它们的所有权,并在该方法最后调用父类即superdealloc方法:

1
2
3
4
- (void)dealloc {
    [_count release];
    [super dealloc];
}

使用弱引用来避免循环引用

retain一个对象就相当于创建了一个指向它的强引用(strong reference)。一个对象只有当它的所有强引用都被 release 后才会被释放掉(deallocated)。因此,这就有可能导致两个对象循环引用的问题,我们把这称为 retain cycle,即两个对象对彼此都有一个强引用(可能是两者间直接的强引用,也可能是通过一些其他的对象形成了一个首尾相接的强引用环)。

右图所示的对象关系就有可能导致循环引用。这里的Document对象含有一个Page对象来表示文档的每一页。每个Page对象都保留一个对其所在的文档的引用。如果Document对象有一个指向Page对象的强引用,同时Page对象也有一个指向Document对象的强引用,这样它们两个就永远都不会被释放掉了。直到Page对象被释放后,Document对象的引用计数才会变为0,而Page对象也同样要等Document对象释放后才会被释放。

解决这类循环引用问题的办法是使用弱引用(weak reference)。弱引用是指源对象不会持有它所引用的对象的一种无从属的关系。

然而为了维持object graph的完整性,有些地方必须使用强引用(如果只使用弱引用,右图中的 page 和 paragraph 就没有了持有者,也因此会被释放掉)。源于此,Cocoa 约定,“父”对象应该使用强引用来持有它的“孩子们”(即属性),而“孩子们”在引用“父”对象时应该使用弱引用。

因此,上图中的document对象有一个指向page对象的强引用(以持有它),而page对象用一个弱引用来指向document对象(不持有它)。

比如在 Cocoa 中有以下几个地方就使用到了弱引用,tableView 的 data source(数据源),outlineView 的 item,通知的观察者,以及各种targetdelegate

当给通过弱引用所持有的对象发送消息时要特别小心。给一个已被释放的对象发送消息,会导致应用 crash。必须清楚地知道一个对象何时是无效的。在多数情况下,被弱引用的对象要知道哪些对象弱引用了它,并且负责在它被释放时通知这些对象。 比如,当在通知中心( notification center )注册一个对象时,通知中心持有的是这个对象的弱引用,并在相应的通知发出时给它发送消息。当这个对象被释放时,同时应该在通知中心把它注销掉,这样可以防止这之后通知中心再给这个已被释放的对象发送消息。同样的,当一个 delegate 对象被释放时,应该给相应的对象发送一个以 nil 为参数的 setDelegate: 消息,以移除它对该 delegate 的引用。上述这些消息通常在对象的 dealloc 方法中发送。
译者注: 当一个对象被释放后,如果没有显式得给它置为nil,它就成了一个野指针,此时再给它发送消息,会导致一个报错为EXC_BAD_ACCESS的 crash。

要避免对象在使用过程中被释放

Cocoa 的所有权策略指出,获取来的对象通常应该确保在使用它的方法的整个作用域内都始终有效。同时也要确保可以放心得从当前作用域中返回该对象而不用担心它在某个时刻被释放掉。从getter方法返回的是一个缓存的实例变量还是一个计算得来的值并不重要,重要的是在任何时候用到这个对象时,它都应该是始终有效的。

对于上述规则有一些例外情况,大概可分为如下两类:

  1. 当对象被从基本的集合类中移除时。

     heisenObject = [array objectAtIndex:n];
     [array removeObjectAtIndex:n];
     // heisenObject could now be invalid.
    

    当一个对象从基本的集合类中被移除时,它会接收到一个release消息(而不是autorelease)。如果这个集合是该被移除对象的唯一持有者,该对象(此例中的heisenObject)将会立即被释放掉。

  2. 当一个“父对象”被释放时。

     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。例如:

1
2
3
4
heisenObject = [[array objectAtIndex:n] retain];
[array removeObjectAtIndex:n];
// Use heisenObject...
[heisenObject release];

不要在 dealloc 方法中管理稀缺资源

通常不应该在dealloc方法中管理稀缺资源( scarce resources ),比如文件描述符( file descriptors )、网络连接和缓冲池( buffers )或缓存( caches )。特别注意,在设计类时不应该想当然得认为dealloc方法会在你认为的时刻被调用。有时由于某个 bug 或者应用被销毁掉( tear-down ),dealloc的调用可能会被延迟,或者干脆就不会被调用。

如果你的类有一个管理稀缺资源的属性,那么通常应该在你认为不再需要这些资源时,告知这个属性,先让它把这些资源“清理掉”,然后再 release 这个属性,这之后dealloc方法将会被调用。这样做的好处是,即使dealloc方法没被调用也不会引其他问题。

如果试图在dealloc方法顶部进行资源管理的操作,很容易引起问题。比如:

  1. object graph被销毁时的顺序依赖关系

    object graph的销毁机制是内在无序的,尽管我们通常期望也确实得到了一个特定的顺序,但这个是不可靠的。例如,如果一个对象被意外得通过 autorelease 而非 release 的方式释放掉了,其 object graph 的销毁顺序就会被改变,并可能导致一些未知的结果。

  2. 稀缺资源未被回收

    会导致内存泄漏的 bug 必须修复,它们通常不会立刻使程序崩溃。然而,如果稀缺资源没有在希望释放的时候被释放掉,这可能会导致更严重的问题。比如,如果应用耗尽了文件描述符( file descriptor ),用户就不能保存数据了。

  3. 资源清理的逻辑在错误的线程中执行

    如果对象在一个意外的时间点儿被 autorelease,无论此时它处于哪个线程的自动释放池中,都会被释放掉。这对于那些只能在某一个线程中被访问的资源,很可能是致命的。

集合类持有它所包含对象的所有权

当把一个对象添加到一个集合(比如arraydictionary,或set)中时,该集合将持有这个对象。当该对象从这个集合中移除,或者这个集合本身被释放时,这个集合释放该对象。因此,如果想创建一个 number 数组,有如下两种实现方式:

1
2
3
4
5
6
7
NSMutableArray *array = <#Get a mutable array#>;
NSUInteger i;
// ...
for (i = 0; i < 10; i++) {
    NSNumber *convenienceNumber = [NSNumber numberWithInteger:i];
    [array addObject:convenienceNumber];
}

在这种情况下,由于没有调用 alloc,因此也就不需要调用release。这里不需要retain新创建的 number ( convenienceNumber ),因为数组将会替你做。

1
2
3
4
5
6
7
8
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];
}

在这种情况下,由于 number 是通过 alloc 创建的,所以需要在当前 for 循环的作用域内给其发送 release 消息(来平衡 alloc)。这里不需要retain新创建的 number ( convenienceNumber ),因为数组将会替你做。由于数组在通过addObject:添加 number 时 retain 了它,所以在该数组内它将不会被释放掉。

为了更好的理解上述问题,我们站在集合类实现者的角度来看。为了确保所有你所托管的对象都不会在你这里突然消失掉,就需要在它们传进来时给其发送一个retain消息。当他们被移除时,发送一个对应的release消息,并且当你被释放时,此时所持有的所有对象都应该在你自己的dealloc方法中接收到一个release消息。

所有权策略通过引用计数实现

所有权策略是基于引用计数(通常在retain方法之后称之为retain count)实现的。每个对象都有一个引用计数。

  • 当创建一个对象时,其引用计数为1.
  • 当给一个对象发送retain消息时,其引用计数加1.
  • 当给一个对象发送release消息时,其引用计数减1.
    当给一个对象发送autorelease消息时,其引用计数将会在该自动释放池结束时减1.
  • 当一个对象的引用计数减为0时,它将被释放掉。

重要: 不要试图让对象给出其当前精确的引用计数。这个结果通常是不准确的,因为可能有你并不知道的 framework 的对象也 retain 了该对象。在调试内存管理问题时,只需要把注意力放在确保你的代码遵从上述所有权规则就行了。

返回顶部