日常开发中,内存泄漏会导致 App 占用内存持续增长,最终触发系统“强杀”;野指针(一个指针指向的对象已经被释放了,但这个指针本身还没被置空)崩溃会让 App 毫无征兆闪退,这些问题的根源都指向内存管理疏漏。

一、ARC

1.核心原理

ARC的核心原理是编译器在编译时,自动分析对象的所有权修饰符以及变量生命周期,来决定什么时候为引用类型对象自动插入retain/release。(ARC的“自动”并非源于运行时有一套主动的“监测”系统。它的本质是编译时技术)在对象初始化或赋值给一个强引用时,插入 retain,在变量离开作用域或被重新赋值时,插入 release。

关键在于,它插入的不是Objective-C消息(如 [obj retain]),而是直接调用Runtime的底层C函数,比如 objc_retain(obj) 和 objc_release(obj)。因为这些操作是代码中最频繁执行的操作之一,绕过消息派发(objc_msgSend)的开销,直接进行函数调用,能节省大量的CPU周期,这对性能提升至关重要。

(为了高效管理weak引用,当第一次创建弱引用时,运行时系统会分配一个单独的Side Table。弱引用实际指向的是这个Side Table,再由它弱引用主对象。这套机制使得在主对象被释放时,能安全、高效地将所有指向它的弱引用自动置为nil)

2.与MRC差异

ARC和MRC最核心的区别在于自动化与手动化的区别。

MRC要求开发者必须手动插入retain和release来管理对象的生命周期。这不仅使代码变得繁琐,更带来了很高的风险:一旦忘记释放就会导致内存泄漏,而过度释放则会立刻引起野指针崩溃。

ARC则将这些工作完全交给了编译器。它在编译时自动插入所有内存管理的代码。这使得开发者的重心发生了根本性的转移:从“如何操作引用计数”转变为“如何设计对象间的引用关系”。我们不再关心具体的retain和release调用,而是专注于正确地使用strong、weak这些所有权修饰符。

因此,ARC带来的最大价值是大幅提升了开发效率和代码的健壮性,几乎根除了因手动操作失误导致的内存问题。但是无论是ARC还是MRC,它们都无法自动解决循环引用这一根本性难题。在ARC环境下,这依然需要我们通过weak或unowned来主动规避。

3.引用修饰符

strong 负责持有对象,是默认选项。

当需要打破循环引用时,如果关系不确定就用安全的 weak;如果能百分百确定生命周期同步,可以考虑用 unowned,但日常开发中 weak 是更通用和安全的选择。

1)strong(强引用)

strong 是默认的也是最常用的。它代表一种强持有关系,每当创建一个 strong 引用指向一个对象时,这个对象的引用计数就会加一。只要还有任何一个 strong 引用存在,这个对象就会一直保留在内存中。日常开发中,大部分属性都是 strong 的。

class Person {
    var name: String
    var cat: Cat?  // 人养了一只猫
    init(name: String) {
        self.name = name
    }
    deinit {
        print("\(name) 被销毁")
    }
}

class Cat {
    var name: String
    var owner: Person?  // 猫有一个主人
    init(name: String) {
        self.name = name
    }
    deinit {
        print("\(name) 被销毁")
    }
}

var heimai: Person? = Person(name: "黑麦")
var justice: Cat? = Cat(name: "正义")
heimai!.cat = justice

justice = nil
heimai = nil

//结果
//黑麦 被销毁
//正义 被销毁

在这个正常的引用例子中,虽然我们先将justice变量设置为nil,但先调用的却是Person的deinit方法。

这是因为执行 justice = nil 时,只是断开了justice变量对Cat对象的强引用,但此时Cat对象还被Person对象中的cat属性强引用着,所以Cat对象的引用计数从2降到1,对象不会被立即销毁。
执行 heimai = nil 时,断开了heimai变量对Person对象的强引用,Person对象的引用计数从1降到0,ARC立即触发Person对象的销毁。在Person对象的deinit方法执行期间,它的cat属性被置为nil,这导致Cat对象的引用计数从1降到0。具体过程如下图:

