My Profile Photo

Jesse


Hi, 我是 Jesse,一名 iOS 开发者,热爱编程,平常也喜欢摄影。欢迎大家能多多交流。


ARC 工作原理与循环引用

内存管理是所有应用程序的基石,而在 Swift 中,理解 ARC(自动引用计数)不仅仅是为了通过面试,更是为了防止 App 在用户不知情的情况下“吃掉”所有内存并最终崩溃。

很多开发者以为 ARC 就是“自动挡”,挂上 D 挡(写代码)就不管了。其实不然,ARC 更像是一套严密的交通规则,如果你不懂红绿灯(引用类型),还是会发生车祸(内存泄漏)。

今天,我们就深入引擎盖下方,看看 ARC 到底是如何工作的,以及如何优雅地解开那些“死结”。


Swift 使用 ARC (Automatic Reference Counting) 来追踪和管理应用的内存。在大多数情况下,这对我们是透明的——你创建对象,用完它,Swift 自动回收它。完美,对吧?

但是,ARC 不是 Java 或 C# 中的“垃圾回收器 (Garbage Collector)”。它不会定期扫描内存清理垃圾,而是实时工作的。一旦引用计数归零,对象立刻销毁。

这种确定性带来了高性能,但也带来了一个致命弱点:循环引用 (Retain Cycles)。如果两个对象互相抓着对方不放,ARC 就永远无法回收它们。

今天我们就来拆解这个机制,并掌握三种“解法模式”。

1. ARC 的核心:强引用 (Strong Reference)

默认情况下,Swift 中的任何引用都是强引用

class Person {
    let name: String
    init(name: String) { self.name = name }
    deinit { print("\(name) 被释放了") }
}

var reference1: Person? = Person(name: "John") 
// 此时: John 的引用计数 = 1

只要 reference1 还指向这个 Person 实例,它就永远不会被销毁。这就像你牵着一个气球的绳子,只要你手(强引用)不松开,气球(对象)就不会飞走(释放)。

2. 致命拥抱:循环引用 (Retain Cycle)

当两个对象互相持有对方的强引用时,问题就来了。

想象一下:Person(人)拥有 Apartment(公寓),而 Apartment 也有一个 tenant(租客)。

class Person {
    let name: String
    var apartment: Apartment? // 默认强引用
    init(name: String) { self.name = name }
    deinit { print("\(name) 释放") }
}

class Apartment {
    let unit: String
    var tenant: Person?      // 默认强引用
    init(unit: String) { self.unit = unit }
    deinit { print("公寓 \(unit) 释放") }
}

var john: Person? = Person(name: "John")
var unit4A: Apartment? = Apartment(unit: "4A")

// 建立循环
john?.apartment = unit4A // John 强引用 4A
unit4A?.tenant = john    // 4A 强引用 John

此时的内存图谱变成了这样:

当我们尝试释放外部变量时:

john = nil
unit4A = nil
// 控制台一片死寂... 没有打印任何 deinit 信息

虽然外部没有变量指向它们了,但它们互相指着对方。引用计数都不为 0。这就是内存泄漏

3. 解法模式一:弱引用 (Weak Reference)

这是解决循环引用最常用的武器。

规则:

  1. 使用 weak 关键字。
  2. 不增加引用计数。
  3. 必须是 Optional (var) 类型。因为当引用的对象被释放时,ARC 会自动把这个变量设为 nil

场景: 典型的“父子关系”或“委托模式”。父持有子(强),子持有父(弱)。

我们修改上面的 Apartment

class Apartment {
    let unit: String
    // ✅ 破局关键:使用 weak
    weak var tenant: Person? 
    
    init(unit: String) { self.unit = unit }
    deinit { print("公寓 \(unit) 释放") }
}

现在,Apartmenttenant 的引用是弱引用。 当 john = nil 时,John 对象不再有强引用(Apartment 的引用不计数),John 被释放。 John 释放后,他持有的 Apartment 强引用也随之断开,于是 Apartment 也被释放。完美解套。

4. 解法模式二:无主引用 (Unowned Reference)

这是另一种更“激进”的解法。

规则:

  1. 使用 unowned 关键字。
  2. 不增加引用计数。
  3. 不是 Optional。它假设引用的对象永远存在

场景: 两个对象生命周期绑定,且“后者”不能独立于“前者”存在。比如:信用卡(CreditCard)必须属于一个客户(Customer),没有客户就没有卡。

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) { self.name = name }
    deinit { print("\(name) 释放") }
}

class CreditCard {
    let number: UInt64
    // ✅ 破局关键:使用 unowned
    // 卡片存在时,客户一定存在,所以不需要是 Optional
    unowned let customer: Customer
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    deinit { print("卡片 \(number) 释放") }
}

⚠️ 风险提示: 如果你在 customer 被释放后,试图访问 card.customer,程序会直接 Crash。这有点像“悬垂指针”。所以,除非你非常确定生命周期,否则优先用 weak

5. 解法模式三:闭包捕获列表 (Capture List)

这是 Swift 开发中最容易踩的坑。闭包(Closure)是引用类型,如果闭包赋值给了类的属性,而闭包内部又捕获了 self,就会形成 self -> closure -> self 的循环引用。

错误示范:

class HTMLElement {
    let name: String
    let text: String?
    
    lazy var asHTML: () -> String = {
        // ❌ 闭包强引用了 self
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }
    // ... init ...
}

**正确解法:[weak self] + guard**

我们在闭包的定义处使用捕获列表来打破循环。

lazy var asHTML: () -> String = { [weak self] in
    // 此时 self 是 weak 的,变成了 Optional
    // ✅ 标准写法:Weak-Strong Dance
    guard let self = self else { return "" }
    
    return "<\(self.name)>\(self.text ?? "")</\(self.name)>"
}

面试题:什么时候在闭包里用 unowned self 只有当闭包和 self 的生命周期完全一致,且闭包不可能在 self 销毁后被调用时(比如某些非逃逸闭包,或者特定的 lazy 属性初始化)。但为了安全起见,工程实践中 99% 的情况建议使用 weak self

总结

Swift 的 ARC 是一套优雅的内存管理机制,但它需要开发者的配合。

引用类型 关键字 引用计数 是否 Optional 变为空时行为 适用场景
强引用 (默认) +1 是/否 保持对象存活 所有的所有权关系
弱引用 weak +0 必须是 自动变为 nil Delegate, 闭包, 可空反向引用
无主引用 unowned +0 导致 Crash 相互依赖且生命周期绑定的非空引用

记住这句口诀:“层级向下用 Strong,层级向上用 Weak,生死与共用 Unowned。”


感谢阅读!你在项目中遇到过最难调试的内存泄漏是什么样的?是用 Instruments 查出来的,还是看代码看出来的?欢迎在评论区分享你的“捉虫”经历!

目录