Swift 中的記憶體管理詳解

Hack Notes發表於2016-04-17

這篇文章是在閱讀《The Swift Programming Language》Automatic Reference Counting(ARC,自動引用計數)一章時做的一些筆記,同時參考了其他的一些資料。

在早期的 iOS 開發中,記憶體管理是由開發者手動來完成的。因為傳統的垃圾回收機制對於移動平臺來說十分低效,蘋果採用的是引用計數(RC,Reference Counting)的方式來管理記憶體,開發者需要通過手工的方式增加或減少一個例項的引用計數。在 iOS 5 之後,引入了 ARC 自動引用計數,使得開發者不需要手動地呼叫 retain 和 release 來管理引用計數,但是實際上這些方法還是會被呼叫,只不過是交給了編譯器來完成,編譯器會在合適的地方幫我們加入這些方法。

什麼是自動引用計數?

每當你建立一個類的例項的時候,ARC 便會自動分配一塊記憶體空間來存放這個例項的資訊,當這個例項不再被使用的時候,ARC 便釋放例項所佔用的記憶體。一般每個被管理的例項都會與一個引用計數器相連,這個計數器儲存著當前例項被引用的次數,一旦建立一個新的引用指向這個例項,引用計數器便加 1,每當指向該例項的引用失效,引用計數器便減 1,當某個例項的引用計數器變成 0 的時候,這個例項就會被立即銷燬。

在 Swift 中,對引用描述的關鍵字有三個:strongweak 和 unowned,所有的引用沒有特殊說明都是 strong 強引用型別。在 ARC 中,只有指向一個例項的所有 strong 強引用都斷開了,這個例項才會被銷燬。

舉一個簡單的例子:

class A {
    let name: String
    init(name: String) {
        self.name = name
    }
    deinit {
        print("A deinit")
    }
}

var a1: A?
var a2: A?

a1 = A(name: "A")
a2 = a1

a1 = nil

上面這個例子中,雖然 a1 這個 strong 強引用斷開了,但是還有 a2 這個強引用指向這個例項,所以不會在命令列中輸出 A deinit,當我們把 a2 也設定為 nil 時,與這個例項關聯的所有強引用均斷開了,這個例項便會被銷燬,在命令列中列印 A deinit

迴圈強引用(Strong Reference Cycles)

但是,在某些情況下,一個類例項的強引用數永遠不能變為 0,例如兩個類例項互相持有對方的強引用,因而每個類例項都讓對方一直存在,這就是所謂的強引用迴圈(Strong Reference Cycles)。

這裡引用 TSPL 中的例子:

class Person {
    let name: String
    init(name: String) { self.name = name }
    var apartment: Apartment?
    deinit { print("\(name) is being deinitialized") }
}

class Apartment {
    let unit: String
    init(unit: String) { self.unit = unit }
    var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}

每一個 Person 例項有一個可選的初始化為 nil 的 Apartment 型別,因為一個人並不總是擁有公寓。同樣,每一個 Apartment 例項都有一個可選的初始化為 nil 的 Person 型別,因為一個公寓並不總是屬於一個人。

接下來的程式碼片段定義了兩個可選型別的變數 john 和 unit4A,並分別設定為下面的 Person 和 Apartment 的例項,這兩個變數都備受設定為 nil

var john: Person?
var unit4A: Apartment?

現在可以建立特定的 Person 和 Apartment 例項,並將它們賦值給 john 和 unit4A 變數:

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

下面一段程式碼將這兩個例項關聯起來:

john!.apartment = unit4A
unit4A!.tenant = john

將兩個例項關聯在一起後,強引用的關係如圖所示:

這兩個例項關聯之後,會產生一個迴圈強引用,當斷開 john 和 unit4A 所持有的強引用時,引用計數器並不會歸零,所以這兩塊空間也得不到釋放,這就導致了記憶體洩漏。

可以將其中一個類中的變數設定為 weak 弱引用來打破這種強引用迴圈:

