ARC記憶體管理以及迴圈引用

Mitsui_發表於2018-01-02

ARC:"Automatic Reference Counting",自動引用計數。Swift語言延續了OC的做法,也是利用ARC機制進行記憶體管理,和OC的ARC一樣,當一些類的例項不在需要的時候,ARC會釋放它們的記憶體。但是,在少數情況下,ARC需要知道你的程式碼之間的關係才能更好的為你管理記憶體,和OC一樣,Swift中的ARC也存在迴圈引用導致記憶體洩露的情況。

一、ARC的工作機制

每當我們建立一個類的新的例項的時候,ARC會從堆中分配一塊記憶體用來儲存有關該例項的資訊。這塊記憶體將持有這個例項的型別資訊以及和它關聯的屬性的值。另外,當這個例項不再被需要的時候,ARC將回收這個例項所佔有的記憶體並且將這部分記憶體給其他需要的例項用。這樣就能保證不再被需要的例項不佔用多餘的記憶體。 但是,如果ARC釋放了正在使用的例項,那麼該例項的屬性將不能被訪問,方法將不能被呼叫,如果你訪問它的屬性或者呼叫它的方法時,應用會崩潰,因為你訪問了一個野指標。 為了解決上述問題,ARC會跟蹤每個類的例項正在被多少個屬性、常量或者變數引用,每當你將類例項賦值給屬性,常量或者變數的時候它就會被"強"引用一次,當它的引用計數為0時,表明它不再被需要,ARC就會銷燬它。 下面舉個例子介紹ARC是如何工作的

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    deinit {
        print("\(name) is being deinitialized")
    }
}
複製程式碼

上述程式碼建立了一個名為Person的類,該類宣告瞭一個非可選的型別的name常量,一個給name賦值的初始化方法,並且列印了一句話,用來標註初始化成功,同時宣告瞭一個解構函式,列印了一句標誌此例項被銷燬的資訊。

var reference1: Person?
var reference2: Person?
var reference3: Person?
複製程式碼

上述程式碼宣告瞭三個Person?型別的變數,這三個變數為可選型別,所以被自動初始化為nil,此時三個例項都沒有指向任何一個Person類的例項。

reference1 = Person(name: "John Appleseed")
// Prints "John Appleseed is being initialized"
複製程式碼

現在建立一個Person類的例項,並且賦值給reference1,此時控制檯會列印"John Appleseed is being initialized"

reference2 = reference1
reference3 = reference1
複製程式碼

然後將該例項賦值給reference2reference3。現在該例項被三個"強"型別的指標引用。

reference1 = nil
reference2 = nil
複製程式碼

如上所示,當我們將其中兩個引用賦值給nil的時候,這兩個"強"引用被打破,但是這個Person的例項並沒有被釋放(釋放資訊未列印),因為還存在一個對這個例項的強引用。

reference3 = nil
// Prints "John Appleseed is being deinitialized"
複製程式碼

當我們將第三個"強"引用打破的時候(賦值為nil),可以看到控制檯列印的"John Appleseed is being deinitialized"析構資訊。

二、兩個類例項之間的迴圈引用

上述的例子中,ARC可以很好的獲取一個例項的引用計數,並且當它的引用計數為0的時候釋放它。但是在實際的開發過程中,會存在一些特殊情況,使ARC沒辦法得到引用計數為0這個關鍵點,就會造成這個例項的記憶體一直不被釋放,兩個類的例項相互"強"引用就會造成這種情況,就是"迴圈引用"。 蘋果官方提供了兩種方法來解決兩個例項之間的迴圈引用,unowned引用和weak引用。

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類和一個Apartment類。每一個Person的例項都有一個name的屬性和一個apartment的可選屬性,初始化為nil,因為並不是每一個人都擁有一個公寓,所以是可選屬性。同樣的,每一個Apartment例項都有一個unit屬性和一個tenant的可選屬性,初始化為nil,同理,不是每一個公寓都有人租。同時,兩個類都定義了deinit方法,並且列印一段資訊,用來讓我們清楚這個例項何時被銷燬。

var john: Person?
var unit4A: Apartment?
複製程式碼

分別定義一個Person型別和Apartment的變數,定義為optional(可選型別),初始化為nil

john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
複製程式碼

然後分別建立一個Person類的例項和Apartment類的例項,並且分別賦值給上面的定義的變數。

ARC記憶體管理以及迴圈引用
上圖為此時變數和例項之間的強引用關係。 然後john將擁有一座公寓unit4A,公寓unit4A將被john承租。

john!.apartment = unit4A
unit4A!.tenant = john
複製程式碼

