Swift语言性能分析
一、两个疑惑
- OC 和 Swift 语言在 Richards 上评测的结果显示,Swift 比 OC 快了4倍,Swift同OC相比会更快,具体应归结在那些因素上面?
- 通常一个 Swift 项目少则编译五六分钟,多则编译个半个小时也是不为过的事情,Swift 语言既然比 OC 速度快,但是为何实际开发中 Swift 编译却很慢?
二、全文思路介绍
通常一门语言的好坏,通常取决于下面三个因素:
- 内存分配:主要是指堆内存分配和栈内存分配。
- 引用计数:主要至于如何权衡引用计数。
- 方法调度: 主要在于静态调度和动态调度。
除了上面这三个因素之外,另外还有另个影响因素。首先是编译器的优化;其次是这门语言中的一些其他额外特性,如Swift语言中的对面向协议的额外处理。
所以在接下来的篇幅中,笔者将重点从编译器优化、内存分配优化、引用计数优化、方法调用优化以及面向协议编程的实现细节这五个方面来谈谈Swift语言的性能。
三、编译器优化分析
![](https://img.haomeiwen.com/i4146031/50384400a6f28caa.jpeg)
不得不说编译内部有很多需要开发者需要掌握的技术点,笔者打算后期有时间针对编译相关的东西做一些整理,顺带介绍iOS中的LLVM编译器。如上图所示,这是Swift编译器中引入的
Whole Module Optimizations
优化机制。在没有这个机制之前,同绝大多数的编译器一样,编译器在编译过程中,会针对每一个源文件先是生成目标文件(.o 文件),然后连接器将不同的目标文件组合起来,最终生成可执行程序。
![](http://upload-images.jianshu.io/upload_images/4146031-5baf4fd967b0b4e6.png)
试想整个项目中我们定义了这样一个函数
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
为编译器提供了更多的信息,使编译器可以从全局角度出发,做更多的全局优化。
![](http://upload-images.jianshu.io/upload_images/4146031-a0001b20ad1b1553.png)
四、内存分配和引用计数优化分析
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()这句代码的时候,内存分配是这样的:
![](http://upload-images.jianshu.io/upload_images/4146031-01616467dc62558f.png)
在调用var person2 = person1的时候,内存分配是这样的:
![](http://upload-images.jianshu.io/upload_images/4146031-804f537d4bfc5534.png)
这种情况明显是不能被接受的,但是我们可以通过把引用类型在封装一层来解决这个问题,代码如下:
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)来放每个带有协议的值类型,而数组里面放的是一个固定大小的容器。具体的细节请往下看。
![](http://upload-images.jianshu.io/upload_images/4146031-f49247448a8bb6eb.png)
![](http://upload-images.jianshu.io/upload_images/4146031-d5330a5ef81a44a3.png)
这是一个最普通的 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 中。就像下图这样。
![](http://upload-images.jianshu.io/upload_images/4146031-42b09808d939ee10.png)
最终,这种设计使得:
- 数组中每个元素的大小都是固定的 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,将会形成如下结构。
![](http://upload-images.jianshu.io/upload_images/4146031-ae370b1abf5fbe1d.png)
按照上图所示,如果再执行一个赋值操作,就会导致属性的copy,从而引起大量的堆内存分配。这就是滥用协议类型导致的后果。
当然这个问题是可以通过合理的设计去避免的。需要将Line改为class即可解决问题,而不是再像之前那样使用 struct,所以说 值类型也不是可以随便滥用的。 更改后的结果是:
![](http://upload-images.jianshu.io/upload_images/4146031-6602c8d9be11b591.png)
这里通过引用类型来替代值类型,增加了引用计数而降低了堆内存分配,这就是一个很好的权衡引用计数和内存分配的问题。
七、总结
-
为什么Swift编译很慢?
因为Swift在编译的时候做了很多事情,所以消耗时间比较多是正常的。如对类型的分析等。
-
为什么Swift相比较OC会更快?
编译器 Whole Module Optimizations 机制的全局优化、更多的栈内存分配、更少的引用计数、更多的静态、协议类型的使用等都是Swift比OC更快的原因。