[关闭]
@qidiandasheng 2021-04-21T03:10:26.000000Z 字数 17950 阅读 1997

iOS里的内存管理

iOS连载


前言

什么是内存管理?是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。

我们本篇学习的就是iOS开发中是如何对内存进行管理的。其中有部分章节是从前人的文章中搬运过来整理而成,这些文章里已经对部分知识点解释的很清楚明了了,我也没有更好的表达方式,所以站在巨人的肩膀上,我只是一个整理者加了部分自己的理解。

内存五大区:栈区、堆区、全局区/静态区、常量区、代码区

7271477-6826f45e95473767.png-24.8kB

下图为Mach-O可执行文件的结构,其中__TEXT__DATA_CONST__DATA对应的就是代码区、常量区、全局区/静态区。
截屏2020-07-14 下午11.30.45.png-200kB

编译器自动分配释放 ,存放函数的参数值,局部变量的值,非OC对象(基础数据类型)等。其操作方式类似于数据结构中的栈,内存地址连续向下增长。

OC对象,一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,内存地址向上增长。

全局区/静态区(static):

包括两个部分:未初始化过 、初始化过。也就是说,(全局区/静态区)在内存中是放在一起的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域;

常量区

常量字符串就是放在这里;

代码区

存放函数的二进制代码,代码区的内存是由系统控制。

内存分配

首先既然我们需要对内存进行管理,就需要知道内存是怎么分配的,是分配在哪里的?

在iOS中数据是存在在堆和栈中的,然而我们的内存管理管理的是堆上的内存,栈上的内存并不需要我们管理。

如下面一段代码:

  1. int a = 10; //栈
  2. int b = 20; //栈
  3. Car *c = [[Car alloc] init];

在内存中的表现形式如下:

函数内的内存分配:

  1. int main(int argc, const char * argv[]) {
  2. // 局部变量是保存在栈区的
  3. // 栈区变量出了作用域之后,就会被销毁
  4. NSInteger i = 10;
  5. NSLog(@"%zd", i);
  6. // 局部变量是保存在栈区的
  7. // 赋值语句右侧,使用 new 方法创建的对象是保存在堆区的
  8. // person 变量中,记录的是堆区的地址
  9. // 在 OC 中,有一个内存管理机制,叫做 `ARC`,可以自动管理 OC 代码创建对象的生命周期
  10. // 因此,在开发 OC 程序的时候,程序员通常不需要考虑内存释放的工作
  11. Person *person = [Person new];
  12. NSLog(@"%@", person);
  13. return 0;
  14. }

类型占用字节数

类型 32位机器 64位机器
char 1 1
*(指针) 4 8
int 4 4
NSInteger 4 8
float 4 4
double 8 8

基础数据类型地址

  1. int a = 10000;
  2. int b = 10000;
  3. NSLog(@"%p",a);
  4. NSLog(@"%x",&a);
  5. NSLog(@"%p",b);
  6. NSLog(@"%x",&b);

以下为输出:

  1. //16进制的值,转为10进制就是10000
  2. 2020-04-28 22:38:58.121006+0800 Test[86042:1440381] 0x2710
  3. //在栈上的地址,从高位到低位
  4. 2020-04-28 22:38:58.121508+0800 Test[86042:1440381] e57390d8
  5. //16进制的值,转为10进制就是10000
  6. 2020-04-28 22:38:58.121606+0800 Test[86042:1440381] 0x2710
  7. //在栈上的地址,从高位到低位(我们看到低位e57390d4进4刚好是高位e57390d8,4个字节刚好就是int类型占的字节数)
  8. 2020-04-28 22:38:58.121710+0800 Test[86042:1440381] e57390d4

未命名文件.png-27.2kB

int b=10000改为NSInteger b=10000,则输出就如下所示,NSInteger类型占8个字节,我们能看到低位e57390d0进8刚好是e57390d8

  1. 2020-04-28 22:52:45.994179+0800 Test[86250:1451844] e57390d8
  2. 2020-04-28 22:52:45.994269+0800 Test[86250:1451844] e57390d0

未命名文件 (1).png-27.2kB

基础数据类型指针地址

  1. int a = 10000;
  2. int *p = &a;
  3. NSLog(@"%x",&a);
  4. NSLog(@"%x",p);
  5. NSLog(@"%x",&p);
  1. //都在栈区,p的值跟&a的值相同,都是表示值的地址
  2. 2020-04-28 23:01:00.383224+0800 Test[86384:1458975] e57390d8
  3. 2020-04-28 23:01:00.383355+0800 Test[86384:1458975] e57390d8
  4. //指针地址也在栈上,从高位到低位,指针类型占用8个字节,刚好e57390d0进8为e57390d8
  5. 2020-04-28 23:01:00.383484+0800 Test[86384:1458975] e57390d0

