Harris'Blog

谨记,技术债迟早要还,宁可慢一点,也要保证高质量

理解Swift中struct和class在不同情况下性能的差异

如果你被问起Swift中struct和class有什么不同的时候你会怎么回答?
我想大多数人的第一反应应该是struct是值类型class是引用类型他俩在语义上面不同。在想其他可能就是他们之间的相同点了,比方都可以用来定义类型,都可以有property,都可以有method,所以单从语法上理解他们没什么意义,其实单从语义不同这一条就可以延伸出很多,从初始化方式,内存管理,方法派发方式都是截然不同,当然这不是我们本次讨论的重点,本次主要讨论struct和class在不同情况下性能的差异

我们主要从三个性能维度来比较struct和class的性能差异

  • 内存分配
  • 引用计数
  • 方法派发

内存分配

内存分配分为栈内存分配和堆内存分配两种

栈内存的存储结构比较简单,你可以简单的理解为push到栈底pop出来这么简单,而要做的就是通过移动栈针来分配和销毁内存

堆内存相比栈有着更为复杂的存储结构,他的分配方式你可以理解为在堆中寻找合适大小的空闲内存块来分配内存,把内存块重新插入堆来销毁内存,当然这些仅仅只是堆内存相比栈内存性能消耗大的一个方面,更重要的是堆内存支持多线程操作,相应的就要通过同步等方式保证线程的安全性。

下面我们从代码角度来说一下struc和class在内存分配方面的性能差异

1
2
3
4
5
6
7
8
9
struct Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
// use `point1`
// use `point2`

定义一个struct Point 含有property x,y method draw

首先在代码执行之前编译器会在栈上分配一块4个word大小的内存,分配的过程就是我在上面提到的移动栈针

用(0,0)来初始化point1 并把point1赋值给point2,你可以理解为将value拷贝到分配好的栈内存上,因为struct是值类型所以point1和point2是两个独立的instance

修改point2.x不会影响point1

最后use point1 use point2离开作用域 移动栈针,销毁栈内存

接下来我们看一下class的情况

1
2
3
4
5
6
7
8
9
class Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
let point2 = point1
point2.x = 5
// use `point1`
// use `point2`

只是把struct换成class其他地方不变
首先在代码执行之前编译器会在栈上分配两个word大小的内存,不过这一次不是用来存储Point的property

代码执行初始化point1在堆上寻找合适大小的内存块分配

然后将value拷贝到堆内存存储,并在栈上存储一个指向这块堆内存的指针

可以看到堆上分配的是4个word大小的内存块,剩余的两个蓝色格子存放的是关于class生命周期相关函数表的指针
let point2 = point1执行的是引用语言,只是在point2的堆内存存储了一个指向point1堆内存的指针

修改point2.x也会影响到point1

最后use point1 use point2 离开作用域,堆内存销毁(查找并把内存块重新插入到栈内存中),栈内存销毁(移动栈针)
当然栈内存在分配的过程中还要保证线程安全(这也是一笔很大的开销)

怎么样,是不是觉得上述情况下Point用struc表示比class表示在内存分配方面要快很多

下面看一个在实际应用中的优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Color { case blue, green, gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }
var cache = [String : UIImage]()
func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
let key = "\(color):\(orientation):\(tail)"
if let image = cache[key] {
return image
}
...
}

这段代码的应用场景是我们常见的iMessage聊天界面气泡生产能function,三个enum表示气泡的不同情况,比方蓝色朝左带尾巴,因为用户可能经常滑动聊天列表来查看消息makeBalloon这个方法的调用时非常频繁的,为了避免多次生成UIImage,我们用Dictionary来充当一个cache,用不同情况下的enum序列化一个String来充当cache的key。

思考一下这样写有什么问题么?
问题就出在key上面,用String充当key,首先在类型安全上面无法保证存放的一定是一个气泡的类型,因为是String所以你可以存放阿猫阿狗等等。其次String的character是存储在堆上面的,所以每次调用makeBalloon虽然命中cache,但仍然不停的设计堆内存分配和销毁

怎么解决?

1
2
3
4
5
struct Attributes : Hashable {
var color: Color
var orientation: Orientation
var tail: Tail
}

let key = Attributes(color: color, orientation: orientation,tail: tail)

看到优化代码你应该恍然大悟,原来Swift已经为我们准备好了更好地类型struct并且他可以作为Dictionary的key,这样就不涉及任何堆内存分配销毁,并且Attributes可以确保类型安全

引用计数

Swift是如何知道堆内存是安全的并且可以释放了呢?答案就是引用计数,原理就是在instance内部插入一个refCount的property来管理引用计数,新增引用refCount+1,引用销毁refCount-1,refCount为0时,Swift就知道了存储instance的这块内存是安全的可以释放。

引用计数方面的性能消耗当然不是加一减一那么简单,首先是一对间接调用retain和release,其次引用计数支持多线程,所以同样需要保证线程安全

