Swift 用 ARC 自动管理内存,开发者不再写 retain/release,但「自动」不等于「不用管」。循环引用导致的内存泄漏是 iOS 工程里最隐蔽也最常见的一类 bug:页面退出后对象没释放,内存曲线只涨不跌,跑久了就 OOM 崩溃。本文把 ARC 的机制和泄漏的成因讲透。
开篇
先纠正一个常见误解:ARC 不是垃圾回收(GC)。GC 会在运行时扫描对象图找出不可达对象;ARC 是编译期插入引用计数代码,对象引用数归零的瞬间就释放。所以 ARC 没有 GC 的「停顿」,但也不会自动打破循环引用——这是所有 ARC 语言(Swift、Objective-C)的共同软肋,必须靠开发者用 weak/unowned 手工打断。
本文按「原理 → 引用类型 → 典型泄漏场景 → 排查工具 → 坑清单」的顺序展开。
一、ARC 的引用计数原理
每个 class 实例内部维护一个引用计数。编译器在三个地方自动插入代码:
- 创建/赋值强引用时
+1。 - 强引用超出作用域或被覆盖时
-1。 - 计数归零时调用
deinit并释放内存。
final class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) 初始化")
}
deinit {
print("\(name) 释放")
}
}
func demo() {
let a = Person(name: "Alice") // 计数 1
var b: Person? = a // 计数 2
b = nil // 计数 1
} // 作用域结束,a 出栈,计数 0 → deinit 触发
只有引用类型(class)参与 ARC。struct、enum、Int、String 这些值类型存在栈上(或内联在容器里),不经过引用计数。这一点是理解后面所有内容的前提。
ARC 的插入发生在编译期,所以有确定的性能开销(每次赋值都是一次原子操作),但可预测、无停顿。
二、strong、weak、unowned 的语义
三种引用修饰符决定了一个引用对目标对象生命周期的态度。
| 修饰符 | 计数影响 | 目标释放后 | 适用场景 |
|---|---|---|---|
strong(默认) | 持有,+1 | 不适用 | 一般持有关系 |
weak | 不持有 | 自动置 nil | 可能先于自己释放的对象 |
unowned | 不持有 | 变悬垂指针,访问崩溃 | 生命周期不短于自己的对象 |
final class Owner {
var strongChild: Child? // 强引用
weak var weakChild: Child? // 弱引用,child 释放后自动 nil
unowned let unownedChild: Child // 非持有,假设一直有效
init(child: Child) {
self.strongChild = child
self.weakChild = child
self.unownedChild = child
}
}
关键区别:
weak必须是var且是Optional,因为它要能在运行时变成nil。unowned可以是let且非 Optional,性能略好(不用维护弱引用表),但一旦目标先释放,访问就是野指针崩溃。weak有运行时开销:系统要维护一张弱引用表,对象释放时遍历置 nil。
取舍原则:拿不准就用 weak,它的崩溃风险为零。只有当你百分之百确定目标生命周期不短于当前对象、且性能敏感时才用 unowned。
三、闭包捕获与捕获列表
闭包会强引用它捕获的所有外部变量。这是循环引用最大的来源。
final class ViewModel {
var title = "首页"
var onUpdate: (() -> Void)?
func setup() {
// 危险:闭包强引用 self,self 又强引用闭包 → 循环
onUpdate = {
print(self.title) // 捕获 self
}
}
}
self 强引用 onUpdate,onUpdate 强引用 self,两个对象互相持有,计数永远不为零,deinit 永不调用。
用捕获列表打断:
func setup() {
onUpdate = { [weak self] in
guard let self else { return }
print(self.title) // self 只在闭包体内短暂持有
}
}
[weak self] 让闭包弱引用 self。进入闭包后用 guard let self else { return } 解包,得到一个在闭包执行期间稳定的强引用——注意这个强引用在闭包体结束后就释放了,不会造成泄漏。
unowned 版本:
onUpdate = { [unowned self] in
print(self.title) // 不用解包,但 self 已释放时会崩
}
捕获列表能捕获任意表达式,不限于 self:
let name = user.name
onUpdate = { [weak self, weak delegate, name] in
// name 按值捕获(拷贝),self/delegate 弱引用
}
值类型在捕获列表里是拷贝语义,引用类型默认是强引用(除非加 weak/unowned)。
四、四类典型循环引用场景
4.1 闭包捕获 self
除了上面的属性闭包,异步回调是重灾区:
// 危险
networkClient.fetch { result in
self.handle(result) // 强捕获,请求期间 self 无法释放
}
// 修复
networkClient.fetch { [weak self] result in
guard let self else { return }
self.handle(result)
}
判断标准:如果闭包被 self 持有(存在属性上)或生命周期可能长于 self,就必须用 [weak self]。如果闭包是同步执行、立即返回的(比如 map、sorted 的闭包),捕获 self 不会泄漏,可以省略。
4.2 Delegate 模式
delegate 属性必须用 weak,否则必然循环:
protocol DetailViewDelegate: AnyObject {
func didTapClose()
}
final class DetailView {
// 必须 weak,否则 DetailView 与 ViewController 互相强引用
weak var delegate: DetailViewDelegate?
}
注意 protocol 要继承 AnyObject(或 class),weak 才能用于协议类型。不用 weak 的 delegate 是新手最常见的泄漏来源。
4.3 Timer 与 CADisplayLink
Timer.scheduledTimer 会强引用 target,且被 RunLoop 强引用,形成三方循环:
// 危险:Timer 强引用 self,self 强引用 timer,RunLoop 强引用 timer
timer = Timer.scheduledTimer(
timeInterval: 1,
target: self,
selector: #selector(tick),
userInfo: nil,
repeats: true
)
三种解法:
// 方案一:用闭包版 + weak self
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
// 方案二:在合适的时机主动 invalidate
deinit { timer?.invalidate() } // 注意:若不 weak,deinit 根本不会被调用
// 方案三:用中间代理对象打断循环(Target-Proxy)
final class WeakProxy: NSObject {
weak var target: AnyObject?
init(target: AnyObject) { self.target = target }
override func forwardingTarget(for aSelector: Selector!) -> Any? { target }
}
CADisplayLink 有同样的问题,处理方式一致。切记 deinit { timer?.invalidate() } 只有在没有循环引用时才有效——如果循环已经形成,deinit 永远不会执行。
4.4 NotificationCenter
addObserver(forName:object:queue:using:) 的闭包版会强引用闭包:
// 危险:闭包强捕获 self,且 token 未保存无法移除
NotificationCenter.default.addObserver(
forName: .didLogin, object: nil, queue: .main
) { notification in
self.handleLogin(notification)
}
修复:保存 token 并在 deinit 里移除,同时用 [weak self]:
private var token: NSObjectProtocol?
func observe() {
token = NotificationCenter.default.addObserver(
forName: .didLogin, object: nil, queue: .main
) { [weak self] notification in
self?.handleLogin(notification)
}
}
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
iOS 9 之后,基于 selector 的 addObserver 在观察者释放时会自动移除,但闭包版不会,必须手工管理 token。
五、值类型与写时复制
值类型不参与 ARC,但底层可能持有堆上的缓冲区,写时复制(Copy-On-Write, COW)是理解其内存行为的关键。
var arrayA = [1, 2, 3, 4, 5] // 底层一个缓冲区
var arrayB = arrayA // 不拷贝,共享同一缓冲区(引用计数 +1)
arrayB.append(6) // 发生写入 → 此时才真正拷贝
Array、Dictionary、Set、String 都实现了 COW:赋值时只共享缓冲区,直到有一方要修改才复制。这让值类型的复制成本从 O(n) 降到 O(1)(无修改时)。
自定义类型也能实现 COW,但要注意嵌套的引用类型会破坏 COW 语义:
struct Container {
var items: [Item] // Item 若是 class,容器拷贝后仍共享同一个 Item
}
坑:如果 struct 里存了 class 实例,这个 struct 的「值语义」就是假的——拷贝后的两个 struct 仍然指向同一个 class 对象,修改其属性会互相影响。要么让内部也是值类型,要么在 didSet 里手动 copy。
六、autoreleasepool
Objective-C 的 autorelease 对象会延迟到当前 autoreleasepool 排空时才释放。Swift 里大部分对象是精确释放的,但桥接到 Objective-C 的 API(如 UIImage、NSData)仍会产生 autorelease 对象。
默认情况下主线程的 RunLoop 每一轮都会排空 autoreleasepool,但循环里创建大量临时对象时,池子要等整个循环结束才排空,峰值内存会很高:
// 危险:循环里创建大量 UIImage,全部累积到循环结束才释放
for path in imagePaths {
let image = UIImage(contentsOfFile: path)
process(image)
}
// 修复:每轮迭代用 autoreleasepool 及时释放
for path in imagePaths {
autoreleasepool {
let image = UIImage(contentsOfFile: path)
process(image)
} // 每轮结束,临时对象立即释放
}
适用场景:批量处理大对象(图片、视频帧、大 JSON 解析)的循环。普通代码不需要手动加 autoreleasepool。
注意:autoreleasepool 是同步的,块结束就排空;它也不能跨线程复用。
七、deinit 与资源释放
deinit 是对象释放前的最后机会,用来清理非内存资源。
final class FileWatcher {
private var source: DispatchSourceFileSystemObject?
private var fileDescriptor: Int32 = -1
deinit {
source?.cancel()
if fileDescriptor >= 0 { close(fileDescriptor) }
print("FileWatcher 已清理")
}
}
要点:
deinit只在引用计数归零时调用。如果对象泄漏了,deinit永远不执行——所以「deinit没打印」本身就是泄漏的信号。- 在
deinit里可以做清理,但不能强引用 self,也不能异步派发任务后指望 self 还活着。 - 关键资源(文件句柄、socket、KVO 观察、通知)都应在
deinit兜底释放。
调试技巧:在 deinit 里加日志或断言,能快速确认某个页面是否正常释放。
八、用 Instruments 排查泄漏
Xcode 的 Instruments 有两个核心工具:
| 工具 | 用途 | 关键信号 |
|---|---|---|
| Leaks | 检测已泄漏(不可达但未释放)对象 | 出现红色 ✕ 标记的泄漏点 |
| Allocations | 追踪所有对象分配与存活 | 内存曲线只涨不跌、对象持续存活 |
排查流程:
- 用 Xcode 打开 Instruments(
Product > Profile,或Cmd+I)。 - 选 Allocations,在目标页面反复进入退出 5~10 次。
- 观察 Persistent(存活对象数)曲线:正常应回落,若只涨不跌说明有对象没释放。
- 用 Statistics 按类名排序,找到存活数持续增长的类。
- 选中该类,看 Allocation Stack Trace,定位创建它的代码路径。
- 切到 Leaks 工具,它会直接标出循环引用环。
补充手段:
- Xcode Memory Graph(调试时点 Debug Memory Graph 按钮):可视化对象引用图,能直接看到循环引用的环,比 Instruments 更快。
deinit日志:最低成本的验证手段。weak自检:临时把可疑引用改成weak,看对象能否释放。
Leaks 工具的局限:它只能发现「完全不可达」的循环,那些被全局单例、缓存无意持有的对象它检测不到——这类要靠 Allocations 的 Persistent 曲线发现。
九、常见内存泄漏坑清单
- delegate 没用
weak:最常见,必查。 - 闭包强捕获 self:存在属性上或异步回调里的闭包,一律
[weak self]。 - Timer/CADisplayLink 未 invalidate:target 强引用形成循环,
deinit里 invalidate 也救不回来。 - NotificationCenter 闭包版未移除 token:闭包版不会自动移除。
- KVO 未移除观察者:
observe(_:options:changeHandler:)的 token 要保存并释放。 - 单例持有视图或控制器:单例生命周期等于 App,持有 VC 等于永久泄漏。
unowned用错:目标先释放时野指针崩溃,拿不准就用weak。- struct 里嵌 class:破坏值语义,拷贝后仍共享引用对象。
- 循环里创建大对象没加 autoreleasepool:峰值内存过高,可能被系统 kill。
- 强引用环跨多个对象:A→B→C→A,单看每一对都不明显,要靠 Memory Graph 找环。
DispatchQueue.main.async闭包强捕获 self:延迟执行的闭包同样会延长生命周期。- 缓存没有上限:自建 Dictionary 缓存无淘汰策略,对象越积越多。
FAQ
Q:[weak self] 会不会有性能问题?
有轻微开销(维护弱引用表、解包),但和泄漏造成的后果相比可忽略。只在极热路径上才考虑 unowned。
Q:闭包里的 guard let self 之后,self 还会被释放吗?guard let self else { return } 创建的强引用只在闭包体执行期间有效,闭包返回后立即释放,不会泄漏。
Q:怎么快速验证一个页面有没有泄漏?
在 deinit 里打日志,反复 push/pop 该页面,看日志是否每次打印。不打印就是泄漏,再用 Memory Graph 找环。
Q:SwiftUI 里也有循环引用吗?@State/@Binding 是值语义,风险低;但 @StateObject、ObservableObject 里的闭包和 Combine 的 sink 仍可能强捕获 self,处理方式与本文一致。
相关阅读
- SwiftData 与 Core Data — 模型对象的持有关系与内存释放
- iOS 性能优化与 Instruments — 内存、CPU、耗电的系统化排查
- Swift 语言基础 — 值类型与引用类型的语言层基础
小结
ARC 是编译期的引用计数,对象归零即释放、无停顿,但它不会自动打破循环引用——这是所有内存问题的根源。
三种引用里,strong 是默认的持有,weak 安全但必须 Optional 且可为 var,unowned 高效但危险。拿不准就用 weak,这是最省心的原则。
四类高频泄漏场景要背下来:闭包捕获 self、delegate 没 weak、Timer/CADisplayLink 没 invalidate、NotificationCenter 闭包版没移除 token。捕获列表 [weak self] 加 guard let self 是标准写法。
排查靠两件工具:Xcode Memory Graph 看引用环,Instruments 的 Allocations 看 Persistent 曲线找不释放的对象。再配合 deinit 日志做最低成本的验证。
最后记住:deinit 不执行本身就是泄漏的证据。把「页面退出后对象是否释放」当成一条硬性验收标准,内存问题就能在开发阶段被拦住,而不是等到线上 OOM。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。