高性能 iOS 应用开发 - 第二部分 - 第 2 章 - 内存管理

46 阅读19分钟

第 2 章 - 内存管理

如果某个应用的内存使用量超过了单个进程的上限,那么它就会被操作系统终止使用。因此,成功的内存管理在 iOS 应用的实现过程中扮演着核心的角色。

与(基于垃圾回收的)Java 运行时不同,Objective-C 和 Swift 的 iOS 运行时使用引用计数。使用引用计数的负面影响在于,可能会出现重复的内存释放和循环引用的情况。

2.1 内存消耗

内存消耗指的是应用消耗的 RAM。

iOS 的虚拟内存模型并不包含交换内存,与桌面应用不同,这意味着磁盘不会被用来分页内存。最终的结果是应用只能使用有限的 RAM。这些 RAM 的使用者不仅包括在前台运行的应用,还包括操作系统服务,甚至还包括其他应用所执行的后台任务。

应用中的内存消耗分为两部分:栈大小和堆大小。

2.1.1 栈大小

应用中新创建的每个线程都有专用的栈空间,该空间由保留的内存和初始提交的内存组成。栈可以在线程存在期间自由使用。线程的最大栈空间很小,这就决定了以下的限制。

  • 可被递归调用的最大方法数
    • 每个方法都有其自己的栈帧,并会消耗整体的栈空间。
  • 一个方法中最多可以使用的变量个数
    • 所有的变量都会载入方法的栈帧中,并消耗一定的栈空间。
  • 视图层级中可以嵌入的最大视图深度
    • 渲染复合视图将在整个视图层级树中递归地调用 layoutSubViews 和 drawRect 方法。如果层级过深,可能会导致栈溢出。

2.1.2 堆大小

一个应用可以使用的堆大小通常远远小于设备的 RAM 值。应用并不能控制分配给它的堆。只有操作系统才能管理堆。

使用 NSString、载入图片、创建或使用 JSON/XML 数据、使用视图等都会消耗大量的堆内存。如果你的应用消耗大量的堆内存,那么你需要格外关注平均值和峰值内存使用的最小化。

与通过类创建的对象相关的所有数据都存放在堆中。

类可能包含属性或值类型的实例变量(iVars),如 int、char 或 struct。但因为对象是在堆内创建的,所以它们只消耗堆内存。

当对象被创建并被赋值时,数据可能会从栈复制到堆。类似地,当值仅在方法内部使用时,它们也可能会被从堆复制到栈。这可能是个代价昂贵的操作。

保持应用的内存需求总是处于 RAM 的较低占比是一个非常好的主意。虽然没有强制规定,但强烈建议使用量不要超过 80%~85%,要给操作系统的核心服务留下足够多的内存。

不要忽视 didReceiveMemoryWarning信号。

2.2 内存管理模型

内存管理模型基于持有关系的概念。如果一个对象正处于被持有状态,那它占用的内存就不能被回收。

当一个对象创建于某个方法的内部时,那该方法就持有这个对象了。如果这个对象从方法返回,则调用者声称建立了持有关系。这个值可以赋值给其他变量,对应的变量同样会声称建立了持有关系。

一旦与某个对象相关的任务全部完成,那么就是放弃了持有关系。这一过程没有转移持有关系,而是分别增加或减少了持有者的数量。当持有者的数量降为零时,对象会被释放,相关的内存会被回收。

这种持有关系计数通常被正式称为引用计数。当你亲自管理时,它被称为手动引用计数(manual reference counting,MRC)。现如今的应用大都使用自动引用计数(automatic reference counting ,ARC)。

2.3 自动释放对象

自动释放对象让你能够放弃对一个对象的持有关系,但延后对它的销毁。当在方法中创建一个对象并需要将其返回时,自动释放就显得非常有用。自动释放可以帮助在 MRC 中管理对象的生命周期。

NSObject 协议定义了可被用于延迟释放的 autorelease 消息。可在从方法中返回对象时使用它。

当创建一个对象并将其从非 alloc 方法返回时,应使用 autorelease。这样可以确保对象将被释放,并尽量在调用方法执行完成时立即释放。