因為可以確定兩個變數都被賦值為相應型別的例項,所以此處用!對可選屬性強解包。 此時,兩個變數和例項以及兩個例項之間的"強"引用關係如下圖。

ARC記憶體管理以及迴圈引用
從圖中可以看到兩個例項互相"強"引用,也就是說這兩個例項的引用計數永遠不會為0,ARC也不會釋放這兩個例項的記憶體。

john = nil
unit4A = nil
複製程式碼

當我們將兩個變數設定為nil,切斷他們與例項之間的"強"引用關係,此時兩個例項之間的"強"引用關係為:

ARC記憶體管理以及迴圈引用
從圖中可以看出,這兩個例項的引用計數仍然不為0,它們佔用的記憶體還是得不到釋放,因此就會造成記憶體洩露。

三、解決兩個類例項之間的迴圈引用

Swift提供了兩種辦法解決類例項之間的迴圈引用。weak引用和unowned引用。這兩種方法都可以使一個例項引用另一個例項的時候,不用保持"強"引用。weak一般應用於其中一個例項具有更短的生命週期,或者可以隨時設定為nil的情況下;unowned用於兩個例項具有差不多長的生命週期,或者說兩個例項都不能被設定為nil

(1) weak引用

weak引用對所引用的例項不會保持"強"引用的關係。假如一個例項同時被若干個"強引用"和一個weak引用引用時,當所有其他的"強"引用都被打破時該例項就會被ARC釋放,並且ARC會自動將這個weak引用置為nil。因此,weak引用一般被宣告為var,因為它會被ARC設定為nil

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 }
    weak var tenant: Person?
    deinit { print("Apartment \(unit) is being deinitialized") }
}
複製程式碼

現在,我們將Apartment類中的tenant變數宣告為weak引用(在var關鍵字前加weak關鍵字),表明某公寓的承租人並不一定一直都是同一個人。

var john: Person?
var unit4A: Apartment?
 
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
 
john!.apartment = unit4A
unit4A!.tenant = john
複製程式碼

然後和上文一樣,將兩個變數和例項關聯。此時,它們之間的引用關係如下圖。

ARC記憶體管理以及迴圈引用
Person例項仍然"強"引用Apartment例項,但是Apartment例項'weak'引用Person例項。johnunit4A兩個變數仍然"強"引用兩個例項。當我們把john變數對Person例項的"強"引用打破的時候,即將john設定為nil,就沒有其他的"強"引用引用Person例項,此時,Person例項被ARC釋放,同時Apartment例項的tenant變數被設定為nil

john = nil
// Prints "John Appleseed is being deinitialized"
複製程式碼

ARC記憶體管理以及迴圈引用
然後將變數unit4A設為nil,可以看到Apartment例項也被銷燬。

unit4A = nil
// Prints "Apartment 4A is being deinitialized"
複製程式碼

ARC記憶體管理以及迴圈引用

(2) unowned引用

weak引用一樣,unowned引用也不會保持它和它所引用例項之間的"強"引用關係,而是保持一種非擁有(或未知)的關係,使用的時候也是用unowned關鍵字修飾宣告的變數。不同的是,兩個互相引用的物件具有差不多長的生命週期,而不是其中一個可以提前被釋放(weak),有點患難與共的意思。 Swift要求unowned修飾的變數必須一直指向一個例項,而不是有些時候為nil,因此,ARC也不會將這個變數設定為nil,所以我們一般將這個引用宣告為非可選型別。PS:請確保你宣告的變數一直指向一個例項,如果這個例項被釋放了,而unowned變數還在引用它的話,你會得到一個執行時錯誤,因為,這個變數是非可選型別的。

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") }
}
複製程式碼

上面這個例子定義了兩個類:CustomerCreditCard,每個顧客都可能會有一張信用卡(可選型別),每個信用卡都一定會有一個持有他們的顧客(非可選型別,卡片為顧客定製)。因此,Customer類有一個CreditCard?型別的屬性,CreditCard類也有一個Customer型別的屬性,並且被宣告為unowned,以此來打破迴圈引用。每張信用卡初始化的時候都需要一名持有它的顧客,因為信用卡本身就是為顧客定製的。

var john: Customer?
複製程式碼

然後宣告一個Customer?型別的變數john,初始化為nil。接著建立一個Customer的例項,並且將它賦值給john(讓john引用它、指向它都是一個意思)。

john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)
複製程式碼

(第一句程式碼賦值之後,我們知道john肯定不是nil,所以用!解包不會有問題) 然後,兩個例項之間的引用關係為:

