这是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的哈希map;weak表则记录这个对象到底被哪些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就用weak,assign留给基础数据类型,尤其是在多线程场景下,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,但编译器并不是忽略内存管理,而是自动帮你插桩。局部强引用对象在超出作用域时,编译器会自动插入release;strong属性被重新赋值时,编译器会先把新值retain,再对旧值release,也就是底层常见的objc_storeStrong逻辑。
ARC并不是一种运行时技术,而是一种编译期技术。它分析代码里的对象所有权流向,在合适的位置插入retain、release或autorelease调用。所以ARC代码还是会走和MRC完全一样的底层机制,只是不需要你手动操心。明白了这点,你就能理解为什么ARC还是会循环引用、还是会野指针——它只是在帮你补上常规管理代码,它不可能看懂你的业务图。
6.2 __strong、__weak、__unsafe_unretained的编译差异
ARC给我们提供了几种所有权修饰符,它们编译后的代码完全不同。
| 修饰符 | 底层行为 | 适用场景 |
|---|---|---|
| __strong | 默认行为,赋值时retain,销毁时release | 绝大多数对象属性、局部变量 |
| __weak | 注册到SideTable weak表,对象释放自动置nil | delegate、block捕获外部对象 |
| __unsafe_unretained | 不参与ARC管理,不做任何retain/release | 需兼容旧代码、或性能极限场景 |
很多人以为__weak就是“不retain”,但从前面的底层分析可以知道,它比“不retain”复杂得多,它要在全局weak表里登记清理。而__unsafe_unretained才是字面意义上“什么都不做”。由于它什么都不做,对象释放后指针仍然指向那块已经被回收的地址,再访问就是野指针。
我见过把delegate改成unsafe_unretained来解决某个崩溃的案例,最后崩溃没解决,反而换了一个更难查的crash。原因很简单:unsafe_unretained只是掩盖症状,没有解决生命周期问题。
6.3 编译器插桩解决不了的两个死结
第一是循环引用。A持有B,B又持有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持有对象B,B又持有A,两边都觉得自己还需要活着,结果谁都释放不了。
我在一个即时通讯项目里处理过一例典型泄漏:聊天页面持有消息管理器,消息管理器里的回调block又捕获了聊天页面。页面关闭后,页面没有被释放,Leaks工具显示几十个聊天页面累积在内存里,每个都占据不小的图片缓存。最后排查到是block循环引用,把管理器里的强引用改成weak之后,内存曲线立刻回归正常。
由此也可以得出一个经验:怀疑泄漏时,先别急着找大块内存,先去看对象的持有关系是不是成环了。很多时候“大块内存”只是表象,真正的根因是一张看不见的引用网。
7.3 我的排查顺序和几个实用习惯
结合前面那些底层机制,我在实际排查内存问题时会按这个顺序走:
- 先确定是崩溃还是持续增长。崩溃优先怀疑野指针,开僵尸对象检测,看崩溃对象的类;持续增长优先怀疑泄漏,借助开发工具的Leaks模板跑一遍。
- 拿到可疑对象后,不要只看它自身,要看对象的引用关系。开发工具里Memory Graph能非常直观地画出对象持有图,看有没有闭环。
- 排除完循环引用后,再回头检查自动释放池相关的代码。大批量循环里有没有临时对象堆积?网络响应解析里是不是一次性创建了大量Data?
- 如果是接踵而至的内存警告,先做分级处理:清缓存、释放未在界面上显示的图片、取消不必要的异步任务。
最后说一个我自己的小习惯:写完任何会长期存活的对象图之后,我都会刻意在代码里做一次“谁持有谁”的推演,把每一根强引用线画出来。这比事后用工具排查省太多时间。内存管理说到底不是玄学,它是一本可以精确到位的账本,只要你能看懂底层是怎么记账的,所有崩溃和泄漏都会变得有迹可循。