我们先来看下class的情况,下面是一段伪代码,旨在解释清楚引用计数的原理

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point {
var refCount: Int
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
let point2 = point1
retain(point2)
point2.x = 5
// use `point1`
release(point1)
// use `point2`
release(point2)

内存分配方面不再赘述,let point1 = Point(x: 0, y: 0)这块内存的refCount 为1
retain(point2)refCount变为2 然后use point1 离开作用域release(point1) refCount 减为1release(point2)refCount减为0 然后销毁内存

从上一个内存分配的环节可知struct Point的内存分配均为栈内存分配,它不涉及任何引用计数。

那如果struct中存有引用类型的property呢?

1
2
3
4
5
6
7
8
9
struct Label {
var text: String
var font: UIFont
func draw() { ... }
}
let label1 = Label(text: "Hi", font: font)
let label2 = label1
// use `label1`
// use `label2`

上面的代码中定义了一个struct Lable 含有一个String值类型但是character存储在堆上,UIFont是一个class 存储在堆上
let label1 = Label(text: "Hi", font: font)初始化label1 看一下会发生什么

let label2 = label1lable1赋值给label2

看到了吧,一共有四次引用计数操作,也就是会有六个调用,我们看一下这个过程的伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Label {
var text: String
var font: UIFont
func draw() { ... }
}
let label1 = Label(text: "Hi", font: font)
let label2 = label1
retain(label2.text._storage)
retain(label2.font)
// use label1
release(label1.text._storage)
release(label1.font)
// use label2
release(label2.text._storage)
release(label2.font)

在引用计数这个维度你想到了什么?
结论就是
在引用计数这个维度不包含引用属性的struct性能消耗最小,因为他不涉及引用计数,含有一个引用类型的struct性能消耗等于class,含有两个及以上引用类型property的struct在引用计数方面的性能消耗是class的整数倍

下面看一个在实际应用中的优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Attachment {
let fileURL: URL
let uuid: String
let mineType: String
init?(fileURL: URL, uuid: String, mimeType: String) {
guard mineType.isMineType
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mineType = mimeType
} }

这段代码是我们给message新建的一个model,表示我们聊天时发送的文件,它含有一个URL类型的fileURL表示文件的位置,一个String类型的uuid方便我们在不同设备定位这个文件,因为不可能支持所有的文件格式,所以有一个String类型的mineType用于过滤不支持的文件格式

想一下这么创建model会有什么性能上的问题或者有什么优化的方案么?
来看一下Attachment的存储情况

因为URL为引用类型,String的character存储在堆上,所以Attachment在引用计数的层面消耗有点大,感觉可以优化,那怎么优化?

首先uuid声明为String类型真的好么?答案是不好,一是无法在类型上限制uuid的安全性,二是涉及到引用计数,那怎么优化呢?在16年Swift Fundation中加入了UUID这个类型,是一个值类型不涉及引用计数,也能在类型安全上让我们满意,再来看一下mineType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension String {
var isMimeType: Bool {
switch self {
case "image/jpeg":
return true
case "image/png":
return true
case "image/gif":
return true
default:
return false
}
}
}

我们是通过给String扩展的方式来过滤我们支持的文件格式,看到这里你肯定想到了Swift为我们提供的更好的表示多种情况的抽象类型enum吧,它同为值类型,不涉及引用计数(enum在个别情况下可以作为引用类型,这里暂时不讨论)

1
2
3
4
5
enum MimeType : String {
case jpeg = "image/jpeg"
case png = "image/png"
case gif = "image/gif"
}

优化后的模型:

1
2
3
4
5
6
7
8
9
10
11
struct Attachment {
let fileURL: URL
let uuid: UUID
let mimeType: MimeType
init?(fileURL: URL, uuid: UUID, mimeType: String) {
guard let mimeType = MimeType(rawValue: mimeType)
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mimeType = mimeType
} }


优化后的model在达到了最佳状态,看完这个例子后不妨瞅瞅自己写过的代码,是否也有这种可以优化的地方

方法派发

静态派发
编译器将函数地址直接编码在汇编中,调用的时候根据地址直接跳转到实现,编译器可以进行内联等优化(struct都是静态派发)

动态派发
运行时查找函数表,找到后再跳转到实现,当然,动态派发仅仅多一个一个查表环节并不是他慢的原因,真正的原因是他阻止了编译器可以进行的内联等优化手段(class派发情况比较特殊,这里我们只讨论动态派发的情况)

看一个内联优化的例子

1
2
3
4
5
6
7
8
9
10
struct Point {
var x, y: Double
func draw() {
// Point.draw implementation
} }
func drawAPoint(_ param: Point) {
param.draw()
}
let point = Point(x: 0, y: 0)
drawAPoint(point)

这段代码要发生这几个调用,

  1. drawAPoint(point)

  2. point.draw()

  3. Point.draw implementation

因为struct中的method是静态派发,也就是说编译器就知道函数的实现地址,那么它可以进行第一步优化

  1. point.draw()

  2. Point.draw implementation

当然还可以进行第二步优化

  1. Point.draw implementation

看到了吧。通过编译器的优化可以省略掉两步调用,直接跳转到函数的实现,理解了静态派发快的原因了吧

如果是把上述情况换为class,那编译器就无法进行内联优化,从方法派发这个维度来说struct的静态派发更胜一筹

资料参考2016WWDC416