Swift語言效能分析
一、兩個疑惑
- OC 和 Swift 語言在 Richards 上評測的結果顯示,Swift 比 OC 快了4倍,Swift同OC相比會更快,具體應歸結在那些因素上面?
- 通常一個 Swift 專案少則編譯五六分鐘,多則編譯個半個小時也是不為過的事情,Swift 語言既然比 OC 速度快,但是為何實際開發中 Swift 編譯卻很慢?
二、全文思路介紹
通常一門語言的好壞,通常取決於下面三個因素:
- 記憶體分配:主要是指堆記憶體分配和棧記憶體分配。
- 引用計數:主要至於如何權衡引用計數。
- 方法排程: 主要在於靜態排程和動態排程。
除了上面這三個因素之外,另外還有另個影響因素。首先是編譯器的優化;其次是這門語言中的一些其他額外特性,如Swift語言中的對面向協議的額外處理。
所以在接下來的篇幅中,筆者將重點從編譯器優化、記憶體分配優化、引用計數優化、方法呼叫優化以及面向協議程式設計的實現細節這五個方面來談談Swift語言的效能。
三、編譯器優化分析

Whole Module Optimizations機制
Whole Module Optimizations
優化機制。在沒有這個機制之前,同絕大多數的編譯器一樣,編譯器在編譯過程中,會針對每一個原始檔先是生成目標檔案(.o 檔案),然後聯結器將不同的目標檔案組合起來,最終生成可執行程式。

常規編譯過程
試想整個專案中我們定義了這樣一個函式
func max<T:Comparable>(x:T, y:T) -> T { return y > x ? y : x }
但是在實際的整個專案中,只有一處我們按照下面的形式使用到了上面這個max方法。
let x = 1 let y = 2 let r = max(x: x, y: y)
因為有了 Whole Module Optimizations
機制,編譯器可以清楚的知道整個專案中只是用到了max函式的Int型別引數比較。所以在編譯的過程中,編譯器完全可以把max函式看做是一個只支援Int型別數值比較的方法,不用再編譯成還需要支援其他型別引數比較的方法。Swift編譯器類似的優化還有很多, Whole Module Optimizations
為編譯器提供了更多的資訊,使編譯器可以從全域性角度出發,做更多的全域性優化。

