Swift 值類型和引用類型的內存管理
1、內存分配
1.1 值類型的內存分配
在 Swift 中定長的值類型都是保存在棧上的,操作時不會涉及堆上的內存。變長的值類型(字符串、集合類型是可變長度的值類型)會分配堆內存。
- 這相當於一個 “福利”,意味著你可以使用值類型更快速的完成一個方法的執行。
- 值類型的實例只會保存其內部的存儲屬性,並且通過 “=” 賦值的實例彼此的存儲是獨立的。
- 值類型的賦值是拷貝的,對於定長的值類型來說,由於所需的內存空間是固定的,所以這種拷貝的開銷是在常數時間內完成的。
struct Point { var x: Double var y: Double }
let point1 = Point(x: 3, y: 5) var point2 = point1 print(point1) // Point(x: 3.0, y: 5.0) print(point2) // Point(x: 3.0, y: 5.0)
上面的示例在棧上的實際分配如下圖。
棧 point1 x: 3.0 y: 5.0 point2 x: 3.0 y: 5.0
如果嘗試修改
point2
的屬性,只會修改point2
在棧上的地址中保存的x
值,不會影響point1
的值。point2.x = 5 print(point1) // Point(x: 3.0, y: 5.0) print(point2) // Point(x: 5.0, y: 5.0)
棧 point1 x: 3.0 y: 5.0 point2 x: 5.0 y: 5.0
1.2 引用類型的內存分配
引用類型的存儲屬性不會直接保存在棧上,系統會在棧上開辟空間用來保存實例的指針,棧上的指針負責去堆上找到相應的對象。
- 引用類型的賦值不會發生 “拷貝”,當你嘗試修改示例的值的時候,實例的指針會 “指引” 你來到堆上,然後修改堆上的內容。
下面把
Point
的定義修改成類。class Point { var x: Double var y: Double init(x: Double, y: Double) { self.x = x self.y = y } }
let point1 = Point(x: 3, y: 5) let point2 = point1 print(point1.x, point1.y) // 3.0 5.0 print(point2.x, point2.y) // 3.0 5.0
因為
Point
是類,所以Point
的存儲屬性不能直接保存在棧上,系統會在棧上開辟兩個指針的長度用來保存point1
和point2
的指針,棧上的指針負責去堆上找到對應的對象,point1
和point2
兩個實例的存儲屬性會保存在堆上。當使用 “=” 進行賦值時,棧上會生成一個
point2
的指針,point2
指針與point1
指針指向堆的同一地址。棧 堆 point1 [ ] --| |--> 類型信息 point2 [ ] --| 引用計數 x: 3 y: 5
在棧上生成
point1
和point2
的指針後,指針的內容是空的,接下來會去堆上分配內存,首先會對堆加鎖,找到尺寸合適的內存空間,然後分配目標內存並解除堆的鎖定,將堆中內存片段的首地址保存在棧上的指針中。相比在棧上保存
point1
和point2
,堆上需要的內存空間要更大,除了保存x
和y
的空間,在頭部還需要兩個 8 字節的空間,一個用來索引類的類型信息的指針地址,一個用來保存對象的 “引用計數”。當嘗試修改
point2
的值的時候,point2
的指針會 “指引” 你來到堆上,然後修改堆上的內容,這個時候point1
也被修改了。point2.x = 5 print(point1.x, point1.y) // 5.0 5.0 print(point2.x, point2.y) // 5.0 5.0
我們稱
point1
和point2
之間的這種關系為 “共享”。“共享” 是引用類型的特性,在很多時候會給人帶來困擾,“共享” 形態出現的根本原因是我們無法保證一個引用類型的對象的不可變性。
2、可變性和不可變性
在 Swift 中對象的可變性與不可變性是通過關鍵字
let
和var
來限制的。Swift 語言默認的狀態是不可變性,在很多地方有體現。
- 比如方法在傳入實參時會進行拷貝,拷貝後的參數是不可變的。
- 或者當你使用
var
關鍵字定義的對象如果沒有改變時,編譯器會提醒你把var
修改為let
。
2.1 引用類型的可變性和不可變性
對於引用類型的對象,當你需要一個不可變的對象的時候,你無法通過關鍵字來控制其屬性的不可變性。
當你創建一個
Point
類的實例,你希望它是不可變的,所以使用let
關鍵字聲明,但是let
只能約束棧上的內容,也就是說,即便你對一個類型實例使用了let
關鍵字,也只能保證它的指針地址不發生變化,但是不能約束它的屬性不發生變化。。class Point { var x: Double var y: Double init(x: Double, y: Double) { self.x = x self.y = y } }
let point1 = Point(x: 3, y: 5) let point2 = Point(x: 0, y: 0) print(point1.x, point1.y) // 3.0 5.0 print(point2.x, point2.y) // 0.0 0.0 point1 = point2 // 發生編譯錯誤,不能修改 point1 的指針 point1.x = 0 // 因為 x 屬性是使用 var 定義的,所以可以被修改 print(point1.x, point1.y) // 0.0 5.0 print(point2.x, point2.y) // 0.0 0.0
如果把所有的屬性都設置成不可變的,這的確可以保證引用類型的不可變性,而且有不少語言就是這麽設計的。
class Point { let x: Double let y: Double init(x: Double, y: Double) { self.x = x self.y = y } }
let point1 = Point(x: 3, y: 5) print(point1.x, point1.y) // 3.0 5.0 point1.x = 0 // 發生編譯錯誤,x 屬性是不可變的
新的問題是如果你要修改
Point
的屬性,你只能重新建一個對象並賦值,這意味著一次沒有必要的加鎖、尋址與內存回收的過程,大大損耗了系統的性能。let point1 = Point(x: 3, y: 5) point1 = Point(x: 0, y: 5)
2.2 值類型的可變性和不可變性
因為值類型的屬性保存在棧上,所以可以被
let
關鍵字所約束。你可以把一個值類型的屬性都聲明稱
var
,保證其靈活性,在需要該類型的實例是一個不可變對象時,使用let
聲明對象,即便對象的屬性是可變的,但是對象整體是不可變的,所以不能修改實例的屬性。struct Point { var x: Double var y: Double }
let point1 = Point(x: 3, y: 5) print(point1.x, point1.y) // 3.0 5.0 point1.x = 0 // 編輯報錯,因為 point1 是不可變的
因為賦值時是 “拷貝” 的,所以舊對象的可變性限制不會影響新對象。
let point1 = Point(x: 3, y: 5) var point2 = point1 // 賦值時發生拷貝 print(point1.x, point1.y) // 3.0 5.0 print(point2.x, point2.y) // 3.0 5.0 point2.x = 0 // 編譯通過,因為 point2 是可變的 print(point1.x, point1.y) // 0.0 5.0 print(point2.x, point2.y) // 0.0 5.0
3、引用類型的共享
“共享” 是引用類型的特性,在很多時候會給人帶來困擾,“共享” 形態出現的根本原因是我們無法保證一個引用類型的對象的不可變性。
下面展示應用類型中的共享。
// 標簽 class Tag { var price: Double init(price: Double) { self.price = price } } // 商品 class Merchandise { var tag: Tag var description: String init(tag: Tag, description: String) { self.tag = tag self.description = description } }
let tag = Tag(price: 8.0) let tomato = Merchandise(tag: tag, description: "tomato") print("tomato: \(tomato.tag.price)") // tomato: 8.0 // 修改標簽 tag.price = 3.0 // 新商品 let potato = Merchandise(tag: tag, description: "potato") print("tomato: \(tomato.tag.price)") // tomato: 3.0 print("potato: \(potato.tag.price)") // potato: 3.0
這個例子中所描述的情景就是 “共享”, 你修改了你需要的部分(土豆的價格),但是引起了意料之外的其它改變(番茄的價格),這是由於番茄和土豆共享了一個標簽實例。
語意上的共享在真實的內存環境中是由內存地址引起的。上例中的對象都是引用類型,由於我們只創建了三個對象,所以系統會在堆上分配三塊內存地址,分別保存
tomato
、potato
和tag
。棧 堆 tamoto Tag --| description | tag |--> price: 3.0 | patoto Tag --| description
在 OC 時代,並沒有如此豐富的值類型可供使用,有很多類型都是引用類型的,因此使用引用類型時需要一個不會產生 “共享” 的安全策略,拷貝就是其中一種。
首先創建一個標簽對象,在標簽上打上你需要的價格,然後在標簽上調用
copy()
方法,將返回的拷貝對象傳給商品。let tag = Tag(price: 8.0) let tomato = Merchandise(tag: tag.copy(), description: "tomato") print("tomato: \(tomato.tag.price)") // tomato: 8.0
當你對
tag
執行copy
後再傳給Merchandise
構造器,內存分配情況如下圖。棧 堆 tamoto Tag -----> Copied tag description price: 8.0 tag price: 8.0
如果有新的商品上架,可以繼續使用 “拷貝” 來打標簽。
let tag = Tag(price: 8.0) let tomato = Merchandise(tag: tag.copy(), description: "tomato") print("tomato: \(tomato.tag.price)") // tomato: 8.0 // 修改標簽 tag.price = 3.0 // 新商品 let potato = Merchandise(tag: tag.copy(), description: "potato") print("tomato: \(tomato.tag.price)") // tomato: 8.0 print("potato: \(potato.tag.price)") // potato: 3.0
現在內存中的分配如圖。
棧 堆 tamoto Tag -----> Copied tag description price: 8.0 tag price: 3.0 patoto Tag -----> Copied tag description price: 3.0
這種拷貝叫做 “保護性拷貝”,在保護性拷貝的模式下,不會產生 “共享”。
4、變長值類型的拷貝
變長值類型不能像定長值類型那樣把全部的內容都保存在棧上,這是因為棧上的內存空間是連續的,你總是通過移動尾指針去開辟和釋放棧的內存。在 Swift 中集合類型和字符串類型是值類型的,在棧上保留了變長值類型的身份信息,而變長值類型的內部元素全部保留在堆上。
定長值類型不會發生 “共享” 這很好理解,因為每次賦值都會開辟新的棧內存,但是對於變長的值類型來說是如何處理哪些尾保存內部元素而占用的堆內存呢?蘋果在 WWWDC2015 的 414 號視頻中揭示了定長值類型的拷貝奧秘:相比定長值類型的 “拷貝” 和引用類型的 “保護性拷貝”,變長值類型的拷貝規則要復雜一些,使用了名為 Copy-on-Write 的技術,從字面上理解就是只有在寫入的時候才拷貝。
在 Swift 3.0 中出現了很多 Swift 原生的變長值類型,這些變長值類型在拷貝時使用了 Copy-on-Write 技術以提升性能,比如 Date、Data、Measurement、URL、URLSession、URLComponents、IndexPath。
5、利用引用類型的共享
“共享” 並不總是有害的,“共享” 的好處之一是堆上的內存空間得到了復用,尤其是對於內存占用空間較大的對象(比如圖片),效果明顯。所以如果堆上的對象在 “共享” 狀態下不會被修改,那麽我們應該對該對象進行復用從而避免在堆上創建重復的對象,此時你需要做的是創建一個對象,然後向對象的引用者傳遞對象的指針,簡單來說,就是利用 “共享” 來實現一個 “緩存” 的策略。
假如你的應用中會用到許多重復的內容,比如用到很多相似的圖片,如果你在每個需要的地方都調用
UIImage(named:)
方法,那麽會創建很多重復的內容,所以我們需要把所有用到的圖片集中創建,然後從中挑選需要的圖片。很顯然,在這個場景中字典最適合作為緩存圖片的容器,把字典的鍵值作為圖片索引信息。這是引用類型的經典用例之一,字典的鍵值就是每個圖片的 “身份信息”,可以看到在這個示例中 “身份信息” 是多麽的重要。enum Color: String { case red case blue case green } enum Shape: String { case circle case square case triangle }
let imageArray = ["redsquare": UIImage(named: "redsquare"), ...] func searchImage(color: Color, shape: Shape) -> UIImage { let key = color.rawValue + shape.rawValue return imageArray[key]!! }
一個變長的值類型實際會把內存保存在堆上,因此創建一個變長值類型時不可避免的會對堆加鎖並分配內存,我們使用緩存的目的之一就是避免過多的堆內存操作,在上例中我們習慣性的把
String
作為字典的鍵值,但是String
是變長的值類型,在searchImage
中生成key
的時候會觸發堆上的內存分配。如果想繼續提升
searchImage
的性能,可以使用定長值類型作為鍵值,這樣在合成鍵值時將不會訪問堆上的內存。要註意的一點是你所使用的定長值類型必須滿足Hashable
協議才能作為字典的鍵值。enum Color: Equatable { case red case blue case green } enum Shape: Equatable { case circle case square case triangle } struct PrivateKey: Hashable { var color: Color = .red var shape: Shape = .circle internal var hsahValue: Int { return color.hashValue + shape.hashValue } }
let imageArray = [PrivateKey(color: .red, shape: .square): UIImage(named: "redsquare"), PrivateKey(color: .blue, shape: .circle): UIImage(named: "bluecircle")] func searchImage(privateKey: PrivateKey) -> UIImage { return imageArray[privateKey]!! }
Swift 值類型和引用類型的內存管理