Swift 性能優化(1)——基本概念

原文連接html

最近看了關於 Swift 底層原理的一些視頻和文章,收穫頗豐,感受對於編程語言有了新的理解。所以,趁熱打鐵,記錄並總結對 Swift 底層原理的理解。因爲相關的內容很是多,這裏準備分紅多篇文章來進行闡述。git

概述

本文主要介紹關於 Swift 性能優化的一些基本概念。編程語言的性能主要涵蓋三個指標:github

  • 內存分配(Memory Allocation)
  • 引用計數(Reference Counting)
  • 派發方式(Method Dispatching)

下面,以 Swift 爲例,分別對這三個指標進行介紹。編程

內存分配

每個進程都有獨立的進程空間,以下圖所示。進程空間中可以用於內存分配的區域主要分爲兩種:swift

  • 棧區(Stack)
  • 堆區(Heap)

爲何會有這兩種區別呢?由於它們的設計目的不一樣。數組

棧區主要用於函數(方法)調用和局部變量管理,每調用一次函數,就會在棧區中生成一個棧幀,棧幀中包含函數運行時產生的局部變量。函數調用返回後當即執行出棧,全部局部變量就此銷燬。緩存

堆區主要用於多線程模型,每一個線程有獨立的棧區,但卻共享同一個堆區,多線程之間經過堆區進行數據訪問,對此咱們須要對堆區的數據進行鎖定和同步。安全

Swift 中的數據類型能夠分紅兩種:值類型引用類型。二者的內存分配區域是不一樣的,值類型默認分配在棧區,引用類型默認分配在堆區。性能優化

棧區分配

值類型,包括:基本數據類型、結構體,默認在棧區進行分配。棧區的內存都是連續的,經過入棧和出棧進行分配和銷燬,速度很快,比堆區的分配速度更快。數據結構

下面,經過 WWDC 的一個例子來講明:

struct Point {
    var x, y: Double
    func draw() { ... }
}

let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
複製代碼

其內存分配及佈局以下圖所示:

上述 Struct 的內存是在棧區分配的。將 point1 賦值給 point2 會在棧區分配一塊內存區域,建立新的實例。二者相互獨立,操做互不影響。

堆區分配

引用類型,如:類,默認分配在堆區。堆區的內存採用徹底二叉樹的形式進行維護,屢次進行分配/銷燬以後,堆區的內存空間就能難連續。所以,在分配內存時,須要查詢可用的內存,因此比棧區的分配速度更慢。

下面,經過 WWDC 的一個例子來講明:

class Point {
    var x, y: Double
    func draw() { ... }
}

let point1 = Point(x: 0, y: 0)
let point2 = point1
point2.x = 5
複製代碼

其內存分配及佈局以下圖所示:

上述 Class 的內存是在堆區分配的,棧區僅僅分配了 point1point2 兩個指針。值得注意的是,爲了管理對象內存,在堆區初始化時,除了分配屬性內存(本例中是 Double 類型的 x, y),還分配了兩個字段:typerefCount。其中,type 表示類型,refCount 表示引用計數。

小結

從內存分配角度而言,Class 在堆區分配,使用了指針,經過引用計數進行管理,具備更強大的特性,可是性能較低。

所以,對於須要頻繁分配內存的需求,應儘可能使用 Struct 代替 Class。由於棧區的內存分配速度更快,更安全。

引用計數

在上述堆區分配中提到,對象在堆區初始化時會額外分配兩個字段,其中一個就是用於引用計數。Swift 經過引用計數管理堆區的對象內存,當引用計數爲 0 時,Swift 會將對應的內存釋放。一方面,引用計數的管理是一個很是高頻的操做,另外一方面,因爲對象處於堆中,還需額外考慮多線程安全,因此產生引用計數的操做會有較高的性能消耗。

對於數據結構而言,只要包含引用類型,就會出現堆區分配。一旦產生堆區分配,則必然出現引用計數。下面,以一個例子來講明:

struct Label {
    var text: String
    var font: UIFont
    func draw() { ... }
}

let label1 = Label(text: "Hi", font: font)
let label2 = label1
複製代碼

其內存分配及佈局以下所示:

對比 Struct Label 和前文的 Class Point,雖然屬性數量相同,可是 Struct Label 產生的引用計數要比 Class Point 多一倍!

以下圖所示,是關於複雜 StructClass 結構引用計數數量的對比。

小結

對於 Struct 類型,再次引用時會觸發內存拷貝,由此引用計數數量會呈倍數增加;對於 Class 類型,則只會增長一次引用計數。

所以,咱們應該儘可能避免在 Struct 類型中包含引用類型,由於這可能產生大量的引用計數。

對於經常使用的引用類型 String,咱們可使用精確類型 UUID 或者 Enum 來替代。

派發方式

