这是iOS底层分析系列里内存管理主题的整理。昨天A同学拿着一张崩溃日志找我日志顶部是EXC_BAD_ACCESS他说他查了一下午数组越界什么都没查到。我帮他把僵尸对象检测打开几分钟之内就定位到已经被释放的旧对象上——问题根本不在数组而在对象生命周期的某次交接出了纰漏。这类问题在业务代码里非常常见但真正的根源往往埋在系统底层引用计数、弱引用、自动释放池。这篇文章不打算教你怎么背strong和weak的使用规则而是想把这层皮拨开看看iOS内存管理到底怎么记账、怎么对账、怎么在对象死亡之后清理现场。1. 先从一次线上崩溃说起内存管理到底在管什么1.1 你写下的每行代码最后都在决定“指针归属”很多开发者在写Objective-C的时候其实没意识到一个事实我们眼中到处移动的对象在系统底层只是堆内存里的一块结构体而所有关于“这个对象还活着吗”的判断都依赖一个数值——引用计数。引用计数这个概念并不复杂。你创建一个对象时它的引用计数是1别人想要长期持有它就调用retain把计数加1用完不再需要就调用release把计数减1。当计数归零时系统认为这个对象已经没有任何人需要它于是执行dealloc把堆内存释放掉。ARC帮我们省掉的是手动敲retain和release的体力活但底层仍然是这套规则。真正容易出问题的地方在于业务代码里到处都是“临时变量”“参数传递”“block捕获”“自动释放池返回”这些场景。一眼看上去一个对象只是被赋值了一下但底层可能发生了不止一次retain也可能在某个隐含的池子里被延迟释放。如果不清楚系统在哪里偷偷改了引用计数就会出现两类经典问题一是对象明明还在用却被提前释放导致野指针崩溃二是对象明明已经不需要了却因为某个引用环始终释放不掉导致内存持续上涨。所以“内存管理”管的是什么在我看来管的不是那些变量名怎么起、属性是strong还是weak而是指针背后的“所有权”。谁最后持有这个对象谁就应该在合适的时机释放它谁是临时看一看谁就别去增加计数。所有权一旦混乱任何工具都救不了你。1.2 iOS进程里的三块地栈、堆和全局区要理解对象生命周期得先知道对象在进程里的存放位置。iOS应用跑起来之后内存地址空间大致分成几块我平时讲给团队就按三块来说区域存放内容特点栈区局部变量、函数调用的上下文由系统自动分配和回收连续内存速度快堆区Objective-C对象、动态分配的block、malloc出来的内存手动分配释放不连续速度相对慢全局区/常量区全局变量、静态变量、字符串常量同作用域内只有一份进程启动时分配栈上的局部变量比如方法里的临时对象指针作用域一结束就跟着出栈了。但对象本身在堆上它不会因为指针出栈就自动释放它只认引用计数。很多人的误解就在这里以为局部变量离开大括号就会被“销毁”其实被销毁的只是栈上的那4个字节指针堆里的对象还活着除非有人执行了release把计数归零。同样的道理也能解释为什么block从栈上复制到堆上之后捕获的外部对象会被额外retain一次。因为block一旦离开栈区它的生命周期变长了底层必须确保它捕获的对象也能跟着活得足够久。这一块是理解后面所有机制的地基。接下来我们深入一个最常见的问题引用计数到底存在哪里。2. 引用计数的底层账本isa优化与SideTable散列表2.1 为什么对象头里没有固定字段直接存引用计数你可能会想既然每个对象都需要引用计数那干脆在对象结构体里留一个Int字段专门存它不就行了最早期确实有类似思路但代价很大。每个对象都要多出一块固定大小的存储哪怕这个对象只被一个变量引用也要白白占用好几个字节。对于系统里动辄成千上万的小对象来说这完全是浪费。所以底层设计走了一条更聪明的路引用计数不总是放在对象头里而是按需存放。大多数对象平时只需要一个很小的计数如果对象的结构体本身能塞得下就塞进去塞不下了再用一个专门的全局哈希表来存。这张表在源码里叫SideTable准确地说是一组SideTable组成的散列表。这套设计的好处是占用小、速度快。坏处也很明显因为引用计数存放位置不固定每次操作前都要先判断“它现在存哪里”再决定走哪条路径。所以retain和release不是一个简单的数值加减内部可能涉及位运算、锁、哈希查找只是我们感知不到而已。2.2 isa不再是普通指针NONPOINTER_ISA的位域玩法要理解引用计数怎么塞进对象头先要认识对象结构体里的第一个成员isa指针。在很老的版本里isa就是一个普通的Class指针指向这个对象的类对象。但后来系统引入了NONPOINTER_ISA也就是“非纯指针isa”让isa不再单纯是一个地址而是一个装满位域的容器。在64位环境下isa底层的联合体里有一大堆位段。我挑和内存管理相关的几个说明一下其中有一个extra_rc区域专门用来存放“引用计数减一”之后的值还有一个weakly_referenced标记表示这个对象是否被weak指针引用过还有一个deallocating标记表示对象是否正在释放另外就是shiftcls它存的是类对象地址这才是isa真正的“身份信息”。为什么extra_rc要存“引用计数减一”而不是直接存引用计数因为对象创建出来时默认引用计数是1这时候extra_rc设为0就够了连一次赋值都可以省。等你多retain一次extra_rc变成1就表示引用计数是2。这套“减一存储”的套路本质是从源头节省位域空间。当extra_rc一直累加或者某些特殊对象不能用NONPOINTER_ISA时位域就不够用了。这时候系统会把一部分计数转移到SideTable里同时把isa中的has_sidetable_rc属性置为1表示“别在这里找计数了去外面的哈希表找”。这也是为什么同一时刻一个对象的引用计数可能被拆成两块isa里的extra_rc一部分SideTable里的一部分。2.3 SideTable散列表全局哈希如何分担压力SideTable并不是一张表而是很多张表。系统根据对象地址做哈希把不同的对象分散到不同的SideTable中每个SideTable管理一批对象。这样做的目的很明确避免所有对象操作引用计数时都去抢同一把全局锁。每个SideTable内部大体上有三个部分一把锁、一张引用计数表、一张weak表。引用计数表本质上是一个以对象地址为key、以引用计数为value的哈希mapweak表则记录这个对象到底被哪些weak指针引用着。两种数据放在同一个SideTable里是因为处理一个对象dealloc时必须同时修改它的引用计数和weak指针放在同一把锁下才能保证一致性。当你调用retain时底层会先判断对象是不是Tagged Pointer再去看看能不能直接用isa里的extra_rc完成计数累加。如果确认需要走SideTable就通过对象地址哈希找到对应的SideTable加锁更新引用计数map。release时相反减到0之后还要再清掉weak表里的记录最后才执行真正的内存释放。这一套“isa内联优先SideTable兜底”的设计让绝大多数小对象的retain/release连锁都不用碰。只有计数大到一定程度或者对象本身不能使用NONPOINTER_ISA时才会走进耗时的哈希路径。这也是iOS内存管理在性能上比较精巧的地方。3. 不要小看Tagged Pointer它省掉的是一整条malloc路径3.1 为什么一个NSNumber也能不占堆内存平时我们写NSNumber、NSDate、短的NSString时很少有人关心它们的存储方式以为它们和普通对象一样走堆分配。但在底层系统对这类小对象做了特殊优化叫Tagged Pointer。一个对象如果是通过正常方式创建需要先在堆上分配内存把isa指针写进对象头再初始化各个字段。这个过程涉及malloc速度不慢但确实有开销。而Tagged Pointer的思路很直接既然你要存的数据可能就几个字节为什么不直接塞进指针本身指针有64位其中一部分用作标识位来标记“这是一个Tagged Pointer不是一个真实地址”剩下的位直接存值。所以某些NSNumber的“对象指针”它指向的地址并不是堆内存地址而是这个对象的值本身。你拿这个指针去访问isa访问不到因为指针里压根没有isa它靠标识位判断自己的身份。你用去比较两个NSNumber如果值相同Tagged Pointer的内容也可能相同所以结果可能为true这跟普通对象的指针比较语义完全不同。3.2 值内联进指针之后的真实成本Tagged Pointer省下来的不只是几个字节而是整整一条malloc路径。一个普通对象从创建到销毁涉及堆内存分配、isa字段初始化、引用计数的增减、内存占用统计、release时dealloc销毁、最后还有free操作。而Tagged Pointer对象没有这些步骤它没有retain概念也不会走dealloc。系统看到这是一个带Tag的指针时retain和release直接是空操作因为它根本不需要管理内存。性能上的差异在小对象高频创建的场景里非常明显。比如在一个循环里连续创建几万次NSDictionary的key或者NSNumber的value普通对象就得不断申请和释放堆内存容易造成内存碎片和堆锁竞争换作Tagged Pointer几乎没有分配和释放动作速度天然快一大截。我在自己做过的一个高频打点模块里测过大量使用小数字和小字符串时换掉一些不必要的对象包装方式后耗时有明显下降。核心原因并不是代码写得多优秀而是让系统走了Tagged Pointer这条捷径绕开了malloc。3.3 Tagged Pointer的边界与识别不过Tagged Pointer不是万能的。它只能处理“完整数据能塞进指针剩余位”的小对象。一旦数字变大字符串变长装不下了系统还是会老老实实创建一个普通对象放到堆上。也就是说同一个NSNumber在值比较小时可能是Tagged Pointer值大了就变成普通对象。这在逻辑上是个隐形的分界点也提醒我们不要依赖任何“对象是否等值”这类底层假设。还有一个常见的坑因为Tagged Pointer不是真地址很多人调试时会发现指针地址非常小或者所有相同值的对象打印出的地址都一样误以为自己遇到了野指针或者内存泄漏。其实这只是Tagged Pointer的表现。另外在写需要遍历对象内部结构的工具时必须先通过isTaggedPointer判断一下否则容易按普通对象去解析结果读到一堆无意义的数据。对我们平时写业务代码的人来说知道它存在就够了。真正需要关注的是不要迷信“对象一定是指针指向堆内存”这种说法。底层优化永远在悄悄改变一个对象的表现形式。4. weak指针底层走过的路4.1 从一句__weak到SideTable里的一条记录weak是iOS开发里最常用的修饰符之一很多人只知道“weak不会让引用计数加一对象释放后weak指针会自动变成nil”。但你有没有想过系统是怎么做到“对象释放后自动把指针置为nil”的这背后就是SideTable里的weak表在起作用。当我们写下__weak id weakObj obj时编译器并不会简单地做一次赋值。它会生成类似objc_initWeak(weakObj, obj)的调用然后在底层走storeWeak流程最终调用weak_register_no_lock把weakObj这个地址登记到对象所属SideTable的weak表里。换句话说weak不是“不持有”而是“在别处记了一笔账”。登记完成后底层还会检查对象的isa里是否已经标记了weakly_referenced。如果之前没有weak指针引用过这个对象就把标记位置为1。这个标志位很重要dealloc时会根据它决定要不要去清理weak表。如果对象从来没有被weak引用过那dealloc时连进入weak表检查的步骤都可以跳过算是一种小小的性能优化。4.2 对象dealloc时系统怎么精确找到所有weak指针对象引用计数归零后会走dealloc。dealloc过程中有一个关键步骤根据对象地址去SideTable里找到对应的weak entry然后遍历里面记录的所有weak指针地址把每一个指针都置为nil最后从weak表里移除这条记录。这里有个很多人会误解的点weak表里存的并不是“对象-指针值”这么简单因为对象释放后原内存可能马上被别人复用如果去读对象地址来对比很容易出错。系统底层对这块处理得非常谨慎它维护的是对象地址和weak指针地址之间的对应关系。dealloc时不是去读那个对象的内存而只是按照登记名单把那些指针位置上的值统一清成nil。所以weak能自动置nil的本质是系统在“对象死亡”时做了一次主动的擦除操作。这个过程不是魔法而是一张哈希表加上一次遍历。4.3 weak的安全是有代价的对比一下weak和assign就能明白weak的安全感来自一套完整的登记和清理机制初始化weak时要在全局哈希表加锁、插入记录对象dealloc时要再次加锁、遍历、清空。每个环节都有哈希查找自然比直接赋值指针要慢。既然weak这么安全为什么还有人用assign去修饰delegate除了历史遗留习惯可能是担心weak在低版本系统上的性能问题。但从今天的系统版本看weak的代价已经做得非常低只要不是在一个高频热路径上创建和销毁大量weak引用它带来的性能损耗基本可以忽略。真正需要注意的是weak的语义限制weak只对“支持弱引用的对象”生效。有些对象因为类对象层面的特殊处理并不支持weak一旦你用weak去修饰运行时会直接崩溃。遇到这种情况通常可以考虑改成unsafe_unretained但要自己承担野指针风险。我个人的建议很简单能用weak就用weakassign留给基础数据类型尤其是在多线程场景下assign悬垂之后崩溃的概率远大于weak。5. autoreleasepool主线程的自动释放池到底怎么运转5.1 自动释放池不是ARC时代的偷懒工具很多项目里的autoreleasepool都是被人随手加的好像包一层成本更低。其实自动释放池比ARC历史古老得多。在没有ARC的年代开发者得手动管理引用计数但有些场景不好明确“谁释放”比如某个方法返回一个对象调用方不知道这个对象是新的还是旧的如果方法内部retain了再返回调用方不能随便release如果方法内部直接返回一个新对象调用方用完不release就又泄漏了。为了统一规则系统引入了“autorelease”这个概念对象不立刻释放而是登记到当前的自动释放池里等池子被排空时统一对池里的对象发送一次release。ARC接手后很多编译器自动生成的临时对象也依赖自动释放池来延迟释放。所以它不是偷懒工具而是内存管理规则里一个必要的缓冲层。5.2 AutoreleasePoolPage与哨兵对象自动释放池在底层并不是一个简单的数组而是一个个AutoreleasePoolPage组成的双向链表结构。每个page占用一个虚拟内存页的大小内部用一个next指针维护栈顶位置。当你往池子里加一个autorelease对象时对象地址会被压到当前page的栈中当栈快要满时系统会新建一个page并通过parent和child指针串起来。这里有一个非常关键的概念叫哨兵对象也就是一个占位标记。每次执行autoreleasepool时底层会做一个push操作往当前池子栈顶压入一个哨兵当执行到pool结束时做pop操作一直pop到哨兵所在位置为止这个过程中遇到的所有autorelease对象都会收到release。因为有了哨兵嵌套的自动释放池才能准确地知道“该停在哪一层”不会把外层池子里的对象也误释放掉。5.3 Runloop里的push和pop一次循环一次换水主线程的自动释放池并不是你手动创建的而是系统在Runloop里自动维护。主线程Runloop被唤醒后会开始一次事件处理处理之前系统会执行一次push处理完事件、准备进入休眠时执行一次pop然后再push一个新的池子。说白了是一次Runloop循环换一回池子。这意味着你在一个UIKit事件处理里创建的大部分临时对象并不是立刻释放的而是要等到这轮Runloop准备休眠前才会被release。正常情况下这个延迟影响不大但如果你在一个死循环里不断创建临时对象而这些对象都依赖自动释放池释放那么池子会一直变大内存峰值也会持续上涨。这时你就在循环体内部主动加一个autoreleasepool让池子频繁排空。5.4 循环里创建大临时对象为什么内存会飙上去我在某个图片处理模块里踩过一次很典型的坑循环处理几百张图片时每张图片生成的中间数据都是临时对象。最初所有代码都堆在一个大循环里没有局部自动释放池跑的时候内存一路狂涨最后由系统触发了内存警告。加上autoreleasepool包住循环体之后问题立刻缓解。原因是那些临时对象不再等整轮Runloop结束而是在每次循环结束时就跟着pool的pop被release掉。这个改动不涉及任何算法逻辑纯粹是让释放时机提前了。所以在写大批量循环、解析大文件、处理多张图片这类代码时我建议把“对象会在什么时候释放”当成一个问题来考虑。不要以为ARC会自动处理一切autoreleasepool压根不是ARC的产物它是你需要手动理解并配合使用的一层机制。6. ARC的插桩细节编译器替你写了哪些内存管理代码6.1 一段MRC代码在ARC下会变成什么样先看一段典型的MRC代码Person *p [[Person alloc] init]; [p doSomething]; [p release];在ARC环境下你写代码时不会手写release但编译器并不是忽略内存管理而是自动帮你插桩。局部强引用对象在超出作用域时编译器会自动插入releasestrong属性被重新赋值时编译器会先把新值retain再对旧值release也就是底层常见的objc_storeStrong逻辑。ARC并不是一种运行时技术而是一种编译期技术。它分析代码里的对象所有权流向在合适的位置插入retain、release或autorelease调用。所以ARC代码还是会走和MRC完全一样的底层机制只是不需要你手动操心。明白了这点你就能理解为什么ARC还是会循环引用、还是会野指针——它只是在帮你补上常规管理代码它不可能看懂你的业务图。6.2 __strong、__weak、__unsafe_unretained的编译差异ARC给我们提供了几种所有权修饰符它们编译后的代码完全不同。修饰符底层行为适用场景__strong默认行为赋值时retain销毁时release绝大多数对象属性、局部变量__weak注册到SideTable weak表对象释放自动置nildelegate、block捕获外部对象__unsafe_unretained不参与ARC管理不做任何retain/release需兼容旧代码、或性能极限场景很多人以为__weak就是“不retain”但从前面的底层分析可以知道它比“不retain”复杂得多它要在全局weak表里登记清理。而__unsafe_unretained才是字面意义上“什么都不做”。由于它什么都不做对象释放后指针仍然指向那块已经被回收的地址再访问就是野指针。我见过把delegate改成unsafe_unretained来解决某个崩溃的案例最后崩溃没解决反而换了一个更难查的crash。原因很简单unsafe_unretained只是掩盖症状没有解决生命周期问题。6.3 编译器插桩解决不了的两个死结第一是循环引用。A持有BB又持有A编译器在插入retain和release时它只知道每个变量各自需要持有对方但它不懂“你俩互相持有会让计数永远到不了0”。所以循环引用没法靠ARC消灭只能通过修改引用关系来打破。常见的解法是把其中一方改成weak例如block里临时弱引用self。第二是block捕获。block内部如果访问了self而block对象被self持有就会形成self持有block、block又强引用self的环。编译器会对block捕获的__strong对象执行retain所以这不是block“偷偷做了坏事”而是它为了活得更久必须持有捕获对象。解决策略也简单在block外面定义一个__weak的弱引用传入block内部打破环。如果你理解了插桩原理自然能明白为什么weakSelf不能随便放在一行里又赋值给strongSelf。因为如果block内部取了strongSelf就会把弱引用转成强引用从而在block执行期间确保对象存活。这个模式不是玄学它是在强持有和弱持有之间做了一次明确的临时加保。7. 处理内存问题时的三张底牌7.1 野指针为什么难查释放后的内存马上被复用野指针是内存管理里最让人头疼的一种崩溃它难查不是因为崩溃信息复杂而是因为崩溃时指针指向的内容可能已经被完全改写了。对象释放后堆上那块内存会被系统回收紧接着可能被另一个对象申请走里面填了新内容。这时候你手里那个旧指针访问的已经不是原对象而是一堆随机数据。如果运气好访问到的内存是未使用区你会立刻收到BAD_ACCESS如果运气不好访问到的是一块恰好长得像对象的数据程序不会立刻崩但可能产生极其诡异的行为。这也是为什么普通的日志和打印在这种场景下作用有限。调试野指针有一个非常实用的手段叫Zombie Objects。开启之后对象在dealloc时不会真正被释放而是变成僵尸对象一旦有人访问已经“死亡”的对象调试器会直接告诉你这个对象是什么类、什么时候释放的。这项能力只适合调试绝不能发布到线上因为僵尸对象会让所有内存只增不减。7.2 泄漏不只是忘了release还有看不见的引用环内存泄漏最直接的形态是CF类型对象忘了释放例如CGMutablePath、CGColor这些用Create/Copy命名规则的对象它们不受ARC管理需要手动释放。这类泄漏比较好查遵守规则即可。真正难缠的是引用环对象A持有对象BB又持有A两边都觉得自己还需要活着结果谁都释放不了。我在一个即时通讯项目里处理过一例典型泄漏聊天页面持有消息管理器消息管理器里的回调block又捕获了聊天页面。页面关闭后页面没有被释放Leaks工具显示几十个聊天页面累积在内存里每个都占据不小的图片缓存。最后排查到是block循环引用把管理器里的强引用改成weak之后内存曲线立刻回归正常。由此也可以得出一个经验怀疑泄漏时先别急着找大块内存先去看对象的持有关系是不是成环了。很多时候“大块内存”只是表象真正的根因是一张看不见的引用网。7.3 我的排查顺序和几个实用习惯结合前面那些底层机制我在实际排查内存问题时会按这个顺序走先确定是崩溃还是持续增长。崩溃优先怀疑野指针开僵尸对象检测看崩溃对象的类持续增长优先怀疑泄漏借助开发工具的Leaks模板跑一遍。拿到可疑对象后不要只看它自身要看对象的引用关系。开发工具里Memory Graph能非常直观地画出对象持有图看有没有闭环。排除完循环引用后再回头检查自动释放池相关的代码。大批量循环里有没有临时对象堆积网络响应解析里是不是一次性创建了大量Data如果是接踵而至的内存警告先做分级处理清缓存、释放未在界面上显示的图片、取消不必要的异步任务。最后说一个我自己的小习惯写完任何会长期存活的对象图之后我都会刻意在代码里做一次“谁持有谁”的推演把每一根强引用线画出来。这比事后用工具排查省太多时间。内存管理说到底不是玄学它是一本可以精确到位的账本只要你能看懂底层是怎么记账的所有崩溃和泄漏都会变得有迹可循。