理解 iOS 和 macOS 的内存管理

243 阅读16分钟
原文链接: click.aliyun.com

在 iOS 和 macOS 应用的开发中,无论是使用 Objective-C 还是使用 swift 都是通过引用计数策略来进行内存管理的,但是在日常开发中80%(这里,我瞎说的,8020 原则嘛)以上的情况,我们不需要考虑内存问题,因为 Objective-C 2.0 引入的自动引用计数(ARC)技术为开发者们自动的完成了内存管理这项工作。ARC 的出现,在一定程度上拯救了当时刚入门的 iOS 程序员们,如果是没有接触过内存管理的开发者,在第一次遇到僵尸对象时一定是吓得发抖My Brains~。但是 ARC 只是在代码层面上自动添加了内存管理的代码,并不能真正的自动内存管理,以及一些高内存消耗的特殊场景我们必须要进行手动内存管理,所以理解内存管理是每一个 iOS 或者 macOS 应用开发者的必备能力。

本文将会介绍 iOS 和 macOS 应用开发过程中,如何进行内存管理,以及介绍一些内存管理使用的场景,帮助大家解决内存方面的问题,本文将会重点介绍内存管理的逻辑、思路,而不是类似教你分分钟手写 weak 的实现,之类的问题,毕竟大家一般拧螺丝比较多,至于️的制造技艺嘛,还是要靠万能的 Google 了。

本文其实是内存管理的起点,而不是结束,各位 iOS 大佬们肯定会发现很多东西在本文中是找不到的,因为这里的内容非常基础,只是帮助初学 iOS 的同学们能够快速理解如何管理内存而写的。

什么是内存管理

很多人接触到内存管理可以追溯到大学时候的 C 语言程序设计课程,在大学中为数不多的实践型语言课程中相信 C 语言以及 C 语言中的指针是很多人的噩梦,并且这个噩梦延续到了 C++,当然这个是后话了。所以 Java 之类的,拥有垃圾回收机制的语言,也就慢慢的变得越来越受欢迎(大雾)。

内存管理基本原则:

在需要的时候分配内存,在不需要的时候释放内存

这里来一段简单的 C 代码~

#define BUFFER_SIZE 128

void dosth() {
    char *some_string = malloc(BUFFER_SIZE);
    // 对 some_string 做各种操作
    free(some_string);
}

这么一句话看起来似乎不是很复杂,但是光这一个内存管理,管得无数英雄尽折腰啊,因为实际的代码并不会像上面那么简单,比如上面我要把字符串 some_string 返回出来的话要怎么办呢?(我不会回答你的)

iOS 的内存管理

内存引用计数(Reference Counting,RC)以及 MRC

Objective-C 和 Swift 的内存管理策略都是引用计数,什么是引用计数呢?下面是 wiki 上摘抄而来的内容:

引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象内存磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。

当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,直至对象的引用计数为0,对象的内存会被立刻释放。

来源:zh.wikipedia.org/wiki/%E5%BC…

似乎有点抽象,这里使用 setter 方法的经典实现作为例子我们来看下代码~

- (void)setSomeObject:(NSObject *aSomeObject) {
    if (_someObject != aSomeObject) {
        id oldValue = _someObject;
        _someObject = [aSomeObject retain];  // aSomeObject retain count +1
        [oldValue release];  // oldValue retain count -1
    }
}

接下来我们图解下这部分代码,图中,矩形为变量(指针),圆圈为实际对象,剪头表示变量指向的对象

1

2

3

4

上面的写法是 MRC 时代的经典方式,这里就不多说了,因为本文的目的是让大家理解 ARC 下的内存管理。

人工内存管理时代 —— Manual Reference Counting(MRC)

人工管理内存引用计数的方法叫做 Manual Reference Counting(MRC),在上一节的最后,我们已经看到了内存管理的一些些代码,也看到了内存管理时发生了一些什么,因为 MRC 是 ARC 的基础,为了更好地理解 ARC,下面是我对 iOS,macOS 下内存管理的总结:

对象之间存在持有关系,是否被持有,决定了对象是否被销毁

也就是说,对于引用计数的内存管理,最重要的事情是理清楚对象之间的持有关系,而不关注实际的引用数字,也就是逻辑关系清楚了,那么实际的引用数也就不会出问题了。