派發方式,也可稱爲 函數派發方法派發,是程序調用一個函數的機制。編譯型語言有三種派發方式:

  • 直接派發(Direct Dispatching)
  • 函數表派發(Table Dispatching)
  • 消息派發(Message Dispatching)

根據函數調用可否在編譯時或運行時肯定,能夠將派發機制分紅兩種類型:

  • 靜態派發(Static Dispatching)
  • 動態派發(Dynamic Dispatching)

其中,直接派發屬於靜態派發,函數表派發、消息派發屬於動態派發。

大多數編程語言都會支持一到兩種派發方式,Java 默認使用函數表派發,可是能夠經過 final 修飾符改爲直接派發。C++ 默認使用直接派發,可是能夠經過 virtual 修飾符改爲函數表派發。Objective-C 老是使用消息派發,可是容許開發者使用 C 直接派發來得到性能的提高。

下面,依次來介紹這三種派發方式。

直接派發

直接派發是最快的,緣由是調用的指令少,並且還能夠經過編譯器進行優化,如:代碼內聯。其缺點是缺乏動態性,所以沒法支持繼承。

下面,以一個例子來講明:

struct Point {
    var x, y: Double
    func draw() { ... }
}

func pointDraw(_ point: Point) {
    point.draw()
}

let point = Point(x: 0, y: 0)
pointDraw(point)
// point.draw()
複製代碼

在這個狀況下,編譯器會對代碼進行內聯優化,調用 pointDraw() 方法會變成直接調用 point.draw()。這樣,函數調用棧會減小一層,從而可以進一步提高性能。

函數表派發

函數表派發是編譯型語言中爲實現動態行爲而使用的一種最多見的實現方式。函數表使用一個數組來存儲類所聲明的每個函數的指針。大部分語言將其稱爲「virtual table」(虛函數表),Swift 中也稱爲 「virtual table」。除此以外,Swift 還包含 「witness table」(見證表),主要用於實現協議類型和泛型的動態派發。

在函數表派發的實現中,每個類都會維護一個函數表,裏面記錄着全部的全部的函數指針。若是子類將父類的函數 override,那麼子類的函數表只會保存 override 以後的函數指針。若是子類添加新的函數,則會在子類的函數表的最後插入新的函數指針。運行時會根據對應類的函數表去查找要指定的函數。

下面,以一個例子來講明:

class ParentClasss {
    func method1() { ... }
    func method2() { ... }
}

class ChildClass: ParentClass {
    override func method2() { ... }
    func method3() { ... }
}

let objc = ChildClass()
obj.method2()
複製代碼

在這個狀況下,編譯器會爲 ParentClassChildClass 各自建立一個函數表。以下圖所示,展現了 ParentClassChildClass 函數表中各個方法在內存中的佈局。

當一個函數被調用時,會經歷如下幾個步驟:

  • 讀取對象 0xB00 的函數表。
  • 讀取函數表中對應的索引項。method2 的索引是 1(偏移量),即 0xB00+1
  • 根據索引項的記錄,跳轉至函數位置。method2 的位置是 0x222

查表是一種簡單、易實現,且性能可預知的實現方式。一方面,因爲多了一次查找和跳轉,另外一方面,因爲編譯器沒法經過類型推導進一步進行優化,因此相比直接派發而言,函數表派發的性能稍差。

消息派發

消息派發是一種更加動態的函數調用方式。ObjC 中的 KVO、UIAppearence、CoreData 都是對這種機制的運用。消息派發能夠在運行時改變函數的行爲,如:ObjC 中的 swizzling 技術。消息派發甚至還能夠在運行時修改對象的繼承關係,如:ObjC 中的 isa-swizzling 技術。

下面,以一個例子來講明:

class ParentClass {
    dynamic func method1() { ... }
    dynamic func method2() { ... }
}

class ChildClass: ParentClass {
    override func method2() { ... }
    dynamic func method3() { ... }
}
複製代碼

在這個狀況下,會利用 Objective-C 的運行時進行消息派發。每一個類只包含本身所定義的方法,一旦調用的方法不存在,會經過父類指針,去父類中進行查找,以此類推。以下圖所示。

當消息被派發時,運行時會順着繼承關係向上查找被調用的方法。很顯然,消息派發要比函數表派發的效率更低。爲了可以提高消息派發的性能,通常都會將查找進行緩存,從而讓效率接近函數表派發。

Swift 派發方式

Swift 支持上述三種派發方式,那麼 Swift 是如何選擇派發方式呢?事實上,影響 Swift 的派發方式有如下幾個方面:

  • 聲明位置(Declare Location)
  • 指定派發(Specifying Dispatch Behavior)
  • 優化派發(Optimize Dispatch Behavior)

聲明位置

在 Swift 中,一個函數有兩個能夠聲明的位置。

  • 初始聲明的做用域
  • 擴展聲明的做用域
// 初始聲明的做用域
class MyClass {
    func mainMethod() { ... }
}

// 擴展聲明的做用域
extension MyClass {
    func extensionMethod() { ... }
}
複製代碼

