尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

iOS内存管理从原理到实战:引用计数、循环引用与排查指南

iOS内存管理从原理到实战:引用计数、循环引用与排查指南 1. 从“怎么答”到“怎么用”内存管理为什么是面试分水岭从业十年来我面试过的 iOS 候选人少说也有三四百人内存管理这一块几乎每次必问。它不像算法题那样可以突击背题也不像 UIKit 那样看几篇文档就能聊两句——引用计数、循环引用、自动释放池这些概念面试官随便换一个角度追问就能看出你是真正写过代码还是只会背八股。这个内容能帮你解决什么问题一句话概括让你在面试里被问到内存管理时不慌同时在日常开发里少制造崩溃和卡顿。它适合三类人准备跳槽的 iOS 开发者、刚转 Swift 的 OC 老手、以及想系统梳理内存知识体系的中级工程师。我会从底层原理讲到实战排查最后给出面试答题策略全程结合真实场景。先强调一个观点内存管理面试题不是考你背了多少概念而是考你在极端条件下能否保住 App 不崩溃、不异常增长。面试官真正想听的是你对引用计数的理解深度、对循环引用的敏感度、以及排查内存问题的实操方法。下面我们一层层拆。2. 引用计数与自动释放池吃透底层答什么都有底气2.1 引用计数的本质一张借书登记表很多人对引用计数的理解停留在“对象被引用就加一释放就减一”这个层面这个说法没错但不够。面试官如果让你讲讲引用计数的存储位置和查找过程你可能就卡住了。实际上引用计数在 Runtime 层就是一张散列表Hash Tablekey 是对象指针即对象的地址value 是引用计数。这张表由 Runtime 统一管理不是每个对象内部都有个数字那么简单。当一个对象创建时引用计数为 1调用 retain 会让表的对应 value 加一调用 release 会让 value 减一当减到 0 时Runtime 会调用对象的 dealloc 方法然后回收内存。这里有个生活化类比引用计数就像图书馆的借书登记表。一本书对象刚采购回来登记表上写着借出 1 次初始引用每有人借阅就记一笔retain每有人归还就划掉一笔release当登记表上所有借阅记录都清空时图书管理员才会把书下架dealloc。这个类比在面试时说出来会让面试官觉得你真正理解了。在 ARC 时代retain/release 这些调用是编译器自动插入的你感知不到但你必须知道机制依然存在。面试官问你“ARC 是不是就没有引用计数了”你要是答“是”那就掉坑里了。ARC 只是把 retain/release 的插入时机从开发者手里移交给了编译器底层机制一模一样。2.2 自动释放池延迟释放的“暂存区”自动释放池Autorelease Pool是另一个高频考点。很多候选人能说出“autorelease 的对象会在池子销毁时统一 release”但面试官追问“什么时候销毁”“RunLoop 和它什么关系”就露怯了。自动释放池的机制可以理解为当你在 MRC 下调用 autorelease对象并不会立刻释放而是被放进当前线程的自动释放池栈中等到池子被 drain销毁时池子里所有对象统一执行一次 release。这个“暂存区”的设计初衷是延迟释放常用于方法返回对象时因为调用方还需要使用这个返回值不能立刻释放。在 iOS 中RunLoop 在每个事件循环开始时会创建一个自动释放池事件循环结束时 drain 这个池子。所以你在主线程上的绝大多数 autorelease 对象都会在RunLoop 循环结束时释放。这解释了为什么主线程的 autorelease 对象不会积累太多——每跑一圈 RunLoop 就会清一次。这也是为什么在大量循环中频繁创建临时对象会很危险如果循环体很重而 RunLoop 一圈还没跑完这些临时对象就会一直堆积在自动释放池里导致峰值内存暴涨。我见过一个图片处理的 for 循环循环里创建了 20 万张临时位图直接导致内存飙到 800MB 被系统杀掉。后来在循环内加了一层autoreleasepool { ... }就解决了。这是非常经典的实际案例面试时讲出来非常加分。注意如果你自己开了一个后台线程这个线程默认没有 RunLoop 自动创建释放池你必须手动添加autoreleasepool来管理临时对象的内存。2.3 面试高频追问Tagged Pointer 和 isa 优化近年来的面试题越来越卷引用计数还会延伸到 Tagged Pointer 和 isa 优化。Tagged Pointer 是苹果对小对象如 NSNumber、NSDate做的优化。当对象的数据比如一个整数能完整放进指针的剩余位时Runtime 就不会真正创建一个对象——指针本身就“携带”了这个值根本没有堆内存分配也没有引用计数这一说。所以面试官如果问“Tagged Pointer 对象有引用计数吗”正确答案是它不是一个真正的对象不走引用计数流程。这也是为什么有些极端情况下 Tagged Pointer 不会被释放也不会泄漏。isa 优化涉及更多的底层细节。在 arm64 架构下isa 本身是一个联合体union引用计数不再单独占用内存而是存储在 isa 的 extra_rc 字段中当 extra_rc 溢出时才会转移到 SideTable 的引用计数表中。答到这个深度说明你读过 Runtime 源码面试官会高看一眼。不过我要提醒一句这些底层知识面试时用到即可实际开发中你不需要手动操作这些字段。我的经验是把原理讲清楚是为了建立“内存到底存在哪里”的心智模型真到排查问题时这个模型会帮你快速缩小范围。3. 所有权修饰符实战从 OC 到 Swift 的跨越3.1 strong、weak、copy、assign每个关键字背后都是血泪OC 里的属性修饰符是面试必考而且经常是连环追问。我先把对比列出来再讲经验。修饰符行为典型使用场景常见误区strong强引用引用计数 1绝大多数对象属性代理属性误用 strong 导致循环引用weak弱引用不增加引用计数对象释放后自动置 nildelegate、Outlet对 weak 变量调用方法时没判空copy拷贝一份新对象持有副本NSString、NSArray、Block直接对 NSMutableString 用 strongassign不改变引用计数对象释放后指针仍指向旧地址野指针CGFloat、NSInteger 等基础类型对对象类型用 assign 导致野指针崩溃unsafe_unretained同 assign但用于对象类型兼容旧代码对象释放后访问即崩溃实战中最常见的坑有两个。第一个是 NSString 用 strong如果传入的是一个 NSMutableString然后外面的可变字符串又改了内容你持有的那个“不可变字符串”实际内容也会跟着变。解决方式就是用 copy让属性持有传入值的副本不受原始对象后续修改的影响。第二个坑是代理属性。理论上 delegate 必须用 weak否则父视图持有子视图、子视图的 delegate 又强持有父视图就是一个循环引用双方永远不会释放。我面试时经常问候选人“你的 delegate 是 strong 还是 weak为什么”很多人在项目里写了无数 delegate却从没想过这个问题这是很容易被识别出“只搬砖不思考”的提问点。3.2 Swift 的 weak 与 unowned选错就是崩溃Swift 内存管理面试点与 OC 类似但有几个 Swift 特有的坑。weak 在 Swift 中与 OC 一致总是可选类型optional对象释放后自动置 nil。而 unowned 则更激进它假定被引用对象一定比当前对象活得久因此不持有强引用、也不置 nil。如果你访问了一个已释放的 unowned 对象App 直接崩溃而不是像 weak 那样给你一个 nil。那么 Swift 里到底怎么选我的经验是三条原则能确定两个对象的生命周期完全一致、被引用者一定不先释放才用 unowned比如 closure 与 self 的某种同步关系拿不准就用 weak反正 nil 可以安全处理在闭包捕获列表里尽量优先考虑 weak。举一个实际崩溃案例有一个页面 A push 到页面 BB 里有一个闭包捕获了[unowned self]但这个闭包其实是被一个后台线程持有的B pop 后后台线程才执行闭包一访问 self 就崩了。改成 weak 后加保护就好。所以我的代码习惯是闭包捕获外部变量默认写 weak只有明确生命周期时才敢用 unowned。3.3 深拷贝、浅拷贝与集合容器考的是细节深拷贝与浅拷贝也是面试里的常客通常会和内存管理结合着考。浅拷贝是复制指针不复制对象本身新旧两个引用指向同一块内存。深拷贝是复制内容生成一个新对象新旧对象互不影响。在 OC 里copy和mutableCopy的行为有细微差别不可变对象的copy是浅拷贝因为没必要拷贝一份不可变对象mutableCopy是深拷贝可变对象的copy是深拷贝得到一个不可变的副本mutableCopy也是深拷贝。集合容器的拷贝最容易踩坑对 NSArray 执行copy得到的新数组里的元素依然和原数组共享同一批对象只是容器本身是新的。也就是说容器的“深拷贝”需要用到initWithArray:copyItems:但这也只是对元素执行 copy 方法如果元素本身没有实现 NSCopying 协议依然不能真正深拷贝。面试时如果能说出“集合深拷贝只浅拷贝了容器和一层元素”这个点会比只说“深拷贝复制对象”的候选人高一个档次。Swift 的值类型天然具备写时复制Copy-on-Write机制Array、Dictionary、String 这些值类型复制时不会立刻拷贝底层存储只有在修改时才真正复制。这个机制既保证性能又保证值语义我在 Swift 面试题中几乎必问候选人能准确讲出 CoW 的触发条件通常说明对性能优化有真实理解。4. 循环引用的五大场景闭包、代理、Timer、CADisplayLink、通知4.1 Block 循环引用的判定法则考察 Block 循环引用面试官最常问的就是“为什么某些情况下 Block 里要写 weakSelf”其实判断循环引用有一套通用方法把对象之间的引用关系画成有向图看有没有形成一个环。Block 本身会被它所在的对象比如 self持有同时 Block 内部又强引用了 self这就形成了self → block → self的环。解决办法是让其中一条边变成弱引用比如在 Block 外用__weak typeof(self) weakSelf self;进入 Block 后在开头加一句__strong typeof(self) strongSelf weakSelf;——这一步是为了防止作用域内 self 被提前释放获得一个强引用保障 Block 执行期间的稳定性。不过我不建议一上来就写这套模板。正确思路是先判断 Block 是否被当前对象长期持有。如果 Block 只是在某个方法内创建、立即执行、用完即弃它根本不会被 self 持有就不存在循环引用。只有 Block 被存起来、被延迟执行比如网络回调、定时器回调、属性持有才需要担心。有一次我 code review 时发现一个小伙子把所有 Block 都加上了 weakSelf反而因为 weakSelf 在异步回调时为 nil 导致界面无法刷新。所以说理解原理比套模板更重要。面试时如果能说出“必须判断 Block 是否被延长持有再决定是否需要 weakSelf”这句话本身就是加分项。4.2 NSTimer 与 CADisplayLink系统组件也有引用计数坑NSTimer 和 CADisplayLink 是循环引用的重灾区也是面试官很喜欢深挖的场景。很多人只记得NSTimer需要在dealloc里 invalidate但忘记了一个关键点Timer 一旦被加入 RunLoop就被 RunLoop 强持有同时 Timer 的 block 或 target 又强持有 self而 self 又持有 Timer。于是形成了self → timer → runloop → timer → self的环。这个环里如果 self 一直不释放dealloc 永远不会执行invalidate 也永远不会被调用就是死锁式泄漏。在 iOS 10 中NSTimer 提供了 block 版本 API配合 __weak 可以解掉一环但 RunLoop 始终强持有 timer所以最终仍然需要手动 invalidate否则 timer 本身会永久存在。iOS 18 开始苹果新增了自动失效的 API但为了兼容旧系统开发时最好还是走手动 invalidate 的老路。我的习惯是封装一个 Timer 管理类页面出现时开启、页面消失时统一 invalidate并且把所有 target 引用都改成 block weak从根上避免这类问题。面试时你可以主动分享这个封装思路证明你不仅知道问题还有实际解决问题的方案。CADisplayLink 与 Timer 类似它是跟随屏幕刷新频率回调的定时器同时被屏幕刷新机制持有不 invalidate 就会持续调用导致页面不能释放。更隐蔽的是CADisplayLink 在 app 进入后台后依然可能被某些场景触发比如系统录屏、控制中心所以必须在合适的时机暂停/恢复而不只是销毁。4.3 代理与通知面试里的“送分题”陷阱代理方向上的循环引用A 持有 BB 的 delegate 指向 A。如果 B 的 delegate 是 strong那么就是A → B → A两个对象都释放不了。前面已经说过delegate 必须用 weak。这道题几乎是送分题但总有人答错。通知NSNotificationCenter的坑在于在 iOS 8 及之前的系统observer 在移除前不会被自动解除注册。如果你在 dealloc 里忘了 removeObserver通知中心依然持有 observer 的引用self 无法释放又形成泄漏。iOS 9 后系统在 dealloc 时自动移除 observer但 KVO 并没有这种自动处理必须手动 removeObserver。这里我踩过一个大坑KVO 在移除时如果观察者和被观察者之间的注册关系不存在会抛异常崩溃。后来我习惯加上try/catch保护但这只能兜底正确做法是在配对出现的地方进行对称管理比如 viewDidLoad 注册dealloc 里移除保持同一个生命周期。4.4 其他隐藏泄漏单例、NSCache、CoreFoundation单例的引用问题也常被忽略。单例类本身持有实例整个 App 生命周期不会释放如果你把某个 controller 存进了单例的数组里controller 即使 pop 后也不会被释放。这不算传统意义的循环引用但同样是内存泄漏面试官如果问到“单例有哪些坑”这就是标准答案之一。NSCache 与 NSDictionary 的选择也是一个经典面试点。NSCache 是系统提供的缓存容器在内存紧张时会自动清理对象线程安全而且它的 key 是被 copy 的。NSDictionary 则会强持有所有对象容易造成缓存无限膨胀。我实际开发里缓存下载图片、缓存接口数据一律用 NSCache除非有明确理由才用 NSDictionary。你可以记住这句话“NSCache 是官方钦定的缓存容器面试时提到它并说出与 NSDictionary 的内存差异能加不少印象分。”CoreFoundation 层面的内存管理对普通 App 开发不太常见但做底层库的同学必须掌握凡是名字里有 Create/Copy 的函数都表示你持有了对象需要用 CFRelease 释放凡是 Get 开头的函数表示你不持有对象不需要释放。面试中被问到 C 语言与 OC 的内存管理差异时ABA 问题的本质是“谁创建谁释放”。5. 内存问题的排查与优化面试官最看重的实操能力5.1 Xcode InstrumentsLeaks 和 Allocations 的正确用法面试聊内存不能只聊原理实操工具和方法是区分高级工程师的重要标尺。第一步就是熟悉 Xcode 自带的 Instruments。Leaks 模板主要用于检测泄漏的对象。使用流程是用真机运行 App模拟器检测结果不够准打开 Leaks 模板操作 App 的各个页面反复进入退出观察 Leaks 是否递增。如果出现泄漏双击进入 Call Tree在底部输入框筛选自己的类名就能定位到具体的泄漏堆栈。Allocations 模板则用来观察内存占用。它会列出所有堆对象及内存地址配合 Mark Generation 功能可以对页面进入前和退出后做内存快照对比。操作方法是在进入页面之前点一下 Mark退出页面后等一下等所有异步回调结束再点一次 Mark然后对比两次 Generation 之间的对象分配列表就能看到哪些对象没有被释放。如果某个页面退了十次它的对象出现了十份那么这些对象百分之百泄漏了。这里有个经验之谈Leaks 只能检测到“完全没有被引用”的泄漏对象对于“被单例或全局变量无限持有”的泄漏Leaks 是检测不到的。这时候必须靠 Allocations 的多次页面进出对比来发现。面试时如果能主动说出 Leaks 和 Allocations 各自的局限面试官会认为你有真正的排查经验。5.2 MLeaksFinder 与幽灵对象自动检测的黄金组合MLeaksFinder 是腾讯开源的自动泄漏检测工具它的设计思路非常值得面试时讲它监控所有 UIViewController 和 UIView 的 pop/移除事件然后延时 2-3 秒后去检查这些对象是否被释放。如果对象还活着说明有泄漏就会给出警告和完整堆栈。它的核心原理是给 UIViewController 的 viewDidDisappear 或 dealloc 相关方法注入检查逻辑利用 Objective-C 的 method swizzling 技术。实际使用中MLeaksFinder 可以捕捉到大部分随页面生命周期泄漏的对象但它无法检测到服务于全局的单例泄漏、纯数据层泄漏所以不要完全依赖它。我自己习惯的组合拳是MLeaksFinder 做开发阶段的自动扫描Instruments Leaks 做发布前的抽检Allocations 做页面级内存对比。三个工具配合使用能覆盖绝大多数泄漏场景。5.3 野指针与僵尸对象排查崩溃的必备技能野指针指对象释放后指针仍指向那块内存。在 Debug 模式下开启 Zombie Objects僵尸对象后已释放的内存会被填充为特殊的僵尸对象如果再次向该指针发送消息Runtime 会立刻崩溃并在 Console 区输出完整的调用链告诉你到底是谁在访问一个已经释放的对象。但这个功能只在 Debug 模式有效且开启后内存占用会数倍增长不建议长时间开着。Release 模式下野指针崩溃通常是 EXC_BAD_ACCESS定位只能靠崩溃日志和符号化。面试时问到“遇到 EXC_BAD_ACCESS 怎么排查”标准回答是先用 Zombie Objects 复现再用 Address Sanitizer地址消毒器来进一步定位最后考虑系统性的代码检查。Address Sanitizer 是 Xcode 自带的检测工具在 Scheme 的 Diagnostics 中可以开启。它对堆内存的越界访问、释放后访问有非常精准的检测代价是运行速度下降不少同样只适合开发阶段。我的经验是Zombie 用于“访问已释放对象”的定位Address Sanitizer 用于“越界和内存破坏”的定位两个工具覆盖的场景不同。5.4 内存优化技巧从“好用”到“不卡”内存管理不只是“别泄漏”还有“降低水位”。面试官如果问“你的 App 内存占用过高怎么办”以下优化点可以分层次展开。图片优化使用 ImageIO 的 downsample 方法做按需降采样避免一次性加载超大图直接用[UIImage imageNamed:]往往会缓存图片到内存频繁使用的可以但一次性大图要控制。按需加载与懒加载列表页只创建当前可见 cell 的视图和数据滚动时复用。干重活放后台图片解码非常耗内存和 CPU建议放在后台线程或者使用支持异步解码的三方库如 SDWebImage、YYKit。批量任务使用自动释放池嵌套循环内用autoreleasepool隔离临时对象。避免过深的视图层级每层 view 背后都有 CALayer几百个临时 layer 叠加会直接吃满内存。我们可以对比一下几类工具的适用场景工具/方法解决的问题使用阶段局限性Instruments Leaks检测无引用泄漏发布前抽检无法检测被全局持有的泄漏Allocations Mark Generation页面级对象对比开发阶段需要人工对比步骤繁琐MLeaksFinder自动检测页面泄漏开发阶段无法检测纯数据层泄漏Zombie Objects定位野指针访问Debug 复现内存膨胀严重Address Sanitizer检测越界/释放后访问Debug 复现运行速度下降明显上面这堆工具和技巧除了能帮你通过面试更重要的是直接提升你日常开发的幸福感。我见过太多因为内存问题在线上崩溃的 App最后定位到的原因不是多复杂就是某个页面持有单例没有释放或者 Timer 忘记 invalidate。这些坑只要养成好习惯完全可以在编译期或开发早期拦住。6. 面试答题思路从“会做”到“会讲”6.1 分层回答法五句话讲清一个知识点面试不是写论文讲究在有限时间里输出高密度有效信息。我的建议是采用“结论先行、机制跟进、例子收尾、延伸补刀、结论强调”五句话法。以“讲讲 ARC 下的内存管理”为例第一句给结论ARC 是编译器在编译期自动插入 retain/release 的机制底层的引用计数表存储结构没有变化。第二句讲机制每个 OC 对象通过 Runtime 的 SideTable 维护引用计数释放时机取决于计数是否归零。第三句给例子比如一个局部变量在作用域结束时编译器自动释放。第四句延伸Swift 的 ARC 机制类似但值类型没有引用计数。第五句强调所以 ARC 节省了手动管理内存的复杂度但循环引用问题依然靠开发者的设计责任心来解决。这种结构的好处是面试官可以随时在任一层次深入追问你也有退路。如果你只说“ARC 就是自动释放内存”面试官觉得你太浅如果你一上来就讲 SideTable 的源码结构面试官可能觉得你在背书不能结合实践。分层回答法能适配不同风格的面试官。6.2 高频题库这些题目背熟一半剩下的触类旁通根据我多年的面试经验iOS 内存管理面试题其实是有限的核心内容集中在以下几类引用计数的底层实现SideTable、weak 表的原理。ARC 下 strong、weak、unsafe_unretained、autoreleasing 的区别底层对应什么行为。MRC 与 ARC 的区别ARC 在编译期还是运行期做哪些事。深拷贝 / 浅拷贝 / 集合拷贝的细节。自动释放池何时创建、何时释放RunLoop 与它的关系。循环引用场景列举Block、Timer、delegate、通知的各自解决方式。内存泄漏检测工具的使用经验Leaks / MLeaksFinder / Zombie Objects。Tagged Pointer、isa 优化、SideTable 溢出处理。Swift 与 OC 内存管理的区别值类型与引用类型、写时复制。大型项目内存优化经验降低内存峰值的具体手段。把这一百来个问题串起来你会发现万变不离其宗底层原理、ARC 修饰符、循环引用、排查工具、优化经验就这五大块。准备面试时先做减法把五大块吃透胜过背散装题。6.3 如何让面试官记住你用真实案例讲道理面试官每天面很多人背八股文的多了真正让人记住的是能结合项目聊出来的经验。在回答内存管理问题时建议穿插一两个真实案例。比如你负责的模块曾经遇到一次内存峰值崩溃后来排查是图片列表一次性加载原图导致的最终用降采样解决线上内存下降 30%。这个故事里有场景、有问题、有排查过程、有数据结果比任何长篇大论都有说服力。我还有一个经验面试官问“你优化内存有什么成果”时不要只说“降低 30%”一定要讲清楚怎么测量的。是从 Xcode 内存报告看的还是 Instruments 的 Allocations 统计的基准是什么优化前后差别多大只有量化才能让人信服。面试答疑时请记住一个原则无论问题多简单回答时都要有结构、有层次、有关键词。这是从“普通开发者”晋升为“可信赖工程师”的必经路径。7. 写在最后内存管理的本质是“责任感”回到开头那句话内存管理面试题不是考背书是考一个工程师对系统资源的敬畏和敏感度。一个动不动就让 App 内存暴涨的开发者写出来的代码再炫也撑不起线上稳定性。我个人在实际项目中的体会是内存管理的很多问题其实在写代码的那一刻就可以避免。比如随手写一个 Timer 之前先想好在哪里 invalidate在 block 里捕获 self 之前先想好它会不会被长期持有在把对象塞进全局数组之前先问自己它真的需要一直活着吗养成这种思考习惯比会背一百道题更重要。如果你正在准备 iOS 面试我的建议是先把这篇文章里的原理部分读懂再用 MLeaksFinder 和 Allocations 把自己负责的模块排查一遍把发现的真实问题记下来面试时直接拿出来讲。你会发现真实经验比任何标准答案都更有力量。最后再分享一个小技巧去面试之前把你做过的最复杂的一个页面或模块完整复盘一遍它的内存生命周期——什么时候创建、什么时候释放、有没有引用环。用笔在纸上画一遍引用关系图。做完这个练习你会发现面试里的内存问题几乎都在这个图的覆盖范围之内。
返回列表