【跟着AI学】系列-【Objective-C底层原理】-【第四章 内存管理与 ARC】

在现代 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
    • 当你不再需要一个由你持有(你曾经 allocretain 过)的对象时,你必须 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):

  1. 你创建,你拥有:通过 alloc, new, copy 等方法创建的对象,你必须负责 releaseautorelease
  2. retain,你拥有:通过 retain 持有的对象,你必须负责 release
  3. 非你所有,请勿释放:如果你没有持有某个对象,就绝对不能 release 它,否则会导致程序崩溃(野指针)。

理解了这个手动管理内存的原理,你就能更好地理解 ARC 是如何自动地在编译时为我们插入这些 retain, release, autorelease 调用的。


上一节我们学习了手动引用计数(MRC)的原理,它是理解自动引用计数(ARC)的基础。ARC (Automatic Reference Counting) 并非一项运行时特性,也不是一个垃圾回收器,它是一个编译器特性。编译器会在代码的适当位置自动为我们插入 retainreleaseautorelease 等内存管理代码,让我们从繁琐的手动管理中解放出来。

第二节: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是所指对象的地址,Valueweak指针的地址数组。
    • weak的实现过程
      1. 初始化时,runtime会调用objc_initWeak函数,初始化一个新的weak指针指向对象的地址
      2. 添加引用时,objc_initWeak会调用objc_storeWeak()函数,objc_storeWeak()的作用是更新指针指向,创建对应的弱引用表
      3. 释放时,调用clearDeallocating函数。clearDeallocating函数首先根据对象地址获取所有weak指针地址的数组,然后遍历这个数组,把其中的数据设为nil,最后把这个entry从weak表中删除,三步完成。
__unsafe_unretained (不安全的弱引用)
  • 含义:与 __weak 类似,它也不持有对象,不增加引用计数。
  • __weak 的区别:当所指向的对象被销毁后,__unsafe_unretained 指针的地址不会被自动置为 nil,它会继续指向那块已经被回收的内存。此时,这个指针就变成了“野指针”,访问它会导致程序崩溃。
  • 使用场景:主要用于兼容 iOS 4 及以下不支持 __weak 的系统版本。在现代开发中,除非有特殊性能要求或需要与 C 语言代码交互,否则应始终使用 __weak
__copy / copy (拷贝)
  • 含义:与 __strong 类似,它也持有对象。但它持有的不是原始对象,而是原始对象的一个副本 (copy)
  • 使用场景:通常用于修饰那些内容可变的对象,如 NSStringNSArrayNSDictionary 等。
  • 为什么对 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 并不是一个简单的 NSArrayNSSet。它的核心是基于一个栈式结构来实现的,主要由以下几个部分构成:

  • 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。

其大致过程如下:

  1. 事件开始(如用户点击屏幕、定时器触发等):主线程的 RunLoop 被唤醒,它会立即创建一个 Autorelease Pool。
  2. 处理事件:执行代码,处理这个事件。在这个过程中产生的所有 autoreleased 对象,都会被添加到当前 RunLoop 创建的那个 Pool 中。
  3. 事件结束:当 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

分析:

  1. ViewController 通过 downloader 属性强引用了 Downloader 实例 (VC -> Downloader)。
  2. Downloader 实例通过 delegate 属性强引用了 ViewController 实例 (Downloader -> VC)。
  3. 循环引用形成,即使 ViewController 被 pop 出导航栈,它和 Downloader 实例也无法被释放。

解决方案:
delegate 属性的修饰符从 strong 改为 weak。这是 Apple 官方推荐的做法。

// Downloader.h
// 解决方案:使用 weak 来修饰 delegate
@property (nonatomic, weak) id<DownloaderDelegate> delegate; 

原理:
weak 引用不会增加对象的引用计数。Downloader 不再“持有”它的 delegate,只是“观察”它。当 ViewController 被销毁时,Downloaderdelegate 属性会自动变为 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

分析:

  1. MyObject 实例 (self) 通过 myBlock 属性强引用了 Block 对象 (self -> Block)。
  2. Block 在其代码块内部使用了 self,ARC 会自动捕获 self,相当于 Block 也强引用了 MyObject 实例 (Block -> self)。
  3. 循环引用形成。

解决方案: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

解决方案:

  1. 手动在适当时机停止 Timer:在 viewWillDisappear:viewDidDisappear: 等视图生命周期方法中,手动调用 [self.timer invalidate] 来停止计时器并解除其对 target 的强引用。这是最常用和直接的方法。

    - (void)viewWillDisappear:(BOOL)animated {
        [super viewWillDisappear:animated];
        [self.timer invalidate];
        self.timer = nil; // 确保 timer 被释放
    }
    
  2. 使用 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 是什么?想象一下,如果所有对象的额外信息都放在一个巨大的哈希表里,并且用一把全局锁来保护,那么多线程环境下对不同对象进行 retainrelease 操作时,就会因为争抢这把锁而导致性能瓶셔。

StripedMap 巧妙地解决了这个问题。它内部并不是一个表,而是 一个包含多个 SideTable 的数组(在 arm64 上是 64 个,以前是 8 个)。

// 简化理解
SideTable SideTables[64];