2.4 自动释放池块

自动释放池块是允许你放弃对一个对象的持有关系、但可避免它立即被回收的一个工具。当从方法返回对象时,这种功能非常有用。

它还能确保在块内创建的对象会在块完成时被回收。这在创建了多个对象的场景中非常有用。本地的块可以用来尽早地释放其中的对象,从而使内存用量保持在较低的水平。

自动释放池块用 @autoreleasepool 表示。

块中收到过 autorelease 消息的所有对象都会在 autoreleasepool 块结束时收到 release 消息。更加重要的是,每个 autorelease 调用都会发送一个 release 消息。这意味着如果一个对象收到了不止一次的 autorelease 消息,那它也会多次收到 release 消息。这一点很棒,因为这能保证对象的引用计数下降到使用 autoreleasepool 块之前的值。如果计数为 0,则对象将被回收,从而保持较低的内存使用率。

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil,
            NSStringFromClass([HPAppDelegate class]));
    }
}

看了 main 方法的代码后,你会发现整个应用都在一个 autoreleasepool 块中,这意味着所有的 autorelease 对象最后都会被回收,不会导致内存泄漏。

与其他的代码块一样,autoreleasepool 块可以被嵌套。

@autoreleasepool {

    // 一些代码
    
    @autoreleasepool {
        // 更多代码
    }
}

因为从一个方法进入另一个方法会传递控制权,所以在同一方法内部使用嵌套的 autoreleasepool 块并不常见。但是,被调用的方法也可能拥有自己的 autoreleasepool 块,以提前执行对象的回收。

自动释放池块无处不在

Cocoa 框架希望代码能在 autoreleasepool 块内执行,否则 autorelease 对象将无法被释放,从而导致应用发生内存泄漏。

AppKit 和 UIKit 框架将事件 - 循环的迭代放入了 autoreleasepool 块中。因此,通常不需要你自己再创建 autoreleasepool 块了。

但在一些特定情况下,可能想创建自己的 autoreleasepool 块,例如以下这些情况。

  • 当你有一个创建了很多临时对象的循环时

    • 在循环中使用 autoreleasepool 块可以为每个迭代释放内存。虽然迭代前后最终的内存使用相同,但你的应用的最大内存需求可以大大降低。
  • 当你创建一个线程时

    • 每个线程都将有它自己的 autoreleasepool 块栈。主线程用自己的 autoreleasepool 启动,因为它来自统一生成的代码。然而,对于任何自定义的线程,你必须创建自己的 autoreleasepool。

2.5 自动引用计数

ARC 是一种编译器特性。它评估了对象在代码中的生命周期,并在编译时自动注入适合的内存管理调用。编译器还会生成适合的 dealloc 方法。这意味着与跟踪内存使用(如确保对象被及时回收了)有关的最大难题被解决了。

ARC 的规则

ARC 强制推行了编写代码时需要遵循的一些规则,意图是提供一个明确的内存管理模型。在某些情况下,这些规则的目的是强制实施最佳实践;在其他情况下,它们可以简化代码,直接的必然结果就是开发人员不必直接进行内存管理。这些规则由编译器强制执行,会导致编译期时的错误而不是运行时的崩溃。以下就是编译器的 ARC 规则。

  • 不能实现或调用 retain、release、autorelease 或 retainCount 方法。这一限制不仅针对对象,对选择器同样有效。因此, [obj release] 或 @selector(retain) 是编译时的错误。

  • 可以实现 dealloc 方法,但不能调用它们。不仅不能调用其他对象的 dealloc 方法,也不能调用超类。[super dealloc] 是编译时的错误。但你仍然可以对 Core Foundation 类型的对象调用 CFRetain、CFRelease 等相关方法。

  • 不能调用 NSAllocateObject 和 NSDeallocateObject 方法。应使用 alloc 方法创建对象,运行时负责回收对象。

  • 不能在 C 语言的结构体内使用对象指针。

  • 不能在 id 类型和 void * 类型之间自动转换。如果需要,那么你必须做显示转换。

  • 不能使用 NSAutoreleasePool,要替换使用 autoreleasepool 块。

  • 不能使用 NSZone 内存区域。

  • 属性的访问器名称不能以 new 开头,以确保与 MRC 的互操作性。

  • 虽然总的来说需要避免许多事情,但仍然可以混合使用 ARC 和 MRC 代码。

