iOS开发内存管理总结,C语言,C++,OC,Swift

最近准备换工作,忙里偷闲梳理下iOS开发过程中内存管理相关的内容,就算给面试做准备了。虽然现在各种ARC,GC已经做得足够好,很少在需要开发者手动管理内存了,但是出于从MRC走过来的情怀,再复习一下吧。
主要从三个方面总结,
第一,系统内存划分
第二,各编程语言的内存管理
第三,在Cocoa框架中需要特别注意的点

系统内存划分

系统内存主要分为如图几个区域,整个内存分配是由低地址向高地址分配,但栈比较特殊,栈的地址是由高到低

代码区

代码区相当于咱们编写的代码,编译成二进制之后在内存中的一个镜像,iOS中只读的,不可修改。部分其他系统允许在运行时修改这一块区域。

常量区

主要存放在开发和编译过程中已经赋值的常量,这个区域也只读,例如宏定义,const常量,枚举等:

1
2
#define M_PI 3.14159265358979323846264338327950288
FOUNDATION_EXPORT NSErrorUserInfoKey const NSStringEncodingErrorKey ; // NSNumber containing NSStringEncoding

全局/静态区

这部分分为数据区和BSS区

数据区/data

data存储已经初始化的全局变量,属于静态内存分配。(注意:初始化为0的全局变量还是被保存在BSS段)
static声明的变量也存储在数据段。
链接时初值加入执行文件;执行时,因为这些变量的值是可以被改变的,所以执行时期必须将其从ROM或Flash搬移到RAM。总之,data段会被加入ROM,但却要寻址到RAM的地址。

BSS

bss段存储没有初值的全局变量或默认为0的全局变量,属于静态内存分配。
bss段不占据执行文件空间(无需加入程序之中,只要链接时将其寻址到RAM即可),但占据程序运行时的内存空间。
执行期间必须将bss段内容全部设为0。

栈(Stack)

栈由系统进行分配和释放,用于存储零时创建的局部变量,函数参数等(static除外,static变量在数据区),严格遵循先进后出,可用于保存/恢复调用现场。UINavigationController的push和pop就是跟栈的原理相似的实现(具体实现不是栈,只是表现一致)。
iOS的栈是一块连续的内存区域,由高地址向低地址分配,需要注意的是iOS主线程的栈只有1MB,一旦超过,程序就会崩溃,死循环引起的奔溃大多数时候就是因为栈空间耗尽,递归爆栈也是一样。

堆(Heap)

由开发者控制分配和释放的区域,数据结构为链表,这一块区域是不连续的。
malloc/alloc/new等操作分配的内存被添加到堆上,堆扩张。
free/release/delete等操作剔除上面分配的内容,但这个时候堆上实际存储的内容并没有被马上销毁,而是标记为这块内存可用,有可能被重复利用。
ARC时代,开发者已经不需要直接操作这一块,但如果有C/C++的混编,这一块还是需要了解。

堆(Heap)和栈(Stack)的区别

堆由开发者申请和释放;栈由系统自动分配和释放
栈在iOS主线程只能申请1MB;堆理论上只要还有物理内存剩余,就可以无限申请
堆内存地址由低到高,甚至可以乱序重复使用;栈内存只能由高到低
分配方式上堆都由开发者分配和回收,圈动态;栈可以由系统进行静态和动态两种方式的分配
使用效率上栈是连续地址,效率更高;堆地址不连续,可能产生内存碎片,效率略低

各编程语言的内存管理

下面将原生iOS开发中用到的主要语言内存管理做一个梳理

1. C语言

C语言中申请和释放堆内存有如下四个函数,其中malloc, calloc, realloc三个函数用于申请内存空间,最后都需要手动调用free进行释放。
C语言由于有realloc可以对内存进行扩展和删除,所以不是严格意义上的谁申请谁释放。
栈空间上申请的内存无需手动释放。

malloc

1
2
3
4
void *malloc(size_t __size) // 函数返回值void *表示可以转换为任意类型的指针,在堆区申请__size个字节的内存空间

int *a = malloc(4); // 申请4个字节的空间用于存放一个int类型的值
char *b = malloc(2); // 申请2个字节的空间用于存放一个char类型的值

calloc

1
2
3
4
void *calloc(size_t __count, size_t __size) // 在堆区申请__count个__size个字节的内存空间

int *c = calloc(10, sizeof(int)); // 申请10个sizeof(int) 字节的空间
char *d = calloc(2, sizeof(char)); // 申请10个sizeof(char) 字节的空间

realloc