優化後的編譯過程
四、記憶體分配和引用計數優化分析
4.1堆疊的介紹
一般程式的記憶體區域,除了程式碼段和資料段之外,剩下的主要是堆記憶體和棧記憶體。
- 堆(heap),堆記憶體一般由程式設計師自己申請、指明大小、釋放,是用於存放程序執行中被動態分配的記憶體段,它的大小並不固定,可動態擴張或 縮減。當程序呼叫malloc等函式分配記憶體時,新分配的記憶體就被動態新增到堆上(堆被擴張); 當利用free等函式釋放記憶體時,被釋放的記憶體從堆中被剔除(堆被縮減)。
- 棧 (stack heap)又稱堆疊, 由編譯器自動建立/分配/釋放,是使用者存放程式臨時建立的區域性變數,也就是說我們函式括弧“{}” 中定義的變數(但不包括static宣告的變數,static意味著在資料段中存放變數)。除此以外, 在函式被呼叫時,其引數也會被壓入發起呼叫的程序棧中,並且待到呼叫結束後,函式的返回值 也會被存放回棧中。由於棧的後進先出特點,所以 棧特別方便用來儲存/恢復呼叫現場。從這個意義上講,我們可以把堆疊看成一個寄存、交換臨時資料的記憶體區。
4.2堆疊的深度問題(額外擴充)
既然說到這裡,就順帶補個知識點-----棧的深度。筆者喜歡以點帶面,由一點知識點擴充到方方面面,這是一種思考方式,也是一種學習方式。當然也不是無止境的在文章中以點帶面,如果真是這樣,那麼估計一篇文章就根本不是給人讀的了,隨便拿出一個“術語”一篇文章都不一定能說的完。
關於棧深度問題通常會出現在遞迴中。因為程式在遞迴時,每一層遞迴的臨時變數和引數,都是被儲存在棧中的,所以遞迴呼叫的深度過多,就會造成棧空間儲存不足。一般來說棧是向下生長的,堆是向上生長的。把記憶體地址像門牌號編號成 1 ~ 10000,棧的使用就是先用第 10000 號記憶體塊,再用第 9999 號記憶體塊,依次減小編號。而堆的話,是先用第 1 號記憶體塊,再用第 2 號記憶體塊,依次增加編號。
堆記憶體可以認為是沒有上限的(除非你的硬碟空間不足),如果消耗光了計算機的記憶體,作業系統還會用硬碟的虛擬記憶體為你提供更多的記憶體,虛擬記憶體和記憶體的讀寫速度幾倍一致。但是如果大量程式佔用了虛擬記憶體,很可能會出現記憶體洩露問題。這種情況,虛擬記憶體很快就會被消耗完畢。
棧記憶體不同於堆記憶體,通常編譯器都會指定程式的棧記憶體空間使用的大小,如果棧記憶體使用超出了限制,就會觸發程式異常退出,即棧溢位錯誤(Stack Over flow)。但是iOS實際開發中很少出現棧溢位問題,這就從側面反映出使用的遞迴比較少。蘋果官方文件指明:對於主執行緒,棧記憶體為 1 MB;非主執行緒,棧記憶體為 512 KB。如果想測試這一點,在主執行緒建立一個大小為100萬的陣列,這是Xcode就會報錯。題外話就到此結束。
4.3 Swift基於堆疊的優化
Swift中,值型別都是存在棧中的,引用型別都是存在堆中的。蘋果官網上明確指出建議開發者多使用值型別。這裡的值型別就是緊密的和棧是繫結在一起的。下面來看看值型別比引用型別好在那裡,為何蘋果會如此建議?
-
資料結構
1、存放在棧中的資料結構較為簡單,只有一些值相關的東西。
2、存放在堆中的資料較為複雜,會包含type、retainCount等。
-
資料的分配與讀取
1、存放在棧中的資料從棧區底部推入 (push),從棧區頂部彈出 (pop),類似一個數據結構中的棧。由於我們只能夠修改棧的末端,因此我們可以通過維護一個指向棧末端的指標來實現這種資料結構,並且在其中進行記憶體的分配和釋放只需要重新分配該整數即可。所以棧上分配和釋放記憶體的代價是很小。
2、存放在堆中的資料並不是直接 push/pop,類似資料結構中的連結串列,需要通過一定的演算法找出最優的未使用的記憶體塊,再存放資料。同時銷燬記憶體時也需要重新插值。
-
多執行緒處理
1、棧是執行緒獨有的,因此不需要考慮執行緒安全問題。
2、堆中的資料是多執行緒共享的,所以為了防止執行緒不安全,需同步鎖來解決這個問題題。
所以基於在記憶體分配方面的考慮,更多的使用棧而不是堆,可以達到優化的效果。
4.4 一個例項
為了更好的理解值型別和引用型別的區別,我們來深入分析一個簡單的例子。
var persons:[Person] = ... for p in persons { //increase RC //decrease RC }
如果這個例子中的 Person 是 class 型別,在遍歷這個陣列的時候,編譯器內部會對於每一個遍歷的元素都會執行增加和減少引用計數操作,實際上這是非常消耗效能的。
但是如果通過 Struct 來解決問題,就是另外一種情況了。如果把Person類改成 Struct ,所有的引用計數將會從編譯器中消失。
但是使用Struct需要注意一點事項,因為在Struct中包含有大齡引用型別成員時,在複製變數時,也會造成大量的引用計數操作。
struct Person { var websit = NSURL("website") var name = NSString(string: "name") var addr = NSString(string: "address") } var person1 = Person() var person2 = person1
在呼叫var person1 = Person()這句程式碼的時候,記憶體分配是這樣的:

在呼叫var person2 = person1的時候,記憶體分配是這樣的:

這種情況明顯是不能被接受的,但是我們可以通過把引用型別在封裝一層來解決這個問題,程式碼如下:
struct Person { var person:PersonWrapper = PersonWrapper() } class PersonWrapper { var websit = NSURL("website") var name = NSString(string: "name") var addr = NSString(string: "address") } var person1 = Person() var person2 = person1
經過這種更改,當發生物件複製的時候,記憶體中只有PersonWrapper的引用計數發生變化,而內部的NSURL和兩個NSString的引用計數不會發生變化。
五、方法呼叫優化分析
稍微有點iOS開發經驗的開發者應該都知道Objective-C 中方法的呼叫,從本質上來說都是向相應的物件傳送訊息。方法經編譯器編譯過後一般就變成了 objc_msgSend
函式,該函式的第一個引數是接受訊息的物件,第二個引數是訊息的名字,後面的都是訊息攜帶的名字,引數從0到 n 個不等。
正是基於這一點Objective-C 中,我們可以字串去呼叫方法,就可以用變數來傳遞這個字串,進而可以實現一些執行時動態呼叫,語言提供的 NSSelectorFromString
是一個很好的說明,runtime 也因此被開發者奉為神器,被廣大開發這熟知的JSPatch 也是基於這點實現的。因為這種動態性的設計使得Objective-C 語言變得異常靈活。
但是,凡事都是要付出代價的,Objective-C語言動態化這種靈活性是以 查表
的方式找出函式地址,既然查表操作,當然要付出時間代價。蘋果官網文件中介紹了方法呼叫時,函式地址查詢過程,蘋果也發現了這種方式呼叫起來會很慢,所以一種這種的辦法就是快取方法呼叫的查詢結果,但即便是這樣,效能上同 將函式地址硬編碼到程式碼中
這種方式相比還是有一些差距。
相比於Objective-C,Swift語言直接放棄了Objective-C這個動態化機制。就這一方面而言,Swift如今算是和很多主流語言保持了一直。因為捨棄了動態特性,Swift語言勢必比Objective-C快了一些,但在一定程度上丟失了靈活性。相信不久的將來,Swift勢必會引入一些動態特性,不過目前而言這並不是它的首要目標。
六、面向協議程式設計分析
6.1 問題
Swift 鼓勵我們使用值型別,也鼓勵使用協議,所以Swift中引入了 協議型別
的概念,下面程式碼中的 Drawable 就是協議型別
protocol Drawable { func draw() } struct Point : Drawable { var x, y: Double func draw() { ... } } struct Line : Drawable { var x1, y1, x2, y2: Double func draw() { ... } } // Drawable 稱為協議型別 let a: Drawable = Point() let b: Drawable = Line() let drawables : [Drawable] = [a, b] for d in drawables { d.draw() }
以上程式碼中定義了一個 Drawable 協議型別,然後值型別 Point 和 Line都實現了這個協議。程式碼的最後將 Point 和 Line 的例項都放到了 [Drawable] 陣列中。
但是會發現 Point 和 Line 實際 Size 大小不同,這樣一個數組中就存在大小不同的元素了,通常對於一般的陣列而言這是一種災難。因為陣列元素大小不一致,就無法很方便的定位其中的元素。假如我們的陣列真的是把不同大小的元素放到一個數組裡面,那就意味著,如果我們想定位到第 i 個元素,我們需要把第 0 ~ i-1 個元素的大小都算出來,這樣還可以算出第 i 個元素的記憶體偏移量。還有一個簡單粗暴的方式,取最大的 Size 作為陣列的記憶體對齊的標準,但是這樣一來不但會造成記憶體浪費的問題,還會有一個更棘手的問題,如何去尋找最大的Size。
6.2 蘋果解決問題的方式
為了解決上述問題,Swift 引入一個叫做 Existential Container 的資料結構。思路是:使用一個額外的容器(Container)來放每個帶有協議的值型別,而數組裡面放的是一個固定大小的容器。具體的細節請往下看。


這是一個最普通的 Existential Container,大小一共是5個 word 。
-
前三個 word 是 Value buffer,用於存放元素的值,如果word數大於3,則採用指標的方式,在堆上分配對應需要大小的記憶體
-
第四個word:Value Witness Table(VWT)。每個型別都對應這樣一個表,用來儲存值的建立,釋放,拷貝等操作函式。(管理 Existential Container 生命週期)
-
第五個word:Protocol Witness Table(PWT),用於存放協議(Protocol)對應的函式的實現函式地址。
如果待存放的例項物件大於 3 個 world,Swift就會在堆記憶體中申請一塊空間,將該值儲存在堆記憶體中,堆記憶體的對應的地址就會儲存在 Value Buffer 的第 1 個 word 中。就像下圖這樣。

最終,這種設計使得:
- 陣列中每個元素的大小都是固定的 5 個 word,解決了陣列元素下標快速定位的問題。
- 因為有 Value Buffer 的存在,我們可以將不同大小的值型別存放到 Value Buffer 中,小於等於 3 個 word 的值直接儲存,更大的則通過儲存引用地址的方式儲存。
- 通過 Value Witness Table,我們可以找到這個值型別的相關生命週期的管理函式。
- 通過 Protocol Witness Table,我們可以找到協議的具體實現函式的地址。
6.3 需要注意的地方
雖然表面上協議型別確實比抽象類更加的好,蘋果也是大力推薦使用協議型別。但是並不意味著可以隨隨便便把協議當做型別來使用。
struct Pair { init(f: Drawable, s: Drawable) { first = f ; second = s } var first: Drawable var second: Drawable }
我們把 Drawable 協議型別作為 Pair 的屬性,因為協議型別的 value buffer 只有三個 word,如果一個 結構體struct(比如上文的Line) 超過三個 word,將會形成如下結構。

按照上圖所示,如果再執行一個賦值操作,就會導致屬性的copy,從而引起大量的堆記憶體分配。這就是濫用協議型別導致的後果。
當然這個問題是可以通過合理的設計去避免的。需要將Line改為class即可解決問題,而不是再像之前那樣使用 struct,所以說 值型別也不是可以隨便濫用的。 更改後的結果是:

這裡通過引用型別來替代值型別,增加了引用計數而降低了堆記憶體分配,這就是一個很好的權衡引用計數和記憶體分配的問題。
七、總結
-
為什麼Swift編譯很慢?
因為Swift在編譯的時候做了很多事情,所以消耗時間比較多是正常的。如對型別的分析等。
-
為什麼Swift相比較OC會更快?
編譯器 Whole Module Optimizations 機制的全域性優化、更多的棧記憶體分配、更少的引用計數、更多的靜態、協議型別的使用等都是Swift比OC更快的原因。