class Apartment {
    let unit: String
    init(unit: String) { self.unit = unit }
    weak var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}

當斷開 john 和 unit4A 所持有的強引用時,Person instance 的引用計數器變成 0,例項被銷燬,從而 Apartment instance 的引用計數器也變為 0,例項被銷燬。

什麼時候使用 weak

當兩個例項是 optional 關聯在一起時,確保其中的一個使用 weak 弱引用,就像上面所說的那個例子一樣。

unowned 無主引用

在某些情況下,宣告的變數總是有值得時候,我們需要使用 unowned 無主引用。

同樣借用一下 TSPL 中的例子:

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

class CreditCard {
    let number: UInt64
    unowned let customer: Customer
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    deinit { print("Card #\(number) is being deinitialized") }
}

這裡定義了兩個類,Customer 和 CreditCard,模擬了銀行客戶和客戶的信用卡,在這個例子中,每一個類都是將另一個類的例項作為自身的屬性,所以會產生迴圈強引用。

和之前那個例子不同的是,CreditCard 類中有一個非可選型別的 customer 屬性,因為,一個客戶可能有或者沒有一張信用卡,但是一張信用卡總是關聯著一個使用者。

var john: Customer?
john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)

關聯兩個例項後,它們的引用關係如圖所示:

當斷開 john 變數持有的強引用時,再也沒有指向 Customer 的強引用了,所以該例項被銷燬了,其後,再也沒有指向 CreditCard 的強引用了,該例項也被銷燬了。

什麼時候使用 unowned 無主引用?

兩個例項 A 和 B,如果例項 A 必須在例項 B 存在的前提下才能存在,那麼例項 A 必須用 unowned 無主引用指向例項 B。也就是說,有強制依賴性的那個例項必須對另一個例項持有無主引用。

例如上面那個例子所說,銀行客戶可能沒有信用卡,但是每張信用卡總是繫結著一個銀行客戶,所以信用卡這個類就需要用 unowned 無主引用。

無主引用以及隱市解析可選屬性

還有一種情況,兩個屬性都必須有值,並且初始化完成之後永遠不會為 nil。在這種情況下,需要一個類使用 unowned 無主引用,另一個類使用隱式解析可選屬性。

閉包引起的迴圈強引用

在 Swift 中,閉包和函式都屬於引用型別。並且閉包還有一個特性:可以在其定義的上下文中捕獲常量或者變數。所以,在一個類中,閉包被賦值給了一個屬性,而這個閉包又使用了這個類的例項的時候,就會引起迴圈強引用。

Swift 提供了一種方法來解決這個問題:閉包捕獲列表(closure capture list)。在定義閉包的同時定義捕獲列表作為閉包的一部分,捕獲列表定義了閉包體內捕獲一個或者多個引用型別的規則。跟解決兩個類例項之間的迴圈強引用一樣,宣告每個捕獲的引用為弱引用或者無主引用。

捕獲列表中的每一項都由一對元素組成,一個元素是 weak 或者 unowned 關鍵字,另一個元素是類例項的引用(例如最常見得是 self),這些在方括號內用逗號隔開。

具體的使用方法請參考官方文件

何時使用 weak,何時使用 unowned

在閉包和捕獲的例項總是相互引用並且總是同時銷燬的時候,將閉包內的捕獲定義為 unowned 無主引用。

在被捕獲的例項可能變成 nil 的情況下,使用 weak 弱引用。如果被捕獲的引用絕對不會變成 nil,應該使用 unowned 無主引用,而不是 weak 弱引用。

Garbage Collection(GC,垃圾回收)

其實 ARC 應該也算 GC 的一種,不過我們一談到 GC,大多都會想到 Java 中的垃圾回收機制,相比較 GC,ARC 簡單得許多。以後有機會可以討論一下 Java 中的記憶體管理。

另外,需要注意的一點是,這裡所講的都是針對於引用型別結構體列舉在 Swift 中屬於值型別,不在 ARC 的考慮範圍之內。

相關文章