ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

iOS内存管理:深入解析weak实现原理与内存泄漏排查

iOS内存管理:深入解析weak实现原理与内存泄漏排查 1. 从一次内存泄漏的排查说起几年前我在维护一个大型的iOS项目时遇到了一个棘手的问题某个核心页面在反复进出几十次后内存占用会缓慢但持续地增长最终在低端设备上引发OOMOut of Memory崩溃。使用Instruments的Leaks工具反复扫描却始终没有发现明确的泄漏点。这感觉就像房间里有个漏水的水龙头但你只能看到地面越来越湿却找不到源头。经过漫长的排查我们把目光锁定在了一个看似无害的delegate属性上。这个属性被声明为weak理论上应该能自动置为nil避免循环引用。然而在特定场景下当持有这个delegate的对象比如一个ViewController被快速创建和销毁时weak引用似乎没有及时被清理导致一个短暂存在的对象被意外地“挂住”了。这个现象迫使我深入去探究weak这个我们每天都在用的关键字它到底是怎么工作的它的“自动置nil”是瞬间发生的吗在什么边界条件下它可能会“失灵”这次经历让我明白仅仅知道“weak可以打破循环引用”是远远不够的。作为一名合格的iOS开发者我们必须理解其背后的实现原理才能写出真正健壮、无内存隐患的代码。今天我们就来彻底拆解iOS中weak的实现机制。2. Objective-C Runtime中的weak表核心数据结构揭秘weak的实现并非魔法其核心是一个由Objective-C Runtime维护的全局哈希表系统我们通常称之为weak表weak table。理解这个数据结构是理解weak行为的关键。2.1 SideTables 与 Striped LockingRuntime并不会为每一个对象单独创建一个weak表那样效率太低。相反它采用了一种称为SideTables的机制。你可以把它想象成一个数组数组里存放着许多个SideTable结构体。每个SideTable主要包含三部分weak_table_t: 这才是真正存储weak引用的哈希表。RefcountMap: 用于存储对象的引用计数在优化过的isa指针不存储引用计数时使用。spinlock_t: 一个自旋锁用于保证对当前SideTable操作的线程安全。当一个对象需要被weak引用或者它的weak引用需要被操作时Runtime会通过一个哈希函数根据这个对象的内存地址计算出它应该属于哪个SideTable。这种设计叫做Striped Locking条纹锁。它的好处是当多个线程同时操作不同内存地址的对象时这些对象很可能落在不同的SideTable里从而可以并行操作无需等待同一把锁大大提升了多线程下的性能。注意虽然Striped Locking提升了并发度但它也意味着对同一个对象的weak引用进行操作比如多个线程同时对其赋值仍然是需要锁保护的因为它们最终会落到同一个SideTable上。2.2 weak_table_t 的结构每个SideTable中的weak_table_t是weak机制的核心。它是一个哈希表其键Key是被弱引用对象的原始内存地址objc_object。通过这个地址可以快速定位到该对象的所有weak引用信息。对应的值是一个名为weak_entry_t的结构体它才是真正存放“弱引用们”的地方。一个weak_entry_t主要包含一个weak_referrer_t数组这个数组里存储了所有指向该对象Key的weak变量的**内存地址objc_object。这里有一个至关重要的概念需要厘清weak表记录的不是weak变量所指向的对象值而是这些weak变量自身在内存中的位置即指针的地址。为什么这么设计因为当被引用的对象销毁时Runtime需要找到所有指向它的weak变量并把它们的值即存储的内容设为nil。如果只记录对象值就无法定位到需要修改的变量在哪里。记录变量的内存地址才能直接进行写入操作。我们可以用一个简单的表格来梳理这个关系数据结构存储内容Key存储内容Value作用SideTables数组索引由对象地址哈希得出SideTable 结构体全局容器实现Striped LockingSideTable-包含weak_table_t,RefcountMap,spinlock_t管理一组对象的weak引用和引用计数weak_table_t被弱引用对象的地址 (objc_object*)weak_entry_t 结构体哈希表建立对象到其所有weak引用的映射weak_entry_t-weak_referrer_t 数组存储weak变量的地址存储指向某个特定对象的所有weak指针的位置3. weak的生命周期从创建到自动置nil的全过程了解了数据结构我们来看weak变量从生到死的完整过程。这主要分为三个关键阶段初始化、对象销毁时的清理、以及访问时的读取。3.1 初始化objc_initWeak当你写下__weak id weakObj strongObj;这行代码时编译器会将其转换为对objc_initWeak函数的调用。这个函数主要做四件事获取对应的SideTable和锁根据strongObj的内存地址找到对应的SideTable并上锁。在weak表中创建或更新entry以strongObj为key在weak_table_t中查找或创建对应的weak_entry_t。注册weak变量的地址将weakObj这个变量自身的内存地址weakObj添加到上一步找到或创建的weak_entry_t的weak_referrer_t数组中。这样weak表就知道有一个叫weakObj的变量正在弱引用strongObj。释放锁并返回完成注册后释放自旋锁函数返回。此时weakObj变量被正确赋值。3.2 对象销毁dealloc 与 weak_clear_no_lock这是weak机制最精妙的部分。当一个对象的引用计数变为0系统会调用它的dealloc方法。在dealloc的底层实现中会触发一个关键函数weak_clear_no_lock。这个函数的逻辑非常清晰根据即将销毁的对象地址从weak_table_t中查找对应的weak_entry_t。如果找到了就遍历这个weak_entry_t中存储的所有weak_referrer_t数组。对于数组中的每一个元素即每一个weak变量的内存地址直接向该地址写入nil值。这就是weak变量自动置nil的瞬间。从weak_table_t中删除这个已经无用的weak_entry_t。最后继续执行对象内存的释放操作。这个过程是原子性的并且发生在对象内存被真正回收free之前。这确保了安全性当你访问一个weak变量时它要么指向一个有效的对象要么一定是nil绝不会指向一块已被释放的僵尸内存Zombie Memory从而避免了野指针崩溃。3.3 访问objc_loadWeakRetained当你使用一个weak变量时例如if (myWeakObj) { ... }或[myWeakObj doSomething]编译器会插入对objc_loadWeakRetained的调用。这个函数的核心作用是在读取weak变量指向的对象的同时将其retain住。为什么要这样做考虑以下代码片段__weak MyClass *weakObj strongObj; if (weakObj) { // 步骤1: objc_loadWeakRetained 被调用获取对象并retain [weakObj doSomething]; // 步骤2: 使用对象 // 步骤3: 作用域结束编译器自动为步骤1中retain的对象插入release }在多线程环境下可能在步骤1判断weakObj不为nil之后步骤2执行之前另一个线程恰好释放了这个对象。如果没有objc_loadWeakRetained的retain操作步骤2就会向一个已释放的对象发送消息导致崩溃。objc_loadWeakRetained通过“读取-保留”这个原子操作保证了在后续使用这个对象的代码块内对象的生命周期被延长是安全的。4. 深入探究weak实现的边界条件与性能考量理解了基本流程我们还需要关注一些边界情况和实现细节这些往往是实践中坑点所在。4.1 weak_entry_t 的优化存储weak_entry_t内部并不总是使用一个动态数组来存储weak_referrer_t。为了优化只有一个或少数几个weak引用的常见情况它采用了内联inline存储。weak_entry_t结构内部有一个固定大小的数组通常大小为4。当weak引用数量不超过这个内联容量时就直接存储在这个固定数组里避免了动态内存分配的开销。只有当weak引用数量超过内联容量时才会分配一个动态增长的哈希表来存储。这种优化对于大量只有1-2个delegate或block引用的对象来说性能提升显著。4.2 自动置nil的时机与“僵尸对象”如前所述weak变量是在对象dealloc执行过程中被置nil的。这意味着在对象的dealloc方法内部如果你通过__weak变量访问self它可能还没有被置nil。但这是一个极其危险的行为绝对禁止。因为此时对象正在销毁其内部状态可能已经不可用。另外启用Zombie Objects僵尸对象调试功能时对象的内存并不会被立即回收而是被一个特殊的“僵尸”占位符替代。此时weak变量会被置nil吗会的。Runtime对僵尸对象做了特殊处理在对象变成僵尸的过程中同样会调用weak_clear_no_lock来清理weak引用。所以即使开了Zombiesweak的行为也是一致的。4.3 性能开销与使用建议使用weak是有开销的空间开销每个被weak引用的对象都会在weak表中至少占用一个entry。entry本身和内联数组有一定内存占用。时间开销初始化weak变量、对象销毁时清理weak引用、访问weak变量涉及retain/release都有额外的CPU开销包括哈希计算和锁操作。因此给出几条实践建议不要滥用weak只在确实需要打破循环引用的地方使用例如delegate、block中捕获self等场景。对于那些明知生命周期更短的对象使用strong引用即可。警惕大规模weak集合例如用一个NSMapTableKeyType, __weak ValueType来缓存大量对象。当值对象被释放时清理这些weak条目会成为性能瓶颈。需要定期清理如通过NSMapTable的weakObjects枚举器或寻找替代方案。理解访问成本频繁访问weak属性比访问strong属性代价更高。在性能敏感的循环中可以考虑将其赋值给一个局部strong变量来使用。5. 从MRC到ARCweak的演进与对比在手动引用计数MRC时代并没有__weak关键字。我们使用__unsafe_unretained来达到类似的目的。__unsafe_unretained修饰的指针同样不增加引用计数但关键区别在于当目标对象被释放时它不会自动置nil而是变成一个野指针。访问野指针会导致程序崩溃。因此使用__unsafe_unretained需要开发者自己严格保证对象生命周期的同步风险极高。ARC引入__weak正是为了解决__unsafe_unretained的安全性难题。Runtime通过全局weak表这套机制为开发者提供了自动生命期管理将内存安全的负担从开发者转移到了编译器和运行时系统。这是ARC带来的最重要的安全特性之一。那么__unsafe_unretained还有用武之地吗在极少数情况下例如维护一些生命周期非常明确、且需要避免weak额外开销的数据结构时或者与某些不支持weak的纯C/C代码交互时可能会用到它。但99%的日常开发中你应该始终使用__weak。6. 在Swift中的weakSwift语言中的weak关键字其语义与Objective-C的__weak完全一致用于打破引用循环。在底层当Swift代码编译并与Objective-C Runtime交互时例如引用一个继承自NSObject的类实例使用的就是同一套weak表机制。对于纯Swift类不继承自NSObjectSwift Runtime有一套自己的、更高效的引用计数和生命周期管理机制。其weak引用的实现细节并未完全公开但原理是相通的需要通过额外的数据结构来追踪哪些弱引用指向了某个对象并在对象销毁时进行清理。Swift的ARC整体上比Objective-C的ARC进行了更多优化但weak的基本成本和保证是类似的。在Swift中使用weak时它必须是可选类型weak var delegate: SomeDelegate?因为最终它可能为nil。访问时通常使用可选绑定if let strongDelegate delegate { ... }这本质上就对应了Objective-C中objc_loadWeakRetained的“读取-保留”安全模式。7. 调试与排查weak相关问题的实战技巧回到我开头提到的那个疑似weak未及时置nil的问题。我们是如何最终确认并解决的呢使用调试内存图Debug Memory Graph这是Xcode中最强大的内存调试工具。在应用运行时点击Debug导航栏的“内存图”按钮可以生成当前所有内存对象的活体快照。你可以清晰地看到对象之间的引用关系并且**weak引用会以虚线箭头表示**。通过这个工具我们最终发现在那个反复创建的ViewController的存活期间有一个后台线程持有的全局缓存非weak间接地引用了它而这个缓存清理有延迟导致ViewController的dealloc被推迟从而weak引用清理也随之推迟。问题根本不在weak本身而在于一个意外的strong引用循环。在dealloc中打断点并观察weak变量在怀疑的对象的dealloc方法中设置符号断点。当断点触发时在控制台使用po命令打印相关的weak变量。如果此时它已经变成nil说明weak清理机制工作正常如果还是一个有效地址则说明对象因为某些原因如后台线程的retain还没有真正开始执行dealloc。审视Side Effects确保在dealloc中不要执行可能意外导致对象被重新retain的操作例如将self赋值给某个全局变量即使是weak的也可能触发objc_loadWeakRetained或者向通知中心发送通知某些监听者可能会retain这个对象。那次排查给我的核心教训是weak机制本身是可靠且强大的但内存问题的表象往往具有欺骗性。当出现疑似内存泄漏而Leaks工具又无能为力时不要轻易怀疑语言的基础设施而应该系统地使用Debug Memory Graph等工具沿着引用链逆向排查找到那个意料之外的strong引用源。理解weak的原理正是为了在排查时能准确判断问题究竟发生在机制之内还是在机制之外的业务逻辑之中。
返回列表