
1. 从 ARC 说起内存管理到底在管理什么做 iOS 开发这些年每年面试我都会问候选人内存管理相关的问题。说实话能把这个问题讲透的人不多大部分人的认知停留在“ARC 会自动释放内存不用我操心”这个层面。但真到线上出问题的时候——内存暴涨、崩溃、卡顿往往就是这些看似“不用操心”的地方在埋雷。iOS 的内存管理本质上管理的是对象的生命周期。OC 里每个对象都有一块堆内存谁创建、谁持有、谁释放必须有一套清晰的规则。在没有 ARC 的年代开发者要手动写 retain、release、autorelease一不小心就过度释放或者内存泄漏。ARC 出现之后编译器替我们插入了这些内存管理代码但理解底层的引用计数机制依然至关重要。先看一段很简单的代码NSObject *obj [[NSObject alloc] init]; NSLog(%ld, CFGetRetainCount((__bridge CFTypeRef)obj)); // 输出 1这就是引用计数的起点。一个对象被创建出来引用计数为 1有人持有它就加 1有人释放它就减 1减到 0 的时候对象被销毁内存被回收。ARC 做的事情就是在编译期分析代码自动在合适的位置插入 retain/release 调用让对象在没人用的时候恰好被释放。这篇文章把 iOS 内存管理的核心考点从头到尾梳理一遍包括 ARC 的原理、weak 的底层实现、循环引用怎么破、autoreleasepool 什么时候有用、以及线上内存问题怎么排查。不管是准备面试还是排查线上问题都能用得上。2. 引用计数的底层实现从 retain/release 到 SideTable2.1 retain、release、dealloc 到底做了什么ARC 时代虽然不需要手动写 retain/release但理解它们的底层实现才能理解 weak、copy、autorelease 这些修饰符的本质。对象的内存布局里isa 指针指向类对象而引用计数存在哪里早期的实现是存在 isa 指针的某些位里后来因为 64 位系统一个 isa 要存的东西太多类指针、是否弱引用、是否有关联对象、是否正在释放等苹果引入了 SideTable 来存引用计数和弱引用信息。当我们对一个对象调用 retain 时底层大致做了这样的事取出对象的 isa 指针检查引用计数是否还能存在 isa 的 extra_rc 里如果存得下直接对 extra_rc 加 1如果存不下就去 SideTable 里找对应的 RefCountMap把引用计数加 1release 则是反向操作减到 0 的时候调用 dealloc然后释放 isa 里关联的各类信息最后把内存归还给系统。这里有个细节值得注意[obj release]减到 0 时系统会调用 dealloc在 dealloc 里释放 ivar、移除关联对象、清理 weak 表里指向该对象的指针把它们置为 nil最后调用free归还内存。dealloc 是在对象引用计数为 0 时自动触发的不要手动调用否则会导致各种诡异的问题。2.2 weak 为什么能自动置 nilweak 修饰符是面试中必问的点。它的核心特征是不增加引用计数并且在对象销毁时自动把指针置为 nil防止野指针。底层实现大致是这样的对象被 weak 指针指向时系统会在 SideTable 的 weak_table 里注册一条记录存的是指向该对象的 weak 指针地址注意是地址不是指针指向的对象当对象的引用计数归零dealloc 执行时会去 weak_table 里找到所有指向该对象的 weak 指针把这些指针的值都置为 nil然后从 weak_table 中移除对应的记录这就保证了哪怕对象已经销毁weak 指针访问时得到的是 nil不会崩溃。你可能会问unsafe_unretained为什么不安全因为它不注册 weak 表对象销毁后指针还指向那块已经释放的内存再访问就是野指针Crash 那是大概率事件。2.3 strong、copy、assign 到底有什么区别面试中另一个高频考点是属性修饰符。直接上表格对比修饰符引用计数变化底层操作适用场景strong1retain对象类型默认持有weak不变注册 weak 表避免循环引用copy1新对象copy 方法NSString、Block 等assign不变无基本数据类型、C 结构体unsafe_unretained不变无需要兼容老系统的场景这里最容易踩坑的是 NSString 的 copy 和 strong。如果把 NSMutableString 赋值给一个 strong 修饰的 NSString 属性外部修改这个 NSMutableString属性也会跟着变。用 copy 则会把内容拷贝一份新生成一个不可变字符串与外界彻底隔离。property (nonatomic, copy) NSString *name; // 推荐这样做能防止 NSMutableString 外部修改带来的不可控问题另一个坑是 Block 用 copy。ARC 下系统会自动处理 Block 的拷贝但写property (nonatomic, copy)仍然是良好的习惯因为 MRC 时代遗留了不少代码而且某些边界情况下编译器不一定能正确推断 Block 的生命周期手动标明 copy 语义最稳妥。3. ARC 不是万能的循环引用与内存泄漏的真相3.1 什么是循环引用循环引用指两个或多个对象互相强引用形成环状结构导致谁也释放不了。ARC 只管插入 retain/release管不了逻辑上的环。最常见的场景是 Blockproperty (nonatomic, copy) void (^completion)(void); - (void)setup { self.completion ^{ self.view.backgroundColor [UIColor redColor]; }; }这里的self被 Block 捕获并强持有Block 又被 self 的属性持有self - Block - self一个环就形成了。解决办法是用 weakSelf__weak typeof(self) weakSelf self; self.completion ^{ [weakSelf doSomething]; };但这里还有一层细节很多人会忽略。Block 内部如果有延迟执行或者异步操作用 weakSelf 也会有问题——当 weakSelf 被释放后Block 的执行结果就无效了。更稳妥的写法是在 Block 里用__strong重新声明__weak typeof(self) weakSelf self; self.completion ^{ __strong typeof(weakSelf) strongSelf weakSelf; if (strongSelf) { [strongSelf doSomething]; } };这样做的逻辑是Block 持有一个 weak 引用在执行期间用 strong 临时接管保证在 Block 执行过程中对象不会被释放执行完毕再释放。3.2 NSTimer 的坑NSTimer 是另一个高频循环引用来源self.timer [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:selector(tick:) userInfo:nil repeats:YES];系统对scheduledTimerWithTimeInterval:target:selector:的 target 是强持有而 timer 又被 self 持有这就形成了一个self - timer - target(self)的环。很多人的解法是在 viewWillDisappear 里 invalidate但如果不走这个方法比如控制器被 pop 的时候没有触发或者 push 了新的控制器timer 就一直释放不了。推荐的做法是 iOS 10 之后用 block 形式的 APIself.timer [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer *timer) { // 在这里用 weakSelf 做事情 }];block 形式的 timer 可以通过 weakSelf 打破循环。如果必须兼容 iOS 10 以下常见的方案是写一个中间代理类让 timer 持有代理代理再持有 weak 的 self切断强引用链。3.3 Delegate 与通知delegate 用 weak 修饰已经深入人心但有一个细节需要特别注意delegate 必须用 weak 还是 assignARC 下两个都可以实现不增加引用计数的效果但 delegate 对象销毁后weak 会被自动置 nilassign 则会留下野指针。所以永远选择 weak。通知中心是单向的NotificationCenter 持有 observerobserver 不需要持有 NotificationCenter所以一般不会循环引用。但从 iOS 9 开始系统对 observer 的实现做了调整不需要在 dealloc 里手动移除通知了。不过如果你的代码要兼容老系统或者观察者回调里涉及网络请求容易造成执行顺序混乱手动在 dealloc 里移除通知仍然值得做。3.4 闭包捕获列表与 block 的引用循环Swift 的闭包同样有循环引用问题。用[weak self]捕获列表是基本操作fetchData { [weak self] result in guard let self self else { return } self.updateUI(result) }如果确定闭包一定会执行且不会长期持有也可以用[unowned self]但 unowned 更像是 C 语言里的裸指针——对象一旦销毁再访问就是崩溃。我的经验是除非你能 100% 保证闭包执行时 self 一定存在否则一律用 weak 做防御。线上崩溃最常见的类型之一就是滥用 unowned。4. autoreleasepool、Tagged Pointer 与内存优化实战4.1 autoreleasepool 到底什么时候用很多面试候选人能背出“使用 autoreleasepool 可以优化内存”但说不清它的原理和使用时机。autoreleasepool 的本质是一个延迟释放的机制。当一个对象收到 autorelease 消息时这个对象会被加入当前线程的 autorelease pool 栈中。等到 pool 被 drain释放时池中所有对象会被统一发送 release 消息——引用计数减 1减到 0 的就走 dealloc。那么什么时候需要手动加 autoreleasepool最典型的是在 for 循环中大量创建临时对象for (NSInteger i 0; i 100000; i) { autoreleasepool { NSString *str [NSString stringWithFormat:%ld, i]; // 处理 str } }如果不加这个池子这 10 万个 NSString 都会进入当前 RunLoop 的 autorelease pool直到 RunLoop 循环结束时才释放内存峰值会立刻飙升。加上 autoreleasepool 后每次循环结束池子立即 drain内存始终保持在一个很低的水平。第二种场景是创建了大量辅助对象的方法体内——比如一个图片处理函数内部用到了不少临时 Data、UIImage外面调用频繁但单次执行时间长用 autoreleasepool 把这些临时对象约束在一个明确的释放边界比等 RunLoop 自己回收要可靠得多。还要注意ARC 下创建 autorelease 对象的时机并不只看代码写法。编译器的优化策略会改变 release 和 autorelease 的调用方式有些方法的返回值是经过 autorelease 返回的——比如[NSString stringWithFormat:]这类构造方法返回的可加可不加 autorelease但常见实现里返回的确实是一个 autorelease 对象。所以不要试图手动把返回值 release交给 ARC 和 pool 管理就好。4.2 Tagged Pointer小对象的奇迹Tagged Pointer 是苹果在 64 位环境下做的一个非常巧妙的优化。对于 NSNumber、NSDate 这类占内存小的对象不再在堆上分配一块内存而是把值直接塞进指针里。这样对象不再有引用计数的概念也没有 retain/release 的开销创建和销毁成本极低。面试中常考的考点是Tagged Pointer 不受 autoreleasepool 的影响也不会被循环引用因为它们根本不是引用计数的堆对象。要注意的是Tagged Pointer 虽然性能好但它会导致一些“伪内存泄漏”的错觉。比如你打印一个 NSNumber 的 retainCount得到的是一个很大的乱值这不是 bug而是它压根不走引用计数机制。4.3 内存警告处理与图片优化iOS 系统内存吃紧的时候会向应用发送内存警告。App 收到警告后应该释放不必要的资源不然会被系统杀掉。处理内存警告最常见的两个落点清理缓存图片缓存、网络缓存可以在这个时机清掉清理可重建的视图比如后台的几个页面图片加载的内存优化也是高频考点。UIImage 有两种加载方式// 方式一系统会缓存加载过的图片 UIImage *img [UIImage imageNamed:icon]; // 方式二不会缓存每次从磁盘读取 NSString *path [[NSBundle mainBundle] pathForResource:icon ofType:png]; UIImage *img [UIImage imageWithContentsOfFile:path];imageNamed:有系统级缓存适合小图标、频繁复用的图片imageWithContentsOfFile:不缓存适合一次性的大图。如果在一个很大的列表中用imageNamed:加载大量高清图内存会被缓存撑爆。这是很多人容易踩的坑。4.4 内存泄漏排查工具链聊完了理论来看看线上环境内存泄漏怎么排查。我的工具箱一直是这几样Xcode Memory Graph可视化展示对象引用关系比 Log 定位循环引用高效得多Instruments 的 Leaks 工具定期检测内存泄漏适合扫描全局泄漏点Instruments 的 Allocations 工具观察内存分配趋势适合定位内存持续增长的问题线上监控接入内存用量上报抓取大内存场景用 Memory Graph 排查循环引用时能看到对象之间的持有关系图循环引用会形成闭环非常直观。排查的时候重点看 ViewController 有没有被释放在 pop 之后打个断点用po [self isKindOfClass:[ViewController class]]或者直接看内存图里是否还存在该控制器就能判断有没有泄漏。5. 面试答题框架与话术实战5.1 怎么回答“谈谈你对 ARC 的理解”很多候选人答得零散、没有章法。我建议按这个框架来答先一句话定性ARC 是编译期的自动内存管理机制编译器在代码编译阶段自动分析对象生命周期并插入 retain/release 代码。然后展开讲三点原理引用计数是内存管理的核心ARC 自动管理引用计数的增减与 MRC 的关系ARC 是编译层面的优化不是运行时 GC因此没有垃圾回收的停顿问题ARC 的限制不能解决循环引用需要开发者用 weak/unowned/捕获列表等手段打破环这个回答的优点是逻辑清晰、层层递进既有底层原理又体现了实践认知。面试官如果深入追问底层再从 SideTable、isa 的 extra_rc、weak 表等维度补充一路可以聊 15 分钟不冷场。5.2 怎么回答“循环引用怎么解决”这个问题的答法建议带场景因为循环引用不是一个孤立概念它发生在具体的编程场景里。我一般这么答循环引用是对象之间互相强引用形成的环可用引用计数减不到 0。典型场景有三个Block 内的 self 捕获、NSTimer 对 target 的强持有、delegate 未用 weak 修饰。解决方案分别对应weakSelf strongSelf 临时接管、改用 block API 或中间代理、属性用 weak 修饰。如果能把每个场景都配合一段代码示例说明并且讲清楚为什么这样能解决问题就已经超过了相当一部分候选人。5.3 面试官深挖时的加分细节讲 weak 时提到 SideTable 和 weak_table 结构区分哈希查找的过程讲 Block 时提到捕获列表的三种情况局部变量是值捕获、__block变量是引用捕获、实例变量是捕获 self讲 Timer 时提到 RunLoop 对 Timer 的强持有以及不同 mode 对 Timer 行为的影响讲 autoreleasepool 时提到 RunLoop 在每个循环结束时会自动创建和 drain autorelease pool而手动添加 pool 是精确控制释放时机的手段这些细节不是死记硬背能拿下的平时写代码时多问一句“这个对象什么时候释放为什么在这个时候释放”积累下来就是面试的底气。6. 面试之外的成长建议准备面试题的过程其实也是重新审视自己代码质量的过程。我每次拿内存管理的题目去问别人自己也在梳理这些知识。内存管理看起来是理论实际上线上性能分析、崩溃排查、代码评审都需要它。平时写代码的几个好习惯分享给大家控制器的 dealloc 里打印一条日志方便判断控制器是否被及时释放涉及异步回调的 Block一律用 weakSelf 起步在确认生命周期安全后可以考虑在 Block 内部转 strongSelf 处理大图加载一定用imageWithContentsOfFile:而不是imageNamed:循环里大量创建临时对象记得加 autoreleasepool内存问题尽早暴露写完功能就打开 Instruments 跑一遍 Allocations别等测试报出来才处理另外特别想说一点内存管理不要只盯着 ARC 下那几行代码。App 的内存占用不是只有 OC 对象图片的位图数据、WKWebView 的渲染进程、网络缓存文件、CoreAnimation 的图层树每一块都在消耗内存。真正的 iOS 内存优化是一个全局工程理解引用计数是基础但不代表只懂引用计数就能处理好所有内存问题。面试题只是一个引子本意是希望你把这些知识点串成体系。ARC 负责大部分脏活累活但开发者始终需要知道什么该让 ARC 管、什么必须自己把握。我在实际项目里见过太多因为不懂引用计数语义写出隐蔽循环引用的代码那种问题是编译器和静态检查工具都很难发现的。理解原理并且有意识地在写代码时做生命周期推演比背一百道面试题都有用。