Swift內存管理機制深度解析
譯文【51CTO.com快譯】
簡介
作為一種現代化高級編程語言,Swift為您的應用程序中的分配、釋放等內存管理需求提供強有力的支持。它使用的是一種稱為自動化引用計數(ARC)的技術。通過本文的學習,你將通過以下內容進一步提升你的Swift開發中的ARC編程水平:
- 了解ARC的工作原理。
- 何謂引用循環以及如何消除這種循環。
- 通過一個實例展示引用循環,并通過最新的Xcode可視化工具了解如何檢測這種循環。
- 如何處理值類型和引用類型混合應用情形。
入門
打開Xcode并單擊命令「File\New\Playground…」。然后,選擇iOS平臺,并將其命名為「MemoryManagement」,然后選擇【Next】命令。最后,把工程保存到你想要存儲的目標位置,然后刪除其中的樣板代碼并保存工程。
接下來,將下面的代碼添加到您的工程文件中:
- class User {
- var name: String
- init(name: String) {
- self.name = name
- print("User \(name) is initialized")
- }
- deinit {
- print("User \(name) is being deallocated")
- }
- }
- let user1 = User(name: "John")
這段代碼定義了一個類User,并創建它的一個實例。該類有一個屬性是name,定義了一個init方法(剛好在內存分配之后進行調用)和一個deinit方法(剛好在內存回收后調用)。打印語句print用于及時輸出你想看到的所發生的事情。
從輸出結果中,你會發現在側邊欄中顯示出“User John is initialized\n”;此信息是通過在初始化方法init中的打印語句print輸出的。但是,你會發現,deinit方法內的print語句永遠不會被調用。這意味著,對象永遠不會被析構。當然,這也就意味著它永遠不會被釋放。這是因為,它被初始化的范圍永遠不會關閉——工程本身永遠不會走出這個范圍——因此,對象不會從內存中刪除。
現在,我們改變上面的初始化方法,像如下這樣:
- do {
- let user1 = User(name: "John")
- }
此語句創建了一個范圍,此圍繞包含了user1對象的初始化。于是,在作用域結束后,我們希望user1對象會被釋放。
現在,你看到對應于初始化和析構方法中的兩個print語句都在側邊欄中輸出了內容。這表明,該對象在上面定義的作用域結束后,也就是恰好在它被從內存中刪除之前被析構。
歸納起來,一個Swift對象的生命周期包括五個階段:
1. 分配(從堆棧或堆中分配內存)
2. 初始化(init代碼運行)
3. 使用(使用對象)
4. 析構(deinit代碼運行)
5. 釋放(內存又回到了堆棧或堆)
雖然沒有直接的鉤子技術埋伏到內存分配和內存回收中,但是您可以在init和deinit方法中 使用print語句作為代理手段來監控上述過程。注意,盡管上面過程4和5中的釋放和析構兩個方法常常交替使用,但實際上它們是在一個對象的生命周期中的兩個不同的階段。
引用計數是當不再需要對象時被釋放的機制。這里的問題是:“你何時可以肯定未來不會需要這個對象?”通過保持一個使用次數的統計計數,即“引用計數”即可實現這一管理目的。引用計數存儲于每一個對象實例內部。
上面的計數能夠確定,有多少“東西”引用了對象。當一個對象的引用計數降為零時,也就是說對象的客戶端不再存在;于是,對象被析構和解除內存分配;請參考下圖示意。
當你初始化User對象時,開始時該對象的引用計數為1,因為常量user1引用該對象。在do語句塊的結束,user1超出范圍,計數減1,并且引用計數遞減到零。這樣一來,user1被析構,并且隨后取消內存分配。
引用循環
在大多數情況下,ARC就像一個魔法一樣起作用。作為開發人員,您通常不必擔心內存泄露,例如不必擔心未使用的對象是否還存活于內存中。
但事情并不總是一帆風順!內存泄漏也可能發生!
泄漏是怎樣發生的?讓我們設想有這樣的情況,某兩個對象不再需要使用它們,但它們各自引用了對方。既然每一個對象都有一個非零的引用計數;那么,這兩個對象的釋放就永遠不會發生。
這就是所謂的強引用循環。它愚弄了ARC,并防止被從內存中清理掉。正如你所看到的,在最后的引用計數并不為零,因而object1和object2是永遠不會釋放的,即使不再需要它們。
為了觀察這種情況的真實例子,請添加以下代碼到User類的定義之后,且正好在現有的do語句之前:
- class Phone {
- let model: String
- var owner: User?
- init(model: String) {
- self.model = model
- print("Phone \(model) is initialized")
- }
- deinit {
- print("Phone \(model) is being deallocated")
- }
- }
然后,把do語句塊修改成如下這樣:
- do {
- let user1 = User(name: "John")
- let iPhone = Phone(model: "iPhone 6s Plus")
- }
這將增加了一個名為Phone的新類,并創建此新類的一個實例。
這個新的類是相當簡單的:擁有兩個屬性,一個用于模型存儲和一個用于擁有者,還有一個初始化方法init和析構方法deinit。其中,owner屬性是可選的,因為Phone可以不需要User而存在。
接下來,將下面的代碼添加到User類中,正好位于name屬性后面:
- private(set) var phones: [Phone] = []
- func add(phone: Phone) {
- phones.append(phone)
- phone.owner = self
- }
這部分代碼將增加一個phones數組屬性來保存一個用戶所擁有的所有電話號碼。而且,這個setter方法是私有的,這樣客戶端會被強制使用add(phone:)方法。此方法可確保當你添加新號碼時owner設置正確。
目前,如你可以在側邊欄中看到的,無論是Phone還是User對象都會按預期釋放。
但現在,你如果把do語句塊修改成如下這樣:
- do {
- let user1 = User(name: "John")
- let iPhone = Phone(model: "iPhone 6s Plus")
- user1.add(phone: iPhone)
- }
在這里,你把iPhone添加到user1。這會自動將iPhone的owner設置為user1。在這兩個對象之間的一個強引用循環防止ARC重新分配它們。這樣一來,無論是user1還是iPhone從未被釋放。
弱引用
為了打破引用循環,您可以將引用計數的對象之間的關系指定為weak。除非另有說明,所有引用都是強引用。相比之下,弱引用并不會增加對象的強引用計數。
換句話說,弱引用并不參加對象的生命周期管理。此外,弱引用總是被聲明為optional類型。這意味著,當引用計數變為零時,引用可被自動設置為nil。
在上圖中,虛線箭頭表示弱引用。注意,圖中的object1的引用計數是1,因為變量variable1引用了它。Object2的引用計數為2,因為variable2和object1都引用了它。但是,object2弱引用object1,這意味著它不會影響object1的強引用計數。
當兩個變量(即變量variable1和變量variable2)銷毀后,object1的引用計數為零并將調用deinit。這將消除對object2的強引用;當然,隨后object2也被析構。
現在,請再打開上面的示例工程,通過使owner成為弱引用,從而打破User和Phone間的引用循環,代碼如下所示:
- class Phone {
- weak var owner: User?
- // other code...
- }
相應的圖示如下:
現在,user1和iphone這兩個變量在do語句塊的最后都能夠正確釋放內存。你可以從側邊欄的輸出結果中觀察到這一點。
無主引用
Swift語言中還引入了另一種不增加引用計數的引用修飾符:unowned。
那么,unowned和weak引用之間的區別是什么?弱引用始終是可選的,并且當引用對象析構時自動變為nil。這就是為什么為了使你的代碼進行編譯(因為變量需要改變)而必須把弱屬性定義為可選的var類型的原因。
無主引用,相比之下,絕不是可有可無的類型。如果您嘗試訪問一個引用了一個析構對象的無主屬性,你會觸發一個運行時錯誤,請參考下圖。
接下來,我們來實際使用一下unowned修飾符。在上面do塊之前添加一個新類CarrierSubscription,如下所示:
- class CarrierSubscription {
- let name: String
- let countryCode: String
- let number: String
- let user: User
- init(name: String, countryCode: String, number: String, user: User) {
- self.name = name
- self.countryCode = countryCode
- self.number = number
- self.user = user
- print("CarrierSubscription \(name) is initialized")
- }
- deinit {
- print("CarrierSubscription \(name) is being deallocated")
- }
- }
CarrierSubscription具有四個屬性:訂閱名name,國家代碼countryCode,電話號碼phone和一個到User對象的引用。
接下來,將以下語句添加到User類中,正好在name屬性的定義后:
var subscriptions: [CarrierSubscription] = []
這將增加一個subscriptions屬性,此屬性中存儲一組CarrierSubscrition對象。
此外,將以下代碼添加到Phone類的頂部,正好位于owner屬性的后面:
- var carrierSubscription: CarrierSubscription?
- func provision(carrierSubscription: CarrierSubscription) {
- self.carrierSubscription = carrierSubscription
- }
- func decommission() {
- self.carrierSubscription = nil
- }
這將增加一個可選的CarrierSubscription屬性和兩個新的函數。
接下來,添加以下代碼到CarrierSubscription類的初始化方法init中,正好位于打印語句之前:
user.subscriptions.append(self)
這將確保CarrierSubscription被添加到用戶的訂閱數組中。
最后,修改do語句塊,如下所示:
- do {
- let user1 = User(name: "John")
- let iPhone = Phone(model: "iPhone 6s Plus")
- user1.add(phone: iPhone)
- let subscription1 = CarrierSubscription(name: "TelBel", countryCode: "0032", number: "31415926", user: user1)
- iPhone.provision(carrierSubscription: subscription1)
- }
請注意觀察在側邊欄的打印結果。同樣,你又看到一個引用循環:user1,iPhone或subscription1在最后都沒有被釋放。你能找到問題出在哪里嗎?
無論是從user1到subscription1的引用,還是從subscription1到user1的引用都應當是無主引用,從而打破這種循環。現在的問題是:這兩個應選擇哪一種?要解決這個問題,需要你有一點關于域(domain)的知識作為幫助。
用戶擁有一個訂閱,而訂閱并不擁有用戶。此外,沒有擁有它的用戶的CarrierSubscription是沒有任何存在意義的。這就是為什么你在最開始的位置把它聲明為一個不可改變的let類型屬性的原因。
由于沒有CarrierSubscription的用戶可以存在,但沒有用戶的CarrierSubscription沒有存在必要;因此,user引用應當是無主類型(unowned)的。
接下來,把CarrierSubscription的user屬性添加上unowned修飾符,像下面這樣:
- class CarrierSubscription {
- let name: String
- let countryCode: String
- let number: String
- unowned let user: User
- // Other code...
- }
這樣一來,就可以打破引用循環,從而讓每一個對象都可以釋放內存分配。
閉包的引用循環問題
當屬性相互引用時就會發生對象引用循環情況。類似于對象,閉包也是引用類型,并因此也可能導致循環引用。但是,閉包能夠捕獲它們所操作的對象。
例如,如果一個閉包被賦值給一個類的屬性,而該閉包使用了同一類的實例屬性,則就出現了一個引用循環。換句話說,在對象中通過保存的屬性擁有了到閉包的引用;而閉包也通過self關鍵字保持著到對象的引用。請參考下圖進一步理解。
添加下面代碼到CarrierSubscription定義,也就是在user屬性的定義之后的位置:
- lazy var completePhoneNumber: () -> String = {
- self.countryCode + " " + self.number
- }
此閉合計算并返回一個完整的電話號碼。注意,這個屬性是使用lazy關鍵字聲明的;這意味著,直到第一次使用它時它才會被分配。這是必要的,因為它要使用self.countryCode和self.number;而直到初始化運行后這才能夠可用。
現在,請添加下面一行代碼到do語句塊的結尾:
- print(subscription1.completePhoneNumber())
從上面輸出中你會發現,user1和iPhone兩個對象都能夠成功地回收內存分配,但CarrierSubscription卻不能,這是由于在對象和閉包之間存在強引用循環所致。
Swift提供了一種簡單而優雅的方式來打破強引用循環中的閉包。方法是:我們只要聲明一個捕獲列表,并在此列表中定義它所捕獲的閉包和對象之間的關系。
為了說明捕獲列表是如何工作的,不妨考慮下面的代碼:
- var x = 5
- var y = 5
- let someClosure = { [x] in
- print("\(x), \(y)")
- }
- x = 6
- y = 6
- someClosure() // Prints 5, 6
- print("\(x), \(y)") // Prints 6, 6
在上面代碼中,變量x是在捕獲列表中;因此,在閉包定義點就創建了x的一個拷貝。這稱為通過值捕獲。另一方面,y沒有定義于捕獲列表中,因此被以引用方式捕獲。這意味著,在閉合運行時,y的值將是對應于此時的任何可能的取值,而不是對應于捕獲點處原來的值。
因此,捕捉列表用于在閉包內部定義弱引用對象或無主引用對象之間的關系。在上述例子中,unowned引用就是一個不錯的選擇,因為在CarrierSubscription的實例消失后閉包是不可能存在的。
現在,請把CarrierSubscription的completePhoneNumber閉包更改成如下樣子:
- lazy var completePhoneNumber: () -> String = {
- [unowned self] in
- return self.countryCode + " " + self.number
- }
這段代碼將把[unowned self]添加到閉包的捕獲列表中。這意味著,self被捕獲為無主引用,而不是強引用。
這種技術徹底解決了引用循環問題!
這里使用的語法實際上是一個較長的捕捉語法的簡寫,這里引入了一個新的標識符。請考慮下面更長的形式:
- var closure = {
- [unowned newID = self] in
- // Use unowned newID here...
- }
在這里,newID是self的一個unowned副本。在閉包范圍外部,self保留其原有的意義。如你上面使用的簡短形式,創建了一個新的self變量——此變量只是在閉包范圍內“遮擋”住現有的self變量。
在你編寫代碼中,self和閉包completePhoneNumber之間的關系應當是無主(unowned)引用。如果您確信閉包中的一個引用對象將永遠不會釋放,那么你可以使用unowned引用。如果這個對象確定要釋放內存,那么就存在麻煩了。
請把下面的代碼添加到上面示例工程文件的結尾:
- class WWDCGreeting {
- let who: String
- init(who: String) {
- self.who = who
- }
- lazy var greetingMaker: () -> String = {
- [unowned self] in
- return "Hello \(self.who)."
- }
- }
- let greetingMaker: () -> String
- do {
- let mermaid = WWDCGreeting(who: "caffinated mermaid")
- greetingMaker = mermaid.greetingMaker
- }
- greetingMaker() // TRAP!
程序運行時將引發一個運行時異常,因為閉包期望self.who仍然有效,但是當mermaid變量脫離其范圍時會被釋放。這個例子似乎有些做作,但在現實開發中很容易發生這種情況——例如,當您使用閉包要很晚時候才運行某些東西的時候(譬如在異步網絡調用完成后)。
好,下面請把WWDCGreeting中的greetingMaker變量更改成如下這樣:
- lazy var greetingMaker: () -> String = {
- [weak self] in
- return "Hello \(self?.who)."
- }
這段代碼中,你對原來的greetingMaker作出兩處修改。首先,使用weak替換unowned。其次,由于self成為weak類型,所以你需要使用self?.who來訪問who屬性。
再次運行示例工程時系統不再崩潰了,但你在側邊欄中得到一個奇怪的輸出結果:“Hello, nil.”。也許,這是可以接受的,但更多的情況下當對象已經一去不復返時你往往想做一些完全與此不同的事情。Swift的guard let語句使得實現這一目的非常容易。
讓我們最后一次重新修改閉包吧,使其看起來像下面這樣:
- lazy var greetingMaker: () -> String = {
- [weak self] in
- guard let strongSelf = self else {
- return "No greeting available."
- }
- return "Hello \(strongSelf.who)."
- }
guard語句綁定一個來自于weak welf的新變量strongSelf。如果self是nil,閉包將返回“No greeting available.”另一方面,如果self不是nil,strongSelf將進行強引用;這樣一來,對象將被確保一直有效,直到閉包末端處。
上述這一術語,有時也被稱為強弱舞蹈(strong-weak dance),它是Swift語言中處理閉包中這種行為的一種強有力的模式。
一個引用循環的完整例子
現在,你已經明白了Swift語言中的ARC原則了,你也理解了什么是引用循環,以及如何打破它們。接下來,讓我們來看看一個真實世界的例子。
首先,請下載我提供的一個啟動項目(https://koenig-media.raywenderlich.com/uploads/2016/08/ContactsStarterProject-1.zip),并在Xcode 8(或更新版本)中打開,因為Xcode 8添加了你要使用的一些有趣的新功能。
之后,構建并運行這個項目,你會看到顯示以下內容:
這是一個簡單的聯系人應用程序。你可以隨意點擊一個聯系人以獲取更多信息,或者使用右上角的【+】按鈕添加一個聯系人。
現在,我們來概述一下關鍵代碼的作用:
- ContactsTableViewController:顯示數據庫所有聯系人對象。
- DetailViewController:顯示每一個具體聯系人的詳細信息。
- NewContactViewController<:允許用戶添加一個聯系人。
- ContactTableViewCell:一個用于顯示聯系人詳細信息的表格單元格。
- Contact:對應于數據庫中的聯系人。
- Number:用于存儲電話號碼。
然而,這個工程中存在一些可怕的錯誤:代碼中存在引用循環!在相當一段時間內,您的用戶不會注意到這一點,因為這個問題中存在的泄漏對象很小——它們的尺寸使得它更難追查。幸運的是,Xcode 8中提供了一個新的內置工具來幫助你找到哪怕是最小的泄漏。
生成并再次運行應用程序。嘗試著刪除三或四個聯系人。看起來,他們已經完全消失了,對吧?
當應用程序仍在運行時,移動到Xcode的底部,然后單擊【Debug Memory Graph】按鈕:
請觀察圖中Xcode 8引入的新的問題類型:Runtime Issues。它們看起來像是在一個紫色方框中放上了一個白色的感嘆號一樣的圖標,請參考顯示在下面這個截圖中選擇的部分:
在導航器中,選擇某一個有問題的聯系人對象。則循環引用現在清晰可見:Contact和Number對象保持彼此存活——通過彼此相互引用。請參考下圖:
這種類型圖表提供了你尋找代碼中錯誤的一種形象標志。請考慮一下:一個聯系人在沒有號碼情況下能夠正常存在,但一個號碼在沒有聯系人時是不應當存在的。那么,你將如何解決這個循環問題呢?
強烈建議讀者先自己嘗試解決一下這個問題。然后,再對照下面的解決方案。
其實,有兩種可能的解決辦法:你可以使從Contact到Number的關系成為弱引用類型,也可以使從Number到Contact的關系成為unowned類型。這兩種方案都能夠有效地解決循環引用問題。
【注意】蘋果官方文檔(https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/MemoryMgmt/Articles/mmPractical.html)中推薦一個父對象應當強引用一個子對象。這意味著,應當在Contact中強引用Number,而使Number無主引用Contact。請參考下面的代碼答案:
- class Number {
- unowned var contact: Contact
- // Other code...
- }
- class Contact {
- var number: Number?
- // Other code...
- }
循環引用與值類型和引用類型
Swift類型分為引用類型(如類)和值類型(如結構或枚舉)。主要的區別是,值類型在傳來傳去時被復制,而引用類型共享引用信息的一個副本。
這是否意味著,使用值類型時就不存在循環問題?是的:如果一切都使用值類型復制的話,就不會存在循環引用關系,因為不會創建真正的引用。你至少需要兩個引用才構成一個循環,是吧?
不妨回到剛才的工程代碼中,在結尾處加上以下內容:
- struct Node { // Error
- var payload = 0
- var next: Node? = nil
- }
運行一下,你會注意到編譯器無法正常通過編譯。原因在于,一個結構(值類型)不能是遞歸的或使用它自己的一個實例;否則,這種類型的結構將有無限的大小。現在,我們將其更改為像下面這樣的一個類:
- class Node {
- var payload = 0
- var next: Node? = nil
- }
自我引用對于類(即引用類型)來說不是問題,所以編譯器錯誤消失了。
現在,再添加下列代碼到您的上述文件中:
- class Person {
- var name: String
- var friends: [Person] = []
- init(name: String) {
- self.name = name
- print("New person instance: \(name)")
- }
- deinit {
- print("Person instance \(name) is being deallocated")
- }
- }
- do {
- let ernie = Person(name: "Ernie")
- let bert = Person(name: "Bert")
- ernie.friends.append(bert) // Not deallocated
- bert.friends.append(ernie) // Not deallocated
- }
這里的例子提供了一個值類型和引用類型混合形成引用循環的例子。
ernie和bert正常存活——通過在他們的friends數組中保持互相引用,雖然數組本身是一個值類型。如果把這個數組改成unowned類型,則Xcode中會顯示一個錯誤:unowned只適用于類類型。
為了打破這里的循環,你必須創建一個泛型包裝對象,并用它來添加實例到數組中。如果你不知道什么是泛型或如何使用它們,請查看官方網站中有關泛型的教程。
好,現在請在上面Person類的定義上面添加如下代碼:
- class Unowned<T: AnyObject> {
- unowned var value: T
- init (_ value: T) {
- self.value = value
- }
- }
然后,更改Person中friends屬性的定義為如下樣子:
- var friends: [Unowned<Person>] = []
最后,把do語句塊修改成看起來像下面這樣:
- do {
- let ernie = Person(name: "Ernie")
- let bert = Person(name: "Bert")
- ernie.friends.append(Unowned(bert))
- bert.friends.append(Unowned(ernie))
- }
現在,ernie和bert都能夠正常釋放了!
在此,friends數組不再是Person對象的一個集合了,而是成為無主對象的集合——此對象用作Person實例的包裝器。
為了從Unowned對象中訪問Person對象,我們可以使用value屬性,像這樣:
- let firstFriend = bert.friends.first?.value // get ernie
小結
完整的示例工程下載地址是https://koenig-media.raywenderlich.com/uploads/2016/08/MemoryManagement.playground.zip。
通過本文學習,你對Swift的內存管理應當有了一個很好的了解,并知道ARC是如何工作的。
如果你想更深入地了解Swift是如何實現弱引用的,請參考一下邁克的博客文章“Swift弱引用”(https://www.mikeash.com/pyblog/friday-qa-2015-12-11-swift-weak-references.html)。
【51CTO譯稿,合作站點轉載請注明原文譯者和出處為51CTO.com】