未命名文件 (2).png-28kB

对象占用内存

什么是内存对齐

CPU访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如32位的CPU,字长为4字节,那么CPU访问内存的单位也是4字节。

这么设计的目的,是减少CPU访问内存的次数,加大CPU访问内存的吞吐量。比如同样读取8个字节的数据,一次读取4个字节那么只需要读取2次。

下面我们来看看,编写程序时,变量在内存中是否按内存对齐的差异。假设我们有如下结构体:

  1. struct Foo {
  2. uint8_t a;
  3. uint32_t b;
  4. }

20020_0.jpg-79.4kB

我们假设CPU以4字节为单位读取内存。

如果变量在内存中的布局按4字节对齐,那么读取a变量只需要读取一次内存,即word1;读取b变量也只需要读取一次内存,即word2。

而如果变量不做内存对齐,那么读取a变量也只需要读取一次内存,即word1;但是读取b变量时,由于b变量跨越了2个word,所以需要读取两次内存,分别读取word1和word2的值,然后将word1偏移取后3个字节,word2偏移取前1个字节,最后将它们做或操作,拼接得到b变量的值。

显然,内存对齐在某些情况下可以减少读取内存的次数以及一些运算,性能更高。

另外,由于内存对齐保证了读取b变量是单次操作,在多核环境下,原子性更容易保证。

但是内存对齐提升性能的同时,也需要付出相应的代价。由于变量与变量之间增加了填充,并没有存储真实有效的数据,所以占用的内存会更大。这也是一个典型的空间换时间的应用场景。

64位CPU内存分配大小

64位CPU的字长为8字节,在iOS里以16字节的倍数进行内存的分配。我们用以下示例进行验证:

  1. void add();
  2. int main()
  3. {
  4. int a = 1; //4字节
  5. int b = 2; //4字节
  6. char c1 = 'a';
  7. char c2 = 'b';
  8. char c3 = 'c';
  9. char c4 = 'd';
  10. char c5 = 'e';
  11. char c6 = 'f';
  12. char c7 = 'g';
  13. char c8 = 'h'; //一共8个字节
  14. add();
  15. }
  16. void add(){
  17. }

在main函数里打断点,然后读取rbp和rsp的地址查看分配的内存大小,我们看到分配的栈帧大小刚好是16字节(0x00007ffee2d0bcc0 - 0x00007ffee2d0bcb0 = 16字节):

  1. (lldb) register read rbp
  2. rbp = 0x00007ffee2d0bcc0
  3. (lldb) register read rsp
  4. rsp = 0x00007ffee2d0bcb0

当我们多加一个char类型时,刚好是17字节,也就是16字节多了一个字节:

  1. void add();
  2. int main()
  3. {
  4. int a = 1; //4字节
  5. int b = 2; //4字节
  6. char c1 = 'a';
  7. char c2 = 'b';
  8. char c3 = 'c';
  9. char c4 = 'd';
  10. char c5 = 'e';
  11. char c6 = 'f';
  12. char c7 = 'g';
  13. char c8 = 'h'; //一共8个字节
  14. char c9 = 'i'; //1字节
  15. add();
  16. }
  17. void add(){
  18. }

在main函数里打断点,然后读取rbp和rsp的地址查看分配的内存大小,我们看到分配的栈帧大小刚好是32字节(0x00007ffeea279cc0 - 0x00007ffeea279ca0 = 32字节):

  1. (lldb) register read rbp
  2. rbp = 0x00007ffeea279cc0
  3. (lldb) register read rsp
  4. rsp = 0x00007ffeea279ca0

查看对象结构体

创建一个工程 , 打开 main.m 在 main 函数创建一个 NSObject.

  1. int main(int argc, const char * argv[]) {
  2. @autoreleasepool {
  3. NSObject *objc = [[NSObject alloc] init];
  4. }
  5. return 0;
  6. }

打开终端, 进入到 main.m 目录. 将其转换为 c++ 源码.

  1. clang -rewrite-objc main.m -o main.cpp

打开main.cpp文件,搜索NSObject_IMPL,这个就是 NSOject 对象对应的 C++ 结构体。里面包含了一个 Class 指针:

  1. struct NSObject_IMPL {
  2. Class isa;
  3. };