好的,如果我们添加一行justice!.owner = heimai为猫添加主人后,执行代码后会发生什么呢?

class Person {
    var name: String
    var cat: Cat?
    init(name: String) {
        self.name = name
    }
    deinit {
        print("\(name) 被销毁")
    }
}

class Cat {
    var name: String
    var owner: Person?
    init(name: String) {
        self.name = name
    }
    deinit {
        print("\(name) 被销毁")
    }
}

var heimai: Person? = Person(name: "黑麦")
var justice: Cat? = Cat(name: "正义")
heimai!.cat = justice
justice!.owner = heimai //为猫添加主人

justice = nil
heimai = nil

//结果什么都没有

我们会发现什么结果也没有,说明这两个对象一直没有被销毁。让我们来分析一下。

执行 justice = nil 后,只是断开了justice变量对Cat对象的强引用,但Cat对象还被Person对象通过cat属性强引用着,Cat对象的引用计数:2 → 1(不会销毁)。执行 heimai = nil 后,只是断开了heimai变量对Person对象的强引用,但Person对象还被Cat对象通过owner属性强引用着,Person对象的引用计数:2 → 1(不会销毁)。我们画个图会发现,构成了一个回环,这就是strong强引用导致的循环引用。

当两个对象互相用 strong 引用对方时,就产生了循环引用,导致两者都无法被释放,这就是内存泄漏的根源。而 weak 和 unowned 就是为了解决这个问题而存在的。它们都不会增加对象的引用计数,也就是都“不持有”对象,从而可以打破循环。但它们的关键区别在于安全性。

2)weak(弱引用)

weak 是一种安全的选择。当它引用的对象被释放时,这个 weak 指针会自动被设置为 nil。正因为它会变 nil,所以我们必须将它声明为可选类型。像我们常用的 delegate 模式,或者闭包中捕获 self 时,为了避免循环引用,都会使用 [weak self]。

回到我们的例子,我们可以在Cat类中使用weak:

class Cat {
    var name: String
    weak var owner: Person?  // 使用 weak
    init(name: String) {
        self.name = name
    }
    deinit { print("\(name) 被销毁") }
}

justice = nil
heimai = nil

//结果
//黑麦 被销毁
//正义 被销毁

这时,当我们执行nil的操作后,我们会发现像之前一样,类被销毁了。因为这时正义对黑麦是弱引用。

使用 weak 就一定安全吗?

 不一定。 如果在闭包中使用了 [weak self],但在闭包内部进行了强制解包 self!.doSomething(),而此时 self 恰好被释放了,就会导致崩溃。正确的做法是使用可选绑定 guard let self = self else { return }。

3)unowned(无主引用)

而 unowned 则是一种不安全的假设。它假定它所引用的对象和它自己的生命周期一样长,甚至更长。实例和本身属性指向的对象应该同时存在,如果unowned目标被释放,程序就会直接崩溃。因此,它通常被定义为非可选类型。它一般用在两个对象生命周期完全绑定的场景,是否可以单独存在。比如一个信用卡和他的用户,信用卡不可能在用户不存在之后还独立存在。

换个例子,我们就用信用卡和用户的例子。

class User {
    var name: String
    var card: CreditCard?  // 用户有张信用卡
    init(name: String) {
        self.name = name
    }
    deinit {
        print("\(name) 被销毁")
    }
}

class CreditCard {
    var cardNumber: String
    unowned let user: User  // 信用卡必须属于一个用户
    init(cardNumber: String, user: User) {
        self.cardNumber = cardNumber
        self.user = user //不是可选类型
    }
    deinit {
        print("卡 \(cardNumber) 被销毁")
    }
}
var heimai: User? = User(name: "黑麦")
heimai!.card = CreditCard(cardNumber: "7672", user: heimai!)

// 销毁用户,信用卡也会一起销毁
heimai = nil

// 输出:
// 黑麦 被销毁
// 卡 7672 被销毁

