美文网首页iOS进阶iOS Developer
高级内存管理编程指南

高级内存管理编程指南

作者: BoomLee | 来源:发表于2017-10-09 18:38 被阅读1003次

    1.简介

    应用程序的内存管理就是在程序的运行期开辟内存,使用内存,使用完毕后释放内存。一个好的程序使用尽可能少的内存。在Objective-C中,内存管理可以认为是一种分配有限内存资源的所有权给多种数据和代码的方式。当你读完这篇文章时,你应该具备管理你程序内存的知识,明确地管理对象的生命周期并在对象不在需要时释放它们。
    尽管内存管理通常只在个别对象的层次考虑,但是我们的目标是管理对象图表。你应该确保内存中不会有实际不需要的对象。

    • 对象图表

    概览

    Objective-C提供了两种方式管理内存

    • 本篇文章用到的方法,被称为“手动持有-释放”,英文为“manual retain-release”,或者叫MRR,通过追踪你持有的对象来明确地管理内存。它通过一种由NSObject提供的叫做引用计数的模型与运行时系统一同来实现。
    • 在自动引用计数(ARC)中,系统像MRR一样使用同样的引用计数系统,只不过ARC在编译阶段帮你插入了合适的内存管理方法调用。虽然现在很少使用MRR,但是了解MRR在某些情况下还是很有用的。

    好的习惯可以避免内存相关问题

    错误的内存管理会导致两类主要的内存问题

    • 释放或者重写仍在使用的数据
      这会导致内存崩溃,通常会导致应用崩溃,更糟的会丢失用户数据。
    • 不释放不再使用的数据导致内存泄漏
      内存泄漏就是开辟的内存不再使用时未被释放。这会导致你的应用使用持续增加的内存,反过来会导致低下的系统性能或者应用被终止。

    但是,从引用计数的角度思考内存管理通常达不到预期效果,因为你趋向于依据实现细节而不是实际目标来思考内存管理。相反,应该从对象所有权和对象图表的角度去思考内存管理。

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

    想在编译阶段找出代码的内存问题,可以使用Clang的静态分析器,快捷键是:command + shift + B。
    如果内存管理问题依旧出现,还有其它的工具和技术来帮助识别和诊断问题所在。



    2.内存管理策略

    在一个引用计数的环境下内存管理的基础模型是由定义在NSObject协议的一组方法和一个标准的方法命名规则提供的。NSObject类也定义了一个dealloc方法,当一个对象被销毁(deallocated)时这个方法会自动调起。本节描述了所有基本的规则,你需要了解这些规则来正确的管理Cocoa项目的内存,并且提供了一些正确使用内存的例子。

    2.1.基本的内存管理规则

    内存管理模型是基于对象所有权的。一个对象也许有一个或多个所有者(owners)。一个对象只要还拥有至少一个所有者,它就会继续存活下去。如果没有所有者,运行时系统会自动销毁这个对象。为了明确何时你拥有一个对象,何时你不拥有一个对象,Cocoa设置了以下策略:

    • 你拥有任何你创建的对象
      使用以“alloc”, “new”, “copy”, 或者 “mutableCopy”开头的方法创建一个对象(例如allocnewObject或者mutableCopy)。
    • 使用retain可以获得对象的所有权
      一个接受的对象通常在方法内是可以保证持续有效的,方法可也以安全的将这个对象返回给它的调用者。在两种情况下使用retain:1.在获取方法的实现或者init方法来持有一个你想存储为属性的对象的所有权;2.避免由于一些其它操作的副作用导致对象变得无效。
    • 当你不再需要一个对象时,你必须放弃你拥有的对象的所有权
      通过向一个对象发送release或者autorelease消息来放弃这个对象的所有权。以Cocoa术语,放弃一个对象的所有权通常被称为释放(“releasing”)一个对象。
    • 你不可以放弃一个你不拥有的对象的所有权
      这只是以上策略的推论。

    一个简单的例子

    为了阐明以上策略,思考以下代码片段:

    Person *aPerson = [[Person alloc] init];
        // ...
        NSString *name = aPerson.fullName;
        // ...
        [aPerson release];
    

    这个Person对象是通过alloc方法创建的,因此接下来当它不再需要时收到了一条release消息。但是注意,这个例子使用了release而不是autorelease

    使用autorelease发送一条延迟释放消息

    使用autorelease来发送一条延迟释放消息,通常用在从一个方法返回一个对象。例如,可以这样实现fullName方法:

    - (NSString *)fullName {
        NSString *string = [[[NSString alloc] initWithFormat:@"%@ %@",
                                              self.firstName, self.lastName] autorelease];
        return string;
    }
    

    你拥有这个通过alloc返回的字符串。为了遵守内存管理规则,你必须在失去对它的引用前放弃这个字符串的所有权。但是,如果使用release,这个字符串会在被返回前就被销毁(这个方法会返回一个无效的对象)。使用autorelease意味着你想要放弃所有权,但是你允许方法的调用者在返回值被销毁前使用它。
    也可以像下面这样实现fullName方法:

    - (NSString *)fullName {
        NSString *string = [NSString stringWithFormat:@"%@ %@",
                                     self.firstName, self.lastName];
        return string;
    }
    

    根据基础规则,你不拥有通过stringWithFormat:返回的字符串,所以可以安全的从这个方法返回这个字符串。
    相反,下面的实现就是错误的:

    - (NSString *)fullName {
        NSString *string = [[NSString alloc] initWithFormat:@"%@ %@",
                                             self.firstName, self.lastName];
        return string;
    }
    

    根据命名规则,fullName方法的调用者并不拥有返回的字符串。调用者因此没有理由去释放这个返回值,进而导致内存泄漏。

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

    Cocoa的一些方法指定一个对象是通过引用返回的,也就是说这个方法使用一个ClassName **或者id *类型的参数。一个通用的模式是使用一个NSError对象,它包含了错误的信息。
    例如NSDatainitWithContentsOfURL:options:error:方法,或者NSStringinitWithContentsOfFile:encoding:error:方法。
    在这些情况下,同样的内存管理规则是适用的。当你唤起任何这样的方法,你并没有创建NSError对象,因此你并不拥有它。因此也就没必要去释放它,像下面的例子这样:

    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];
    

    2.2.实现dealloc来放弃对象的所有权

    NSObject类定义了一个dealloc方法,当一个对象没有所有者并且这个对象的内存被回收时会自动调起这个方法--用Cocoa的术语就是这个对象被释放或者销毁了。dealloc方法的角色是释放这个对象自己的内存,清除任何它持有的资源,包括任何对象实例变量的所有权。
    下面的例子演示了如何为一个Person类实现一个dealloc方法:

    @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];
    }
    @end
    

    重要:永远不要直接调用另一个对象的dealloc方法。
    必须在实现的最后调用父类的实现。
    当程序终止时,对象也许不会收到dealloc消息。因为在退出时进程的内存会自动被清理,让操作系统来清理内存要比调起所有的内存管理方法高效的多。

    2.3.Core Foundation适用相似但不同的规则

    对于Core Foundation对象有相似的内存管理规则,详见Memory Management Programming Guide for Core Foundation。但是对于Cocoa和Core Foundation的命名规则是不同的。尤其是Core Foundation的创建规则(详见The Create Rule)并不适用于返回Objective-C对象的方法。例如下面的代码片段,你并不负责放弃myInstance的所有权:

    MyClass *myInstance = [MyClass createInstance];
    


    3.实用的内存管理

    尽管上面介绍的基础概念非常简单,但是依然有一些实用的技巧可以让管理内存更加容易,确保你的程序在最小化内存开销时仍然可靠,健壮。

    3.1.使用存取方法让内存管理更加简单

    如果你的类有一个对象类型的存取属性,必须确保任何设置为这个值的对象在使用期间不会被销毁。因此在设置这个对象时必须认领其所有权。同样必须确保稍后放弃当前持有值的所有权。
    有时这看起来有些冗长和迂腐的,但是如果你持续使用存取方法,那么出现内存管理错误的概率会大大降低。如果你的代码中的实例变量充斥着retainrelease,那么实在是在做一件错事。
    假设一个计数器对象,你想设置它的值。

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

    这个属性声明了两个存取方法。通常的你应该请求编译器来合成这两个方法;但是如果了解它们是如何实现的是非常有意义的。
    get方法,只需要返回合成的实例变量,因此不需要retainrelease

    - (NSNumber *)count {
        return _count;
    }
    

    set方法,如果其它所有对象都遵循同样的规则那么必须假设这个新值也许会在任何时候被丢弃,因此你必须持有这个对象的所有权--通过向它发送一个retain消息--来确保这个新值不会被丢弃。同样也必须放弃旧值的所有权通过向旧值发送一个release消息(在Objective-C中向nil发送消息是允许的,如果_count还没有被赋值的话那么实现仍会起作用)。必须在[newCount retain]之后发送release消息以免新值和旧值是同一个对象--你绝对不想意外的让这个对象被销毁。

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

    使用存取方法设置属性值

    假设你想实现一个方法来重置这个计数器。你有几个选择。第一个实现是使用alloc创建这个NSNumber实例,因此需要发送release消息来达到平衡。

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

    其次是使用一个便利构造器来创建一个新的NSNumber对象。因此没必要发送retainrelease消息

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

    注意以上两个方法都使用了set方法。
    下面的方法对于简单的例子大部分情况下也会正确工作,但是像绕开存取方法一样诱人,这样做将会很可能在某些阶段导致错误(例如,当你忘记了retain或者release,或者对这个实例变量的内存管理语义发生变化时)。

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

    注意,如果你使用KVO,使用这种方式改变这个变量是不被KVO允许的。

    不要在初始化和dealloc方法中使用存取方法

    只有两个地方不应该使用存取方法来设置一个实例变量:初始化方法和dealloc方法。以一个代表0的值对象初始化一个计数器对象,可以像下面这样实现一个init方法:

    - init {
        self = [super init];
        if (self) {
            _count = [[NSNumber alloc] initWithInteger:0];
        }
        return self;
    }
    

    为了初始化一个非0的计数器对象,可以像下面这样实现一个initWithCount:方法:

    - initWithCount:(NSNumber *)startingCount {
        self = [super init];
        if (self) {
            _count = [startingCount copy];
        }
        return self;
    }
    

    因为计数器类有一个对象类型的实例变量,所以必须实现一个dealloc方法。计数器类应该放弃任何实例变量的所有权通过发送release消息,并在最后调用父类的实现:

    - (void)dealloc {
        [_count release];
        [super dealloc];
    }
    

    3.2.使用弱引用避免循环引用

    持有一个对象会产生对这个对象的强引用。一个对象只有当它所有的强引用被释放时才会被销毁。如果两个对象有了循环引用--也就是说彼此都有一个强引用(可以是直接的,也可以是通过其它彼此从头到尾都拥有一个强引用的对象),那么一个叫做“引用环”的问题就会产生。
    图1中的对象关系展示了一个潜在的引用环。Document 对象对于文档中的每个page有一个Page对象。每个Page对象都有一个属性记录它属于哪个文档。如果Document 对象对Page对象持有一个强引用,Page 对象对Document对象持有一个强引用,那么这两个对象永远也不会被销毁。Document的引用计数永远也不会为0直到Page对象被释放,同样的Page对象永远也不会被释放直到Document对象被释放。

    循环引用图示
    解决引用环问题的方法是使用弱引用。一个弱引用是一个非持有的关系--源对象并不持有它引用的对象。
    但是为了对象图表的完整性,在某些地方必须存在强引用(如果只有弱引用,那么pages和paragraphs就不会有任何所有者,也就会被销毁)。Cocoa建立了一套规则,“父类”对象应该对它的“子类”持有强引用,“子类”对象应该对它的“父类”持有弱引用。
    因此,在图1中,Document 对象对它的Page 对象持有强引用,Page 对象对它的Document 对象持有弱引用。
    Cocoa中的弱引用例子包括:table的数据源,IBOutlet连接的视图项,通知的观察者,各种各样的targets和代理等。
    当向一个你持有弱引用的对象发送消息时要格外小心。如果在一个对象被销毁后向它发送了一个消息,程序会crash。必须明确的知道这个对象何时是有效的。在大多数情况下,弱引用对象知道其它对象对它的弱引用,同循环引用一样,当它销毁时负责通知其它对象。例如,当你用通知中心注册了一个对象,通知中心会存储一个对这个对象的弱引用,当合适的通知发出后,会向这个对象发送消息。当这个对象被销毁后,你需要在通知中心对它进行移除注册来避免通知中心未来向这个已经被销毁的对象发送消息。同样的,当一个代理对象被销毁时,你需要通过发送setDelegate:消息传入nil参数来移除这个代理连接。这些消息通常在dealloc方法发送。

    3.3.避免引起正在使用的对象的销毁

    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.
    

    在某些情况下你从另外一个对象获取到一个对象,然后直接或间接地释放了这个父类对象。如果释放父类对象导致父类对象被销毁,父类对象恰巧是这个子类的唯一所有者,那么子类会同时被销毁(假设子类在父类的dealloc方法中收到了release而不是autorelease消息)。
    为了避免这些情况,一旦接收到heisenObject就retain它,使用完毕后release它,例如:

    heisenObject = [[array objectAtIndex:n] retain];
    [array removeObjectAtIndex:n];
    // Use heisenObject...
    [heisenObject release];
    

    3.4.不要使用dealloc管理稀缺资源

    通常的不要在dealloc方法中处理稀缺资源,例如文件描述符,网络连接,缓冲区或者缓存。尤其不要设计这样的类:当你认为dealloc方法将会被调起时它就会被调起。dealloc方法的调起也许会延迟或者干脆就不调起,这可能是因为一个bug或者应用tear-down。
    相反,如果你有一个类它的实例变量管理着稀缺资源,你应该这样设计你的应用:你知道何时不再需要这些资源并且同时通知实例来清理这些资源。然后释放这个实例变量,紧接着dealloc会被调起,但是你不会遭到额外的问题即使dealloc未被调起。
    如果你尝试在dealloc方法中处理资源管理,那么可能导致以下问题:

    • 1.顺序取决于对象图表tear-down机制
      对象图表tear-down机制本身是无序的。尽管你也许期望--或者得到了一个特别的顺序,实际这种顺序是很脆弱的。如果一个对象被不可预期的自动释放而不是立刻释放,tear-down顺序也许就会改变,这也许会导致不可预期的结果。
    • 2.稀缺资源没有重复利用。
      内存泄漏是bug需要修复,但是它们通常不会立刻导致致命问题。但是如果稀有资源没有在你期望释放时释放,那么你也许会陷入严重问题。例如,如果你的程序用光了文件描述符,用户也许无法保存数据。
    • 3.在错误的线程执行清理逻辑的操作。
      如果一个对象在一个不可预料的时间被释放,它将会在它恰巧处在的任何线程的自动释放池代码块内被释放。这对于应该只在一个线程访问的资源来说很可能导致致命问题。

    3.5.集合持有其包含的对象

    当你向一个集合添加了一个对象,这个集合就会持有这个对象的所有权。当对象从集合移除或者集合本身被释放时,集合会放弃对象的所有权。例如,如果你想创建一个数值组成的数组,可以有以下两种方式:

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

    这个例子中,你没有调用alloc,所以没必要调用release。没必要retainconvenienceNumber,因为数组会帮你做。

    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];
    }
    

    这个例子中,你需要在for循环内向allocedNumber发送release消息来平衡alloc。因为数组在调用addObject:添加值时持有了它,所以在这个值只要在数组内就不会被销毁。

    3.6.所有权策略是通过引用计数来实现的

    所有权策略是通过引用计数来实现的--通常叫做“retain count”。每个对象都有一个retain count。

    • 当你创建了一个对象,它的retain count为1。
    • 当你向一个对象发送一条retain消息,它的retain count加1
    • 当你向一个对象发送一条release消息,它的retain count减1
      当你向一个对象发送一条autorelease消息,它的retain count会在当前的autorelease pool block末尾减1
    • 如果一个对象的retain count减到0,它就会被销毁
      重要:没有理由明确的请求一个对象的retain count。结果经常是错误的,因为你也许不清楚什么框架的对象持有了一个你感兴趣的对象。在调试内存管理问题时,你应该只关心确保你的代码坚持了所有权规则。


    4.使用自动释放池代码块

    自动释放池代码块提供了一种机制:你可以放弃一个对象的所有权,但是可以避免这个对象被立即销毁(例如当你从一个方法返回一个对象时)。通常你不需要创建你自己的自动释放池代码块,但是有些情况下你不得不创建或者创建是有利的。

    4.1.关于自动释放池代码块

    一个自动释放池代码块以@autoreleasepool标记,像下面这样:

    @autoreleasepool {
        // Code that creates autoreleased objects.
    }
    

    在自动释放池代码块末尾,在块内接收了autorelease消息的对象会接收一条release消息--对象每在块内接收一次autorelease消息,就会在块末尾收到一条release消息。
    像其它代码块一样,自动释放池代码块也可以嵌套:

    @autoreleasepool {
        // . . .
        @autoreleasepool {
            // . . .
        }
        . . .
    }
    

    对于一条指定的autorelease消息,对应的release消息会在autorelease消息被发送的自动释放池代码块末尾发送。
    Cocoa总是期望代码在自动释放池代码块内执行,否则自动释放的对象得不到释放你的应用就会泄漏内存(如果在自动释放池代码块外发送autorelease消息,Cocoa会报错)。AppKit和UIKit框架在自动释放池代码块内处理每个时间循环迭代(例如鼠标下移或者点击)。因此你通常不需要自己创建一个自动释放池代码块,或者甚至看不到这样的代码。但是,有三种情况你可能需要使用你自己的自动释放池代码块:

    • 如果你在编写一个不是基于UI框架的项目,例如命令行工具
    • 如果你编写了一个产生许多临时对象的循环
      你也许可以在下一个迭代之前在这个循环内部使用一个自动释放池代码块来去除那些临时对象。在循环内使用一个自动释放池代码块可以帮助降低程序的内存峰值。
    • 如果你大量创建了子线程。
      你必须在线程开始执行前创建你自己的自动释放池代码块;否则你的应用将会泄漏内存。

    4.2.使用局部的自动释放池代码块降低内存峰值

    许多程序会创建自动释放的临时对象。这些对象会添加到程序的内存中直到block的结尾。在许多情况下,允许临时对象在当前时间循环迭代结束前累计不会导致过多的开销;但是在一些情况下,你也许会创建大量的临时对象持续的添加到内存中,你想更快速的处理掉它们。这时,你可以创建你自己的自动释放池代码块。在块的末尾,这些临时对象会被销毁,从而降低程序的内存。
    下面的例子展示了如何在for循环内使用局部的自动释放池代码块。

    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循环每次处理一个文件。任何在块内接收autorelease消息的对象(例如fileContents)都会在块的末尾被释放。
    在一个自动释放池代码块之后,你应该将任何在块内自动释放的对象当做被处理掉了。不要向这个对象发送消息或者将这个对象返回给方法的调用者。如果你必须要在块外使用一个临时对象,那么应该在块内向这个对象发送retain消息,块外发送autorelease消息,像下面这样:

    – (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. */
    }
    

    在块内向match发送retain消息并且在块外发送autorelease消息扩展了match的声明周期,允许它在循环外部接收消息并返回给findMatchingObject:的调用者。

    4.3.自动释放池代码块和线程

    Cocoa应用的每个线程都维持着它自己的自动释放池代码块栈。如果你编写的是纯Foundation的程序或者detach了一个线程,你需要创建自己的自动释放池代码块。
    如果你的应用或者线程是长期存在的,并且可能产生大量的自动释放对象,你应该使用自动释放池代码块。否则,自动释放的对象会累积,你的内存会增长。如果你detached的线程没有做Cocoa调用,那么没必要使用自动释放池代码块。
    注意:如果你使用POSIX线程API而不是NSThread开辟了子线程,你不能使用Cocoa除非Cocoa在多线程模式。Cocoa 只在分离出它的第一个NSThread对象时进入多线程模式。为了在POSIX的子线程使用Cocoa,你的程序必须首先分离出至少一个可以立刻退出的NSThread对象。可以使用NSThread类的isMultiThreaded方法测试是否在多线程模式。

    5.参考文献



    提升代码质量最神圣的三部曲:模块设计(谋定而后动) -->无错编码(知止而有得) -->开发自测(防患于未然)

    相关文章

      网友评论

      本文标题:高级内存管理编程指南

      本文链接:https://www.haomeiwen.com/subject/rxwuextx.html