当需要为某个对象查找它的 SideTable 时,系统会:

  1. 获取这个对象的内存地址(指针值)。
  2. 对这个地址做一个哈希计算(通常是简单的位移或取余)。
  3. 根据哈希结果,从 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. 弱引用表
};
  1. slock (spinlock_t)

    • 这是一个自旋锁。当一个线程需要访问这个 SideTable 时,它会先锁住 slock。如果发现已经被其他线程锁住,它不会像普通互斥锁那样进入休眠,而是会“自旋”——不断地循环检查锁是否被释放。这适用于锁占用时间极短的场景,可以避免线程上下文切换带来的开销。
  2. ref***ts (RefcountMap)

    • 这是一个哈希表 (DenseMap),用于存储那些引用计数已经溢出 isa 指针的对象
    • Key: 对象的内存地址。
    • Value: 该对象的完整引用计数值(包含了 isa 中存储的部分)。
    • isa 中的引用计数字段达到最大值后,Runtime 会将 isa 中的一个特殊位(has_sidetable_rc)标记为 1,然后将对象完整的引用计数值(最大值 + 1)迁移到这个 ref***ts 哈希表中。之后,对此对象的所有引用计数操作都会在这个表里进行。
  3. weak_table (weak_table_t)

    • 这是我们下一节要讲的弱引用表,是实现 weak 功能的核心。它本身也是一个复杂的哈希表结构,用于记录:“有哪些 weak 指针正指向着本 SideTable 管理的那些对象”
工作流程总结

让我们用一个流程来串联起整个概念:

  1. 一个对象 obj 被创建,它的 isaNONPOINTER_ISA,引用计数为 1,直接存在 isa 的位域里。

  2. 很多地方强引用了 obj,它的引用计数不断增加。这些操作都直接在 isa 上进行,非常快。

  3. 临界点 1: 引用计数溢出

    • obj 的引用计数达到 isa 能存储的上限时,下一次 retain 操作会触发一个“升级”流程。
    • Runtime 通过哈希 obj 的地址,找到对应的 SideTable
    • 锁住这个 SideTableslock
    • obj 的完整引用计数值存入 SideTableref***ts 表中。
    • objisa 指针中的 has_sidetable_rc 位置为 1,表示“我的引用计数已经交由 SideTable 管理了”。
    • 从此以后,对 obj 的引用计数操作都会在 SideTableref***ts 中进行。
  4. 临界点 2: 出现弱引用

    • 当第一个 weak 指针 __weak id weakPtr = obj; 出现时,也会触发一个类似的“升级”流程。
    • Runtime 找到 obj 对应的 SideTable
    • 锁住 slock
    • objweakPtr 的信息记录到 SideTableweak_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) 的调用。这个函数会做以下事情:

  1. 定位 SideTable: 通过 obj 对象的地址进行哈希,找到它归属的那个 SideTable
  2. 加锁: 锁住该 SideTable,保证线程安全。
  3. 查找或创建 weak_entry_t:
    • obj 的地址为 Key,在 weak_table 中查找对应的 weak_entry_t
    • 如果找到了: 说明 obj 之前已经被其他 weak 指针引用了。此时,只需将 weakPtr地址 (&weakPtr) 添加到该 entryreferrers 数组中。
    • 如果没找到: 说明这是第一个指向 objweak 指针。系统会创建一个新的 weak_entry_t,将 obj 的地址存入 referent,并将 &weakPtr 存入 inline_referrers 数组的第一个位置。然后,将这个新的 entry 插入到 weak_table 哈希表中。
  4. 解锁: 操作完成,解锁 SideTable

通过这个过程,weak_table 就建立起了一张清晰的关系网:obj 对象被 weakPtr 这个指针弱引用着

2. 清理弱引用 (clearDeallocating)

obj 对象的引用计数变为 0 时,它的 dealloc 方法会被调用。在 dealloc 的深处,runtime 会调用一个名为 clearDeallocating 的函数。这个函数是实现 weak 自动置 nil 的关键。

  1. 定位 SideTable: 同样,通过 obj(即 self)的地址找到它归属的 SideTable
  2. 加锁: 锁住该 SideTable
  3. 查找 weak_entry_t: 以 obj 的地址为 Key,在 weak_table 中查找对应的 weak_entry_t。因为对象即将被释放,所以这个 entry 必定存在(否则 isa 中的 weakly_referenced 位会是 0,流程会提前终止)。
  4. 遍历并置 nil:
    • 从找到的 weak_entry_t 中获取 referrers 数组。这个数组里存储了所有指向 objweak 指针的地址(比如 &weakPtr)。
    • 遍历这个数组,对于每一个地址,将其指向的内容设置为 nil
    • 用代码理解就是 for (objc_object **referrer in entry->referrers) { *referrer = nil; }
  5. 清理 entry: 将所有 weak 指针都置为 nil 之后,这个 weak_entry_t 也就没有存在的意义了。runtime 会将它从 weak_table 中移除。
  6. 解锁: 操作完成,解锁 SideTable

clearDeallocating 执行完毕后,所有曾经指向 objweak 指针现在都变成了 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 时,两个数组都受到了影响。这说明内容是浅拷贝的

如果你需要对集合进行真正的深拷贝,必须自己实现,例如遍历数组,对其中每一个元素都调用 copymutableCopy,然后将返回的新对象添加到新数组中。

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);
  • 如果 namestrongperson.name 会跟着变成 "Tom Jerry"Person 对象的内部状态被外部意外地修改了,这破坏了对象的封装性,是潜在的 bug 源。
  • 如果 namecopy:在 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
总结:
  1. NSCopying 是一个行为协议,具体实现由开发者决定。
  2. 系统类的 copy 行为需要牢记,尤其是集合类的内容浅拷贝特性。
  3. @property 中使用 copy 是保护对象封装性的重要手段。
  4. 在自定义类中实现 copy 时,必须明确你希望提供的是深拷贝还是浅拷贝,并据此编写代码。

这部分内容已经讲解完毕,它将您对 OC 内存管理的理解从“引用计数”的微观层面,提升到了“对象所有权和图结构”的宏观层面。

转载请说明出处内容投诉
CSS教程网 » 【跟着AI学】系列-【Objective-C底层原理】-【第四章 内存管理与 ARC】

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买