深入总结iOS内存管理

文章也同时在个人博客 http://kimihe.com/更新

引言

本文主要针对iOS内存管理进行总结,相信看过之后一定会让你有所收获。对于内存管理,网上有很多这方面的文章,但论质量可谓良莠不齐。其中的很多总结已经过时,甚至有些内容是似懂非懂的误读。因此,本文立足严谨的治学态度,希望能够尽可能全面地让大家理解内存管理的要点。

本文涵盖的知识点比较多,大家可以根据需要择章节阅读(部分章节待更),下面是目录:

  • 基础知识
  • 引用计数
  • ARC
  • delloc方法
  • 循环引用
  • 自动释放池@autoreleasepool
  • 关于retainCount

基础知识

在理解内存管理之前,你需要理解内存的到底如何被我们的操作系统使用的,需要有一个清晰的内存模型。

对于内存的使用模型,大家应该不陌生堆栈这次词。但具体的内部原理其实还是比较复杂的。大家谷歌或者百度一下,可以搜到如下这张内存堆栈图:

内存堆栈图

这张图的信息量还是比较大的,请大家耐心阅读笔者的讲解。

首先常说的堆栈一词包含了“堆”和“栈”两个概念。它们是有区别的,而且和数据结构中的堆栈是不同的。“堆”和“栈”分别代表了两种数据管理方式,前者有程序员自己控制,后者由操作系统(由编译器在编译时确定套路)控制。这句话是网上很多文章常说的,但可能并没有说明白其中的本质。如果你想更加清楚地了解堆栈在内存中的底层细节,可以参考一下笔者的这篇文章《内存中的堆和栈到底是什么》

在明白了上述细节后,大家请看如下代码:

NSString *someString = @"Some String";
NSString *anotherString = someString;

对于上述代码,我们表明上看起来似乎创建了两个NSString对象,其实它们二者是复用的关系。要理解这一点,你可能需要明白什么是指针。可以看笔者的这篇文章《快速入门Linux下GDB和汇编开发工具》中介绍指针原理的部分。

如果你清楚指针的含义,请继续往后看。实际上,上述代码只创建了一个NSString对象,它分配在堆中,需要进行内存管理。注意,我们并不是说这个字符串常量在堆上,而是创建一个OC对象需要的内存开销是在堆上,普通的字符串常量存在数据段的.rodata区。笔者强烈建议先阅读一下介绍内存堆栈的那篇文章。

someStringanotherString是两个指针(请注意它们C语言风格的*号),它们存在于栈中,跟随栈的管理由系统控制,我们人工无需也最好不要去控制。具体看来,我们在当前代码区域的栈中分配了两个内存,但这两个内存只是用来存放指针,与堆中用来存储字符串实例是完全不同的。这两个内存的大小刚好能够分别存下一个指针(在32位CPU上是4字节,在64位上是8字节),而且两个内存中的值是一样的,即两个指针的值是一样的,于是两个指针指向同一个堆内存区域,从而导致someStringanotherString这两个指针指向的是相同的字符串实例。如下图:

NSString堆栈示例图

总结一下:someStringanotherString分配在栈上,无需我们控制。NSString: @"Some String"分配在堆上,需要我们进行内存管理。而这里的内存管理你需要理解引用计数,请看下一小节。

除此之外,OC虽然是一门面向对象语言,但本质上它是一个C的超级。很多理念和C是通用的,如果你熟悉C,学习OC将很快。注意到上述指针的*号,OC中的对象都是以指针来引用的,因此对象类型的实例的名字前都会带有一个*号。

对于普通的非对象类型的变量,比如CGRect结构,它们也会使用栈空间,而非对象类型所用的堆空间。因此,相比于创建结构体创建对象需要分配和释放内存等额外开销,所以若需要存储int,float,double,char,bool等非对象类型,建议直接使用结构体。

建议花一点时间学习一下C,这将有助于OC的学习。

其余章节待更~

自动释放池@autoreleasepool

OC对象的生命周期取决于引用计数,我们有两种方式可以释放对象:一种是直接调用release释放;另一种是调用autorelease将对象加入自动释放池中。自动释放池用于存放那些需要在稍后某个时刻释放的对象。

自动释放池的创建

如果没有自动释放池而给对象发送autorelease消息,将会收到控制台报错。但一般我们无需担心自动释放池的创建问题。