2.6 引用类型

ARC 带来了新的引用类型:弱引用。支持的类型包括以下两种。

  • 强引用

强引用是默认的引用类型。被强引用指向的内存不会被释放。强引用会对引用计数加 1,从而扩展对象的生命周期。

  • 弱引用

弱引用是一种特殊的引用类型。它不会增加引用计数,因而不会扩展对象的生命周期。在启用了 ARC 的 Objective-C 编程中,弱引用格外重要。

其他类型的引用 Objective-C 当前并不支持其他类型的引用。但了解一下其他类型也是非常有趣的事情。

  • 软引用
    • 软引用与弱引用非常相似,只是前者没有那么迫切地抛弃它所引用的对象。如果一个对象只有弱引用存在,那么这个对象会在下个垃圾回收周期被回收;如果一个对象只有软引用可达,那么这个对象一般还能再坚持一会。
  • 幽灵引用
    • 这是力量最弱的引用类型,会被最早地回收清理。幽灵引用的对象与已回收的对象比较相似,但是前者的内存没有被回收利用。

这些引用类型没有基于引用计数系统。它们更适合用于垃圾回收系统。

2.6.1 变量限定符

ARC 为变量供了四种生命周期限定符。

  • __strong

    这是默认的限定符,无需显式引入。只要有强引用指向,对象就会长时间驻留在内存中。可以将 __strong 理解为 retain 调用的 ARC 版本。

  • __weak

    这表明引用不会保持被引用对象的存活。当没有强引用指向对象时,弱引用会被置为 nil。可将 __weak 看作是 assign 操作符的 ARC 版本,只是对象被回收时,__weak 具有安全性——指针将自动被设置为 nil。

  • __unsafe_unretained

    与 __weak 类似,只是当没有强引用指向对象时,__unsafe_unretained 不会被置为 nil。可将其看作 assign 操作符的 ARC 版本。

  • __autoreleasing

    __autoreleasing 用于由引用使用 id * 传递的消息参数。它预期了 autorelease 方法会在传递参数的方法中被调用。

使用这些限定符的语义如下:

TypeName * qualifier variable;

属性限定符

属性声明有两个新的持有关系限定符:strong 和 weak。此外,assign 限定符的语义也被更新了。一言以蔽之,现在共有六个限定符。

  • strong

    • 默认符,指定了 __strong 关系。
  • weak

    • 指定了 __weak 关系。
  • assign

    • 这不是新的限定符,但其含义发生了改变。在 ARC 之前,assign 是默认的持有关系限定符。在启用 ARC 之后,assign 表示了 __unsafe_unretained 关系。
  • copy

    • 暗指了 __strong 关 系。 此外, 它还暗示了 setter 中的复制语义的常规行为。
  • retain

    • 指定了 __strong 关系。
  • unsafe_unretained

    • 指定了 __unsafe_unretained 关系。

2.8 僵尸对象

僵尸对象是用于捕捉内存错误的调试功能。

通常情况下,当引用计数降为 0 时对象会立即被释放,但这使得调试变得困难。如果开启了僵尸对象,那么对象就不会立即释放内存,而是被标记为僵尸。任何试图对其进行访问的行为都会被日志记录,因而你可以在对象的生命周期中跟踪对象在代码中被使用的位置。

NSZombieEnabled 是一个环境变量,可以控制 Core Foundation 的运行时是否将使用僵尸对象。不应长期保留 NSZombieEnabled,因为默认情况下不会有对象被真正析构,这会导致应用使用大量的内存。特别说明一点,在发布的构建包中一定要禁用 NSZombieEnabled。

