Swift 内存管理与 ARC 循环引用

ARC 让 Swift 摆脱了手动内存管理,但循环引用、闭包捕获、Timer 持有这些坑依然普遍。本文从 ARC 的引用计数原理讲起,梳理 strong/weak/unowned 的语义与取舍,逐一拆解闭包捕获 self、delegate、Timer、NotificationCenter 四类典型泄漏场景,讲解捕获列表的写法、值类型与写时复制、autoreleasepool 的适用时机,并给出用 Instruments 的 Leaks/Allocations 定位泄漏的完整流程与常见坑清单。

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 是新手最常见的泄漏来源。

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追踪所有对象分配与存活内存曲线只涨不跌、对象持续存活

排查流程:

  1. 用 Xcode 打开 Instruments(Product > Profile,或 Cmd+I)。
  2. 选 Allocations,在目标页面反复进入退出 5~10 次。
  3. 观察 Persistent(存活对象数)曲线:正常应回落,若只涨不跌说明有对象没释放。
  4. 用 Statistics 按类名排序,找到存活数持续增长的类。
  5. 选中该类,看 Allocation Stack Trace,定位创建它的代码路径。
  6. 切到 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,处理方式与本文一致。

相关阅读

小结

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。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全