unowned 不增加引用计数,CreditCard的user属性是unowned,所以黑麦对象的引用计数始终是1。当heimai = nil时,黑麦对象引用计数立即归零。先销毁User对象,在销毁过程中自动断开对CreditCard的强引用,然后CreditCard对象引用计数归零,随之销毁。
如果我们错误,创建独立的CreditCard引用,并且将heimai销毁,程序就会崩溃!

var heimai: User? = User(name: "黑麦")
var dangerousCard: CreditCard? = CreditCard(cardNumber: "7672", user: heimai!)
// 注意:这里没有 heimai!.card = dangerousCard
// 销毁用户
heimai = nil
print(dangerousCard?.user.name)// ⚠️ 报错。CreditCard还活着,但user已销毁

unowned 和 weak 的性能有区别吗?

有细微区别。weak 需要Runtime在弱引用表进行注册和清理操作,有额外开销。unowned 则没有。所以在性能极度敏感的、且能保证生命周期同步的场景,unowned 稍好。但安全第一,不确定时用 weak。

3.Swift内存管理

主要针对引用类型class进行管理,可以观看Struct和Class的区别与应用,里面有讲解在内存管理上的差异。

二、内存泄漏

1.泄漏场景

所有内存问题都是循环引用吗?

不是。 内存问题主要有三类:第一是对象间的循环引用,比如相互强引用;第二是系统资源未正确释放,比如Timer、文件句柄等;第三是全局或长生命周期的持有,比如缓存数据忘记清理。我们平时说的循环引用只是其中最常见的一种而已。

循环引用

1)对象循环引用

当两个对象相互持有对方的强引用时,就形成了循环引用。即使没有外部变量引用它们,它们也无法被释放,因为彼此的引用计数始终 ≥ 1。

解决办法:其中一个引用定义为弱引用或无主引用。如果两个对象的生命周期相同,则应该使用弱引用,否则应该使用无主引用。

就是上面举的例子,class对象循环引用。可以翻回去看一下。

2)闭包强引用self

在闭包中捕获self,持有对self的强引用,可能会导致循环引用。闭包会强引用所有在内部使用的外部变量。当对象持有闭包,而闭包又强引用该对象时,就形成了循环引用。下面是错误实例:

class MyClass {
    var closure: (() -> Void)?
    func someMethod() {
        closure = { 
            self.someOtherMethod() // 直接强引用self
        }
    }
    func someOtherMethod() {
        // ...
    }
    deinit { print("MyClass 销毁") }
}

var obj: MyClass? = MyClass()
obj?.someMethod()
obj = nil  // 无输出,内存泄漏!

MyClass 强引用 closure,closure 又强引用 self(MyClass 实例),形成循环引用。

解决办法:使用weak或unowned来定义捕获的引用。如果闭包可能在self释放之后被调用,应该使用unowned,否则应该使用weak。

class MyClass {
    var closure: (() -> Void)?
    func someMethod() {
        closure = { [weak self] in
            self?.someOtherMethod() // ✅ weak引用,使用self?
        }
    }
    func someOtherMethod() {
        // ...
    }
    deinit { print("MyClass 销毁") }
}

var obj: MyClass? = MyClass()
obj?.someMethod()
obj = nil  // 输出:MyClass 销毁

3)Delegate强引用

委托和代理模式可能会导致循环引用。在委托模式中,如果双方相互强引用,就会形成循环引用。通常被委托方不应该影响委托方的生命周期。

class Manager {
    var delegate: Delegate?  // 应该是weak!
    deinit { print("Manager 销毁") }
}

class Controller: Delegate {
    var manager = Manager()
    
    init() {
        manager.delegate = self  // 相互强引用
    }
    
    deinit { print("Controller 销毁") }
}

var c: Controller? = Controller()
c = nil  // 无输出,内存泄漏

Controller 强引用 Manager,Manager 也强引用 Controller(通过 delegate),形成循环引用。

将 delegate 声明为 weak,这样 Manager 不会强引用 Controller。当 Controller 销毁时,Manager 的引用计数归零也随之销毁。

