ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

iOS内存管理:深入解析weak关键字实现原理与底层机制

2026/8/17 22:55:53 拓冰建站 浏览量
iOS内存管理:深入解析weak关键字实现原理与底层机制 1. 项目概述为什么需要深入理解weak在iOS开发中内存管理是每个开发者都必须跨越的一道坎。从早期的MRC手动引用计数到现在的ARC自动引用计数苹果为我们提供了越来越便捷的工具但便捷的背后是更复杂的运行时机制在支撑。weak关键字就是ARC时代一个看似简单、实则精妙的设计。你肯定用过它来打破循环引用比如在delegate模式或者block中但你是否想过当一个对象被释放时系统是如何神奇地将所有指向它的weak指针自动置为nil的这个过程并非魔法而是建立在Objective-C运行时和Side Tables等底层数据结构之上的一套精密系统。理解weak的实现原理远不止是为了应付面试。它能让你在遇到一些诡异的内存问题时比如某个weak变量意外为nil或者该为nil时却还不是拥有直指问题根源的洞察力。你会明白这不仅仅是“弱引用”三个字那么简单其背后涉及哈希表操作、内存地址的原子性访问以及运行时系统的紧密协作。接下来我们就从设计思路开始一层层揭开weak的神秘面纱。2. 核心思路与架构设计weak系统的设计目标非常明确提供一种不增加对象引用计数的指针并在所指对象被销毁时安全、自动地将该指针置为nil。为了实现这个目标仅靠编译器在源码层面做手脚是不够的必须依赖运行时Runtime在底层提供支持。整个架构的核心可以概括为一个全局的、用于跟踪所有弱引用关系的侧表Side Table系统。2.1 核心设计思想分离与映射为什么不能直接把弱引用信息放在对象本身的内存块里主要出于性能和内存紧凑性的考虑。每个对象都可能被零个、一个或多个weak指针指向。如果把这些信息内嵌到每个对象中即使该对象从未被弱引用也会为此付出固定的内存开销这在大量对象存在的系统中是不可接受的。因此苹果采用了“分离”的设计思想主对象结构保持精简对象本身的isa指针和存储实例变量的内存区域不直接包含弱引用信息。外部集中管理创建一个全局的弱引用管理表即Side Table只在对象第一次被弱引用时才在表中为其创建一条记录。这是一种典型的“按需分配”策略。这种设计类似于图书馆的索引系统。书籍本身对象只包含内容数据而“哪些读者预约了这本书”弱引用关系则记录在图书馆的中央预约系统Side Table里。当一本书被销毁下架中央系统会立刻通知所有预约者weak指针预约失效。2.2 关键数据结构Side Table 与 weak_table_t整个弱引用系统的基石是SideTable结构体。在Objective-C运行时中存在一个全局的SideTables数组它实际上是一个哈希表其键Key是对象的内存地址值Value就是对应的SideTable。struct SideTable { spinlock_t slock; // 自旋锁保证操作原子性 RefcountMap refcnts; // 存储对象的引用计数在某些架构下 weak_table_t weak_table; // 弱引用表核心中的核心 };我们重点关注weak_table_tstruct weak_table_t { weak_entry_t *weak_entries; // 一个动态数组存储所有弱引用条目 size_t num_entries; // 当前条目数量 uintptr_t mask; // 用于哈希查找的掩码 size_t max_hash_displacement; // 最大哈希冲突探测次数 };而weak_entry_t才是真正存储具体弱引用关系的地方。一个weak_entry_t对应一个被弱引用的对象我们称之为主对象。它内部主要包含referent指向被弱引用对象的指针即主对象的地址。这是该条目的“键”。referrers一个动态数组里面存储了所有指向这个主对象的weak变量的地址。注意这里存的不是weak变量的值而是weak变量自身所在的内存地址。举个例子假设我们有一个NSObject *obj并声明了__weak id weakPtr1 obj;和__weak id weakPtr2 obj;。那么运行时会在weak_table中找到一个或创建一个weak_entry_t其referent是obj的内存地址其referrers数组里则保存了weakPtr1和weakPtr2这两个地址。2.3 工作流程总览理解了数据结构整个生命周期就清晰了初始化弱引用存储当执行__weak id weakPtr strongObj;时编译器会插入运行时函数objc_initWeak。该函数会根据strongObj的地址在全局的SideTables哈希表中找到对应的SideTable。在SideTable-weak_table中以strongObj的地址为key查找或创建对应的weak_entry_t。将weakPtr变量的地址weakPtr添加到这个weak_entry_t的referrers数组中。关键一步它不会增加strongObj的引用计数retain count。对象销毁清理与置nil当strongObj的引用计数降为0其dealloc方法执行到最后时运行时函数_objc_rootDealloc会触发弱引用清理流程weak_clear_no_lock。根据即将销毁对象的地址在weak_table中找到对应的weak_entry_t。遍历这个条目里的referrers数组取出里面记录的每一个weak变量的地址。将每一个地址所指向的内存即那个weak变量本身的内容原子性地置为nil。最后从weak_table中删除这个weak_entry_t条目。读取弱引用加载当我们在代码中读取weakPtr变量时编译器会调用objc_loadWeakRetained。这个函数会先检查weakPtr当前指向的对象是否还存在通过一个内部的“存活”检查。如果对象还存在则会对其执行一次retain操作确保在本次使用期间对象不会被释放然后返回这个对象。这解释了为什么在使用weak变量时通常需要先将其赋值给一个strong局部变量id strongPtr weakPtr;。这行代码隐含了那次关键的retain创建了一个强引用防止对象在后续代码执行中途被释放。3. 核心细节与实现难点解析了解了宏观流程我们深入几个关键细节这些正是weak机制稳定运行的保障也是容易出问题的环节。3.1 哈希表与冲突解决全局的SideTables和每个SideTable中的weak_table.weak_entries都是哈希表。使用哈希表是为了实现O(1)时间复杂度的快速查找。对象的地址经过哈希函数计算后得到一个索引用于定位其在表中的位置。难点在于哈希冲突两个不同的对象地址可能哈希到同一个索引。苹果采用的解决方案是开放定址法Open Addressing具体是线性探测Linear Probing。当发生冲突时就顺序检查下一个位置直到找到空位或目标条目。max_hash_displacement就记录了在插入过程中发生的最大的探测次数用于优化查找过程。在并发环境下哈希表的扩容rehash是一个复杂操作。为了保证线程安全操作weak_table时需要加锁即SideTable中的slock自旋锁。注意虽然查找效率很高但在极端情况下哈希冲突严重或表项过多时性能会下降。不过在实际的App中需要同时存活的、被大量弱引用的对象数量级通常不会触及这个瓶颈。3.2 内存管理与原子操作weak的核心承诺是“自动置nil”这必须是线程安全的。想象一个场景对象正在被销毁线程A正在遍历referrers准备置nil同时另一个线程线程B正在创建一个指向该对象的新weak引用。锁的粒度SideTable级别的自旋锁slock保证了对其下weak_table操作的互斥性。任何读写weak_table的操作都必须先获取这把锁。置nil的原子性将weak变量置为nil这个操作本身必须是原子的。CPU和运行时库会提供原子写操作如store指令配合合适的内存屏障确保其他线程读取时不会看到一个处于中间状态的、无效的指针值。这是防止野指针导致崩溃的最后一道防线。3.3weak_entry_t的设计演进在早期版本中referrers被设计成一个固定大小的内联数组例如4个元素。当弱引用数量不超过这个阈值时所有weak变量地址直接存储在这个数组里避免了额外的内存分配效率很高。只有当弱引用数量超过阈值时才会动态分配一个更大的哈希表来存储。这种“小对象优化”的设计在苹果的代码中很常见。它基于一个观察大多数对象只会被很少的weak指针引用比如一个View的delegate通常只有一个。这种设计在内存和性能之间取得了很好的平衡。4. 从源码角度追踪weak的生命周期让我们结合伪代码模拟一下关键函数的执行逻辑这能让你对整个过程有更感性的认识。4.1 weak的初始化objc_initWeakid objc_initWeak(id *location, id newObj) { // 1. 如果newObj是nil直接让location指向nil并返回 if (!newObj) { *location nil; return nil; } // 2. 调用storeWeak函数核心操作都在这里 return storeWeakDontHaveOld, DoHaveNew, DoCrashIfDeallocating (location, (objc_object*)newObj); }storeWeak是一个模板函数它根据情况处理新旧对象的更替。在我们的初始化场景中它主要做两件事注册新引用根据newObj找到或创建对应的weak_entry_t并将location即weak变量的地址插入其referrers列表。设置指针将weak变量的值*location设置为newObj。注意这只是简单的指针赋值不retain。4.2 对象释放时的清理weak_clear_no_lock这是最精彩的环节发生在对象的dealloc过程中。void weak_clear_no_lock(weak_table_t *weak_table, id referent_id) { objc_object *referent (objc_object *)referent_id; // 1. 根据对象地址在weak_table中查找对应的weak_entry_t weak_entry_t *entry weak_entry_for_referent(weak_table, referent); if (entry nil) return; // 如果没有弱引用直接返回 // 2. 遍历entry中所有weak指针的地址 for (size_t i 0; i entry-referrers.size(); i) { id *referrer entry-referrers[i]; // 取出weak变量的地址 if (referrer) { // 3. 原子性地将该地址的内容置为nil *referrer nil; } } // 4. 从weak_table中移除这个entry weak_entry_remove(weak_table, entry); }这个过程清晰明了。正是这个遍历置nil的操作保证了所有weak指针在对象销毁后都变得安全。4.3 读取weak变量objc_loadWeakRetained当我们使用weak变量时编译器会插入类似下面的逻辑id objc_loadWeakRetained(id *location) { id obj; id result; // 1. 获取当前weak指针指向的对象 obj *location; // 2. 如果对象不存在已释放直接返回nil if (!obj) return nil; // 3. 尝试retain这个对象增加引用计数 if (objc_object::tryRetain(obj)) { result obj; // retain成功返回对象 } else { result nil; // retain失败对象正在释放返回nil } return result; }这就是为什么我们需要“强转弱”__weak MyClass *weakObj strongObj; // ... MyClass *strongObjAgain weakObj; // 这里编译器会调用objc_loadWeakRetained // 此时strongObjAgain是一个强引用对象在作用域内不会被释放5. 常见问题与实战排查技巧理解了原理很多实际问题就迎刃而下了。下面是一些典型场景和排查思路。5.1 问题weak变量在预期之外变成了nil这是最常见的问题。根本原因只有一个它指向的对象已经被释放了。但我们需要找到“谁”释放的。排查思路检查局部强引用确认在weak变量被使用的作用域内是否存在一个强引用持有该对象。如果没有对象可能在任何时候被释放。检查异步操作在异步回调如网络请求、GCD延时、动画完成块中使用weakSelf时回调执行时self可能已经销毁了。这是正常现象你的代码应该能处理weakSelf为nil的情况。使用调试工具Xcode Memory Graph Debugger这是最直观的工具。运行App触发问题场景后点击Debug Memory Graph按钮。你可以查看对象的引用关系图清晰地看到是哪个强引用在什么时候消失了。Zombie Objects在Xcode的Scheme设置中启用Zombie Objects。当访问一个已释放对象比如一个本该为nil的weak指针却读到了僵尸对象时Xcode会抛出异常并停在问题发生处。这对于排查野指针访问非常有效。Malloc Stack Logging这是一个更底层的工具可以记录每个对象内存分配和释放的堆栈信息。在Scheme设置中将其设置为“All Allocation and Free History”然后在控制台使用malloc_history命令来查看特定地址对象的完整生命周期。5.2 问题weak变量在预期之内没有及时变成nil已非常罕见在极早期的iOS版本或特定复杂并发场景下理论上可能存在一个极短的“时间窗”对象dealloc已开始执行但尚未完成weak_clear_no_lock此时另一个线程读取weak变量可能读到非nil但已无效的地址。但现代版本的运行时和编译器屏障已经极大地消除了这种风险。排查思路如果遇到疑似问题首先检查Xcode和iOS版本并尝试在真机而非模拟器上复现。绝大多数情况下这可能是其他逻辑错误导致的错觉。5.3 性能考量与最佳实践避免创建大量无意义的weak引用每个weak引用都需要在weak_table中维护一条记录有内存和CPU开销。不要仅仅因为“可能产生循环引用”就对所有属性都使用weak。只在确实可能形成循环如delegate、block捕获self的地方使用。理解__unsafe_unretained这是一个不安全的弱引用它不会置nil。它的唯一优势是性能开销略低于__weak因为它不参与上述复杂的Side Table管理。但除非你在进行极度底层的性能优化并且能百分百保证对象的生命周期否则不要使用它。在dealloc中慎用weakSelf在对象的dealloc方法内部self的引用计数正在降为0此时再通过__weak引用去访问self结果是未定义的很可能直接得到nil。应避免在dealloc中通过weak引用访问自己或触发需要self的操作。5.4 一个典型的调试案例循环引用与weak假设你有一个视图控制器ViewController它持有一个NetworkManager而NetworkManager的完成block中又捕获了self。// NetworkManager.h typedef void(^CompletionBlock)(BOOL success); property (nonatomic, copy) CompletionBlock completion; // ViewController.m self.networkManager [[NetworkManager alloc] init]; self.networkManager.completion ^(BOOL success) { // 这里直接使用self形成了强引用循环 [self updateUI]; };此时ViewController强引用networkManagernetworkManager强引用completionblockblock强引用ViewController三者都无法释放。解决方案使用__weak打破循环。__weak typeof(self) weakSelf self; self.networkManager.completion ^(BOOL success) { __strong typeof(weakSelf) strongSelf weakSelf; // 在block内部强引用防止self在回调执行中途释放 [strongSelf updateUI]; };原理block捕获的是weakSelf这个弱引用变量它不增加ViewController的引用计数。当ViewController被外部释放时其引用计数能降为0从而触发dealloc。在dealloc中networkManager也会被释放进而释放block循环被打破。在block执行时通过__strong在局部创建一个强引用确保了执行过程中self存活。通过这个案例你可以看到weak原理是如何直接指导我们写出正确代码的。理解Side Table和自动置nil你就明白为什么weakSelf是安全的以及为什么需要在block内部将其转为strongSelf来保证执行期的稳定性。