NSObject 对象对应的结构体只包含一个 isa 指针变量 , 一个指针变量在 64 位的机器上大小是 8 个字节。然而根据内存对齐,实际占用的内存为16个字节。

查看占用内存

  1. #import <Foundation/Foundation.h>
  2. #import <objc/runtime.h>
  3. #import <malloc/malloc.h>
  4. int main(int argc, const char * argv[]) {
  5. @autoreleasepool {
  6. NSObject *lbobjc = [[NSObject alloc] init];
  7. NSLog(@"lbobjc对象实际需要的内存大小: %zd",class_getInstanceSize([lbobjc class]));
  8. NSLog(@"lbobjc对象实际分配的内存大小: %zd",malloc_size((__bridge const void *)(lbobjc)));
  9. }
  10. return 0;
  11. }
  1. 打印结果
  2. iOS-OC对象占用内存探索[2903:181348] lbobjc对象实际需要的内存大小: 8
  3. iOS-OC对象占用内存探索[2903:181348] lbobjc对象实际分配的内存大小: 16

接下来我们打个断点,查看内存地址,我们能看到占用的16个字节,后面部分都为00:

屏幕快照 2020-04-21 下午11.01.02.png-97kB

我们可以通过阅读 objc4 的源码来找到答案。通过查看跟踪 obj4 中 alloc 和 allocWithZone 两个函数的实现,会发现这两个函数都会调用一个 instanceSize 的函数:

  1. size_t instanceSize(size_t extraBytes) {
  2. size_t size = alignedInstanceSize() + extraBytes;
  3. // CF requires all objects be at least 16bytes.
  4. if (size < 16) size = 16;
  5. return size;
  6. }

包含属性时内存占用

  1. #import <Foundation/Foundation.h>
  2. #import <objc/runtime.h>
  3. #import <malloc/malloc.h>
  4. @interface DSPerson : NSObject
  5. @property (nonatomic,assign) int age;
  6. @property (nonatomic,assign) int height;
  7. @property (nonatomic,assign) int row;
  8. @end
  9. @implementation DSPerson
  10. @end
  11. int main(){
  12. @autoreleasepool {
  13. DSPerson * obj = [[DSPerson alloc] init];
  14. obj.age = 4;
  15. obj.height = 5;
  16. obj.row = 6;
  17. NSLog(@"lbobjc对象实际需要的内存大小: %zd",class_getInstanceSize([obj class]));
  18. NSLog(@"lbobjc对象实际分配的内存大小: %zd",malloc_size((__bridge const void *)(obj)));
  19. }
  20. return 0;
  21. }
  1. 打印结果
  2. iOS-OC对象占用内存探索[3012:201559] lbobjc对象实际需要的内存大小: 24
  3. iOS-OC对象占用内存探索[3012:201559] lbobjc对象实际分配的内存大小: 32

同样打断点查看内存:

屏幕快照 2020-04-21 下午11.14.02.png-92.9kB

我们能看到这里一共只需要20个字节就够了,然而实际却需要24个字节,这里就需要说一下结构体内存分配的原理:

  1. - 结构体每个成员相对于结构体首地址的偏移量都是这个成员大小的整数倍,如果有需要,编译器会在成员之间加上填充字节
  2. - 结构体的总大小为结构体最宽成员大小的整数倍。
  3. - 结构体变量的首地址能够被其最宽基本类型成员的大小所整除。
  4. - 对于结构体成员属性中包含结构体变量的复合型结构体,在确定最宽基本类型成员时,应当包括复合类型成员的子成员。但在确定复合类型成员的偏移位置时则是将复合类型作为整体看待。

由于原本结构体 isa 指针占用8个 , age 属性占用4个, height 占用 4个, row 属性再占用4个,这中间由于满足整除并没有自动偏移补充。而由于结构体的总大小为结构体最宽成员大小的整数倍,所以需要内存为3*8=24。同时为了满足 16 字节对齐原则,实际开辟内存为16*2=32。

为什么需要16字节对齐

因为我们知道,iOS中所有的对象都是继承NSObject对象,而NSObject对象中是一个结构体指针,大小为8个字节,如果还是以8个字节对齐的话,会有可能出现内存数据出错,没有容错处理,因此采用16字节对齐。

引用计数

引用计数解释

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

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