我们的Mac以及iOS系统会自动创建一些线程,例如主线程和GCD中的线程,都默认拥有自动释放池。每次执行 “事件循环”(event loop)时,就会将其清空,这一点非常重要,请务必牢记! 关于事件循环,其涉及到runloop,可以看这篇文章:深入理解RunLoop

因此我们一般不需要手动创建自动释放池,通常只有一个地方需要它,那就是在main()函数里,如下:

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

这个main()函数里面的池并非必需。因为块的末尾是应用程序的终止处,即便没有这个自动释放池,也会由操作系统来释放。但是这些由UIApplicationMain函数所自动释放的对象就没有池可以容纳了,系统会发出警告。因此,这里的池可以理解成最外围捕捉全部自动释放对象所用的池。

@autoreleasepool的作用

大家可以先看一下下面的iOS笔试题的第5题(修改代码的错误),如下图:


优酷iOS笔试题

这段代码问题在哪里呢?题目的解答请继续阅读。笔者先给一个提示:与内存的释放有关。

现考虑如下代码:

for (int i = 0; i < 10000; i++) {
    [self doSthWith:object];
}

这段代码和笔试题关键部分大同小异。如果"doSthWith:"方法要创建一个临时对象,那么这个对象很可能会放在自动释放池里。笔试题中最后stringByAppendingString方法很有可能属于上述的方法。因此如果涉及到了自动释放池,那么问题也应该就出在上面。

注意:即便临时对象在调用完方法后就不再使用了,它们也依然处于存活状态,因为目前它们都在自动释放池里,等待系统稍后进行回收。但自动释放池却要等到该线程执行下一次事件循环时才会清空,这就意味着在执行for循环时,会有持续不断的新的临时对象被创建出来,并加入自动释放池。要等到结束for循环才会释放。在for循环中内存用量会持续上涨,而等到结束循环后,内存用量又会突然下降。

而如果把循环内的代码包裹在“自动释放池”中,那么在循环中自动释放的对象就会放在这个池,而不是在线程的主池里面。如下:

for (int i = 0; i < 1000000; i++) {
        @autoreleasepool {
            NSString *str = @"abc";
            str = [str lowercaseString];
            str = [str stringByAppendingString:@"xyz"];
        }        
}

新增的自动释放池可以减少内存用量,因为系统会在块的末尾把这些对象回收掉。而上述这些临时对象,正在回收之列。

自动释放池的机制就像“栈”。系统创建好池之后,将其压入栈中,而清空自动释放池相当于将池从栈中弹出。在对象上执行自动释放操作,就等于将其放入位于栈顶的那个池。

实验验证

我们可以通过实验进行验证。新建工程加入上述代码,并关闭ARC(不然是看不到区别的)。

在未添加autoreleasepool时,我们的堆内存实时分配情况如下图:

HeapWithoutAutoreleasepool

大家可以看到Persistent Bytes不断增加,到达100W次的创建峰值后(出for循环)开始逐步释放。因此图像是一个向上凸的曲线。

在加入autoreleasepool后,我们看到如下的曲线:

HeapWithAutoreleasepool

可以发现尽管字符串在不断地创建,但由于得到了及时的释放,堆内存始终保持在一个很低的水平。

其他注意点

@autoreleasepool语法还有一个好处,就是可以避免无意间误用那些在清空池之后已被系统回收的对象,例如:

@autoreleasepool {
    id obj = [self createObject];
}
[self useObject:obj];

上述代码在编译时就会基于错误警告,因为obj出了自动释放池就不可用了。

@autoreleasepool小结

  • 自动释放池排布在栈中,对象受到autorelease消息后,系统将其放入栈顶的池里。
  • 合理运用自动释放池,可以降低程序的内存峰值。

关于retainCount

大家在搜索引擎中输入“retainCount”,可以看到非常多的文章讲述retainCount的作用。

大多数文章都在强调这个retainCount是用来标记对象,进行内存管理的。所谓“reatin +1;release -1;到0释放;打印retainCount查看当前计数”等等。但其实retainCount并不应该像上述那样使用,OC的引用计数也不是那样理解的。

误区一

retainCount似乎可以返回某对象的保留计数,其实即便在MRC下,retainCount在实际编程过程中也并没有什么用。首要原因在于:它返回的保留计数只是某个时间点上的值。该方法并未考虑到系统会稍后把自动释放池清空,因而没有将后续的释放操作从返回值里减去。

