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

最近看了关于 Swift 底层原理的一些视频和文章,收获颇丰,感觉对于编程语言有了新的理解。因此,趁热打铁,记录并总结对 Swift 底层原理的理解。由于相关的内容非常多,这里准备分成多篇文章来进行阐述。

概述

本文主要介绍关于 Swift 性能优化的一些基本概念。编程语言的性能主要涵盖三个指标:

  • 内存分配(Memory Allocation)
  • 引用计数(Reference Counting)
  • 派发方式(Method Dispatching)

下面,以 Swift 为例,分别对这三个指标进行介绍。

内存分配

每一个进程都有独立的进程空间,如下图所示。进程空间中能够用于内存分配的区域主要分为两种:

  • 栈区(Stack)
  • 堆区(Heap)

为什么会有这两种区别呢?因为它们的设计目的不同。

栈区主要用于函数(方法)调用和局部变量管理,每调用一次函数,就会在栈区中生成一个栈帧,栈帧中包含函数运行时产生的局部变量。函数调用返回后立即执行出栈,所有局部变量就此销毁。

堆区主要用于多线程模型,每个线程有独立的栈区,但却共享同一个堆区,多线程之间通过堆区进行数据访问,对此我们需要对堆区的数据进行锁定和同步。

Swift 中的数据类型可以分成两种:值类型引用类型。两者的内存分配区域是不同的,值类型默认分配在栈区,引用类型默认分配在堆区。

栈区分配

值类型,包括:基本数据类型、结构体,默认在栈区进行分配。栈区的内存都是连续的,通过入栈和出栈进行分配和销毁,速度很快,比堆区的分配速度更快。

下面,通过 WWDC 的一个例子来说明:

1
2
3
4
5
6
7
8
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 的一个例子来说明:

1
2
3
4
5
6
7
8
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 会将对应的内存释放。一方面,引用计数的管理是一个非常高频的操作,另一方面,由于对象处于堆中,还需额外考虑多线程安全,所以产生引用计数的操作会有较高的性能消耗。

对于数据结构而言,只要包含引用类型,就会出现堆区分配。一旦产生堆区分配,则必然出现引用计数。下面,以一个例子来说明:

1
2
3
4
5
6
7
8
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 Dispatch)
  • 消息派发(Message Dispatch)

根据函数调用能否在编译时或运行时确定,可以将派发机制分成两种类型:

  • 静态派发(Static Dispatching)
  • 动态派发(Dynamic Dispatching)

其中,直接派发属于静态派发,函数表派发、消息派发属于动态派发。

大多数编程语言都会支持一到两种派发方式,Java 默认使用函数表派发,但是可以通过 final 修饰符改成直接派发。C++ 默认使用直接派发,但是可以通过 virtual 修饰符改成函数表派发。Objective-C 总是使用消息派发,但是允许开发者使用 C 直接派发来获得性能的提升。

下面,依次来介绍这三种派发方式。

直接派发

直接派发是最快的,原因是调用的指令少,而且还可以通过编译器进行优化,如:代码内联。其缺点是缺少动态性,因此无法支持继承。

下面,以一个例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
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 之后的函数指针。如果子类添加新的函数,则会在子类的函数表的最后插入新的函数指针。运行时会根据对应类的函数表去查找要指定的函数。

下面,以一个例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
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 技术。

下面,以一个例子来说明:

1
2
3
4
5
6
7
8
9
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 中,一个函数有两个可以声明的位置。

  • 初始声明的作用域
  • 扩展声明的作用域
1
2
3
4
5
6
7
8
9
// 初始声明的作用域
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 派发机制