例子
这里引用《Objective-C 高级编程》里面办公室的灯的例子,不过我们稍微改改

  1. 自习室有一个灯,灯可以创建灯光,老师要求大家节约用电,只有在有人需要使用的时候才打开灯
  2. 同学 A 来看书,他打开了灯(创建灯光) —— A 持有灯光
  3. 同学 B,C,D 也来看书,他们也需要灯光 —— B,C,D 分别持有灯光
  4. 这时候 A,B,C 回宿舍了,他们不需要开灯了 —— A,B,C 释放了灯光
  5. 由于这时候 D 还需要灯光,所以灯一直是打开的 —— D 依然持有灯光
  6. 当 D 离开自习室时 —— D 释放了灯光
  7. 这时候自习室里面已经没有人需要灯光了,于是灯光被释放了(灯被关了)

上面的例子“灯光”就是我们的被持有的对象,同学们是持有“灯光”的对象,在这个场景,只要我们理清楚谁持有了“灯光”,那么我们就能完美的控制“灯光”,不至于没人的时候“灯光”一直存在导致浪费电(内存泄漏),也不至于有同学需要“灯光”的时候“灯光”被释放。

这里看上去很简单,但是实际项目中将会是这样的场景不断的叠加,从而产生非常复杂的持有关系。例子中的同学 A,B,C,D,自习室以及灯也是被其他对象持有的。所以对于最小的一个场景,我们再来一遍:

对象之间存在持有关系,是否被持有,决定了对象是否被销毁

创造力的解放 —— Automatic Reference Counting(ARC)

但是平时大家会发现从来没用过 retainrelease 之类的函数啊?特别是刚入门的同学,CoreFoundation 也没有使用过就更纳闷了

原因很简单,因为这个时代我们用上了 ARC,ARC 号称帮助程序员管理内存,而很多人曲解了“帮助”这个词,在布道的时候都会说:

ARC 已经是自动内存管理了,我们不需要管理内存

这是一句误导性的话,ARC 只是帮我们在代码中他可以推断的部分,自动的添加了 retainrelease 等代码,但是并不代表他帮我们管理内存了,实际上 ARC 只是帮我们省略了部分代码,在 ARC 无法推断的部分,是需要我们告诉 ARC 如何管理内存的,所以就算是使用 ARC,本质依然是开发者自己管理内存,只是 ARC 帮我们把简单情况搞定了而已

但是,就算是 ARC 仅仅帮我们把简单的情况搞定了,也非常大的程度上解放了大家的创造力、生产力,因为毕竟很多时候内存管理代码都是会被漏写的,并且由于漏写的时候不一定会发现问题,而是随着程序运行才会出现问题,在开发后期解决起来其实挺麻烦的

ARC 下的内存管理

那么我们来说说 ARC 中如何进行内存管理,当然核心还是这句话:对象之间存在持有关系,是否被持有,决定了对象是否被销毁,当然我们补充一句话:ARC 中的内存管理,就是理清对象之间的持有关系

strongweak

在上面一节中,其实大家应该发现只写了 retain,是因为 MRC 的时代只有 retainreleaseautorelease 这几个手动内存管理的函数。而 strongweak__weak 之类的关键字是 Objective-C 2.0 跟着 ARC 一起引入的,可以认为他们就是 ARC 时代的内存管理代码

对于属性 strongweakassigncopy 告诉 ARC 如何构造属性对应变量的 setter 方法,对于内存管理的意义来说,就是告诉编译器对象属性和对象之间的关系,也就是说平时开发过程中,一直在使用的 strongweak 其实就是在做内存管理,只是大部分时间大家没有意识到而已

  • strong:设置属性时,将会持有(retain)对象
  • weak:设置属性时,不会持有对象,并且在对象被释放时,属性值将会被设置为 nil
  • assign:设置属性时,不会持有对象(仅在属性为基本类型时使用,因为基本类型不是对象,不存在释放)
  • copy:设置属性时,会调用对象的 copy 方法获取对象的一个副本并持有(对于不可变类型非常有用)

一般情况下,我们都会使用 strong 来描述一个对象的属性,也就是大部分场景下,对象都会持有他的属性,那么下面看下不会持有的情况

属性描述的场景 —— delegate 模式

这里用经典的 UITableViewDelegateUITableViewDataSource 来进行举例

UITableView 的 delegate 和 datasource 应该是学习 iOS 开发过程中最早接触到的 iOS 中的 delegate 模式
在很多的的例子中,教导我们自己开发的对象,使用的 delegate 的属性要设置为 weak 的,但是很少有说为什么(因为循环引用),更少有人会说为什么会产生循环引用,接下来这里用 UITableView 的来详解下

先看 UITableView 中的定义