在遥远的以前,iOS开发的内存管理是手动处理引用计数,在合适的地方使引用计数-1,直到减为0,内存释放。现在的iOS开发内存管理使用的是ARC,自动管理引用计数,会根据引用计数自动监视对象的生存周期,实现方式是在编译时期自动在已有代码中插入合适的内存管理代码以及在 Runtime 做一些优化。

文艺的解释

记得在《寻梦环游记》里对于一个人的死亡是这样定义的:当这个这个世界上最后一个人都忘记你时,就迎来了终极死亡。类比于引用计数,就是每有一个人记得你时你的引用计数加1,每有一个人忘记你时,你的引用计数减1,当所有人都忘记你时,你就消失了,也就是从内存中释放了。

如果再深一层,包含我们后面要介绍的ARC中的强引用和弱引用的话,那这个记住的含义就不一样了。强引用就是你挚爱的亲人,朋友等对你比较重要的人记得你,你的引用计数才加1。

而弱引用就是那种路人,一面之缘的人,他们只是对你有一个印象,他们记得你是没有用的,你的引用计数不会加1。当你挚爱的人都忘记你时,你的引用计数归零,你就从这个世界上消失了,而这些路人只是感觉到自己记忆中忽然少了些什么而已。

代码测试

我们创建一个工程,在Build Phases里设置AppDelegate的Compiler Flags-fno-objc-arc来开启手动管理引用计数的模式。

  1. - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  2. NSObject *object = [[NSObject alloc] init];
  3. NSLog(@"\n 引用计数 = %lu \n 对象内存 = %p \n object指针内存地址 = %x", (unsigned long)[object retainCount], object, &object);
  4. self.property = object;
  5. NSLog(@"\n 引用计数 = %lu \n 对象内存 = %p \n object指针内存地址 = %x \n property指针内存地址 = %x", (unsigned long)[object retainCount], object, &object, &_property);
  6. [object release];
  7. NSLog(@"\n 引用计数 = %lu \n 对象内存 = %p \n object指针内存地址 = %x \n property指针内存地址 = %x", (unsigned long)[object retainCount], object, &object, &_property);
  8. return YES;
  9. }

输出:

  1. 2018-08-25 21:01:01.323677+0800 test[26304:9610044]
  2. 引用计数 = 1
  3. 对象内存 = 0x60000000e290
  4. object指针内存地址 = ee0fee28
  5. 2018-08-25 21:01:01.323880+0800 test[26304:9610044]
  6. 引用计数 = 2
  7. 对象内存 = 0x60000000e290
  8. object指针内存地址 = ee0fee28
  9. property指针内存地址 = 301b8
  10. 2018-08-25 21:01:01.324088+0800 test[26304:9610044]
  11. 引用计数 = 1
  12. 对象内存 = 0x60000000e290
  13. object指针内存地址 = ee0fee28
  14. property指针内存地址 = 301b8

我们看到object持有对象引用计数+1为1,然后self.property又持有了对象,引用计数再+1为2,然后我们主动释放object,引用计数-1变为1。我们能看到[object release]释放后指向对象的指针仍就被保留在object这个变量中,只是对象的引用计数-1了而已。

对应的内存上的分配如下图所示:

MRC手动管理引用计数

在MRC中增加的引用计数都是需要自己手动释放的,所以我们需要知道哪些方式会引起引用计数+1;

对象操作 OC中对应的方法 引用计数的变化
生成并持有对象 alloc/new/copy/mutableCopy等 +1
持有对象 retain +1
释放对象 release -1
废弃对象 dealloc -1

四个法则

  1. /*
  2. * 自己生成并持有该对象
  3. */
  4. id obj0 = [[NSObeject alloc] init];
  5. id obj1 = [NSObeject new];
  1. /*
  2. * 持有非自己生成的对象
  3. */
  4. id obj = [NSArray array]; // 非自己生成的对象,且该对象存在,但自己不持有
  5. [obj retain]; // 自己持有对象
  1. /*
  2. * 不在需要自己持有的对象的时候,释放
  3. */
  4. id obj = [[NSObeject alloc] init]; // 此时持有对象
  5. [obj release]; // 释放对象
  6. /*
  7. * 指向对象的指针仍就被保留在obj这个变量中
  8. * 但对象已经释放,不可访问
  9. */
  1. /*
  2. * 非自己持有的对象无法释放
  3. */
  4. id obj = [NSArray array]; // 非自己生成的对象,且该对象存在,但自己不持有
  5. [obj release]; // ~~~此时将运行时crash 或编译器报error~~~ 非 ARC 下,调用该方法会导致编译器报 issues。此操作的行为是未定义的,可能会导致运行时 crash 或者其它未知行为