class Manager {
    weak var delegate: Delegate?  // weak引用
    deinit { print("Manager 销毁") }
}

c = nil  // 输出:Controller 销毁 → Manager 销毁

系统资源未正确释放

4)Timer未注销

Timer 会强引用它的 target 对象。如果 Timer 一直在运行,即使外部没有引用,target 对象也无法被释放。

class MyClass {
    var timer: Timer?
    
    init() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.update()  // Timer强引用self
        }
    }
    
    deinit { print("MyClass 销毁") }
}

var obj: MyClass? = MyClass()
obj = nil  // 无输出,内存泄漏

Timer 系统内部强引用着 MyClass 实例,即使 obj = nil,Timer 仍在运行并持有对象。

使用 [weak self] 避免 Timer 强引用对象,同时在 deinit 中调用 invalidate() 确保 Timer 完全停止。

class MyClass {
    var timer: Timer?
    
    init() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.update()  // weak引用
        }
    }
    
    deinit {
        timer?.invalidate()  // 停止Timer
        print("MyClass 销毁")
    }
}

obj = nil  // 输出:MyClass 销毁

全局持有

5)全局缓存未清理

var globalCache: [Any] = []

class Data {
    deinit { print("Data 销毁") }
}

var data: Data? = Data()
globalCache.append(data!)  // 全局强引用
data = nil  // 无输出,内存泄漏

对象被全局或长生命周期的容器持有,即使没有循环引用也无法释放,因为全局容器的生命周期与应用相同。

Data 对象被 globalCache 强引用,globalCache 的生命周期与应用相同,Data 对象永远无法释放。

正确解释:三种解决方案:

1. 手动清理:明确调用清理方法
2. 弱引用容器:使用系统提供的弱引用容器
3. 定期清理:实现缓存淘汰策略,定期清理过期数据

// 方案1:手动清理
globalCache.removeAll()  // 清理后data才能释放

// 方案2:弱引用容器
var weakCache = NSPointerArray.weakObjects()

// 方案3:定期清理策略
func clearCache() {
    globalCache.removeAll()
}

2.排查工具

1)deinit

这是最基础、最直接的方法,用于在代码层面确认对象生命周期。上面的例子中就用了deinit来判断。

原理:
在 Swift 中,当一个类的实例被释放时,如果该类实现了 deinit 方法,该方法会被自动调用。通过在其中打印日志,我们可以直观地看到对象是否被正确销毁。

使用方法:
在可能存在泄漏的类中,添加 deinit 方法。

2)Memory Graph(内存图调试)

这是 Xcode 内置的、极其强大的内存分析工具,可以可视化地查看内存中的对象和它们之间的引用关系。

原理:
它生成一个应用程序当前内存状态的“快照”,以图形化的方式展示所有存活的对象以及它们之间的引用链。

使用方法:

  1. 运行你的 App,进入可能发生泄漏的场景(例如,打开然后关闭一个 ViewController)。

  2. 在 Xcode 调试栏,点击 Memory Graph 按钮。Xcode 会暂停 App,并生成当前的内存图。在左侧导航栏,紫色感叹号 ❗️ 标记的通常是可疑的循环引用。

  3. 点击可疑对象,在右侧面板可以看到它的强引用链(Strong References)。这个引用链会清晰地展示出是哪个对象持有了它,导致它无法被释放。

3)Instrument

Instruments 是 Apple 官方的性能分析套件,功能极其强大。对于内存问题,主要使用 Leaks 和 Allocations 模板。

原理:

  • Leaks 工具: 专门用于检测已无法访问的内存块(即真正的内存泄漏)。它会自动运行并标记出哪些内存被分配了,但程序中已经没有任何指针可以访问到它们。

  • Allocations 工具: 跟踪所有内存分配和释放的历史记录。你可以看到每个对象的创建和销毁栈,对于分析“未释放的内存”(可能仍有引用但逻辑上已无用)非常有用。

(我没有使用过)

参考文章:一篇文章看懂自动引用计数和循环引用到底是怎么回事

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