前言
ARC 让 90% 的 iOS 开发者"无痛"管理内存,但剩下的 10% 却能把 App 拖进 OOM 深渊。
ARC 原理:一张图先记住
swift
实例创建 ➜ 强引用 +1
有强引用指针指向 ➜ 强引用 +1
引用离开作用域或被置 nil ➜ 强引用 -1
计数 = 0 ➜ 执行 deinit ➜ 内存归还
结构体 / 枚举是值类型,不走 ARC;只有类(class)才参与计数。
引用计数示例代码
swift
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) 正在初始化,分配内存")
}
deinit {
print("\(name) 被析构,内存即将释放")
}
}
// 三个可选类型变量,默认 nil
var ref1: Person?
var ref2: Person?
var ref3: Person?
// 1. 创建对象 → 强引用计数 = 1
ref1 = Person(name: "John Appleseed")
print(CFGetRetainCount(ref1))
// 2. 再挂两条强引用 → 计数 = 3
ref2 = ref1
print(CFGetRetainCount(ref2))
ref3 = ref1
print(CFGetRetainCount(ref3))
// 3. 断开两条 → 计数 = 1,对象仍在
ref1 = nil
print(CFGetRetainCount(ref2))
ref2 = nil
print(CFGetRetainCount(ref3))
// 4. 最后一条断开 → 计数 = 0,deinit 执行
ref3 = nil // 控制台打印:John Appleseed 被析构
print(CFGetRetainCount(ref3))
/*
John Appleseed 正在初始化,分配内存
2
3
4
3
2
John Appleseed 被析构,内存即将释放
*/
强引用循环(Retain Cycle)
循环出现的条件:
两个类实例互相强引用,外部指针断开后,内部仍"手拉手",计数永不为 0 → 内存泄漏。
swift
class Person {
let name: String
init(name: String) { self.name = name; print("\(name) 初始化") }
deinit { print("\(name) 析构") }
var apartment: Apartment? // 人可能没房
}
class Apartment {
let unit: String
init(unit: String) { self.unit = unit; print("公寓 \(unit) 初始化") }
deinit { print("公寓 \(unit) 析构") }
var tenant: Person? // 房可能没人
}
var john: Person? = Person(name: "张三")
var unit4A: Apartment? = Apartment(unit: "4A")
print("初始化后的引用计数 john:\(CFGetRetainCount(john)) unit4A:\(CFGetRetainCount(unit4A))")
// 互相挂强引用 → 形成循环
john!.apartment = unit4A
unit4A!.tenant = john
print("循环引用后的引用计数 john:\(CFGetRetainCount(john)) unit4A:\(CFGetRetainCount(unit4A))")
weak var weakUnit4A = unit4A
weak var weakJohn = john
// 外部指针断开,但内部仍互相强引用 → 泄漏
john = nil // 无析构日志
unit4A = nil // 无析构日志
print("指针断开后的引用计数 john:\(CFGetRetainCount(weakJohn)) unit4A:\(CFGetRetainCount(weakUnit4A))")
/**
张三 初始化
公寓 4A 初始化
初始化后的引用计数 john:2 unit4A:2
循环引用后的引用计数 john:3 unit4A:3
指针断开后的引用计数 john:2 unit4A:2
*/
破解循环的两把钥匙
| 引用类型 | 是否强持有 | 可否为 nil | 适用场景 | 关键字 |
|---|---|---|---|---|
| weak | 否 | 可以(自动置 nil) | 对方寿命 ≤ 自己 | weak |
| unowned | 否 | 不可为 nil(不会自动置 nil,需程序员保证) | 对方寿命 ≥ 自己 | unowned |
- weak 版 Apartment
swift
class Apartment {
weak var tenant: Person? // 房不强持有住客
}
执行结果:
外部 john = nil → Person 析构 → Apartment.tenant 自动变 nil → 再 unit4A = nil → Apartment 析构。
日志正确打印两行析构。
- unowned 版 Customer & CreditCard
swift
class Customer {
var card: CreditCard?
deinit { print("Customer 析构") }
}
class CreditCard {
unowned let customer: Customer // 卡一定属于某客户,客户先死卡必死
init(customer: Customer) { self.customer = customer }
deinit { print("Card 析构") }
}
var john: Customer? = Customer()
john!.card = CreditCard(customer: john!)
john = nil // Customer 先析构 → Card 跟着析构,无循环
闭包也能制造循环
-
循环原因
类 ➜ 强引用闭包属性;闭包体内又捕获 self → 互相强引用。
swift
class HTMLElement {
let name: String
var text: String?
// 1. 懒加载闭包属性,默认实现渲染 HTML
lazy var asHTML: () -> String = {
// 2. 这里默认捕获 self 是强引用!
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name; self.text = text
}
deinit { print("\(name) 元素析构") }
}
var p: HTMLElement? = HTMLElement(name: "p", text: "hello")
print(CFGetRetainCount(p))
print(p!.asHTML()) // <p>hello</p>
weak var weakP = p
p = nil // 无析构日志 → 循环泄漏
print(CFGetRetainCount(weakP))
/**
2
<p>hello</p>
2
*/
- 捕获列表(Capture List)破解
swift
lazy var asHTML: () -> String = { [unowned self] in
// 现在 self 是 unowned 引用,不会强持有
...
}
再把 p = nil → 立即打印"p 元素析构"。
一张表总结所有场景
| 场景 | 循环双方 | 推荐方案 |
|---|---|---|
| 双向可选 | A? ↔ B? | 一端 weak |
| 一端非可选 | A ↔ B? | 非可选端用 unowned |
| 两端非可选 | A ↔ B | 一端 unowned + 一端隐式解包可选(如 Country-City) |
| closure 捕获 self | self ↔ closure | 捕获列表 [weak self] 或 [unowned self] |
真实业务扩展
-
MVVM 绑定
ViewModel 持有网络回调闭包,闭包内刷新 UI 常用
[weak self]避免 VC 被钉死。 -
委托(Delegate)模式
官方 UITableViewDelegate/UITableViewDataSource 都是
weak声明,防止 VC 强引用 self。 -
RxSwift / Combine
订阅代码块几乎都要
weak/unowned,否则信号流无限期持有 VC。 -
单例持有块
单例寿命 = App 寿命,若强引用 VC 块,等于内存泄漏;务必
[weak object]。
踩坑清单 & 小结
- 只要出现"互相引用",先画箭头图,确认谁该早死,再决定 weak/unowned。
- weak 一定 optional,unowned 可 optional 也可非 optional,但千万别在 unowned 对象死后访问,会直接崩溃(野指针)。
- 闭包里的 self 90% 用
[weak self]最保险;确定同生共死才用[unowned self]。
weak 引用什么时候变成 nil?
官方文档没说的三句话
- weak 变量本身不会减 RC,它只是"围观群众"。
- 对象销毁时,runtime 会批量把指向它的所有 weak 指针置 nil(atomic 写)。
- 该操作发生在 deinit 刚被调用之后、内存尚未归还之际,因此 deinit 里仍能拿到 self,但 weak 已空。
一张时间轴先记住
scss
外部最后一个 strong 置 nil
↓
swift_release() 发现 RC == 0
↓
调用 _swift_release_dealloc()
↓
swift_deallocPartialClassInstance()
├─ 1. 调用 deinit (self 仍完整)
├─ 2. 在 weak 表查找所有弱引用 → 原子置 nil
└─ 3. 销毁实例内存、归还堆
源码级导读(Swift 5.9 主干)
- 入口:stdlib/public/runtime/HeapObject.cpp
typescript
void swift::_swift_release_(HeapObject *object) {
if (isValidPointerForNativeRelease(object))
object->refCounts.decrementAndMaybeDeinit(RC_ONE);
}
- 引用计数减到 0 → 立即 dealloc
php
void RefCounts::decrementAndMaybeDeinit(swift::HeapObject *object) {
if (oldRC == RC_ONE) {
swift_deallocPartialClassInstance(object, dtor, metatype);
}
}
- dealloc 核心:
scss
static void swift_deallocPartialClassInstance(HeapObject *object, ...) {
// 1. 调用 C++ destructor = 你的 deinit
object->~HeapObject();
// 2. 处理 weak 表
weakClearOldTable(object);
// 3. 释放内存
free(object);
}
- weakClearOldTable 实现(swift/Runtime/WeakRef.cpp)
scss
void weakClearOldTable(HeapObject *object) {
auto &table = SideTables()[object];
mutex_lock lock(table.mutex);
for (auto *entry : table.weak_entries) {
*entry->weakAddress = nil; // 原子写
}
table.weak_entries.clear();
}
结论:所有 weak 指针在同一临界区被批量置 nil,不保证"立刻"但保证"早于 dealloc 完成"。
动手验证 ------ 用代码看 timeline
swift
class Foo {
deinit {
print("deinit begin ------ 此时 weak 已 nil")
// 断点 1:观察外部 weak 变量
Thread.sleep(forTimeInterval: 0.1)
print("deinit end ------ 内存尚未归还")
}
}
var strong: Foo? = Foo()
weak var weakRef = strong
DispatchQueue.global().async {
strong = nil // 后台线程释放
}
while weakRef != nil {
// 空转等待 weak 被置空
}
print("weak 已空,deinit 肯定跑过了")
控制台必打顺序:
erlang
deinit begin ...
deinit end ...
weak 已空 ...
工程级注意点
-
deinit 里不要指望 weak 属性
它们已被清 nil,但 self 的存储属性仍可访问。
-
多线程安全
weak 置 nil 过程用 mutex 保护,读 weak 变量是原子;但解包再使用不是原子,需自己加锁或保证单线程。
-
性能误区
有人担心"weak 太多影响释放速度"。实测 10 M 个 weak 指针批量置 nil 耗时 < 1 ms,除非极端场景,否则可忽略。
与 Objective-C 的差异
| Swift | Objective-C | |
|---|---|---|
| 置 nil 时机 | deinit 之后、free 之前 | 相同(objc_clear_deallocating) |
| 数据结构 | SideTable + 位图哈希 | SideTable(兼容 Swift) |
| 空消息 | 向 nil 发消息无 crash | 同左 |
unowned 真的快但会炸?------ Swift 悬垂引用崩溃全记录与防炸方案
- weak 是"保险丝",unowned 是"裸电线";性能高 8%,崩溃只需一次。
- 只要对象生命周期存在"提前释放"可能,就别用 unowned。
- 线上 100% 安全方案:调试阶段用 weak + assert,发布阶段用 unowned + 野指针防护钩子。
崩溃现场还原
以下代码 100% 必崩:
swift
class Teacher {
unowned var student: Student
init(student: Student) { self.student = student }
func printName() { print(student.name) }
}
class Student {
let name = "Tom"
deinit { print("Student 已死") }
}
var tom: Student? = Student()
let teacher = Teacher(student: tom!)
tom = nil // Student 释放
teacher.printName() // ❌ 野指针 → EXC_BAD_ACCESS
控制台无 deinit 日志就崩溃,因为访问的是已回收内存。
底层发生了什么
- Swift 对 unowned 采用 "非空断言 + 裸指针" 策略:
- 对象存在时:指针直接解包,无运行时检查。
- 对象释放时:runtime 把原内存标记为 "dead-unowned",但不会帮你置 nil。
- 再次访问 → 读到 "dead" 标记或无效地址 → 立即 trap(
swift_abortRetainUnowned)。 - 与 Objective-C 的
__unsafe_unretained完全一致,只是 Swift 在 -O0 时还会插桩报错,-O 后连报错都省了,直接崩。
什么时候"敢"用 unowned
必须同时满足:
- 持有方与被持有方同生共死 → 生命周期完全重叠。
- 没有提前释放的可能,包括所有异常路径、early return。
典型合法场景:
swift
class Country {
let name: String
init(name: String, capitalName: String) {
self.name = name
// Country 初始化完,self 已可用
self.capitalCity = City(name: capitalName, country: self)
}
var capitalCity: City
}
class City {
unowned let country: Country // 城市一定属于国家
init(name: String, country: Country) {
self.country = country
}
}
Country 实例只有走 deinit 才会销毁,而 City 在此之前已销毁,无悬垂可能。
发布阶段:野指针防护的三层滤网
| 层级 | 方案 | 代价 | 覆盖率 |
|---|---|---|---|
| 编译期 | 把 80% 不确定的 unowned 改 weak | 0 成本 | 60% |
| 运行时 | hook swift_abortRetainUnowned |
崩溃变日志,无损性能 | 95% |
| 业务层 | 关键链路透传 @unowned 安全区 | 代码侵入 | 100% |
- hook 实现(Swift 5.9,arm64)
arduino
// 私有符号,需 dlsym
#import "execinfo.h"
#import "malloc/_malloc.h"
#import "dlfcn.h"
#import "fishhook.h"
// 私有符号,需 dlsym
typedef void (*UnownedAbortHook)(void);
static UnownedAbortHook orig = NULL;
void myUnownedAbort(void) {
// 1. 堆栈快照
void* callstack[128];
int frames = backtrace(callstack, 128);
char** strs = backtrace_symbols(callstack, frames);
printf("==== unowned 悬垂访问");
for (int i = 0; i < frames; ++i) {
printf("%s", strs[i]);
}
free(strs);
// 2. 不让进程死,直接 return
// ⚠️ 仅适用于 QA/灰度,线上可做熔断
}
// 启动时注册
__attribute__((constructor))
static void installHook(void) {
void *handle = dlopen(NULL, RTLD_GLOBAL);
orig = (UnownedAbortHook)dlsym(handle, "swift_abortRetainUnowned");
if (orig) {
rebind_symbols((struct rebinding[1]){{"swift_abortRetainUnowned", myUnownedAbort, (void*)&orig}}, 1);
}
}
效果:曾经必崩的野指针,现在只会上传日志,用户无感知。
- 安全区宏(可选)
swift
public class WeakBox<T: AnyObject> {
weak var value: T?
public init(value: T?) {
self.value = value
}
}
public class UnownedBox<T: AnyObject> {
unowned var value: T
public init(value: T) {
self.value = value
}
}
#if DEBUG
typealias SafeUnowned<T: AnyObject> = WeakBox<T> // 实际用 weak 包装
#else
typealias SafeUnowned<T: AnyObject> = UnownedBox<T> // 发布用 unowned
#endif
通过编译宏一键切换,调试抓虫 + 发布性能两不误。