如果你想根据这个数值来进行某些释放操作,那就很糟糕了。例如:

while ([object retainCount]) {
    [object release];
}

其中存在两个错误,第一个就是没有考虑到自动释放操作,只是不停地release,直到对象被系统回收。假如此对象在自动释放池里,那么稍后系统在清理空池子时还要仔把它释放一次,这将导致程序崩溃。

第二个错误就是:retainCount可能永远也不会减少到0。因为有时候系统会优化对象的释放行为,在保留计数还是1的时候就把它回收了。只有在系统不打算这么优化时,计数值才会递减址0。所以靠上述条件进行判断,完全是在赌运气。

我们真正应该做的是确保release和retain操作的匹配(即可以相互抵消),我们在期望系统回收某对象时,应该确保没有尚未抵消的保留操作,也就是不要让保留计数大于我们的期望值。如果发现内存泄漏了,那么应该检查还有社仍然保留这个对象,并查明为何没有释放。

虽然Apple建议大家使用ARC,实际也确实如此。但基于MRC的开发也并非一无是处,至少它能够让我们更加清晰地理解内存管理的真谛。

误区二

如果我们打印一些字符串或者数值对象的retainCount,如下代码:

NSString *string = @"Apple";
NSLog(@"string retainCount: %lu", (unsigned long)[string retainCount]);
    
NSNumber *numberI = @1;
NSLog(@"numberI retainCount: %lu", (unsigned long)[numberI retainCount]);
    
NSNumber *numberF = @3.14f;
NSLog(@"numberF retainCount: %lu", (unsigned long)[numberF retainCount]);

其输出将会如下:

2016-12-04 20:04:51.007 tmp[1522:138528] string retainCount: 18446744073709551615
2016-12-04 20:04:51.008 tmp[1522:138528] numberI retainCount: 9223372036854775807
2016-12-04 20:04:51.008 tmp[1522:138528] numberF retainCount: 1

我们发现结果非常奇怪。怎么回事呢?

第一个string的保留计数其实为2的64次方-1,而第二个numberI是2的63次方-1。二者皆为“单例对象”(singleton object),所以保留计数都很大。

系统会尽可能地把NSString实现成单例对象。像上述代码中的字符串,其实是一个编译期常量,编译器会把NSString对象所表示的数据放到应用程序的二进制文件里,这样的话,运行程序时就能直接用了。这一点对应到底层的话,隶属于程序的可执行文件中数据段的.rodata小段的作用,如果你阅读过开头笔者提到的《内存中的堆和栈到底是什么》这篇文章,应该可以很容易理解。

NSNumber也类似,它使用了一种叫做“标签指针”(tagged pointer)的概念来标注特定类型的数值。关于tagged pointer,可以阅读这篇唐巧的文章:深入理解Tagged Pointer

tagged pointer不使用NSNumber对象,而是把数值有关的全部消息都放在指针值里面。运行期系统会在消息派发期间检测到这种标签指针,并对它执行相应操作,使其行为看上去和真正的NSNumber对象一样。这种优化只在某些场合使用,比如上述代码中的浮点数对象就没有优化,所以保留计数就是1。

上述的这种单例对象,其保留计数绝对不会变。这种对象的保留及释放操作都是“空操作”(noop)。

retainCount小结

对象的保留计数看似有用,其实最好根本不要去使用。因为任何给定时间点上的“绝对保留计数”(absolute retain count)都无法反应对象生命期的全貌。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容

  • 内存管理 简述OC中内存管理机制。与retain配对使用的方法是dealloc还是release,为什么?需要与a...
    丶逐渐阅读 1,946评论 1 16
  • 29.理解引用计数 Objective-C语言使用引用计数来管理内存,也就是说,每个对象都有个可以递增或递减的计数...
    Code_Ninja阅读 1,470评论 1 3
  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,121评论 29 470
  • 11.看下面的程序,第一个NSLog会输出什么?这时str的retainCount是多少?第二个和第三个呢? 为什...
    AlanGe阅读 718评论 1 4
  • 多线程、特别是NSOperation 和 GCD 的内部原理。运行时机制的原理和运用场景。SDWebImage的原...
    LZM轮回阅读 2,003评论 0 12