修改现有指针的内存分配,需要注意必须是调用malloc和calloc分配的内存才能调用此方法,否则运行时错误。
如果 ptr 为 NULL,它的效果和 malloc() 相同,即分配 size 字节的内存空间。
如果 size 的值为 0,那么 ptr 指向的内存空间就会被释放,但是由于没有开辟新的内存空间,所以会返回空指针;类似于调用 free()。
调用成功后 ptr 将会被系统回收,不可再对其进行操作,包括free。

1
2
3
4
void *realloc(void *__ptr, size_t __size) // 修改指针指向的内存大小,*__ptr为需要修改的指针,__size为修改后的内存空间大小

char *d = calloc(2, sizeof(char)); // 申请2个sizeof(char) 字节的空间
char *f = realloc(d, 5 * sizeof(char)); // 将原来变量d指向的2个sizeof(char) 字节的空间更改到5个sizeof(char) 字节的空间并由变量f指向。

free

free() 可以释放由 malloc()、calloc()、realloc() 分配的内存空间,以便其他程序再次使用。
需要注意的是:free() 不会改变 传入的指针的值,调用 free() 后它仍然会指向相同的内存空间,但是此时该内存已无效,不能被使用。
所以记得将释放完的指针设置为 NULL,避免野指针产生。

1
2
3
char *g = malloc(sizeof(char)); // 申请sizeof(char)大小内存空间
free(g); // 释放掉g指针指向的内存空间
g = NULL; // 将g指针指向NULL

free()函数只能释放动态分配在堆区的内存,并不能释放任意分配的内存,比如:

1
2
int h[10]; // 在栈上申请的10乘以sizeof(int)大小的内存空间
free(h); // 此处报错:不能释放栈上分配的空间

2. C++

C++在和C混合调用的情况下遵循上面C语言的所有规则(实际上就是C的内存管理)。引入了新的内存管理关键字new和delete。
C++的new关键字可以理解为自带sizeof的malloc,使用起来比c的malloc要方便很多。
由于C++的内存管理涉及到形参实参,可以在调用new的时候不分配内存空间,比较不好理解,所以做个表格跟C对比下吧,尽量直观

表格对比

特征 new/delete malloc/free 备注
分配内存的位置 自由存储区 关于自由存储区和堆,它俩虽然看起来就是同一个地方,但是还是有一些细微的区别,参见这篇帖子https://www.cnblogs.com/QG-whz/p/5060894.html
内存分配成功的返回值 完整类型指针,指针类型安全 void*,类型不安全,需要转换 类型安全可以在编译过程中就发现很多的类型错误,避免很多不必要的运行时错误
内存分配失败的返回值 默认抛出异常 返回NULL 个人认为,在面向对象语言中,抛出异常的方式更加灵活;但是这也导致了写习惯了C刚到C++的时候依然判断new 的对象是否为NULL,在C++中这种判断根本不起作用
分配内存的大小 由编译器根据类型计算得出 必须显式指定字节数 这一点C++更方便
处理数组 有处理数组的new版本new[] 需要用户计算数组的大小后进行内存分配 对应的C++中释放内存也需要使用 delete[],不能直接使用delete。
已分配内存的扩充 无法直观地处理 使用realloc简单完成 realloc在重新分配的时候也需要连续内存足够,聊胜于无
是否相互调用 可以,看具体的operator new/delete实现 不可调用new
分配内存时内存不足 客户能够指定处理函数或重新制定分配器 无法通过用户代码进行处理 面向对象的优势
构造/析构函数重载 允许 不允许
构造函数与析构函数 调用 不调用 malloc在不会自动调用C++的构造函数

3. OC

在OC和Swift中都离不开一个重要的概念叫引用计数(Reference counting)和一个重要的原则叫谁申请谁释放。
引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。
使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。
当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,直至对象的引用计数为0,对象的内存会被立刻释放。
详细介绍可参考维基百科

谁申请谁释放则是MRC时代的东西了,可以进博物馆了,不做展开。

方法alloc,delloc, new, release,retain,copy,mutableCopy,autoRelease

简单来说类调用alloc或者new后,retainCount加一,调用release方法后retainCount减一,当retainCount为0的时候回调用delloc销毁当前对象。
在没有手动加Autorelease Pool的情况下,Autorelease对象是在当前的runloop迭代结束时释放的,而它能够释放的原因是系统在每个runloop迭代中都加入了自动释放池Push和Pop。
copy和mutableCopy这里涉及到一个浅拷贝和深拷贝的问题,在下面例子中说明。
简单在MRC下写个例子,测试下这些方法的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// alloc调用后retainCount为1,如果要释放此时的对象,需要调用release
NSString *temp = [[NSString alloc] initWithFormat:@"test string %d", 1];

NSLog(@"alloc retainCount = %lu", (unsigned long)[temp retainCount]);
// 调用retain后retainCount+1
[temp retain];
NSLog(@"after retain retainCount = %lu", (unsigned long)[temp retainCount]);

