原文连接html
最近看了关于 Swift 底层原理的一些视频和文章,收获颇丰,感受对于编程语言有了新的理解。所以,趁热打铁,记录并总结对 Swift 底层原理的理解。因为相关的内容很是多,这里准备分红多篇文章来进行阐述。git
本文主要介绍关于 Swift 性能优化的一些基本概念。编程语言的性能主要涵盖三个指标:github
下面,以 Swift 为例,分别对这三个指标进行介绍。编程
每个进程都有独立的进程空间,以下图所示。进程空间中可以用于内存分配的区域主要分为两种:swift
为何会有这两种区别呢?由于它们的设计目的不一样。数组
栈区主要用于函数(方法)调用和局部变量管理,每调用一次函数,就会在栈区中生成一个栈帧,栈帧中包含函数运行时产生的局部变量。函数调用返回后当即执行出栈,全部局部变量就此销毁。缓存
堆区主要用于多线程模型,每一个线程有独立的栈区,但却共享同一个堆区,多线程之间经过堆区进行数据访问,对此咱们须要对堆区的数据进行锁定和同步。安全
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
的内存是在堆区分配的,栈区仅仅分配了 point1
和 point2
两个指针。值得注意的是,为了管理对象内存,在堆区初始化时,除了分配属性内存(本例中是 Double
类型的 x
, y
),还分配了两个字段:type
、refCount
。其中,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
多一倍!
以下图所示,是关于复杂 Struct
和 Class
结构引用计数数量的对比。
对于 Struct
类型,再次引用时会触发内存拷贝,由此引用计数数量会呈倍数增加;对于 Class
类型,则只会增长一次引用计数。
所以,咱们应该尽可能避免在 Struct
类型中包含引用类型,由于这可能产生大量的引用计数。
对于经常使用的引用类型 String
,咱们可使用精确类型 UUID
或者 Enum
来替代。
派发方式,也可称为 函数派发 或 方法派发,是程序调用一个函数的机制。编译型语言有三种派发方式:
根据函数调用可否在编译时或运行时肯定,能够将派发机制分红两种类型:
其中,直接派发属于静态派发,函数表派发、消息派发属于动态派发。
大多数编程语言都会支持一到两种派发方式,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()
复制代码
在这个状况下,编译器会为 ParentClass
和 ChildClass
各自建立一个函数表。以下图所示,展现了 ParentClass
和 ChildClass
函数表中各个方法在内存中的布局。
当一个函数被调用时,会经历如下几个步骤:
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 中,一个函数有两个能够声明的位置。
// 初始声明的做用域
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 代码进行优化。
整体而言,对于内存分配,咱们应该尽可能使用栈区内存分配;对于引用计数,咱们须要进行权衡,使用引用计数能带来灵活性,但也会带来性能开销;对于派发方法,咱们应该尽可能使用更加高效的派发方式,同时也须要进行权衡,动态派发可以带来更强大的编程特性,但也会带来性能开销。