其中,初始聲明的做用域中的函數 mainMethod 會使用 函數表派發;擴展聲明的做用域中的函數 extensionMethod 會使用 直接派發

上述例子是關於 Class 類型中不一樣的聲明位置對於派發方式的影響。事實上,不一樣的類型的做用域中聲明的函數,派發方式也不必定相同。下表展現了默認狀況下,類型、聲明位置與派發方式的關係圖。

Initial Declaration Extension Declaration
Value Type static static
Protocol table static
Class table static
NSObject Subclass table message

上表的總結有如下幾點:

  • 值類型:不管初始聲明仍是擴展聲明,都使用 直接派發
  • Protocol 類型:初始聲明使用 函數表派發,擴展聲明使用 直接派發。即默認實現均使用
  • Class 類型:初始聲明使用 函數表派發,擴展聲明使用 直接派發
  • NSObject 類型:初始聲明使用 函數表派發,擴展聲明使用 消息派發

指定派發

Swift 有一些修飾符能夠指定派發方式。

final

final 修飾符容許類中的函數使用 直接派發final 修飾符會讓函數失去動態性。任何函數均可以使用 final 修飾符,包括 extension 中本來就是直接派發的函數。

須要注意的是,Objective-C 的運行時獲取不到使用 final 修飾符的函數的 selector

dynamic

dynamic 修飾符容許類中的函數使用 消息派發。使用 dynamic 修飾符以前,必須導入 Foundation 框架,由於框架中包含了 NSObject 和 Objective-C 的運行時。dynamic 修飾符能夠修飾全部的 NSObject 子類和 Swift 原生類。

此外,dynamic 修飾符可讓擴展聲明(extension)中的函數也可以被 override

@objc & @nonobjc

@objc@nonobjc 顯式地聲明瞭一個函數可否被 Objective-C 運行時捕獲到。

@objc 典型的用法就是給 selector 一個命名空間 @objc(xxx_methodName),從而容許該函數能夠被 Objective-C 的運行時捕獲到。

@nonobjc 會改變派發方式,能夠禁用消息派發,從而阻止函數註冊到 Objective-C 的運行時中。@nonobjc 的效果相似於 final,使用的場景幾乎也是同樣,我的猜想,@nonobjc 主要是用於兼容 Objective-C,final 則是做爲原生修飾符,以用於讓 Swift 寫服務端之類的代碼。

final @objc

在使用 final 修飾符的同時,可使用 @objc 修飾符讓函數可使用消息派發。同時使用這兩個修飾符的結果是:調用函數時會使用直接派發,但也會在 Objective-C 運行時中註冊響應的 selector。函數能夠響應 perform(seletor:) 以及別的 Objective-C 特性,但在直接調用時又能夠具備直接派發的性能。

@inline

@inline 修飾符告訴編譯器函數可使用直接派發。

派發優化

Swift 會在這上面作優化,好比一個函數沒有 override,Swift 就可能會使用直接派發的方式,因此若是屬性綁定了 KVO,那麼屬性的 getter 和 setter 方法可能會被優化成直接派發而致使 KVO 的失效,因此記得加上 dynamic 的修飾來保證有效。後面 Swift 應該會在這個優化上去作更多的處理。

小結

下表總結了引用類型、修飾符對 Swift 派發方式的影響。

Direct Dispatch Table Dispatch Message Dispatch
NSObject @nonobjc, final Initial Declaration Extension Declaration, dynamic
Class Extension Declaration, final Initial Declaration dynamic
Protocol Extension Declaration Initial Declaration @objc
Value Type All Method n/a n/a

總結

本文總結了評測 Swift 性能的幾個方面,咱們能夠經過內存分配、引用計數、派發方式等幾個方面了對 Swift 代碼進行優化。

整體而言,對於內存分配,咱們應該儘可能使用棧區內存分配;對於引用計數,咱們須要進行權衡,使用引用計數能帶來靈活性,但也會帶來性能開銷;對於派發方法,咱們應該儘可能使用更加高效的派發方式,同時也須要進行權衡,動態派發可以帶來更強大的編程特性,但也會帶來性能開銷。

擴展

  1. Friday Q&A 2010-01-29: Method Replacement for Fun and Profit
  2. Are method swizzling and isa siwzzling the same thing?
  3. Increasing Performance by Reducing Dynamic Dispatch

參考

  1. WWDC 2016, Session 416, Understanding Swift Performance.
  2. LLVM Developer's Meeting: "Implementing Swift Generics".
  3. Method Dispatch in Swift
  4. Why Swift? Generics(泛型), Collection(集合類型), POP(協議式編程), Memory Management(內存管理)
  5. 【基本功】深刻剖析Swift性能優化
  6. GOTO 2016 • Exploring Swift Memory Layout • Mike Ash
  7. 深刻理解 Swift 派發機制
相關文章
相關標籤/搜索