非自己生成的对象,且该对象存在,但自己不持有

其中关于非自己生成的对象,且该对象存在,但自己不持有是如何实现的呢?这个特性是使用autorelease来实现的,示例代码如下:

  1. - (id) getAObjNotRetain {
  2. id obj = [[NSObject alloc] init]; // 自己持有对象
  3. [obj autorelease]; // 取得的对象存在,但自己不持有该对象
  4. return obj;
  5. }

使用autorelease方法可以使取得的对象存在,但自己不持有对象。autorelease 使得对象在超出生命周期后能正确的被释放(通过调用release方法)。在调用 release 后,对象会被立即释放,而调用 autorelease 后,对象不会被立即释放,而是注册到 autoreleasepool 中,经过一段时间后 pool结束,此时调用release方法,对象被释放。

[NSMutableArray array] [NSArray array]都可以取得谁都不持有的对象,这些方法都是通过autorelease实现的。

ARC自动管理引用计数

ARC介绍

ARC其实也是基于引用计数,只是编译器在编译时期自动在已有代码中插入合适的内存管理代码(包括 retain、release、copy、autorelease、autoreleasepool)以及在 Runtime 做一些优化。

现在的iOS开发基本都是基于ARC的,所以开发人员大部分情况都是不需要考虑内存管理的,因为编译器已经帮你做了。为什么说是大部分呢,因为底层的 Core Foundation 对象由于不在 ARC 的管理下,所以需要自己维护这些对象的引用计数。

还有就算循环引起情况就算由于互相之间强引用,引用计数永远不会减到0,所以需要自己主动断开循环引用,使引用计数能够减少。

这里有几个ARC的基本原则:

strong和weak的本质简单点可以这么理解:

在内存中开辟一块空间,strong应该相当于如果多个指针指向该内存空间时,必须所有指向该内存的指针离开时,该对象才会被释放,而weak是该对象的拥有者的指针(强引用)离开时,其余指向该对象的指针(弱引用)就会自动指向空,防止出现野指针。就像协议一般都是weak类型修饰,防止出现野指针。

所有权修饰符

Objective-C编程中为了处理对象,可将变量类型定义为id类型或各种对象类型。 ARC中id类型和对象类其类型必须附加所有权修饰符。

其中有以下4种所有权修饰符:

所有权修饰符和属性的修饰符对应关系如下所示:

__strong

__strong 表示强引用,对应定义 property 时用到的 strong。当对象没有任何一个强引用指向它时,它才会被释放。如果在声明引用时不加修饰符,那么引用将默认是强引用。当需要释放强引用指向的对象时,需要保证所有指向对象强引用置为 nil。__strong 修饰符是 id 类型和对象类型默认的所有权修饰符。

在ARC中我们创建的对象默认都是强引用的如:

  1. NSString *string = [NSString stringWithFormat:@"齐滇大圣"];等价于
  2. __strong NSString *string = [NSString stringWithFormat:@"齐滇大圣"];

原理:

  1. {
  2. id __strong obj = [[NSObject alloc] init];
  3. }
  1. //编译器的模拟代码
  2. id obj = objc_msgSend(NSObject,@selector(alloc));
  3. objc_msgSend(obj,@selector(init));
  4. // 出作用域的时候调用
  5. objc_release(obj);

虽然ARC有效时不能使用release方法,但由此可知编译器自动插入了release。

对象是通过除alloc、new、copy、multyCopy外方法产生的情况

  1. {
  2. id __strong obj = [NSMutableArray array];
  3. }

结果与之前稍有不同:

  1. //编译器的模拟代码
  2. id obj = objc_msgSend(NSMutableArray,@selector(array));
  3. objc_retainAutoreleasedReturnValue(obj);
  4. objc_release(obj);

objc_retainAutoreleasedReturnValue函数主要用于优化程序的运行。它是用于持有(retain)对象的函数,它持有的对象应为返回注册在autoreleasePool中对象的方法,或是函数的返回值。像该源码这样,在调用array类方法之后,由编译器插入该函数。

而这种objc_retainAutoreleasedReturnValue函数是成对存在的,与之对应的函数是objc_autoreleaseReturnValue。它用于array类方法返回对象的实现上。下面看看NSMutableArray类的array方法通过编译器进行了怎样的转换:

  1. + (id)array
  2. {
  3. return [[NSMutableArray alloc] init];
  4. }
  1. //编译器模拟代码
  2. + (id)array
  3. {
  4. id obj = objc_msgSend(NSMutableArray,@selector(alloc));
  5. objc_msgSend(obj,@selector(init));
  6. // 代替我们调用了autorelease方法
  7. return objc_autoreleaseReturnValue(obj);
  8. }

