循环强引用与 weak/unowned
本教程共 93 篇 · 第 76 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:看清两个类实例互相抓住导致永远无法释放的内存泄漏,学会用 weak 和 unowned 打破循环强引用。
上一章说过,只要有强引用指着,对象就不会死。那如果两个对象互相抓着对方呢?A 指着 B,B 指着 A,谁都不肯先放手,引用计数就永远到不了零。这种僵局叫循环强引用(strong reference cycle),是 Swift 里最常见的内存泄漏来源。
好消息是,破解方法很简单:把其中一侧的引用改成”弱一点”的引用。Swift 提供两种——weak 和 unowned。这一章就把它们的区别讲透。
1-1 循环强引用是怎么形成的
设想一个真实场景:一个人 Person 租了一套公寓 Apartment。人可以有公寓,公寓也可以有租客。于是 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: "张三")
var unit4A: Apartment? = Apartment(unit: "4A")
john!.apartment = unit4A
unit4A!.tenant = john
现在把两个变量都设成 nil,按理说对象该被回收了:
john = nil
unit4A = nil
可是你不会看到任何 deinit 的打印。因为 Person 实例还被 Apartment.tenant 强引用着,Apartment 实例还被 Person.apartment 强引用着。外面的两个变量虽然松手了,内部的互相抓握让引用计数始终不为零。两个对象就这么悄无声息地泄漏在内存里。
Warning循环引用最阴险的地方是你看不见。代码照常运行,只是内存一点点涨。等程序卡死,你往往已经很难定位是哪对对象在互相抓着。
1-2 weak:当对方可能先于自己消失
打破循环的核心思路是:让其中一方用”不会死死抓住对方”的引用。weak 就是这种弱引用。弱引用不增加引用计数,所以它不会阻止 ARC 回收它指向的实例。
规则很清晰:当被引用的一方生命周期更短、有可能先变成 nil,就用 weak。在上面的例子里,一套公寓完全可能某段时间没有租客,所以 Apartment 对 Person 的引用适合用 weak。
class Person {
let name: String
var apartment: Apartment?
init(name: String) { self.name = name }
deinit { print("\(name) 被销毁了") }
}
class Apartment {
let unit: String
weak var tenant: Person? // 改成弱引用
init(unit: String) { self.unit = unit }
deinit { print("公寓 \(unit) 被销毁了") }
}
重新走一遍流程:
var john: Person? = Person(name: "张三")
var unit4A: Apartment? = Apartment(unit: "4A")
john!.apartment = unit4A
unit4A!.tenant = john
john = nil
// 打印:张三 被销毁了
john = nil 之后,Person 实例的强引用只剩 Apartment.tenant 那一个,但那是弱引用,不计入计数,所以计数归零,Person 被销毁。ARC 在销毁时顺手把 tenant 设为 nil。接着 unit4A = nil,公寓也正常销毁。两侧都释放,循环破了。
1-3 weak 一定是可选类型
weak 有个硬性规定:它必须声明为可选类型的变量(不能用 let 常量)。原因上一章提过——弱引用指向的对象可能随时被回收,回收后这个弱引用会被 ARC 自动置为 nil。既然运行时可能变 nil,它就得是可选值,而且得是可变的。
所以你访问弱引用时,像访问任何可选值一样用 if let 或 guard let 解包即可:
if let tenant = unit4A?.tenant {
print("租客是 \(tenant.name)")
} else {
print("这套公寓目前空着")
}
这样你永远不会拿到一个指向已销毁对象的野指针,安全有保障。
Tip经验法则:两个类属性互相引用,且其中一方”可以为空”,通常就该把可为空那一侧标成
weak。这是处理父子、拥有/被拥有关系最常用的一招。
1-4 unowned:当对方与自己同生共死
unowned(无主引用)和 weak 一样不增加引用计数,区别在于:它假定自己指向的实例永远不会提前消失,所以它不是可选类型,ARC 也绝不会把它设成 nil。
适用场景是:被引用一方的生命周期等于或长于引用方。典型例子是信用卡和持卡人。一张信用卡一定属于某个客户,不可能脱离客户单独存在;而一个客户可以没有信用卡。于是 CreditCard 对 Customer 用 unowned 最合适。
class Customer {
let name: String
var card: CreditCard?
init(name: String) { self.name = name }
deinit { print("\(name) 被销毁了") }
}
class CreditCard {
let number: UInt64
unowned let customer: Customer // 无主引用,非可选
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
deinit { print("卡 \(number) 被销毁了") }
}
使用时:
var john: Customer? = Customer(name: "张三")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)
john = nil
// 打印:张三 被销毁了
// 打印:卡 1234567890123456 被销毁了
客户一旦销毁,信用卡也没存在的意义,跟着一起释放。注意 customer 不是可选,访问时不用解包,代码更顺手——但代价是你必须百分之百确认它指向的实例还活着。
Warning如果你用
unowned却在对象已销毁后还去访问它,程序会直接崩溃(运行时错误)。所以只有当你能从设计上保证对方活得比自己久时,才敢用unowned。拿不准就退而用weak。
1-5 怎么选:weak 还是 unowned
记住这条最实用的判断线:
- 对方可能为
nil、可能先消失 → 用weak。 - 对方一定存在、和自己同生共死 → 用
unowned。
更稳妥的默认选择是 weak,因为它容错——即便你的假设错了,最多是拿到 nil,不会崩溃。unowned 只是省去解包的便利,代价是崩溃风险。所以行业里有一条不成文的建议:除非明确需要非可选且能保证存活,否则优先 weak。
还有第三种不太常见的组合:两个属性都必须有值、初始化完成后都不该是 nil,这时可以把一侧声明为 unowned,另一侧声明为隐式解包可选(Type!),从而在初始化阶段互相引用又避免循环。这个模式稍复杂,属于进阶技巧,理解 weak/unowned 的取舍才是本章重点。
1-6 何时该警惕循环引用
光懂规则不够,得知道”什么时候该停下来想想循环引用”。给你几个高频信号。
第一,你定义了两个类,彼此在属性里互相持有对方类型的实例,比如”节点”持有”父节点”、“父节点”又持有”子节点”。这种相互引用一旦出现,立刻问自己:哪一侧生命周期更短?就把那一侧标 weak。
第二,你写了委托(delegate)模式:一个对象把另一个对象设为自己的 delegate。委托方对代理方的引用几乎永远该是 weak,否则极易形成循环。这是 iOS 开发里循环引用的头号现场。
第三,你看到某段代码反复创建对象,却从没在控制台看到对应的 deinit 打印,而程序内存曲线稳步上扬。这是循环引用最直观的”犯罪现场”,别犹豫,去查谁在互相抓着。
Tip养成一个肌肉记忆:凡是”类 A 的属性是类 B,类 B 的属性是类 A”的结构,第一反应就是”这里需要 weak 或 unowned”。提前防,比事后查内存泄漏省事得多。
1-7 小结
循环强引用是两个类实例互相持有强引用导致的死锁式内存泄漏。破解方法是把其中一侧改成 weak 或 unowned:
weak是可选、可变、不增加计数、对象销毁后自动变nil,用于对方可能先消失的场景。unowned是非可选、不增加计数、假定对方永在,用于对方同生共死的场景,用错会崩溃。
拿不准就用 weak,这是最安全的默认。下一章我们看另一种循环:类实例和它的闭包互相抓住,以及怎么用捕获列表解决它。