// 调用一次浅拷贝,从输出将结果可以可以看出,浅拷贝也会retainCount+1,并且对象的指针没有变化
NSString *newString = [temp copy];
NSLog(@"after copy point = %p, retainCount = %lu", temp, (unsigned long)[temp retainCount]);
NSLog(@"copy result point = %p, retainCount = %lu", newString, (unsigned long)[newString retainCount]);

// 对拷贝后的对象执行一次release,此时retainCount-1,由于跟原来的对象实际指向同一个地方temp的retainCount也会-1
[newString release];
NSLog(@"newString point = %p, retainCount = %lu", newString, (unsigned long)[newString retainCount]);

// 再执行一次release,retainCount再减一
[temp release];
NSLog(@"temp point = %p, retainCount = %lu", temp, (unsigned long)[newString retainCount]);

// 执行一次深拷贝,此时可以发现原temp 的retainCount没有辩护,newString retainCount为1,这是因为深拷贝创建了新的内存空间并复制原有的值,生成的是新对象
newString = [temp mutableCopy];
NSLog(@"newString point = %p, retainCount = %lu", newString, (unsigned long)[newString retainCount]);
NSLog(@"temp point = %p, retainCount = %lu", temp, (unsigned long)[newString retainCount]);

// 执行最后一次release,此时retainCount为0,内存会被系统回收
[temp release];
[newString release];

// 下面的调用在老版本iOS SDK上编译retainCount依然是1(runtime 发消息持有了当前对象)可以正常输出,在最新iOS SDK上会抛出exc_bad_access错误
// NSLog(@"temp point = %p, retainCount = %lu", temp, (unsigned long)[newString retainCount]);

输出结果如下:

1
2
3
4
5
6
7
8
alloc retainCount = 1
after retain retainCount = 2
after copy point = 0x6000027e91a0, retainCount = 3
copy result point = 0x6000027e91a0, retainCount = 3
newString point = 0x6000027e91a0, retainCount = 2
temp point = 0x6000027e91a0, retainCount = 1
newString point = 0x6000029b4780, retainCount = 1
temp point = 0x6000027e91a0, retainCount = 1

自动释放池autoreleasepool

前面提到了autorelease自动加入了autoreleasepool的push和pop;ARC的main.m中,整个application都包含在一个@autoreleasepool中,所以有必要把这部分内容单独拎出来回顾一下。
自动释放池的内容都可以单开一篇文章了,这里只简单描述一下原理。

1
2
3
4
5
6
7
8
9
10
11
#import <UIKit/UIKit.h>
#import "AppDelegate.h"

int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

自动释放池是由 AutoreleasePoolPage 以双向链表的方式连接起来的,每一个AutoreleasePoolPage的大小为4096字节(物理内存一页的大小)
在调用objc_autoreleasePoolPush的时候会将一个哨兵对象 POOL_SENTINEL(其实就是nil,0x0, 0)压栈入栈顶,当调用objc_autoreleasePoolPop的时候会向池子中的对象挨个发送release消息,直到第一个哨兵对象POOL_SENTINEL
autoreleaseFast 方法会在页内存足够的时候直接压栈,page内存不足时生成新的page存储对象后更新链表连接
当对象调用 autorelease 方法时,会将对象加入 AutoreleasePoolPage 的栈中
调用 AutoreleasePoolPage::pop 方法会向栈中的对象发送 release 消息以释放内存

@property关键字strong, retain, copy, assign, weak,

strong&retain分别是两个时代的强引用,引用计数会加1,可以理解为在set方法中调用了retain, MRC中需要调用release
copy关键字也会引用计数加1,set方法中调用了[obj copy], 可以避免对象在外部被修改, MRC中需要调用release
assign只是简单的指针或者值的赋值,不会有引用计数的变化
weak比较特殊,它不会增加引用计数,原理是runtime会调用objc_initWeak创建一个指向weak对象的指针,在添加weak引用的时候创建个表记录都有哪些地方用到了这些weak引用,最后当对象释放的时候遍历这个表,挨个把指针指向nil,然后清理这个弱引用节点。

循环引用(Reference Cycle)以及处理

引用计数算法虽然简单高效,但是不能很好的解决循环引用问题,内存不能正查给你释放导致的内存泄漏在开发过程中也会经常遇到,如图的情况都会引起循环引用,而且幻上的节点越多,越难发现和查找。

处理循环引用的方法主要有主动断开循环引用,使用弱引用这两种办法

主动断开循环引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 造成循环引用的方法
{
....
NSMutableArray *firstArray = [NSMutableArray array];
NSMutableArray *secondArray = [NSMutableArray array];
[firstArray addObject:secondArray];
[secondArray addObject:firstArray];
....
}