我们可以看见调用了objc_autoreleaseReturnValue函数且这个函数会返回注册到自动释放池的对象,但是,这个函数有个特点,它会查看调用方的命令执行列表,如果发现接下来会调用objc_retainAutoreleasedReturnValue则不会将返回的对象注册到autoreleasePool中而仅仅返回一个对象。达到了一种最优效果。如下图:

628297-769a5d0ff4575b03.png-415kB

__weak

__weak 表示弱引用,对应定义 property 时用到的 weak。弱引用不会影响对象的释放,而当对象被释放时,所有指向它的弱引用都会自定被置为 nil,这样可以防止野指针。使用__weak修饰的变量,即是使用注册到autoreleasePool中的对象。__weak 最常见的一个作用就是用来避免循环循环。需要注意的是,__weak 修饰符只能用于 iOS5 以上的版本,在 iOS4 及更低的版本中使用 __unsafe_unretained 修饰符来代替。

__weak 的几个使用场景:

原理:

  1. {
  2. id __weak obj = [[NSObject alloc] init];
  3. }

编译器转换后的代码如下:

  1. id obj;
  2. id tmp = objc_msgSend(NSObject,@selector(alloc));
  3. objc_msgSend(tmp,@selector(init));
  4. objc_initweak(&obj,tmp);
  5. objc_release(tmp);
  6. objc_destroyWeak(&object);

对于__weak内存管理也借助了类似于引用计数表的散列表,它通过对象的内存地址做为key,而对应的__weak修饰符变量的地址作为value注册到weak表中,在上述代码中objc_initweak就是完成这部分操作,而objc_destroyWeak
则是销毁该对象对应的value。当指向的对象被销毁时,会通过其内存地址,去weak表中查找对应的__weak修饰符变量,将其从weak表中删除。所以,weak在修饰只是让weak表增加了记录没有引起引用计数表的变化。

对象通过objc_release释放对象内存的动作如下:

  1. objc_release
  2. 因为引用计数为0所以执行dealloc
  3. _objc_rootDealloc
  4. objc_dispose
  5. objc_destructInstance
  6. objc_clear_deallocating

而在对象被废弃时最后调用了objc_clear_deallocating,该函数的动作如下:

  1. 从weak表中获取已废弃对象内存地址对应的所有记录
  2. 将已废弃对象内存地址对应的记录中所有以weak修饰的变量都置为nil
  3. 从weak表删除已废弃对象内存地址对应的记录
  4. 根据已废弃对象内存地址从引用计数表中找到对应记录删除
  5. 据此可以解释为什么对象被销毁时对应的weak指针变量全部都置为nil,同时,也看出来销毁weak步骤较多,如果大量使用weak的话会增加CPU的负荷。

还需要确认一点是:使用__weak修饰符的变量,即是使用注册到autoreleasePool中的对象。

  1. {
  2. id __weak obj1 = obj;
  3. NSLog(@"obj2-%@",obj1);
  4. }

编译器转换上述代码如下:

  1. id obj1;
  2. objc_initweak(&obj1,obj);
  3. id tmp = objc_loadWeakRetained(&obj1);
  4. objc_autorelease(tmp);
  5. NSLog(@"%@",tmp);
  6. objc_destroyWeak(&obj1);

objc_loadWeakRetained函数获取附有__weak修饰符变量所引用的对象并retain, objc_autorelease函数将对象放入autoreleasePool中,据此当我们访问weak修饰指针指向的对象时,实际上是访问注册到自动释放池的对象。因此,如果大量使用weak的话,在我们去访问weak修饰的对象时,会有大量对象注册到自动释放池,这会影响程序的性能。

解决方案:要访问weak修饰的变量时,先将其赋给一个strong变量,然后进行访问

为什么访问weak修饰的对象就会访问注册到自动释放池的对象呢?

因为weak不会引起对象的引用计数器变化,因此,该对象在运行过程中很有可能会被释放。所以,需要将对象注册到自动释放池中并在autoreleasePool销毁时释放对象占用的内存。

__unsafe_unretained