要想设置 NSZombieEnabled 环境变量,需要进入 Product → Scheme → Edit Scheme。选择左侧的 Run,然后在右侧选取 Diagnostics 标签页。选中 Enable Zombie Objects 选项。

2.9 内存管理规则

内存管理有四个基本规则。

  • 你拥有所有自己创建的对象,如 new、alloc、copy 或 mutableCopy。

  • 你可以用 MRC 中的 retain 或者 ARC 中的 __strong 引用来拥有任何对象的持有关系。

  • 在 MRC 中,当不再需要某个对象时,你必须立即使用 release 方法来放弃对该对象的持有关系。而在 ARC 中则无需任何特殊操作。持有关系会在对象失去最后的引用(如方法中的最后一行代码)时被抛弃。

  • 一定不能抛弃原本并不存在持有关系的对象。要想避免内存泄漏和应用崩溃,你应当在编写 Objective-C 代码时牢记这些规则。

2.10 循环引用

引用计数的最大陷阱在于,它不能处理环状的引用关系,即 Objective-C 的循环引用。

声明持有关系会增加引用计数,而放弃持有关系则会减少引用计数。当引用计数降为 0 时,系统将回收对象并释放内存。

2.10.1 避免循环引用的规则

  • 对象不应该持有它的父对象,应该用 weak 引用指向它的父对象。

  • 一个层级体系中的子对象应该保留祖先对象。

  • 连接对象不应持有它们的目标对象。目标对象的角色是持有者。连接对象包括以下几种。

    1. 使用委托的对象。委托应该被当作目标对象,即持有者。

    2. 包含目标和 action 的对象,这是由上一条规则推理得到的。例如,UIButton 会调用它的目标对象上的 action 方法。按钮不应该保留它的目标。

    3. 观察者模式中被观察的对象。观察者就是持有者,并会观察发生在被观察对象上的变化。

  • 使用专用的销毁方法中断循环引用。

    • 双向链表中存在循环引用,环形链表中也存在循环引用。
    • 在这类情况下,一旦明确对象不会再被使用时(当链表的表头超出作用范围),你要编写代码以打破链表的链接。创建一个(名为 delink 的)方法切断其自身与链表中下一个节点的链接。通过访问者模式递归地执行这一过程,从而避免无限递归。

2.10.2 循环引用的常见场景

大把的常见场景会导致循环引用。例如,使用线程、计时器、简单的块方法或委托都可能会导致循环引用。接下来我们将逐步探索这些场景,并给出避免循环引用的步骤。

1. 委托

委托很可能是引入循环引用的最常见的地方。

解决方案是在委托中建立对操作的强引用,并在操作中建立对委托的弱引用。

2. 块

与不正确地使用委托对象导致的问题类似,在使用块时,捕获外部变量也是导致循环引用的原因。

解决方案是通过弱引用获得强引用,类似于 __weak typeof(self) weakSelf = self;

3. 线程与计时器

不正确地使用 NSThread 和 NSTimer 对象也可能会导致循环引用。运行异步操作的典型步骤如下。

  • 如果没有编写更高级的代码来管理自定义的队列,则在全局队列上使用 dispatch_async 方法。

  • 在需要的时间和地点用 NSThread 开启异步执行。

  • 使用 NSTimer 周期性地执行一段代码。

当使用 NSTimer 和 NSThread 时,总是应该通过间接的层实现明确的销毁过程。这个间接层应使用弱引用,从而保证所拥有的对象能够在停止使用后执行销毁动作。

2.10.3 观察者

与使用委托和订阅复杂数据变化的回调不同,系统提供了两种内置的可用选择,以便监听变化。之所以说它们是内置的,主要是因为我们无需编写任何自定义的代码来跟踪观察者——运行时对管理观察者提供了支持。这类技术包括:

  • 键-值观察

  • 通知中心

1. 键-值观察

Objective-C 允许用 addObserver:forKeyPath:options:context: 方法在任何 NSObject 子类的对象上添加观察者。观察者会通过 observeValueForKeyPath:ofObject:change:context: 方法得到通知。removeObserver:forKeyPath:context: 方法用于解除注册或移除观察者。

