Swift 内存管理:吃透 ARC 、weak、unowned

前言

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
  1. weak 版 Apartment
swift 复制代码
class Apartment {
    weak var tenant: Person?   // 房不强持有住客
}

执行结果:

外部 john = nil → Person 析构 → Apartment.tenant 自动变 nil → 再 unit4A = nil → Apartment 析构。

日志正确打印两行析构。

  1. 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 跟着析构,无循环

闭包也能制造循环

  1. 循环原因

    类 ➜ 强引用闭包属性;闭包体内又捕获 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
 */
  1. 捕获列表(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]

真实业务扩展

  1. MVVM 绑定

    ViewModel 持有网络回调闭包,闭包内刷新 UI 常用 [weak self] 避免 VC 被钉死。

  2. 委托(Delegate)模式

    官方 UITableViewDelegate/UITableViewDataSource 都是 weak 声明,防止 VC 强引用 self。

  3. RxSwift / Combine

    订阅代码块几乎都要 weak/unowned,否则信号流无限期持有 VC。

  4. 单例持有块

    单例寿命 = App 寿命,若强引用 VC 块,等于内存泄漏;务必 [weak object]

踩坑清单 & 小结

  • 只要出现"互相引用",先画箭头图,确认谁该早死,再决定 weak/unowned。
  • weak 一定 optional,unowned 可 optional 也可非 optional,但千万别在 unowned 对象死后访问,会直接崩溃(野指针)。
  • 闭包里的 self 90% 用 [weak self] 最保险;确定同生共死才用 [unowned self]

weak 引用什么时候变成 nil?

官方文档没说的三句话

  1. weak 变量本身不会减 RC,它只是"围观群众"。
  2. 对象销毁时,runtime 会批量把指向它的所有 weak 指针置 nil(atomic 写)。
  3. 该操作发生在 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 主干)

  1. 入口:stdlib/public/runtime/HeapObject.cpp
typescript 复制代码
void swift::_swift_release_(HeapObject *object) {
  if (isValidPointerForNativeRelease(object))
    object->refCounts.decrementAndMaybeDeinit(RC_ONE);
}
  1. 引用计数减到 0 → 立即 dealloc
php 复制代码
void RefCounts::decrementAndMaybeDeinit(swift::HeapObject *object) {
  if (oldRC == RC_ONE) {
    swift_deallocPartialClassInstance(object, dtor, metatype);
  }
}
  1. dealloc 核心:
scss 复制代码
static void swift_deallocPartialClassInstance(HeapObject *object, ...) {
  // 1. 调用 C++ destructor = 你的 deinit
  object->~HeapObject();

  // 2. 处理 weak 表
  weakClearOldTable(object);

  // 3. 释放内存
  free(object);
}
  1. 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 已空 ...

工程级注意点

  1. deinit 里不要指望 weak 属性

    它们已被清 nil,但 self 的存储属性仍可访问。

  2. 多线程安全

    weak 置 nil 过程用 mutex 保护,读 weak 变量是原子;但解包再使用不是原子,需自己加锁或保证单线程。

  3. 性能误区

    有人担心"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 悬垂引用崩溃全记录与防炸方案

  1. weak 是"保险丝",unowned 是"裸电线";性能高 8%,崩溃只需一次。
  2. 只要对象生命周期存在"提前释放"可能,就别用 unowned。
  3. 线上 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 日志就崩溃,因为访问的是已回收内存。

底层发生了什么

  1. Swift 对 unowned 采用 "非空断言 + 裸指针" 策略:
    • 对象存在时:指针直接解包,无运行时检查。
    • 对象释放时:runtime 把原内存标记为 "dead-unowned",但不会帮你置 nil。
  2. 再次访问 → 读到 "dead" 标记或无效地址 → 立即 trap(swift_abortRetainUnowned)。
  3. 与 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%
  1. 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);
    }
}

效果:曾经必崩的野指针,现在只会上传日志,用户无感知。

  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

通过编译宏一键切换,调试抓虫 + 发布性能两不误。

相关推荐
HarderCoder3 小时前
【Swift 访问控制全解析】一篇就够:从 open 到 private,让接口与实现各就其位
swift
Digitally3 小时前
5种将照片从iPhone传输到戴尔PC/笔记本电脑的方法
ios·电脑·iphone
ajassi20003 小时前
开源 Objective-C IOS 应用开发(三)第一个iPhone的APP
ios·开源·objective-c
HarderCoder3 小时前
Swift 6 实战:从“定时器轮询”到 AsyncSequence 的优雅实时推送
swift
Daniel_Coder9 小时前
iOS Widget 开发-9:可配置 Widget:使用 IntentConfiguration 实现参数选择
ios·swiftui·swift·widget·intents
非专业程序员Ping12 小时前
Vibe Coding 实战!花了两天时间,让 AI 写了一个富文本渲染引擎!
ios·ai·swift·claude·vibecoding
m0_4955627813 小时前
Swift的逃逸闭包
服务器·php·swift
00后程序员张14 小时前
HTTP抓包工具推荐,Fiddler配置方法、代理设置与使用教程详解(开发者必学网络调试技巧)
网络·http·ios·小程序·fiddler·uni-app·webview
m0_4955627814 小时前
Swift-static和class
java·服务器·swift