ARC 是在 iOS5 引入的,而 __unsafe_unretained 这个修饰符主要是为了在ARC刚发布时兼容iOS4以及版本更低的系统,因为这些版本没有弱引用机制。这个修饰符在定义property时对应的是unsafe_unretained__unsafe_unretained 修饰的指针纯粹只是指向对象,没有任何额外的操作,不会去持有对象使得对象的 retainCount +1。而在指向的对象被释放时依然原原本本地指向原来的对象地址,不会被自动置为 nil,所以成为了野指针,非常不安全。

__unsafe_unretained的应用场景:

在 ARC 环境下但是要兼容 iOS4.x 的版本,用__unsafe_unretained 替代 __weak 解决强循环循环的问题。

__autoreleasing

将对象赋值给附有__autoreleasing修饰符的变量等同于MRC时调用对象的autorelease方法。

  1. @autoeleasepool {
  2. // 如果看了上面__strong的原理,就知道实际上对象已经注册到自动释放池里面了
  3. id __autoreleasing obj = [[NSObject alloc] init];
  4. }

编译器转换上述代码如下:

  1. id pool = objc_autoreleasePoolPush();
  2. id obj = objc_msgSend(NSObject,@selector(alloc));
  3. objc_msgSend(obj,@selector(init));
  4. objc_autorelease(obj);
  5. objc_autoreleasePoolPop(pool);
  1. @autoreleasepool {
  2. id __autoreleasing obj = [NSMutableArray array];
  3. }

编译器转换上述代码如下:

  1. id pool = objc_autoreleasePoolPush();
  2. id obj = objc_msgSend(NSMutableArray,@selector(array));
  3. objc_retainAutoreleasedReturnValue(obj);
  4. objc_autorelease(obj);
  5. objc_autoreleasePoolPop(pool);

上面两种方式,虽然第二种持有对象的方法从alloc方法变为了objc_retainAutoreleasedReturnValue函数,都是通过objc_autorelease,注册到autoreleasePool中。

Core Foundation 对象的内存管理

底层的 Core Foundation 对象,在创建时大多以 XxxCreateWithXxx 这样的方式创建,例如:

  1. // 创建一个 CFStringRef 对象
  2. CFStringRef str= CFStringCreateWithCString(kCFAllocatorDefault, hello world", kCFStringEncodingUTF8);
  3. // 创建一个 CTFontRef 对象
  4. CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);

对于这些对象的引用计数的修改,要相应的使用 CFRetain 和 CFRelease 方法。如下所示:

  1. // 创建一个 CTFontRef 对象
  2. CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
  3. // 引用计数加 1
  4. CFRetain(fontRef);
  5. // 引用计数减 1
  6. CFRelease(fontRef);

对于 CFRetainCFRelease 两个方法,读者可以直观地认为,这与 Objective-C 对象的 retainrelease 方法等价。

所以对于底层 Core Foundation 对象,我们只需要延续以前手工管理引用计数的办法即可。

除此之外,还有另外一个问题需要解决。在 ARC 下,我们有时需要将一个 Core Foundation 对象转换成一个 Objective-C 对象,这个时候我们需要告诉编译器,转换过程中的引用计数需要做如何的调整。这就引入了bridge相关的关键字,以下是这些关键字的说明:

内存泄漏

循环引用介绍

什么是循环引用?循环引用就是在两个对象互相之间强引用了,引用计数都加1了,我们前面说过,只有当引用计数减为0时对象才释放。但是这两个的引用计数都依赖于对方,所以也就导致了永远无法释放。

最容易产生循环引用的两种情况就是DelegateBlock。所以我们就引入了弱引用这种概念,即弱引用虽然持有对象,但是并不增加引用计数,这样就避免了循环引用的产生。也就是我们上面所说的所有权修饰符__weak的作用。关于原理在__weak部分也有描述,简单的描述就是每一个拥有弱引用的对象都有一张表来保存弱引用的指针地址,但是这个弱引用并不会使对象引用计数加1,所以当这个对象的引用计数变为0时,系统就通过这张表,找到所有的弱引用指针把它们都置成nil。

所以在ARC中做内存管理主要就是发现这些内存泄漏,关于内存泄漏Instrument为我们提供了 Allocations/Leaks 这样的工具用来检测。

block下的循环引用

在ARC下基本上不用我们内存管理释放什么的了,但是还是有可能发生内存泄漏的,一个很容易发生的地方就是使用block的时候。block中导致的内存泄漏常常就是因为强引用互相之间持有而发生了循环引用无法释放。

