iOS概念攻坚之路(三):内存管理
栏目: Objective-C · 发布时间: 5年前
内容简介:iOS 的内存管理不止是 「引用计数表」。iOS 开发者基本都知道 iOS 是通过「引用计数」来管理内存的,但是也许并不知道 iOS 其他的内存管理方式,比如 「Tagged Pointer」(带标记的指针),比如 「NONPOINTER_ISA」(非指针型 isa),这个要根据不同的场景进行区分。我们就这篇文章主要来谈一谈这三种内存管理方式。
iOS 的内存管理不止是 「引用计数表」。
iOS 开发者基本都知道 iOS 是通过「引用计数」来管理内存的,但是也许并不知道 iOS 其他的内存管理方式,比如 「Tagged Pointer」(带标记的指针),比如 「NONPOINTER_ISA」(非指针型 isa),这个要根据不同的场景进行区分。
我们就这篇文章主要来谈一谈这三种内存管理方式。
关于内存
在说内存管理之前,我们先来说一下关于内存的概念。
内存是计算机中重要的部件之一,它是与 CPU 进行沟通的桥梁。计算机中所有的程序都是在内存中进行的。内存(Menory)也被成为「内存储器」和「主存储器」,其作用是用于暂时存放 CPU 中的运算数据,以及与硬盘等外部存储器交换的数据。只要计算机在运行中,CPU 就会把需要运算的数据调到内存中进行运算,当运算完成后 CPU 再将结果传送出来,内存的运行也决定了计算机的稳定运行。(来自度娘)
在 App 启动后,系统会把 App 程序拷贝到内存中,然后在内存中执行代码。
内存的概念大家多多少少都有点了解,我们也不说那么多。一块内存条,是一个从下至上、地址依次递增结构。来看一下内存的分区:
上面这张图来自这里。
大致说一下 iOS 内存分区的情况,五大区域:
-
栈区(Stack)
- 由编译器自动分配释放,存放函数的参数,局部变量的值等
- 栈是向低地址扩展的数据结构,是一块连续的内存区域
-
堆区(Heap)
- 由 程序员 分配释放
- 是向高地址扩展的数据结构,是不连续的内存区域
-
全局区
- 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域
- 程序结束后由系统释放
-
常量区
- 常量字符串就是放在这里的
- 程序结束后由系统释放
-
代码区
- 存放函数体的二进制代码
另外说一下一些值得注意的地方:
- 在 iOS 中,堆区的内存是应用程序共享的,堆中的内存分配是系统负责的
- 系统使用一个链表来维护所有已经分配的内存空间(系统仅仅记录,并不管理具体的内容)
- 变量使用结束后,需要释放内存,OC 中是判断引用计数是否为 0,如果是就说明没有任何变量使用该空间,那么系统将其回收
- 当一个 app 启动后,代码区、常量区、全局区大小就已经固定,因此指向这些区的指针不会产生崩溃性的错误。而堆区和栈区是时时刻刻变化的(堆的创建销毁,栈的弹入弹出),所以当使用一个指针指向这个区里面的内存时,一定要注意内存是否已经被释放,否则会产生程序崩溃(也即是野指针报错)
Tagged Pointer
为了节省内存和提高执行效率,苹果提出了 Tagged Pointer
的概念。对于 64 位程序,引入 Tagged Pointer
后,相关逻辑能减少一半的内存占用,以及 3 倍的访问速度提升,100 倍的创建、销毁速度提升。
(有没有那么牛逼咱也不知道,咱也不敢问)
我们先看看原有的对象为什么会浪费内存,假设我们要存储一个 NSNumber
对象,其值是一个整数。正常情况下,如果这个整数只是一个 NSInteger
的普通变量,那么它所占用的内存是与 CPU
的位数有关,在 32 位 CPU 下占 4 个字节,在 64 位 CPU 下是占 8 个字节的。而指针类型的大小通常也是与 CPU 位数相关的,一个指针所占用的内存在 32 位 CPU 下为 4 个字节,在 64 位 CPU 下也是 8 个字节。
所以一个普通的 iOS 程序,如果没有 Tagged Pointer
对象,从 32 位机器迁移到 64 位机器中后,虽然逻辑没有任何变化,但这种 NSNumber
、 NSDate
一类的对象所占用的内存会翻倍。
我们再来看看效率上的问题,为了存储和访问一个 NSNumber
对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命周期。这些都给程序增加了额外的逻辑,造成了运行效率上的损失。
所以为了改进上面提到的内存占用和效率问题,苹果提出了 Tagged Pointer
对象,由于 NSNumber
、 NSDate
一类的变量本身的值需要占用的内存大小常常不需要 8 个字节,拿整数来说,4 个字节所能表示的有符号整数就可以达到 20 多亿(2 ^ 31 = 2147483648,另外 1 位作为符号位),对于绝大多数情况都是可以处理的。
所以我们可以将一个对象的指针拆分成两部分,一部分直接保存数据,另一部分作为特殊标记,表示这是一个特别的指针,不指向任何一个地址。
Tagged Pointer 特点:
-
Tagged Pointer
专门用来存储小的对象,例如NSNumber
和NSDate
-
Tagged Pointer
指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的 普通变量 而已。所以,它的内存并不存储在堆中,也不需要malloc
和free
- 在内存读取上有着 3 倍的效率,创建时比以前快 106 倍
-
objc_msgSend
能识别Tagged Pointer
,比如NSNumber
的intValue
方法,直接从指针提取数据 - 使用
Tagged Pointer
后,指针内存储的数据变成了Tag + Data
,也就是将数据直接存储在了指针中
NONPOINTER_ISA
苹果将 isa
设计成了联合体,在 isa
中存储了与该对象相关的一些内存的信息,原因也如上面所说,并不需要 64 个二进制位全部都用来存储指针。
来看一下 isa
的结构:
// x86_64 架构 struct { uintptr_t nonpointer : 1; // 0:普通指针,1:优化过,使用位域存储更多信息 uintptr_t has_assoc : 1; // 对象是否含有或曾经含有关联引用 uintptr_t has_cxx_dtor : 1; // 表示是否有C++析构函数或OC的dealloc uintptr_t shiftcls : 44; // 存放着 Class、Meta-Class 对象的内存地址信息 uintptr_t magic : 6; // 用于在调试时分辨对象是否未完成初始化 uintptr_t weakly_referenced : 1; // 是否被弱引用指向 uintptr_t deallocating : 1; // 对象是否正在释放 uintptr_t has_sidetable_rc : 1; // 是否需要使用 sidetable 来存储引用计数 uintptr_t extra_rc : 8; // 引用计数能够用 8 个二进制位存储时,直接存储在这里 }; // arm64 架构 struct { uintptr_t nonpointer : 1; // 0:普通指针,1:优化过,使用位域存储更多信息 uintptr_t has_assoc : 1; // 对象是否含有或曾经含有关联引用 uintptr_t has_cxx_dtor : 1; // 表示是否有C++析构函数或OC的dealloc uintptr_t shiftcls : 33; // 存放着 Class、Meta-Class 对象的内存地址信息 uintptr_t magic : 6; // 用于在调试时分辨对象是否未完成初始化 uintptr_t weakly_referenced : 1; // 是否被弱引用指向 uintptr_t deallocating : 1; // 对象是否正在释放 uintptr_t has_sidetable_rc : 1; // 是否需要使用 sidetable 来存储引用计数 uintptr_t extra_rc : 19; // 引用计数能够用 19 个二进制位存储时,直接存储在这里 }; 复制代码
注意这里的 has_sidetable_rc
和 extra_rc
, has_sidetable_rc
表明该指针是否引用了 sidetable 散列表,之所以有这个选项,是因为少量的引用计数是不会直接存放在 SideTables 表中的,对象的引用计数会先存放在 extra_rc
中,当其被存满时,才会存入相应的 SideTables 散列表中,SideTables 中有很多张 SideTable,每个 SideTable 也都是一个散列表,而引用计数表就包含在 SideTable 之中。
SideTables
原理
引用计数要么存放在 isa
的 extra_rc
中,要么存放在引用计数表中,而引用计数表包含在一个叫 SideTable
的结构中,它是一个散列表,也就是哈希表。而 SideTable
又包含在一个全局的 StripeMap
的哈希映射表中,这个表的名字叫 SideTables
。
散列表(Hash table,也叫哈希表),是根据建(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过一个关于键值得函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称作散列函数,存放记录的数组称作散列表。
来看一下 NSObject.mm
中它们对应的源码:
// SideTables static StripedMap<SideTable>& SideTables() { return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf); } // SideTable struct SideTable { spinlock_t slock; // 自旋锁 RefcountMap refcnts; // 引用计数表 weak_table_t weak_table; // 弱引用表 // other code ... }; 复制代码
它们的关系如下图:
一个 SideTables
包含众多 SideTable
,每个 SideTable
中又包含了三个元素, spinlock_t
自旋锁、 RefcountMap
引用计数表、 weak_table_t
弱引用表。所以既然 SideTables
是一个哈希映射的表,为什么不用 SideTables
直接包含自旋锁,引用计数表和弱引用表呢?这是因为在众多线程同时访问这个 SideTable
表的时候,为了保证数据安全,需要给其加上自旋锁,如果只有一张 SideTable
的表,那么所有数据访问都会出一个进一个,单线程进行,非常影响效率,虽然自旋锁已经是效率非常高的锁,这会带来非常不好的用户体验。针对这种情况,将一张 SideTable
分为多张表的 SideTables
,再各自加锁保证数据的安全,这样就增加了并发量,提高了数据访问的效率,这就是为什么一个 SideTables
下涵盖众多 SideTable
表的原因。
自旋锁:计算机科学用于多线程同步的一种锁,线程会反复检查锁变量是否可用。由于线程在这一过程中保持执行(没有进入休眠),因此是一种忙等。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
自旋锁适用于小型数据、耗时很少的操作,速度很快。
弱引用表也是一张哈希表的结构,其内部包含了每个对象对应的弱引用表 weak_entry_t
,而 weak_entry_t
是一个结构体数组,其中包含的则是 每一个对象弱引用的对象 所对应的弱引用指针。
如何进行引用计数操作
当需要去查找一个对象对应的 SideTable
并进行引用计数或者弱引用计数的操作时,系统又是怎样实现的呢?
当一个对象访问 SideTables
时:
- 首先会取得对象的地址,将地址进行哈希运算,与
SideTables
中SideTable
的个数取余,最后得到的结果就是该对象所要访问的SideTable
- 在取得的
SideTable
中的RefcountMap
表中再进行一次哈希查找,找到该对象在引用计数表中对应的位置 - 如果该位置存在对应的引用计数,则对其进行操作,如果没有对应的引用计数,则创建一个对应的
size_t
对象,其实就是一个uint
类型的无符号整型
引用计数
引用计数(Reference Count)是一个简单而有效的管理对象生命周期的方式。当我们创建一个新对象的时候,它的引用计数为 1,当有一个新的指针指向这个对象时,我们将其引用计数加 1,当某个指针不再指向这个对象时,我们将其引用计数减 1,当对象的引用计数变为 0 时,说明这个对象不再被任何指针指向了,这个时候我们就可以将对象销毁,回收内存。
上面是唐巧的理解 iOS 的内存管理 中对引用计数的一个定义,简单来说就是采取计数的方式对内存进行管理,内存首先需要被创建出来,然后有人用这块内存,计数 +1
,那个人不用了,计数 -1
,如果计数为 0
,释放它。
当然,创建、使用、释放是有一个规则的,来看一下 iOS 中内存管理的思考方式:
- 自己生成的对象,自己所持有
- 非自己生成的对象,自己也能持有
- 不再需要自己持有的对象时释放
- 非自己持有的对象无法释放
与之对应的 Objective-C 方法:
对象操作 | Objective-C 方法 |
---|---|
生成并持有对象 | alloc/new/copy/mutableCopy 等方法 |
持有对象 | retain 方法 |
释放对象 | release 方法 |
废弃对象 | dealloc 方法 |
这些有关 Objective-C 内存管理的方法,实际上不包括在 Objective-C 语言中,而是包含在 Cocoa 框架中用于 OS X,iOS 应用开发,swift 也采用引用计数的方式进行内存管理。Cocoa 框架中 Foundation 框架类库的 NSObject 类担负内存管理的职责。Objective-C 内存管理中的 alloc/retain/release/dealloc
方法分别指代 NSObject 类的 +alloc
、 -retain
、 -release
、 -dealloc
方法。
而引用计数又分为 MRC(Manual Reference Counting,手动引用计数) 和 ARC(Automatic Reference Counting,自动引用计数) 。
我们来看一下官方对于自动引用计数的说明:
在 Objective-C 中采用 Automatic Reference Counting(ARC)机制,让编译器来进行内存管理。在新一代 Apple LLVM 编译器(LLVM 3.0 或以上)中设置 ARC 为有效状态,就无需再次键入 retain 或者 release 代码,这在降低程序崩溃、内存泄漏等风险的同时,很大程度上减少了开发程序的工作量。编译器完全清楚目标对象,并能立刻释放那些不再被使用的对象,如此一来,应用程序将具有可预测性,且能流畅运行,速度也将大幅提升。
其实最主要的是一点:
在 LLVM 编译器中设置 ARC 为有效状态,就无需再次键入 retain 或者是 release 代码。
那么我们也就知道了 MRC 是怎么回事了,MRC 就是需要程序员手动插入 retain
、 release
等管理内存的代码,不过现在 MRC 已经属于远古时代的事情了,这里只是顺便提提,我们主要看 ARC,ARC 其实做的事情不止是自动插入管理内存的方法,还做了一些优化,我们放到后面一点讲。我们先来看看 alloc/retain/release/dealloc
这几个方法的大致实现,这里有一份编译好的 runtime 源码 ,版本是 objc4-750
,或者大家可以到opensource.apple 去下载。
alloc
NSObject
中类方法 alloc
做的事情:
首先看看 alloc
方法的实现:
+ (id)alloc { return _objc_rootAlloc(self); } 复制代码
alloc
中调用 _objc_rootAlloc()
。
id _objc_rootAlloc(Class cls) { return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/); } 复制代码
_objc_rootAlloc
中调用 callAlloc()
。
static ALWAYS_INLINE id callAlloc(Class cls, bool checkNil, bool allocWithZone=false) { // some code ... id obj = class_createInstance(cls, 0); return obj; } 复制代码
省略了一部分代码, callAlloc
中会调用 class_createInstance()
。
id class_createInstance(Class cls, size_t extraBytes) { return _class_createInstanceFromZone(cls, extraBytes, nil); } 复制代码
class_createInstance()
中直接调用 _class_createInstanceFromZone
,调用 calloc
方法分配内存。
static __attribute__((always_inline)) id _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, bool cxxConstruct = true, size_t *outAllocatedSize = nil) { // some code ... id obj; obj = (id)calloc(1, size); // 此时分配内存 obj->initInstanceIsa(cls, hasCxxDtor); return obj; } 复制代码
_class_createInstanceFromZone
中会调用 obj->initInstanceIsa()
,以下就是初始化的方法了,此时内存已经分配。
inline void objc_object::initInstanceIsa(Class cls, bool hasCxxDtor) { initIsa(cls, true, hasCxxDtor); } 复制代码
initInstanceIsa()
中调用 initIsa()
。
inline void objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) { if (!nonpointer) { isa.cls = cls; } else { isa_t newisa(0); #if SUPPORT_INDEXED_ISA newisa.bits = ISA_INDEX_MAGIC_VALUE; newisa.has_cxx_dtor = hasCxxDtor; newisa.indexcls = (uintptr_t)cls->classArrayIndex(); #else newisa.bits = ISA_MAGIC_VALUE; newisa.has_cxx_dtor = hasCxxDtor; newisa.shiftcls = (uintptr_t)cls >> 3; #endif isa = newisa; } } 复制代码
这里就是对 isa
的一个初始化。
所以关于 alloc
方法,其大概步骤如下:
-
alloc/allocWithZone
-
class_createInstance
/initInstanceIsa
-
calloc
(在这一步开始分配内存) -
initIsa
(初始化isa
指针里面的内容)
关于 NSObject 的源码解析大家可以看看以下两篇文章:
slowpath
和 fastpath
这里我想提一嘴 slowpath
和 fastpath
,看一下 callAlloc
的完整实现:
static ALWAYS_INLINE id callAlloc(Class cls, bool checkNil, bool allocWithZone=false) { if (slowpath(checkNil && !cls)) return nil; #if __OBJC2__ if (fastpath(!cls->ISA()->hasCustomAWZ())) { if (fastpath(cls->canAllocFast())) { // No ctors, raw isa, etc. Go straight to the metal. bool dtor = cls->hasCxxDtor(); id obj = (id)calloc(1, cls->bits.fastInstanceSize()); if (slowpath(!obj)) return callBadAllocHandler(cls); obj->initInstanceIsa(cls, dtor); return obj; } else { // Has ctor or raw isa or something. Use the slower path. id obj = class_createInstance(cls, 0); if (slowpath(!obj)) return callBadAllocHandler(cls); return obj; } } #endif if (allocWithZone) return [cls allocWithZone:nil]; return [cls alloc]; } 复制代码
注意到方法中使用到的 slowpath
和 fastpath
,其实这两个都是宏定义,与代码逻辑本身无关,定义如下:
// x 很可能不为 0,希望编译器进行优化 #define fastpath(x) (__builtin_expect(bool(x), 1)) // x 很可能为 0,希望编译器进行优化 #define slowpath(x) (__builtin_expect(bool(x), 0)) 复制代码
其实它们是所谓的快路径和慢路径,为了解释这个,我们来看一段代码:
if (x) return 1; else return 39; 复制代码
由于计算机并非一次只读取一条指令,而是读取多条指令,所以在读到 if
语句时也会把 return 1
读取进来。如果 x
为 0,那么会重新读取 return 39
,重读指令相对来说比较耗时。
如果 x
有非常大的概率是 0,那么 return 1
这条指令每次不可避免的会被读取,并且实际上几乎没有机会执行,造成了不必要的指令重读。
因此,在苹果定义的两个宏中, fastpath(x)
依然返回 x
,只是告诉编译器 x
的值一般不为 0,从而编译可以进行优化。同理, slowpath(x)
表示 x
的值很可能为 0,希望编译器进行优化。
这个例子的讲解来自 bestsswifter 的深入理解GCD,大家感兴趣可以看看。
所以以下代码的解释就出来了:
// 很可能 cls 是有值的,编译器可以不用每次都读取 return nil 指令 if (slowpath(checkNil && !cls)) return nil; 复制代码
fastpath
也是同样的机制,但是大家要知道,当 checkNil && !cls
判断成立的时候, return nil
指令还是会被读取,然后执行的。
还有一个就是 #if __OBJ2__
、 #endif
,如果查看源码的话,还会碰到 #if !__LP64__
、 #elif 1
、 #else
这类的宏判断,这是因为苹果针对不同的版本做了不同的实现,比如 32 位架构下和 64 位架构下的实现,有一些代码在不同的情况下是不需要参与编译的,其实也跟我们平时的 if-else
是一样的概念。
retain & release
retain
方法用于增加引用计数, release
用于减少引用计数。那么引用计数存储在哪里?其实有两个地方,一个是 NONPOINTER_ISA
,也就是非指针型 isa
中, isa
有个 extra_rc
属性,就是用于存放引用计数的,在 ARM 64 下, extra_rc
占 19 位。
extra_rc
只会保存额外的自动引用计数,对象的实际的引用计数会在这个基础上 +1。当 isa
的 extra_rc
中存不下的时候,会使用 SideTable
来存储, SideTable
中包含了我们大家都知道的引用计数表。
通过引用计数表管理引用计数的好处在于:
- 对象用内存块分配无需考虑内存块头部
- 引用计数表各记录中存有内存块地址,可从各个记录追溯到各对象的内存块
第二点在调试时有着举足轻重的作用,即使出现故障导致对象占用的内存块损坏,但只要引用计数表没有被破坏,就能够确认各内存块的位置。另外,在利用 工具 检测内存泄漏时,引用计数表的记录也有助于检测各个对象的持有者是否存在。
如果想了解 retain
和 release
的底层实现,可以看一下 黑箱中的 retain 和 release 。
autorelease
简介
顾名思义, autorelease
就是自动释放。这看上去很像 ARC,但实际上它更类似于 C 语言中自动变量(局部变量)的特性。
在计算机编程领域, 自动变量 (Automatic Variable)指的是局部作用域 变量 ,具体来说即是在控制流进入 变量 作用域时系统 自动 为其分配存储空间,并在离开作用域时释放空间的一类 变量 。
程序执行时,若某自动变量超出其作用域,该自动变量将被自动废弃。
autorelease
会像 C 语言的自动变量那样来对待对象实例,当超出其作用域(相当于变量作用域)时,对象实例的 release
实例方法被调用。另外,同 C 语言的自动变量不同的是,编程人员可以设定变量的作用域。
需要被自动释放的对象会被添加到离它最近的自动释放池中(AutoreleasePool),我们先明确什么对象会自动加入自动释放池:
- MRC 下需要对象调用
autorelease
才会入池,ARC 下可以通过__autoreleasing
修饰符,否则的话看方法名,通过alloc/new/copy/mutablecopy
以外的方法取得的对象,编译器帮我们自动加入 autoreleasepool。(使用alloc/new/copy/mutablecopy
方法进行初始化时,由系统管理对象,在适当的位置release
,不加入 autoreleasepool) - 使用
array
会自动将返回对象注册到 autoreleasepool -
__weak
修饰的对象,为了保证在引用时不被废弃,会被注册到 autoreleasepool 中 -
id
的指针或对象的指针,在没有显式指定时会被注册到 autoreleasepool 中
那 Autorelease 的对象什么时候释放?
在没有手动添加 AutoreleasePool 的情况下,Autorelease 对象是在当前的 runloop
迭代结束时释放的,而它能够释放的原因是 系统在每个 runloop 迭代中都加入了自动释放池的 Push 和 Pop 。
App 启动后,苹果在主线程 runLoop
里注册了两个 Observer
,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()
。
第一个 Observer
监视的事件是 Entry(即将进入 loop)
,其回调会调用 _objc_autoreleasePoolPush()
创建自动释放吃。其 order
是 -2147483647
,优先级最高,保证创建释放池发生在其他所有回调之前。
第二个 Observer
监视了两个事件: BeforeWaiting(准备进入休眠)
时调用 _objc_autoreleasePoolPop()
和 _objc_autoreleasePoolPush()
释放旧的池并创建新池; Exit(即将退出 Loop)
时调用 _objc_autoreleasePoopPop()
来释放自动释放池,这个 Observer
的 order
是 2147483647
,优先级最低,保证释放池释放发生在其他所有回调之后。
在主线程执行的代码,通常是写在诸如事件回调、Timer 回调内的。这些回调会被 RunLoop
创建好的 AutoreleasePool
环绕着,所以不会出现内存泄漏,开发者也不必显式创建 Pool。
使用方法
autorelease
的具体使用方法如下:
NSAutoreleasePool autorelease NSAutoreleasePool
NSAutoreleasePool
对象的生存周期相当于 C 语言变量的作用域,对于所有调用过 autorelease
实例方法的对象,在废弃 NSAutoreleasePool
对象时,都将调用 release
实例方法。
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; id obj = [[NSObject alloc] init]; [obj autorelease]; [pool drain]; // 等同于 [obj release] 复制代码
在 Cocoa 框架中,相当于程序主循环的 NSRunLoop
或者在其他程序可运行的地方,对 NSAutoreleasePool
对象进行生成、持有和废弃处理。因此,开发者一般不需要使用手动创建释放池。Objective-C 的 main.m
的 UIApplicationMain
方法就是被一个自动释放池环绕着的,也就是说,整个 iOS 应用都是包含在一个自动释放池 block
中:
int main(int argc, char * argv[]) { @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } } 复制代码
不过,在大量产生 autorelease
的对象时,只要不废弃 NSAutoreleasePool
对象,那么生成的对象就不能被释放,因此有时会由于内存不足而到达内存峰值。典型的例子是读入大量图片的同时改变其尺寸,图像文件读入到 NSData
对象,并从中生成 UIImage
对象,改变该对象尺寸后生成新的 UIImage
对象。这种情况下,就会大量产生 autorelease
的对象:
for (int i = 0; i < 图像数 ; ++i) { /* 读入图像 * 大量产生 autorelease 的对象 * 由于没有废弃 NSAutoreleasePool 对象 * 最终导致内存不足! */ } 复制代码
在这种情况下,有必要在适当的地方生成、持有或废弃 NSAutoreleasePool
对象:
for (int i = 0; i < 图像数; ++i) { NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; /* * 读入图像 * 大量产生 autorelease 的对象 */ [pool drain]; /* * 通过 [pool drain], * autorelease 的对象被一起 release。 */ } 复制代码
在 ARC 下我们使用 @autoreleasepool{}
将代码环绕即可。
原理
那么系统是如何实现 Autorelease 的,在 ARC 下,我们使用 @autoreleasepool{}
来使用一个 AutoreleasePool
,随后编译器将其改写成下面的样子:
void *context = objc_autoreleasePoolPush(); // {} 中的代码 objc_autoreleasePoolPop(context); 复制代码
这两个函数都是对 AutoreleasePoolPage
的简单封装,所以自动释放机制的核心就在于这个类。
class AutoreleasePoolPage { magic_t const magic; id *next; pthread_t const thread; AutoreleasePoolPage * const parent; AutoreleasePoolPage *child; uint32_t const depth; uint32_t hiwat; // other code ... } 复制代码
AutoreleasePoolPage
是一个 C++ 实现的类。
-
AutoreleasePool
并没有单独的结构,而是由若干个AutoreleasePoolPage
以双向链表的形式组合而成(分别对应结构中的parent
指针和child
指针) -
AutoreleasePool
是按线程一一对应的(结构中的thread
指针指向当前线程) -
AutoreleasePoolPage
每个对象会开辟4096
字节内存(也就是虚拟内存一页的大小),除了上面的实例变量所占空间,剩下的空间全部用来储存autorelease
对象的地址 - 上面的
id *next
指针作为游标(哨兵对象)指向栈顶最新add
进来的autorelease
对象的下一个位置 - 一个
AutoreleasePoolPage
的内存被占满时,会新建一个AutoreleasePoolPage
对象,连接链表,后来的autorelease
对象会被添加到新的page
中
所以,若当前线程中只有一个 AutoreleasePoolPage
对象,并记录了很多 autorelease
对象地址,如下图:
图中的情况,这一页再加入一个 autorelease
对象就要满了(也就是 next
指针马上指向栈顶),这时就要执行上面说的操作,建立下一页 page
对象,与这一页链表连接完成后,新的 page
的 next
指针被初始化在栈底( begin
的位置),然后继续向栈顶添加新对象。
所以,向一个对象发送 autorelease
消息,就是将这个对象加入到当前的 AutoreleasePoolPage
的栈顶 next
指针指向的位置。
每当进行一次 objc_autoreleasePoolPush
调用时, runtime
向当前的 AutoreleasePoolpage
中 add
进一个 哨兵对象
,值为 0(也就是个 nil
),那么这一个 page
就变成了下面的样子:
objc_autoreleasePoolPush
的返回值正是这个哨兵对象的地址,被 objc_autoreleasePoolPop(哨兵对象)
作为入参,所以:
- 根据传入的哨兵对象地址找到哨兵对象所处的
page
- 在当前
page
中,将晚于哨兵对象插入的所有autorelease
对象都发送一次-release
消息,并向回移动next
指针到正确位置,从最新加入的对象一直向前清理,可以向前跨越若干个page
,直到哨兵对象所在的page
刚才的 objc_autoreleasePoopPop
执行后,最终变成了下面的样子:
知道了上面的原理,嵌套的 AutoreleasePool
就非常简单了, pop
的时候总会释放到上次 push
的位置,多层的 pool
就是多个哨兵对象而已,就像剥洋葱一样,每次一层,互不影响。
在对象的引用计数归零时,会调用 dealloc
方法回收对象。
原理部分的讲解来自于孙源大神的 黑幕背后的Autorelease ,讲的非常好,大家可以看看。
另外说一下 ARC 中对 autorelease
和 retain
的一些优化:
如果 ARC 在运行时检测到类函数中的 autorelease
后紧跟着一个 retain
操作,此时不直接调用对象的 autorelease
方法,而是改为调用 objc_autoreleaseReturnValue
。 objc_autoreleaseReturnValue
会检视当前方法返回之后将要执行的那段代码,若那段代码要在返回对象上执行 retain
操作,则设置全局数据结构中的一个标志位,而不执行 autorelease
操作,与之相似,如果方法返回了一个自动释放的对象,而调用方法的代码要保留此对象,那么此时不直接执行 retain
,而是改为执行 objc_retainAutoreleasedReturnValue
函数。此函数要检测刚才提到的标志位,若已经置位,则不执行 retain
操作,设置并检测标志位,要比调用 autorelease
和 retain
更快。
dealloc
当对象的引用计数为 0 时,也就是对象的所有者都不持有该对象,该对象被废弃时,不管 ARC 是否有效,都会调用对象的 dealloc
方法,对对象进行析构。
简单列举一下 dealooc
的调用流程,大家可以结合 runtime 源码来看:
-
dealloc
调用流程- 首先调用
_objc_rootDealloc()
- 接下来调用
rootDealloc()
- 这时候会判断是否可以被释放,判断的依据主要有 5 个:
NONPointer_ISA weakly_reference has_assoc has_cxx_dtor has_sidetable_rc
- 如果没有之前 5 种情况的任意一种,则可以执行释放操作,C 函数的
free()
- 执行完毕
- 首先调用
-
objc_dispose()
调用流程objc_destructInstance() free()
-
objc_destructInstance()
调用流程- 先判断
hasCxxDtor
,如果有c++
相关内容,要调用object_cxxDestruct()
,销毁 c++ 相关内容 - 再判断
hasAssociatedObjects
,如果有关联对象,要调用object_remove_associations()
,销毁关联对象的一系列操作 - 然后调用
clearDeallocating()
- 执行完毕
- 先判断
-
clearDeallocating()
调用流程- 先执行
sideTable_clearDeallocating()
- 再执行
waek_clear_no_lock
,将指向该对象的弱引用指针置为nil
- 接下来执行
table.refcnts.eraser()
,从引用计数表中擦除该对象的引用计数 - 至此为此,
dealloc
的执行流程结束
- 先执行
以上所述就是小编给大家介绍的《iOS概念攻坚之路(三):内存管理》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- STARKs,Part-3:攻坚(下)
- 干货 | STARKs,Part-3:攻坚(下)
- 曙光公司:攻坚突破关键领域核心技术
- iOS概念攻坚之路(一):RunLoop
- iOS概念攻坚之路(二):Runtime
- iOS概念攻坚之路(七):block
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
网红经济3.0 自媒体时代的掘金机会
王先明、陈建英 / 当代世界出版社 / 2016-9-1 / 42
深入剖析网红经济的商业模式和整体产业链! 正在崛起的网红经济,打造出多元化的盈利模式,催生了众多新兴的产业投资机会,成为移动互联网时候的资本新风口一起来看看 《网红经济3.0 自媒体时代的掘金机会》 这本书的介绍吧!
HEX HSV 转换工具
HEX HSV 互换工具
HSV CMYK 转换工具
HSV CMYK互换工具