@interface UITableView : UIScrollView <NSCoding, UIDataSourceTranslating>
// Other Definations ...
@property (nonatomic, weak, nullable) id <UITableViewDataSource> dataSource;
@property (nonatomic, weak, nullable) id <UITableViewDelegate> delegate;
// Other Definations ...
@end

接下来看下 UITableViewController 中一般的写法

@interface XXXTableViewController : UITableViewController

@property (nonatomic, strong) UITableView *tableView;

@end

@implementation XXXTableViewController()

- (void)viewDidLoad {
    [super viewDidLoad];
    self.tableView.delegate = self;
    self.tableView.dataSource = self;
}

@end

下面用一个图梳理一下持有关系

持有关系

图上有三个对象关系

  1. controller 持有 tableViewstrong 属性
  2. tableView 没有持有 conntrollerweak 属性
  3. 其他对象持有 controllerstrong 属性

那么当第三个关系被打破时,也就是没有对象持有 controller 了(发生 [controller release],这时候 controller 会释放他所有的内存,发生下面的事情:

  1. 其他对象调用 [controller release],没有对象持有 controllercontroller 开始释放内存(调用 dealloc
  2. [tableView release],没有对象持有 tableView 内存被释放
  3. controller 内存被释放

因为 weak 属性不会发生持有关系,所以上面过程完成后,都没有任何对象持有 tableViewcontroller 于是都被释放

假设上面对象关系中的 2 变为 tableView 持有 conntrollerstrong 属性

那么当第三个关系被打破时,也就是没有对象持有 controller 了(发生 [controller release],这时候 controller 会释放他所有的内存,发生下面的事情:

  • 其他对象调用 [controller release]tableView 依然持有 controllercontroller 不会释放内存(不会调用 dealloc

这样,tableViewcontroller 互相持有,但是没有任何对象在持有他们,但是他们不会被释放,因为都有一个对象持有着他们,于是内存泄漏,这种情况是一种简单的循环引用

所以,这就是为什么我们写的代码如果会使用到 delegate 模式,需要将 delegate 的属性设置为 weak,但是从上面例子我们可以理解到,并不是 delegate 需要 weak 而是因为出现了 delegate 和使用 delegate 的对象互相持有(循环引用),那么如果我们的代码中不会出现循环引用,那么使用 weak 反而会出错(delegate 被过早的释放),不过这种时候往往有其他对象会持有 delegate

上面其实只描述了最简单的循环引用场景,在复杂的场景中,可能会有很多个对象依次持有直到循环,面对各种各样复杂的场景,本文认为解决内存问题的方法都是,针对每个对象,每个类,理清他们之间的持有关系,也就是:

对象之间存在持有关系,是否被持有,决定了对象是否被销毁,ARC 中的内存管理,就是理清对象之间的持有关系

__weak__strong

strongweak 是在设置属性的时候使用的,__weak__strong 是用于变量的,这两个关键字在开发的过程中不会频繁的用到,是因为如果没有指定,那么变量默认是通过 __strong 修饰的,不过当我们需要使用这两个关键字的时候,那么也将是我们面对坑最多的情况的时候 —— block 的使用

  • __strong:变量默认的修饰符,对应 property 的 strong,会持有(这里可以认为是当前代码块持有)变量,这里的持有相当于在变量赋值后调用 retain 方法,在代码块结束时调用 release 方法
  • __weak:对应 property 的 weak,同样在变量被释放后,变量的值会变成 nil
变量描述符场景 —— block 的循环引用

下面我们来看个平常经常会遇到的场景,考虑下面的代码:

// 文件 Dummy.h
@interface Dummy : NSObject

@property (nonatomic, strong) void (^do_block)();

- (void)do_sth:(NSString *)msg;

@end

// 文件 Dummy.m
@interface Dummy()
@end

@implementation Dummy

- (void)do_sth:(NSString *)msg {
    NSLog(@"Enter do_sth");
    self.do_block = ^() {
        [self do_sth_inner:msg];
    };
    self.do_block();
    NSLog(@"Exit do_sth");
}

- (void)do_sth_inner:(NSString *)msg {
    NSLog(@"do sth inner: %@", msg);
}

@end

// 文件 AppDelegate.m
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    Dummy *dummy = [[Dummy alloc] init];
    [dummy do_sth:@"hello"];
    return YES;
}

新建一个空白的单页面 iOS 应用,这里大家一定知道结果了,在控制台会输出这样的内容:

2018-11-15 22:56:34.281346+0800 iOSPlayground[42178:5466855] Enter do_sth
2018-11-15 22:56:34.281445+0800 iOSPlayground[42178:5466855] do sth inner: hello
2018-11-15 22:56:34.281536+0800 iOSPlayground[42178:5466855] Exit do_sth

当然相信大家已经看出问题来了,上面的代码会造成循环引用,当然很多时候我们在学习写 iOS 代码的时候,都会有人教导过我们 block 里面的 self 是会存在循环引用的(如上代码的结果),必须要使用 __weak,那么为什么呢?这里依然回到上面的内存管理原则,我们来梳理一下持有关系,首先这里有一个基础知识,那就是 block 是一个对象,并且他会持有所有他捕获的变量,这里我们来看下内存持有关系:

持有关系

同样,我们来分析下这个持有关系

  1. self 对象持有了 do_block 对象
  2. 由于 selfdo_block 中使用了,所以 do_block 的代码区块持有了 self
  3. 其他对象(这里是 AppDelegate 实例)通过变量的方式持有对外的 dummy 对象

那么在我们的代码执行到 -application:didFinishLaunchingWithOptions: 最后一行的时候,由于代码块的结束,ARC 将会对块内产生的对象分别调用 release 释放对象,这时候,上面 3 的持有关系被打破了

但是,由于 1,2 这两条持有关系存在,所以无论是 self 对象,还是 do_sth block 他们都至少被一个对象所持有,所以,他们无法被释放,并且也无法被外界所访问到,形成了循环引用导致内存泄漏,通过 Xcode 提供的内存图(Debug Memeory Graph)我们也可以看到,这一现象:

内存图

那么这里的解决方法就是,进行下面的修改:

- (void)do_sth:(NSString *)msg {
    NSLog(@"Enter do_sth");
    __weak typeof(self) weakself = self;
    self.do_block = ^() {
        [weakself do_sth_inner:msg];
    };
    self.do_block();
    NSLog(@"Exit do_sth");
}

这样打破了上面持有关系 2 中,do_block 持有 self 的问题,这样就和上面描述 delegate 的场景一样了

变量描述符场景 —— block 的循环引用 2

接下来看下另外一个循环引用的场景,Dummy 类的定义不变,使用方法做一些调整:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    Dummy *dummy = [[Dummy alloc] init];    
    dummy.do_block = ^{
        [dummy do_sth_inner:@"hello2"];
    };
    dummy.do_block();
    return YES;
}

奇怪,这里没有 self 了啊,为什么依然循环引用了啊?接着继续看持有关系图:

持有关系

是不是和上一个场景很像?因为就是一样的,只是一个视野在类的内部,另一个视野在类的外部,在类的内部那就是 selfdo_block 互相持有,形成循环引用;在类的外部那就是 dummydo_block 互相持有,形成循环应用

一点个人经验

实际项目肯定不会是本文中这么明显简单的场景,但是再多复杂的场景肯定是这些简单的场景不断的嵌套组合而成,所以保证代码内存没有问题的最好的方法是每次遇到需要处理内存场景时,仔细分析对象间的持有关系,也就是保证组成复杂场景的每个小场景都没有问题,那么基本就不会出现问题了,对于出现内存管理出现问题的情况,一般我们都能定位到是某一部分代码内存泄漏了,那么直接分析那部分代码的持有关系是否正确

iOS macOS 开发中的内存管理不要在意引用计数,引用计数是给运行时看的东西,作为人类我们需要在意对象间的持有关系,理清持有关系那么就表明引用计数不会有问题

结语

到此对于内存管理的思路算是结束了,但是就像本文一开始所说的,这里并不是结束而是开始,接下来建议大家在有了一定经验后可以再去深入了解下面的内容:

  • Core Foundation 框架的内存管理,没有 ARC 的眷顾
  • Core Foundation 框架和 Objective-C 的内存交互 —— Toll-Free Bridging,ARC 和 CF 框架的桥梁
  • Objective-C 高级编程 —— 《iOS 与 OS X 多线程和内存管理》,我从这本书里面收益良多
  • Swift 下的内存管理,分清 weakunowned 有什么区别,逻辑依然是理清持有关系
  • C 语言入门,Objective-C 源自于 C 语言,所有 C 语言的招式在 Objective-C 中都好用,在某些特殊场景会必定会用到

最后欢迎大家订阅我的微信公众号 Little Code

Little Code

  • 公众号主要发一些开发相关的技术文章
  • 谈谈自己对技术的理解,经验
  • 也许会谈谈人生的感悟
  • 本人不是很高产,但是力求保证质量和原创