在现代 Objective-C 开发中,我们几乎都使用 ARC (Automatic Reference Counting - 自动引用计数),它极大地简化了内存管理。但是,理解 ARC 背后的手动引用计数(MRC - Manual Reference Counting)原理,对于我们深入理解 Objective-C 的内存管理机制、排查内存泄漏问题以及掌握 strong, weak 等关键字的本质至关重要。
第一节:引用计数原理:retain, release, autorelease
1. 核心思想:对象的生命周期
想象一下,一个对象就像一个气球,而引用就像是牵着气球的绳子。
-
创建对象:当你
alloc一个新对象时,就相当于制造了一个新气球,它的初始“绳子数量”(引用计数)为 1。 -
持有对象:当有一个新的指针指向这个对象,并希望“拥有”它时,就需要增加一根绳子,这个过程叫做
retain,引用计数加 1。 -
释放对象:当你不再需要这个对象时,就需要松开你手中的绳子,这个过程叫做
release,引用计数减 1。 -
对象销毁:当最后一根绳子被松开时(引用计数变为 0),就没有人再能控制这个气球了,它就会飞走(对象被系统回收销毁)。这个销毁过程会自动调用对象的
dealloc方法。
这个“绳子数量”就是 引用计数 (Reference Count)。它是每个 Objective-C 对象内部维护的一个整数,记录了当前有多少个“所有者”正在引用这个对象。
2. 引用计数的操作方法 (MRC 时代)
在 MRC 环境下(通过编译设置可以开启),我们需要手动调用以下方法来管理引用计数:
-
alloc/new/copy/mutableCopy- 这些方法会创建一个新对象,并使其引用计数为 1。
- 规则:谁创建,谁持有。当你通过这些方法获得一个对象时,你就有责任在未来某个时刻释放它。
// 创建一个 Person 对象,此时 p1 的引用计数为 1 Person *p1 = [[Person alloc] init]; -
retain- 使对象的引用计数 +1。
- 当你需要持有一个并非由你创建的对象时(例如,从一个数组中获取或者作为方法参数传入),你需要
retain它,以表明你也要成为它的“所有者”。
// 假设 p1 是一个已经存在的对象 Person *p2 = p1; // p2 也指向了同一个对象 [p2 retain]; // 明确表示我也要持有它,此时对象引用计数变为 2 -
release- 使对象的引用计数 -1。
- 当你不再需要一个由你持有(你曾经
alloc或retain过)的对象时,你必须release它,放弃所有权。 -
注意:
release不会立即销毁对象,只是减少引用计数。只有当计数减到 0 时,系统才会调用dealloc方法来销毁对象。
// p1 不再需要了 [p1 release]; // 对象引用计数变为 1 // p2 也不再需要了 [p2 release]; // 对象引用计数变为 0,此时系统会调用 dealloc 方法销毁对象 -
dealloc- 当对象的引用计数变为 0 时,系统会自动调用此方法。
-
职责:在这里释放当前对象所持有的其他对象(即向其拥有的实例变量发送
release消息),并释放占用的资源。 -
规则:永远不要手动调用
dealloc方法,这是系统的工作。你需要做的仅仅是重写它,以释放你自己的资源。
// 在 Person.m 中 - (void)dealloc { // 释放自己持有的其他对象 [_name release]; // 假设 Person 有一个 _name 属性 [_car release]; // 假设 Person 有一个 _car 属性 // 最后,必须调用父类的 dealloc [super dealloc]; }
3. autorelease:延迟释放
有时候,一个方法需要创建一个对象并返回它,但又不希望调用者立即负责 release。例如,一个类方法 [NSString stringWithFormat:@"..."],它创建了一个字符串并返回,我们拿到后可以直接使用,而不需要 release。
这就是 autorelease 的用武之地。
-
autorelease- 它不会立即减少引用计数,而是将对象放入一个叫做 “自动释放池” (
Autorelease Pool) 的地方。 - 当这个“池子”被清空(drain)时,池子会向其中的每一个对象发送一次
release消息。 -
作用:将
release的调用延迟到未来的某个时刻。
// 一个工厂方法 + (Person *)createPerson { // person 创建后引用计数为 1 Person *person = [[Person alloc] init]; // 将 person 放入自动释放池,并返回它 // autorelease 不会改变当前的引用计数,但承诺在未来会 release 它一次 return [person autorelease]; } // 调用者 - (void)doSomething { // @autoreleasepool 创建了一个自动释放池 @autoreleasepool { // 通过工厂方法获取对象 p,此时 p 的引用计数仍然是 1 // 并且已经被放入了自动释放池 Person *p = [Person createPerson]; // ... 在这里可以安全地使用 p ... } // 当代码块结束时,池子被 drain,池中的 p 对象会收到 release 消息 // 如果没有其他地方 retain 它,p 的引用计数会变为 0,然后被销毁 } - 它不会立即减少引用计数,而是将对象放入一个叫做 “自动释放池” (
总结一下所有权策略 (Ownership Policy):
-
你创建,你拥有:通过
alloc,new,copy等方法创建的对象,你必须负责release或autorelease。 -
你
retain,你拥有:通过retain持有的对象,你必须负责release。 -
非你所有,请勿释放:如果你没有持有某个对象,就绝对不能
release它,否则会导致程序崩溃(野指针)。
理解了这个手动管理内存的原理,你就能更好地理解 ARC 是如何自动地在编译时为我们插入这些 retain, release, autorelease 调用的。
上一节我们学习了手动引用计数(MRC)的原理,它是理解自动引用计数(ARC)的基础。ARC (Automatic Reference Counting) 并非一项运行时特性,也不是一个垃圾回收器,它是一个编译器特性。编译器会在代码的适当位置自动为我们插入 retain、release、autorelease 等内存管理代码,让我们从繁琐的手动管理中解放出来。
第二节:ARC 工作机制与修饰符
1. ARC 工作机制
简单来说,ARC 遵循与 MRC 同样的所有权策略,但这些策略的实施由编译器自动完成。编译器会分析代码中对象的生命周期,并在编译时决定在何处插入管理引用计数的代码。
示例:
// 在 MRC 下,我们需要这样写:
- (void)doSomethingMRC {
Person *p = [[Person alloc] init]; // p 引用计数为 1
[p doWork];
[p release]; // 手动释放,p 引用计数变为 0
}
// 在 ARC 下,我们只需要这样写:
- (void)doSomethingARC {
Person *p = [[Person alloc] init]; // p 引用计数为 1
[p doWork];
} // 当 p 的作用域结束时(即 '}' 处),编译器会自动插入 [p release]
2. 所有权修饰符
为了告诉编译器我们希望如何管理对象的生命周期(即如何持有对象),ARC 引入了几个所有权修饰符。这些修饰符用来修饰指针变量,明确指针与所指向对象之间的关系。
__strong (强引用)
-
默认修饰符:所有对象指针变量默认都是
__strong类型的。 -
含义:只要存在任何一个
__strong指针指向一个对象,这个对象就会一直存活在内存中,不会被销毁。这相当于 MRC 中的retain。 -
生命周期:当
__strong指针被销毁(例如,离开作用域)或被重新赋值时,它所指向的对象的引用计数会减 1。
Person *person; // 默认为 __strong Person *person;
{
Person *p = [[Person alloc] init]; // p 是强引用,持有对象,对象引用计数为 1
person = p; // person 也是强引用,也持有了对象,对象引用计数变为 2
} // p 离开了作用域,不再指向对象,对象引用计数减为 1
// 但因为 person 仍然强引用着对象,所以对象不会被销毁
__weak (弱引用)
-
含义:
__weak指针不持有对象,它仅仅“观察”对象,不会增加对象的引用计数。这对于解决“循环引用”问题至关重要。 -
关键特性(自动置 nil):当所指向的对象被销毁后,
__weak指针会自动被设置为nil。这可以有效防止野指针错误的发生。
Person *strongPerson = [[Person alloc] init]; // strongPerson 持有对象,引用计数为 1
__weak Person *weakPerson = strongPerson; // weakPerson 不持有对象,引用计数仍为 1
strongPerson = nil; // strongPerson 不再持有对象,对象引用计数变为 0,对象被销毁
// 此时,weakPerson 会被自动设置为 nil
NSLog(@"weakPerson: %@", weakPerson); // 输出: weakPerson: (null)
-
思考:为什么
__weak修饰的指针,可以在它指向的对象被释放后,自动设置为nil?-
runtime维护了一个weak表,用于存储指向某个对象的所有weak指针。weak表是一个哈希表,Key是所指对象的地址,Value是weak指针的地址数组。 -
weak的实现过程- 初始化时,
runtime会调用objc_initWeak函数,初始化一个新的weak指针指向对象的地址 - 添加引用时,
objc_initWeak会调用objc_storeWeak()函数,objc_storeWeak()的作用是更新指针指向,创建对应的弱引用表 - 释放时,调用
clearDeallocating函数。clearDeallocating函数首先根据对象地址获取所有weak指针地址的数组,然后遍历这个数组,把其中的数据设为nil,最后把这个entry从weak表中删除,三步完成。
- 初始化时,
-
__unsafe_unretained (不安全的弱引用)
-
含义:与
__weak类似,它也不持有对象,不增加引用计数。 -
与
__weak的区别:当所指向的对象被销毁后,__unsafe_unretained指针的地址不会被自动置为nil,它会继续指向那块已经被回收的内存。此时,这个指针就变成了“野指针”,访问它会导致程序崩溃。 -
使用场景:主要用于兼容 iOS 4 及以下不支持
__weak的系统版本。在现代开发中,除非有特殊性能要求或需要与 C 语言代码交互,否则应始终使用__weak。
__copy / copy (拷贝)
-
含义:与
__strong类似,它也持有对象。但它持有的不是原始对象,而是原始对象的一个副本 (copy)。 -
使用场景:通常用于修饰那些内容可变的对象,如
NSString、NSArray、NSDictionary等。 -
为什么对
NSString使用copy:- 想象一下,你有一个
strong修饰的NSString属性。如果给它赋一个NSMutableString对象,那么当这个NSMutableString对象在其他地方被修改时,你的属性值也会跟着改变,这可能不是你期望的,会带来潜在的风险。 - 如果使用
copy修饰,赋值时会创建一个不可变的新NSString对象。无论原始的NSMutableString如何变化,你的属性值都保持不变,保证了数据的安全性。
- 想象一下,你有一个
@property (nonatomic, copy) NSString *name;
// ...
NSMutableString *mutableName = [NSMutableString stringWithString:@"Tom"];
self.name = mutableName; // 这里会调用 [mutableName copy],self.name 持有的是一个不可变的 "Tom" 副本
[mutableName appendString:@" Jerry"]; // 修改原始的可变字符串
NSLog(@"%@", self.name); // 输出: Tom (不受影响)
NSLog(@"%@", mutableName); // 输出: Tom Jerry
总结
| 修饰符 | 所有权 | 引用计数 | 对象销毁后指针状态 | 主要用途 |
|---|---|---|---|---|
__strong |
持有 | +1 |
(无) | 默认选项,持有对象 |
__weak |
不持有 | 不变 | 自动置 nil
|
解决循环引用,防止野指针 |
__unsafe_unretained |
不持有 | 不变 | 保持原值 (野指针) | 兼容旧版 iOS,性能要求高的场景 |
__copy |
持有副本 |
+1 (副本) |
(无) | 修饰内容可变的对象 (NSString, NSArray 等) |
这些修饰符是 ARC 的核心,正确地使用它们是编写健壮、无内存泄漏的 Objective-C 代码的关键。
我们在讲解 MRC 时曾提到,autorelease 可以将对象的 release 操作延迟执行。而负责管理这些延迟释放对象的机制,就是自动释放池(Autorelease Pool)。即使在 ARC 时代,Autorelease Pool 依然在底层默默工作,理解它的原理对于我们编写高性能、低内存峰值的代码至关重要。
第三节:Autorelease Pool 的实现原理
1. Autorelease Pool 是什么?
从概念上讲,Autorelease Pool 是一个装载被 autorelease 修饰的对象的集合。当这个 Pool 本身被销毁(drain)时,它会遍历其中的所有对象,并向它们发送 release 消息。
在代码中,我们通常使用 @autoreleasepool 块来创建一个 Autorelease Pool:
@autoreleasepool {
// 在这个块内创建的 autoreleased 对象,
// 会被添加到这个新创建的 pool 中。
NSString *str = [NSString stringWithFormat:@"test"]; // stringWithFormat: 返回的是一个 autoreleased 对象
// ARC 下,编译器可能会将某些对象自动标记为 autoreleased
// 例如,返回非自己创建的对象的函数
NSArray *array = [NSArray arrayWithObjects:@"a", @"b", nil];
} // 当执行到这个 '}' 时,这个 pool 被 drain,
// str 和 array 对象会收到 release 消息。
2. Autorelease Pool 的数据结构
Autorelease Pool 并不是一个简单的 NSArray 或 NSSet。它的核心是基于一个栈式结构来实现的,主要由以下几个部分构成:
-
AutoreleasePoolPage: 自动释放池是以页(Page)为单位进行内存管理的。每一页的大小是固定的(通常是 4KB)。这种分页设计可以减少内存碎片,并提高访问速度。 -
双向链表: 多个
AutoreleasePoolPage会以双向链表的形式连接在一起,形成一个完整的 Pool 结构。当一页存满后,会自动创建新的一页并链接上来。 -
POOL_SENTINEL(哨兵对象): 这是一个特殊的标记(就是一个nil指针),用于分隔不同的 Autorelease Pool。当你创建一个@autoreleasepool块时,实际上是向栈顶压入了一个哨兵对象。
我们可以把整个结构想象成一个栈(Stack),里面存放着指向 autoreleased 对象的指针。
-
objc_autoreleasePoolPush(): 当进入@autoreleasepool块时,会调用这个函数。它会在当前AutoreleasePoolPage的栈顶压入一个POOL_SENTINEL(哨兵)。 -
[obj autorelease]: 当一个对象被调用autorelease方法时(在 ARC 下通常是隐式的),它的指针会被压入当前AutoreleasePoolPage的栈顶。 -
objc_autoreleasePoolPop(void *ctxt): 当离开@autoreleasepool块时,会调用这个函数。它会以传入的哨兵ctxt为界,从栈顶开始,向栈中所有在哨兵上方的对象发送release消息,然后将栈顶指针回退到哨兵的位置。
图解栈操作:
栈底 栈顶
|-------------------------------------------------------------------------|
| |
| [POOL_SENTINEL_1] [obj1] [obj2] [POOL_SENTINEL_2] [obj3] [obj4] |
| |
|-------------------------------------------------------------------------|
^ ^
| |
@autoreleasepool { @autoreleasepool {
... ...
[obj1 autorelease]; [obj3 autorelease];
[obj2 autorelease]; [obj4 autorelease];
} } // pop(POOL_SENTINEL_2) -> [obj4 release], [obj3 release]
|
} // pop(POOL_SENTINEL_1) -> [obj2 release], [obj1 release]
3. Autorelease Pool 与 RunLoop
在我们的应用程序中,我们通常不会手动创建 @autoreleasepool。那么,那些成千上万的 autoreleased 对象是在哪里被管理的呢?答案是 RunLoop。
- 每个线程(包括主线程)都有自己的 RunLoop。
- RunLoop 在每个事件循环(Event Loop)中,都会自动创建和释放 Autorelease Pool。
其大致过程如下:
- 事件开始(如用户点击屏幕、定时器触发等):主线程的 RunLoop 被唤醒,它会立即创建一个 Autorelease Pool。
- 处理事件:执行代码,处理这个事件。在这个过程中产生的所有 autoreleased 对象,都会被添加到当前 RunLoop 创建的那个 Pool 中。
-
事件结束:当 RunLoop 即将进入休眠状态(等待下一个事件)时,它会销毁之前创建的那个 Autorelease Pool。这个销毁操作会
release池中的所有对象。
这个机制保证了在一次事件循环中产生的临时对象,能够在事件处理完毕后被及时释放,防止内存无限制增长。
4. 何时需要手动创建 Autorelease Pool?
虽然 RunLoop 会自动为我们管理 Pool,但在某些特定场景下,我们需要手动干预,以避免内存峰值过高:
-
在循环中创建大量临时对象:如果你在一个
for循环中需要创建大量的临时对象,这些对象会一直累积在 RunLoop 的主 Pool 中,直到循环完全结束、事件处理完毕后才能被释放。这会导致内存峰值瞬间飙高,甚至可能导致应用因内存不足而崩溃。// 不好的写法 for (int i = 0; i < 100000; i++) { // 每次循环都创建一个临时对象,内存峰值会很高 NSString *tempString = [NSString stringWithFormat:@"%d", i]; // ... 使用 tempString ... } // 优化的写法 for (int i = 0; i < 100000; i++) { @autoreleasepool { // 在循环内部创建 pool // 每次循环结束时,当次循环创建的临时对象就会被释放 NSString *tempString = [NSString stringWithFormat:@"%d", i]; // ... 使用 tempString ... } // 这里的 pool 被 drain,tempString 被释放 } -
在后台线程中执行任务:如果你创建了一个新的后台线程来执行任务(例如使用
NSThread),这个线程默认是没有 RunLoop 的,或者其 RunLoop 可能没有被激活。在这种情况下,你必须自己创建 Autoretlease Pool,否则所有 autoreleased 对象都将无法被释放,导致严重的内存泄漏。
理解 Autorelease Pool 的工作原理,特别是它与 RunLoop 的关系以及其栈式数据结构,能帮助你写出更高效、内存更稳定的代码。
第四节:循环引用问题与解决方案
1. 什么是循环引用 (Retain Cycle)?
循环引用,又称“保留环”,指的是两个或多个对象相互之间存在强引用(strong reference),导致它们的引用计数永远无法降为 0,从而使它们占用的内存永远无法被系统回收。
一个简单的比喻:
想象一下对象 A 和对象 B。
- A 说:“我需要 B,在我还用着的时候,你(系统)不准把它拿走。” (A 强引用了 B)
- 同时,B 也说:“我需要 A,在我还用着的时候,你也不准把它拿走。” (B 强引用了 A)
现在,即使所有外部指向 A 和 B 的指针都消失了,A 和 B 依然因为互相“指着”对方,导致谁也无法被释放。它们就像两个被困在孤岛上的人,互相依赖,但永远无法离开,最终造成了内存泄漏。
A <---> B
2. 常见的循环引用场景及解决方案
场景一:Delegate 模式
这是最经典的循环引用场景。一个对象(通常是控制器)创建并持有一个子对象(如下载器、视图等),并设置自己为该子对象的代理,以便接收回调。
问题代码:
// Downloader.h
@class Downloader;
@protocol DownloaderDelegate <NSObject>
- (void)downloaderDidFinish:(Downloader *)downloader;
@end
@interface Downloader : NSObject
// 问题所在:delegate 属性被声明为 strong
@property (nonatomic, strong) id<DownloaderDelegate> delegate;
@end
// ViewController.m
#import "Downloader.h"
@interface ViewController () <DownloaderDelegate>
@property (nonatomic, strong) Downloader *downloader;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.downloader = [[Downloader alloc] init];
self.downloader.delegate = self; // 产生循环引用
}
- (void)dealloc {
NSLog(@"ViewController deallocated"); // 这句日志永远不会被打印
}
@end
分析:
-
ViewController通过downloader属性强引用了Downloader实例 (VC -> Downloader)。 -
Downloader实例通过delegate属性强引用了ViewController实例 (Downloader -> VC)。 - 循环引用形成,即使
ViewController被 pop 出导航栈,它和Downloader实例也无法被释放。
解决方案:
将 delegate 属性的修饰符从 strong 改为 weak。这是 Apple 官方推荐的做法。
// Downloader.h
// 解决方案:使用 weak 来修饰 delegate
@property (nonatomic, weak) id<DownloaderDelegate> delegate;
原理:
weak 引用不会增加对象的引用计数。Downloader 不再“持有”它的 delegate,只是“观察”它。当 ViewController 被销毁时,Downloader 的 delegate 属性会自动变为 nil,从而打破了循环。
场景二:Block 中的循环引用
Block 在捕获外部变量时,如果捕获了 self,很容易造成循环引用。
问题代码:
// MyObject.h
@interface MyObject : NSObject
@property (nonatomic, copy) void (^myBlock)(void);
- (void)setupBlock;
@end
// MyObject.m
@implementation MyObject
- (void)setupBlock {
// self 持有 block
self.myBlock = ^{
// block 又捕获并持有了 self
NSLog(@"Hello from block, self is: %@", self);
};
}
- (void)dealloc {
NSLog(@"MyObject deallocated"); // 这句日志永远不会被打印
}
@end
分析:
-
MyObject实例 (self) 通过myBlock属性强引用了Block对象 (self -> Block)。 -
Block在其代码块内部使用了self,ARC 会自动捕获self,相当于Block也强引用了MyObject实例 (Block -> self)。 - 循环引用形成。
解决方案:The Weak-Strong Dance
在 block 外部创建一个 self 的弱引用,并在 block 内部使用这个弱引用。
// 解决方案
- (void)setupBlock {
// 1. 创建一个 self 的弱引用
__weak typeof(self) weakSelf = self;
self.myBlock = ^{
// 2. 在 block 内部使用这个弱引用
// 这样 block 就不再强引用 self,打破了循环
NSLog(@"Hello from block, weakSelf is: %@", weakSelf);
[weakSelf doSomething];
};
}
进一步优化 (The Strong-Weak Dance):
如果在 block 执行期间,weakSelf 可能因为外部对象被释放而变为 nil,导致后续代码出错,可以在 block 内部再创建一个临时的强引用。
- (void)setupBlock {
__weak typeof(self) weakSelf = self;
self.myBlock = ^{
// 在 block 开头,将 weakSelf 转为 strongSelf
// 这个强引用只在 block 的作用域内有效
__strong typeof(weakSelf) strongSelf = weakSelf;
// 如果 strongSelf 为 nil,说明原始的 self 已经释放,直接返回
if (!strongSelf) {
return;
}
// 在 block 执行期间,可以安全地使用 strongSelf,不用担心它会突然变 nil
NSLog(@"Hello from block, strongSelf is: %@", strongSelf);
[strongSelf doSomething];
};
}
场景三:NSTimer
NSTimer 会强引用它的 target,如果 target 同时又强引用了 NSTimer,就会产生循环引用。
问题代码:
// TimerViewController.m
@interface TimerViewController ()
@property (nonatomic, strong) NSTimer *timer;
@end
@implementation TimerViewController
- (void)viewDidLoad {
[super viewDidLoad];
// self -> timer (通过属性)
// timer -> self (通过 target)
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:@selector(onTick:)
userInfo:nil
repeats:YES];
}
- (void)dealloc {
NSLog(@"TimerViewController deallocated"); // 这句日志永远不会被打印
[self.timer invalidate]; // 因为 dealloc 永远不执行,所以 invalidate 也不会执行
}
@end
解决方案:
-
手动在适当时机停止 Timer:在
viewWillDisappear:或viewDidDisappear:等视图生命周期方法中,手动调用[self.timer invalidate]来停止计时器并解除其对target的强引用。这是最常用和直接的方法。- (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [self.timer invalidate]; self.timer = nil; // 确保 timer 被释放 } -
使用 Block 版本的 API (iOS 10+):这个 API 将
target-selector模式转换为了 block 模式,我们可以用解决 block 循环引用的方法来解决它。__weak typeof(self) weakSelf = self; self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) { __strong typeof(weakSelf) strongSelf = weakSelf; if (strongSelf) { [strongSelf onTick:timer]; } else { // self 不存在了,可以考虑停止 timer [timer invalidate]; } }];
循环引用是导致内存泄漏的主要原因之一。通过将委托关系设置为 weak、在 block 中使用 __weak、以及妥善管理 NSTimer 等对象的生命周期,我们可以有效地避免这类问题。
第五节:SideTables
要彻底理解它,我们需要先回答一个问题:为什么需要 SideTables?
在早期的 Objective-C 中(32位时代),一个对象的 isa 指针就真的只是一个指向其所属类的指针。对象的引用计数是存放在一个独立的哈希表中的。当 64 位时代到来后,指针本身只需要 40多位就足以映射所有内存,还剩下很多位是闲置的。为了优化性能,Apple 的工程师们决定把这些闲置的位利用起来,这就是我们熟知的 NONPOINTER_ISA(非指针型 isa)。
NONPOINTER_ISA 将引用计数、weak 标记、关联对象标记等信息直接存储在了 isa 指针的位域里。这样一来,对引用计数的普通操作(比如 +1 或 -1)只需要一次原子性的指针修改,速度极快,因为它避免了去一个全局哈希表中查找和加锁的开销。
但是,isa 指针的位数毕竟是有限的。它只能存储有限的引用计数值(在 arm64 上是 19 位)。如果一个对象的引用计数超过了这个最大值,或者有其他更复杂的信息(比如有 weak 指针指向它)需要存储,isa 指针的空间就不够用了。
这时,SideTables 就登场了。
SideTables 可以理解为是 NONPOINTER_ISA 的“后备仓库”。当 isa 指针存不下时,运行时系统会将超出的引用计数值、完整的弱引用表等“重量级”数据存放到 SideTables 这个全局结构中。
SideTables 结构解析
1. 全局唯一的 SideTables
首先要明确,SideTables 不是每个对象都有一个,也不是只有一个。系统中有且只有一个全局的 SideTables 集合。它本质上是一个 哈希表,或者更准确地说,是一个 分离锁的哈希表(Striped Hash Map)。
在 Objective-C 的源码 objc-runtime-new.h 中,它的定义是这样的:
static StripedMap<SideTable> SideTablesMap;
StripedMap 是什么?想象一下,如果所有对象的额外信息都放在一个巨大的哈希表里,并且用一把全局锁来保护,那么多线程环境下对不同对象进行 retain 或 release 操作时,就会因为争抢这把锁而导致性能瓶셔。
StripedMap 巧妙地解决了这个问题。它内部并不是一个表,而是 一个包含多个 SideTable 的数组(在 arm64 上是 64 个,以前是 8 个)。
// 简化理解
SideTable SideTables[64];
当需要为某个对象查找它的 SideTable 时,系统会:
- 获取这个对象的内存地址(指针值)。
- 对这个地址做一个哈希计算(通常是简单的位移或取余)。
- 根据哈希结果,从
SideTables数组中选取一个SideTable来使用。
这样一来,对不同对象的内存操作就会被均匀地分散到不同的 SideTable 中,每个 SideTable 都有自己的锁。只要两个对象没有被哈希到同一个 SideTable,对它们的操作就可以完全并行,大大降低了锁竞争,提升了并发性能。
2. SideTable 的内部结构
现在我们来看单个 SideTable 的结构。每个 SideTable 都是一个 C++ 的结构体,包含了三样东西:
struct SideTable {
spinlock_t slock; // 1. 自旋锁
RefcountMap ref***ts; // 2. 引用计数表
weak_table_t weak_table; // 3. 弱引用表
};
-
slock(spinlock_t)- 这是一个自旋锁。当一个线程需要访问这个
SideTable时,它会先锁住slock。如果发现已经被其他线程锁住,它不会像普通互斥锁那样进入休眠,而是会“自旋”——不断地循环检查锁是否被释放。这适用于锁占用时间极短的场景,可以避免线程上下文切换带来的开销。
- 这是一个自旋锁。当一个线程需要访问这个
-
ref***ts(RefcountMap)- 这是一个哈希表 (
DenseMap),用于存储那些引用计数已经溢出isa指针的对象。 - Key: 对象的内存地址。
-
Value: 该对象的完整引用计数值(包含了
isa中存储的部分)。 - 当
isa中的引用计数字段达到最大值后,Runtime 会将isa中的一个特殊位(has_sidetable_rc)标记为 1,然后将对象完整的引用计数值(最大值 + 1)迁移到这个ref***ts哈希表中。之后,对此对象的所有引用计数操作都会在这个表里进行。
- 这是一个哈希表 (
-
weak_table(weak_table_t)- 这是我们下一节要讲的弱引用表,是实现
weak功能的核心。它本身也是一个复杂的哈希表结构,用于记录:“有哪些weak指针正指向着本SideTable管理的那些对象”。
- 这是我们下一节要讲的弱引用表,是实现
工作流程总结
让我们用一个流程来串联起整个概念:
-
一个对象
obj被创建,它的isa是NONPOINTER_ISA,引用计数为 1,直接存在isa的位域里。 -
很多地方强引用了
obj,它的引用计数不断增加。这些操作都直接在isa上进行,非常快。 -
临界点 1: 引用计数溢出
- 当
obj的引用计数达到isa能存储的上限时,下一次retain操作会触发一个“升级”流程。 - Runtime 通过哈希
obj的地址,找到对应的SideTable。 - 锁住这个
SideTable的slock。 - 将
obj的完整引用计数值存入SideTable的ref***ts表中。 - 将
obj的isa指针中的has_sidetable_rc位置为 1,表示“我的引用计数已经交由 SideTable 管理了”。 - 从此以后,对
obj的引用计数操作都会在SideTable的ref***ts中进行。
- 当
-
临界点 2: 出现弱引用
- 当第一个
weak指针__weak id weakPtr = obj;出现时,也会触发一个类似的“升级”流程。 - Runtime 找到
obj对应的SideTable。 - 锁住
slock。 - 将
obj和weakPtr的信息记录到SideTable的weak_table中。 - 这个过程我们将在下一节 “弱引用表” 中详细讲解。
- 当第一个
通过这套机制,Objective-C 实现了一套高效且功能完备的内存管理系统:常规操作在 isa 上飞速完成,复杂情况则优雅地降级到 SideTables 中处理。
至此,关于 SideTables 的结构和它“为什么存在”以及“如何工作”的核心概念就讲解完了。这部分知识是理解 ARC 底层,特别是 weak 实现原理的基石。
第六节:弱引用表 (Weak Table)
我们接着上一节的 SideTables,深入剖析其内部最复杂、也最关键的组件——弱引用表 (Weak Table)。这正是 __weak 关键字能够自动将指针置为 nil 的魔法核心。
理解了 SideTables 是一个后备仓库,我们就能明白 weak_table 的使命:当一个对象被释放时,必须有一种机制能够找到所有指向它的 weak 指针,并将它们清零。 weak_table 就是实现这个机制的数据库。
弱引用表 (weak_table_t) 的核心结构
weak_table 本身是一个哈希表,它的定义在 objc-private.h 中:
struct weak_table_t {
weak_entry_t *weak_entries; // 哈希数组,存放 weak_entry_t
size_t num_entries; // 当前存储的条目数量
uintptr_t mask; // 哈希表的容量 - 1,用于快速计算索引
// ... 其他一些用于性能优化的成员
};
-
weak_entries: 这是哈希表的主体,一个动态数组,里面存放着weak_entry_t结构。 - 哈希表的 Key: 哈希表的键(Key)是被弱引用指向的那个对象的内存地址。
-
哈希表的 Value: 哈希表的值(Value)是一个
weak_entry_t结构,它记录了**“谁”在弱引用这个对象**。
那么,weak_entry_t 又是什么呢?
struct weak_entry_t {
DisguisedPtr<objc_object> referent; // 被引用的对象 (obj)
union {
struct {
weak_referrer_t *referrers; // 动态数组,存放所有弱引用该对象的指针地址
};
weak_referrer_t inline_referrers[4]; // 当数量不多时,直接用内联数组,避免额外内存分配
};
};
-
referent: 指向被弱引用的对象,比如obj。 -
referrers/inline_referrers: 这是最关键的部分。它是一个union(联合体),用于性能优化。- 如果弱引用
obj的指针少于等于4个,它们的地址就直接存放在inline_referrers这个内联数组里。 - 如果超过4个,系统就会分配一块新的内存,用
referrers指针指向它,形成一个动态数组来存储所有弱引用指针的地址。 -
weak_referrer_t本质上就是objc_object **,即指向弱引用指针的指针。
- 如果弱引用
总结一下这个层级关系:
SideTables (全局,包含多个 SideTable)
└── SideTable (每个 SideTable 有自己的锁和表)
└── weak_table_t (弱引用哈希表)
└── weak_entry_t (表中的一个条目,代表一个被弱引用的对象)
└── referrers (一个数组,存储所有指向该对象的 weak 指针的地址)
两大核心操作的实现原理
weak 机制的实现依赖于两个关键的 runtime 函数:objc_storeWeak (或 objc_initWeak) 和 clearDeallocating。
1. 存储弱引用 (objc_storeWeak)
当你写下这行代码时:
NSObject *obj = [[NSObject alloc] init];
__weak NSObject *weakPtr = obj;
编译器会将其转换为类似 objc_initWeak(&weakPtr, obj) 的调用。这个函数会做以下事情:
-
定位
SideTable: 通过obj对象的地址进行哈希,找到它归属的那个SideTable。 -
加锁: 锁住该
SideTable,保证线程安全。 -
查找或创建
weak_entry_t:- 以
obj的地址为 Key,在weak_table中查找对应的weak_entry_t。 -
如果找到了: 说明
obj之前已经被其他weak指针引用了。此时,只需将weakPtr的地址 (&weakPtr) 添加到该entry的referrers数组中。 -
如果没找到: 说明这是第一个指向
obj的weak指针。系统会创建一个新的weak_entry_t,将obj的地址存入referent,并将&weakPtr存入inline_referrers数组的第一个位置。然后,将这个新的entry插入到weak_table哈希表中。
- 以
-
解锁: 操作完成,解锁
SideTable。
通过这个过程,weak_table 就建立起了一张清晰的关系网:obj 对象被 weakPtr 这个指针弱引用着。
2. 清理弱引用 (clearDeallocating)
当 obj 对象的引用计数变为 0 时,它的 dealloc 方法会被调用。在 dealloc 的深处,runtime 会调用一个名为 clearDeallocating 的函数。这个函数是实现 weak 自动置 nil 的关键。
-
定位
SideTable: 同样,通过obj(即self)的地址找到它归属的SideTable。 -
加锁: 锁住该
SideTable。 -
查找
weak_entry_t: 以obj的地址为 Key,在weak_table中查找对应的weak_entry_t。因为对象即将被释放,所以这个entry必定存在(否则isa中的weakly_referenced位会是 0,流程会提前终止)。 -
遍历并置
nil:- 从找到的
weak_entry_t中获取referrers数组。这个数组里存储了所有指向obj的weak指针的地址(比如&weakPtr)。 -
遍历这个数组,对于每一个地址,将其指向的内容设置为
nil。 - 用代码理解就是
for (objc_object **referrer in entry->referrers) { *referrer = nil; }。
- 从找到的
-
清理
entry: 将所有weak指针都置为nil之后,这个weak_entry_t也就没有存在的意义了。runtime 会将它从weak_table中移除。 -
解锁: 操作完成,解锁
SideTable。
clearDeallocating 执行完毕后,所有曾经指向 obj 的 weak 指针现在都变成了 nil。之后,系统才会真正地 free() 掉 obj 对象的内存。
总结
weak 关键字的背后,是 runtime 通过 SideTables 中的 weak_table 维护的一张精密的关系图。
-
赋值时:通过
objc_storeWeak将 [被引用的对象 -> 一组弱引用指针的地址] 这个映射关系记录到weak_table中。 -
释放时:通过
clearDeallocating,根据即将被释放的对象,从weak_table中找到所有指向它的弱引用指针,并把它们全部设置为nil。
这个设计虽然复杂,但它保证了内存的绝对安全,避免了野指针的产生,是 Objective-C 内存管理体系中非常优雅的一环。
第七节:NSCopying 协议与深/浅拷贝的关联性
1. 核心概念:什么是拷贝?
拷贝的目的是创建一个功能上与原对象相同的新对象。当你调用一个对象的 copy 方法时,你期望得到一个新的对象实例,它拥有自己独立的内存空间。
这个过程引出了两种不同层次的拷贝方式:浅拷贝 (Shallow Copy) 和 深拷贝 (Deep Copy)。
-
浅拷贝 (Shallow Copy)
- 定义:只复制对象本身,不复制它所引用的内部对象。新对象和原对象会共享内部所引用的对象。
- 比喻:你有一份文档的快捷方式,你把这个快捷方式复制了一份。现在你有两个快捷方式,但它们都指向同一份原始文档。修改原始文档,通过任何一个快捷方式打开看到的内容都会变。
- 实现:创建一个新的对象,然后将原对象中的实例变量(指针)的值直接赋给新对象。
-
深拷贝 (Deep Copy)
- 定义:不仅复制对象本身,还会递归地复制它所引用的所有内部对象。新对象和原对象是完全独立的,互不影响。
- 比喻:你有一份文档,你把它拿去复印机复印了一份。现在你有两份完全独立、内容相同的文档。修改其中一份,另一份不会有任何变化。
-
实现:创建一个新的对象,然后对原对象中的每一个实例变量,都调用其
copy方法,将返回的新对象赋给新创建的实例变量。
2. NSCopying 协议:一个“君子协定”
NSCopying 是一个非常简单的协议,它只定义了一个必须实现的方法:
- (id)copyWithZone:(NSZone *)zone;
(注:NSZone 是早期内存管理的概念,现在已经废弃,我们通常直接传 nil 或忽略它。)
当你调用 [someObject copy] 时,实际上底层调用的就是 [someObject copyWithZone:nil]。
这里有一个最最关键的认知误区需要澄清:
NSCopying协议本身 并不规定 你必须实现的是深拷贝还是浅拷贝。它只是一个“君子协定”,约定了你的类有能力创建一个副本。至于这个副本是深是浅,完全由类的实现者来决定。
3. 系统框架中的 copy 行为
理解 Foundation 框架中各种类的默认拷贝行为至关重要。
a. 非集合类 (如 NSString, NSNumber)
-
对不可变对象 (
NSString) 调用copy:- 行为:浅拷贝。但这里是“指针拷贝”,它并不会创建新的对象,而是直接返回
self并增加其引用计数。 - 原因:因为对象是不可变的,创建一份一模一样的副本毫无意义,还会浪费内存。共享同一个实例是最高效、最安全的方式。
- 行为:浅拷贝。但这里是“指针拷贝”,它并不会创建新的对象,而是直接返回
-
对不可变对象 (
NSString) 调用mutableCopy:- 行为:单层深拷贝。它会创建一个新的、内容相同的可变对象 (
NSMutableString)。
- 行为:单层深拷贝。它会创建一个新的、内容相同的可变对象 (
-
对可变对象 (
NSMutableString) 调用copy:- 行为:单层深拷贝。它会创建一个新的、内容相同的不可变对象 (
NSString)。这是为了获得一个“在某个时间点内容的快照”,防止后续被修改。
- 行为:单层深拷贝。它会创建一个新的、内容相同的不可变对象 (
-
对可变对象 (
NSMutableString) 调用mutableCopy:- 行为:单层深拷贝。创建一个新的、内容相同的可变对象 (
NSMutableString)。
- 行为:单层深拷贝。创建一个新的、内容相同的可变对象 (
b. 集合类 (如 NSArray, NSDictionary, NSSet)
这是最容易出错的地方。对于集合类的拷贝,我们必须区分 “容器的拷贝” 和 “内容的拷贝”。
-
[NSArray copy]/[NSMutableArray copy]/[NSMutableArray mutableCopy]都只对容器本身进行拷贝,而对容器内的元素执行的是浅拷贝(指针拷贝)。
看一个经典的例子:
// 1. 创建一个包含可变字符串的数组
NSMutableString *str = [NSMutableString stringWithString:@"hello"];
NSMutableArray *originalArray = [NSMutableArray arrayWithObject:str];
// 2. 对可变数组进行 mutableCopy,得到一个新数组
NSMutableArray *copiedArray = [originalArray mutableCopy];
// 3. 修改原数组中的元素
[str appendString:@" world"];
// 4. 打印两个数组
NSLog(@"Original Array: %@", originalArray); // 输出: ["hello world"]
NSLog(@"Copied Array: %@", copiedArray); // 输出: ["hello world"]
// 5. 修改新数组的结构
[copiedArray addObject:@"new object"];
NSLog(@"Original Array after modification: %@", originalArray); // 输出: ["hello world"]
NSLog(@"Copied Array after modification: %@", copiedArray); // 输出: ["hello world", "new object"]
分析:
-
mutableCopy创建了一个新的NSMutableArray实例 (copiedArray),所以我们可以向copiedArray添加新对象而不影响originalArray。这说明容器是深拷贝的。 - 但是,新旧两个数组中的第一个元素,都指向了同一个
NSMutableString实例 (str)。所以当我们修改str时,两个数组都受到了影响。这说明内容是浅拷贝的。
如果你需要对集合进行真正的深拷贝,必须自己实现,例如遍历数组,对其中每一个元素都调用 copy 或 mutableCopy,然后将返回的新对象添加到新数组中。
4. @property 中的 copy 关键字
现在我们就能理解为什么在声明属性时,copy 关键字如此重要了。
@property (nonatomic, copy) NSString *name;
假设我们有一个 Person 类,它有一个 name 属性。为什么要用 copy 而不是 strong?
考虑以下场景:
NSMutableString *mutableName = [NSMutableString stringWithString:@"Tom"];
Person *person = [[Person alloc] init];
person.name = mutableName; // 调用了 [person setName:mutableName]
// 如果 name 是 strong 属性,person.name 现在就指向了 mutableName
// 如果 name 是 copy 属性,person.name 指向的是 [mutableName copy] 返回的一个不可变的 "Tom"
// 外部在不知情的情况下修改了 mutableName
[mutableName appendString:@" Jerry"];
NSLog(@"Person's name is: %@", person.name);
-
如果
name是strong:person.name会跟着变成"Tom Jerry"。Person对象的内部状态被外部意外地修改了,这破坏了对象的封装性,是潜在的 bug 源。 -
如果
name是copy:在setName:方法内部,实际上执行的是_name = [mutableName copy]。这会根据传入的NSMutableString创建一个新的、不可变的NSString。所以person.name永远是"Tom",无论外部的mutableName如何变化。
结论:对于那些具有“可变”子类的类型的属性(如 NSString/NSMutableString, NSArray/NSMutableArray),总是使用 copy 关键字来修饰,这是一种防御性编程,可以确保对象内部状态的稳定和安全。
5. 在自定义类中实现 NSCopying
假设我们要让 Person 类也支持拷贝。
// Person.h
@interface Person : NSObject <NSCopying>
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSArray *friends; // 假设朋友列表
@end
// Person.m
@implementation Person
- (id)copyWithZone:(NSZone *)zone {
// 1. 创建新对象,这是标准写法
Person *newPerson = [[[self class] allocWithZone:zone] init];
// 2. 对属性进行赋值
// name 是 NSString,直接拷贝即可获得一个不可变的副本
newPerson.name = self.name; // 因为 name 属性本身是 copy 的,这里直接赋值即可。
// 或者更严谨地写 newPerson.name = [self.name copy];
// friends 是 NSArray,系统默认的 copy 是浅拷贝
newPerson.friends = [self.friends copy]; // 这是浅拷贝
// 如果需要深拷贝 friends,需要手动实现
// NSMutableArray *deepCopiedFriends = [[NSMutableArray alloc] initWithCapacity:self.friends.count];
// for (id friend in self.friends) {
// [deepCopiedFriends addObject:[friend copy]]; // 假设 friend 也实现了 NSCopying
// }
// newPerson.friends = deepCopiedFriends;
return newPerson;
}
@end
总结:
-
NSCopying是一个行为协议,具体实现由开发者决定。 - 系统类的
copy行为需要牢记,尤其是集合类的内容浅拷贝特性。 - 在
@property中使用copy是保护对象封装性的重要手段。 - 在自定义类中实现
copy时,必须明确你希望提供的是深拷贝还是浅拷贝,并据此编写代码。
这部分内容已经讲解完毕,它将您对 OC 内存管理的理解从“引用计数”的微观层面,提升到了“对象所有权和图结构”的宏观层面。