当你为目标对象添加键-值观察者时,目标对象的生命周期至少应该和观察者一样长,因为只有这样才有可能从目标对象移除观察者。这可能会导致目标对象的生命周期比预期要长,也是你需要额外小心的地方。

2. 通知中心

一个对象可以注册为通知中心(NSNotificationCenter 对象)的观察者,并接收 NSNotification 对象。与键-值观察者相似,通知中心不会对观察者持有强引用。

2.10.4 返回错误

当用某个方法接收 NSError ** 参数,并在发生错误时填充错误变量,则必须使用 __autoreleasing 限定符。使用这一范式的最常见场景是需要处理输入并返回可能包含错误的值的时候。

需要关注这一语法。关键字 __autoreleasing 要塞到两个星号之间。要牢记这一点:

NSError* __autoreleasing *error;

2.11 弱类型:id

在使用常规命名的方法时,应避免使用 id。尽量使用具体的类取而代之。

2.12 对象寿命与泄漏

对象在内存中活动的时间越长,内存不能被清理的可能性就越大。所以应当尽可能地避免出现长寿命的对象。当然,你需要保留代码中关键操作对象的引用,为的是不必每次都浪费时间来创建它们。尽量在使用这些对象时完成对它们的引用。

长寿命对象的常见形式是单例。日志器是典型的例子——只创建一次,从不销毁。

另一个方案是使用全局变量。全局变量在程序开发中是可怕的东西。

要想合理地使用全局变量,必须满足以下条件:

  • 没有被其他对象所持有;

  • 不是常量;

  • 整个应用中只有一个,而不是每个组件一个。

如果某个变量不符合这些要求,那么它不应该被用作全局变量。

复杂的对象图使得回收内存的机会变得更少,同时增加了应用因内存耗尽而崩溃的风险。

2.13 单例

单例模式是限制一个类只初始化一个对象的一种设计模式。在实践中,初始化常常在应用启动不久后执行,而且这些对象不会被销毁。

让一个对象有着与应用一样长的生命周期可不是什么好主意。如果这个对象是其他对象的源头(如一个服务定位器),若定位器的实现不正确则有可能造成内存风险。

单例通常会在应用启动时进行初始化,打算使用单例的组件需要等它们准备得当。这会增加应用的启动时间。

  • 尽可能地避免使用单例。

  • 识别需要内存的部分,如用于埋点的内存缓冲区(在尚未将数据同步到服务器前使用)。寻求减少内存的方法。注意,你需要将减少内存与其他事情做权衡。减小缓冲区意味着更多的服务器通信。

  • 尽量避免对象级的属性,因为它们会与对象共存亡。尽量使用本地变量。

2.14 找到神秘的持有者

就算类设计精良,对象被良好持有,是否会发生内存泄漏还是不确定的。如果发生内存泄漏,获取引用图是一个不错的解决办法。

2.15 最佳实践

  • 避免大量的单例。具体来说,不要出现上帝对象(如职责特别多或状态信息特别多的对象)。这是一个反模式,指代一种常见解决方案的设计模式,但很快产生了不良效果。日志器、埋点服务和任务队列这样的辅助单例都是很不错的,但全局状态对象不可取。

  • 对子对象使用 __strong。

  • 对父对象使用 __weak。

  • 对使引用图闭合的对象(如委托)使用 __weak。

  • 对数值属性(NSInteger、SEL、CGFloat 等)而言,使用 assign 限定符。

  • 对于块属性,使用 copy 限定符。

  • 当声明使用 NSError ** 参数的方法时,需要使用 __autoreleasing,并要注意用正确的语法:NSError * __autoreleasing *。

  • 避免在块内直接引用外部的变量。在块外面将它们 weakify,并在块内再将它们 strongify。 参见 libextobjc 库 github.com/jspahrsumme… 来了解 @weakify 和 @strongify。

  • 进行必要清理时遵循以下准则:

    • 销毁计时器

    • 移除观察者(具体来说,移除对通知的注册)

    • 解除回调(具体来说,将强引用的委托设置为 nil)