// 主动打破循环, 取消B对A的引用后就不会再有内存
{
[secondArray removeObject: addObject:firstArray];
}

使用弱引用:

第一种,我们通常使用的delegate写成weak关键字就是使用弱引用的一种体现,在闭环中使用weak关键字解除循环引用
第二种,在block块外面使用__weak定义weakSelf

1
2
3
4
__weak CurrentTypeClass *weakSelf = self;
self.invokeBlock = ^(id obj, NSUInteger idx, BOOL *stop) {
[weakSelf doSomething:idx];
}

使用Instruments工具查找内存泄漏和循环引用

在 Xcode 的菜单栏选择:Product -> Profile,出现Instruments工具然后选择 “Leaks”,再点击右下角的”Profile” 按钮开始检测。
得到结果以后点解Cycle&Roots, 如图,就可以直观的查看到循环引用的情况了。

iOS中需要手动释放内存的框架

前面已经梳理了OC中内存管理的大多数部分,但在iOS中还有相当一大部分框架采用C和C++编写,需要自己手动进行内存管理,以及和OC进行桥接。
这些框架比如CoreFoundation,CoreGraphics,CoreImage,ImageIO,AudioToolbox等。
这些框架有一个共同点,都显式的定义了create方法,retain,release方法,类似CGCreate….,CGCopy…, CGRetain,CGRelease;CFCreate…,CFCopy…, CFRetain,CFRelease等。
其中create方法可以理解为创建一个对象,引用计数为1,copy和retain方法将引用计数加一,用完以后需要手动调用release方法释放内存。风格很OC,很好理解。
但是,很多时候我们需要将这些框架生成的对象桥接到OC中进行使用,就需要用到下面的关键字:
__bridge:只做类型转换,不修改相关对象的引用计数,原来的Core *对象在不用时,需要调用C*Release方法。
__bridge_retained:类型转换后,将相关对象的引用计数加1,原来的Core *对象在不用时,需要调用C*Release方法。
__bridge_transfer:类型转换后,将该对象的引用计数交给ARC管理,Core *对象在不用时,不再需要调用C*Release方法。

4. Swift

Swift作为一门非常现代化的语言,延续了OC runtime和引用计数,并且不再有MRC,开发者只需要注意循环引用即可, 处理方法也类似,主要是主动解除循环引用和使用弱引用。

主动打破循环引用

跟上面OC同理,在适当的时候手动调用方法,解除循环引用

使用弱引用

使用这样的形式来断开引用闭环

1
public weak var delegate: DelegateType?

特殊关键字[weak handler] 和 [unowned handler]

Swift中专门引入了两个只作用于闭包的弱引用关键字[weak handler] 和 [unowned handler]。
这里为了简单可以把[weak handler]理解为先将handler做一个weak标记,可能在闭包执行的时候被释放,只做weak持有,可能在调用的时候为空,如果为空,可以取消调用,或做其它的对应操作。
[unowned handler]在闭包执行的时候不会为空,handler在整个闭包的声明周期内一定存在,在不造成循环引用的情况下,保证闭包能正确执行。
执行效率上[unowned handler] 高于 [weak handler]

特殊管理方式Unmanaged

Unmanaged 表示对不清晰的内存管理对象的封装,以及用烫手山芋的方式来管理他们。
这个问题的根源在于,虽然现在Swift已经重写或者桥接了绝大部分C和C++框架,但仍然有一部分类型需要开发者自行桥接。
但Swift只有ARC,__bridge_retained这种东西不会再有了,所以引入了Unmanaged对象。

一个 Unmanaged 实例封装有一个需要被桥接的类型 T,它在相应范围内持有对该 T 对象的引用。从一个 Unmanaged 实例中获取一个 Swift 值的方法有两种:

takeRetainedValue():

返回该实例中 Swift 管理的引用,并在调用的同时减少一次引用次数,所以可以按照上面的OC桥接时候的create和copy规则来对待其返回值。用完以后需要手动释放它。

takeUnretainedValue():

返回该实例中 Swift 管理的引用而 不减少 引用次数,所以可以按照__bridge_transfer的样子来对待其返回值。如果你想持久使用它,就需要自己想办法copy它或者retain它。

在Cocoa框架中需要特别注意的点

第一,在开发工程中不可避免的需要用到混编和桥接,需要注意不同语言的内存管理差异;桥接的过程中需要结合两边的内存管理,避免内存泄漏或者过度释放
第二,循环引用并没有那么可怕,不要因为害怕内存泄漏就导出使用weak,浪费执行效率

发表评论

电子邮件地址不会被公开。 必填项已用*标注