ARC記憶體管理以及迴圈引用
Customer例項"強"引用CreditCard例項,CreditCard例項'unowned'引用Customer例項,接著,我們將johnCustomer例項的"強"引用打破,即將john設定為nil

john = nil
// Prints "John Appleseed is being deinitialized"
// Prints "Card #1234567890123456 is being deinitialized"
複製程式碼

ARC記憶體管理以及迴圈引用
可以看到Customer例項和CreditCard例項都被銷燬了。john被設定為nil之後,就沒有"強"引用引用Customer例項,所以,Customer例項被釋放,也就沒有"強"引用引用CreditCard例項,因此CreditCard例項也被釋放。 以上例子證明,兩種方式都可以解決迴圈引用的問題,但是要注意它們使用的範圍。weak修飾的變數可以被設定為nil(引用的例項的生命週期短於另一個例項),unowned修飾的變數必須要指向一個例項(造成迴圈引用的兩例項的生命週期差不多長,不會出現一方被提前釋放的情況),一旦它被釋放了,就千萬別再使用了。

四、閉包引起的迴圈引用

Swift中的閉包是一種獨立的函式程式碼塊,它可以像一個類的例項一樣在程式碼中賦值、呼叫和傳遞,也可以被認為某個匿名函式的例項,其實就是OC中的block。它和類一樣也是引用型別的,所以它的函式體中使用的引用都是"強"引用。

class HTMLElement {
    
    let name: String
    let text: String?
    
    lazy var asHTML: () -> String = {
        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) is being deinitialized")
    }
    
}
複製程式碼

上述例子中,閉包被賦值給asHTML變數,所以閉包被HTMLElement例項"強"引用,而閉包又捕獲(關於閉包捕獲變數,參考官方文件Capturing Values)了HTMLElement的例項中的textname屬性,因此它又"強"引用HTMLElement例項,這樣就造成了迴圈引用,因為text屬性可能為空,所以定義為可選屬性。

var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// Prints "<p>hello, world</p>"
複製程式碼

我們建立一個HTMLElement例項,並將它賦值給paragraph變數,然後訪問它的asHTML屬性。此時的記憶體示例為下圖,可以看到HTMLElement例項和閉包之間的迴圈引用。

ARC記憶體管理以及迴圈引用
當我們將paragraph 設定為nil時,控制檯並沒有列印任何銷燬資訊,因為迴圈引用。
ARC記憶體管理以及迴圈引用
上圖為使用Instruments分析得到的迴圈引用以及造成的記憶體洩漏。

五、使用unowned和weak解決迴圈引用

通過上文(三)的分析,我們知道unowned引用對例項的非擁有關係,因此,我們可以通過如下方式解決迴圈引用:

lazy var asHTML: () -> String = {
        [unowned self] in
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }
複製程式碼

[unowned self] in,這段程式碼,代表閉包中的self指標都被unowned修飾。這樣就可以使閉包對例項的"強"引用變成'unowned'引用,從而打破迴圈引用。 當HTML的element為標題的時候,此時如果text屬性為空,我們想返回一個預設的text作為標題,而不是隻有<h/>這種標籤。

let heading = HTMLElement(name: "h1")
let defaultText = "some default text"
heading.asHTML = {
    return "<\(heading.name)>\(heading.text ?? defaultText)</\(heading.name)>"
}
print(heading.asHTML())
// Prints "<h1>some default text</h1>"
複製程式碼

這段程式碼也會造成HTMLElement對其自身的迴圈引用。我們仍然可以使用unowned關鍵字打破迴圈引用:

heading.asHTML = {
    [unowned heading] in
    return "<\(heading.name)>\(heading.text ?? defaultText)</\(heading.name)>"
}
// Prints "<h1>some default text</h1>"
// Prints "h1 is being deinitialized"
複製程式碼

unowned會使閉包中對heading的"強"都改為'unowned'引用。 或者,可以使用weak屬性打破迴圈引用:

weak var weakHeading = heading
heading.asHTML = {
    return "<\(weakHeading!.name)>\(weakHeading!.text ?? defaultText)</\(weakHeading!.name)>"
}
// Prints "<h1>some default text</h1>"
//Prints "h1 is being deinitialized"
複製程式碼

上文(三)中可知,weak修飾的變數為可選型別,而且,我們對變數進行了一次賦值,就可以確保weakHeading指向heading引用的例項,所以可以放心的使用!對它解包。 上面這段程式碼同樣可以使閉包對HTMLElement例項的"強"引用變為weak引用,從而打破迴圈引用。 (ARC會自動回收不被使用的物件,所以不用手動將變數設定為nil

本文參考Automatic Reference Counting

相關文章