贴上一段AFNetWorking上的经典代码,防止循环引用的。

  1. //创建__weak弱引用,防止强引用互相持有
  2. __weak __typeof(self)weakSelf = self;
  3. AFNetworkReachabilityStatusBlock callback = ^(AFNetworkReachabilityStatus status) {
  4. //创建局部__strong强引用,防止多线程情况下weakSelf被析构
  5. __strong __typeof(weakSelf)strongSelf = weakSelf;
  6. strongSelf.networkReachabilityStatus = status;
  7. if (strongSelf.networkReachabilityStatusBlock) {
  8. strongSelf.networkReachabilityStatusBlock(status);
  9. }
  10. };

这里需要了解__weak__block的一些概念,推荐一篇个人觉得写的比较好的文章__weak与__block区别。强烈推荐给对这两个概念不太清楚的同学们,这里就简单的总结下面两段:

__weak 本身是可以避免循环引用的问题的,但是其会导致外部对象释放了之后,block 内部也访问不到这个对象的问题,我们可以通过在 block 内部声明一个 __strong 的变量来指向 weakObj,使外部对象既能在 block 内部保持住,又能避免循环引用的问题

__block 本身无法避免循环引用的问题,但是我们可以通过在 block 内部手动把 blockObj 赋值为 nil 的方式来避免循环引用的问题。另外一点就是 __block 修饰的变量在 block 内外都是唯一的,要注意这个特性可能带来的隐患。

DSBlockTestDemo这个是block引起的内存泄漏及解决方法的demo。

使用系统的某些block api

使用系统的某些block api(如UIView的block版本写动画时),是否也考虑引用循环问题?

系统的某些block api中,UIView的block版本写动画时不需要考虑,但也有一些api 需要考虑:

所谓“引用循环”是指双向的强引用,所以那些“单向的强引用”(block 强引用 self )没有问题,比如这些:

  1. [UIView animateWithDuration:duration animations:^{ [self.superview layoutIfNeeded]; }];
  1. [[NSOperationQueue mainQueue] addOperationWithBlock:^{ self.someProperty = xyz; }];
  1. [[NSNotificationCenter defaultCenter] addObserverForName:@"someNotification"
  2. object:nil
  3. queue:[NSOperationQueue mainQueue]
  4. usingBlock:^(NSNotification * notification) {
  5. self.someProperty = xyz;
  6. }
  7. ];

但如果你使用一些参数中可能含有 ivar 的系统 api ,如 GCD 、NSNotificationCenter就要小心一点:比如GCD 内部如果引用了 self,而且 GCD 的其他参数是 ivar,则要考虑到循环引用,比如以下这些:

  1. __weak __typeof__(self) weakSelf = self;
  2. dispatch_group_async(_operationsGroup, _operationsQueue, ^
  3. {
  4. __typeof__(self) strongSelf = weakSelf;
  5. [strongSelf doSomething];
  6. [strongSelf doSomethingElse];
  7. } );
  1. __weak __typeof__(self) weakSelf = self;
  2. _observer = [[NSNotificationCenter defaultCenter] addObserverForName:@"testKey"
  3. object:nil
  4. queue:nil
  5. usingBlock:^(NSNotification *note) {
  6. __typeof__(self) strongSelf = weakSelf;
  7. [strongSelf dismissModalViewControllerAnimated:YES];
  8. }];

NSTimer导致的内存泄露

NSTimer的生命周期问题,我们来看一下我们平时是怎么创建NSTimer的:

  1. NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(printMessage) userInfo:nil repeats:YES];

Timer 添加到 Runloop 的时候,会被 Runloop 强引用。
Timer 又会有一个对 Target 的强引用。
所以说如果不对Timer进行释放,Timer的targer(self)也一直不会被释放。
有时候我们我们对某个Timer的targer设置了nil。但没设置[timer invalidate]。
其实这个对象还是没被释放的。timer对应的执行方法也一直会在线程中执行。容易造成内存泄露。

那么问题来了:如果我就是想让这个 NSTimer 一直输出,直到 DemoViewController 销毁了才停止,我该如何让它停止呢?

这是大神写的整个思路及解决办法的文章

这是解决方法的NSTimer的封装这个HWWeakTimer不需要你手动的去设置[timer invalidate],即使你忘记了也不会再发生内存泄露问题了。

参考

iOS中堆和栈的区别

Objective-C 中的内存分配

iOS内存管理

理解 iOS 的内存管理

iOS底层系统:虚拟内存

WWDC 2018:iOS 内存深入研究

iOS Memory 内存详解

OC对象占用内存原理

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注