主要内容
讲解了Swift中对引用类型和值类型的内存管理方式,并提出一些优化建议。
影响性能的重要因素
- 栈内存 vs 堆内存
栈内存的分配和释放仅仅是通过移动栈指针来实现的。而堆内存则要根据申请的大小在堆中寻找合适的位置,释放时需要将内存块回收到堆中,并且要考虑线程安全,所以会慢得多。Swift中的各种基本数据类型和容器(如Int, String, Array, Dictionary等)都是struct
,编译器会尽可能用栈的方式来为它们分配内存。 - 引用计数
编译器会自动在我们的代码中加入retain
和release
的调用。这两个函数的调用频率极高且涉及线程安全的控制,使其有可能成为性能瓶颈。 - 动态函数调用 vs 静态函数调用
动态函数调用就是在运行时才确定函数名对应的函数地址。对于动态调用,程序必须动态地查询函数地址,而无法在编译期使用inline等优化手段,会相对慢一些。
优化
以struct
作为Dictionary
的key
此处把3个数据点序列化为一个String
,并以此为key。这样做有两个缺点:
- 使用
String
为key意味着可以传入任意String
,哪怕不是这3个数据点的序列化结果,比如"abc"或"123"。 -
String
本身虽然是struct
,但它内部储存字符的数组却是分配在堆上的。每次序列化意味着一次堆操作。
改为struct
可以优雅地解决这两个问题。
尽量使用(纯)值类型
值类型如果包含引用类型的数据成员,则程序仍需为此数据成员进行引用计数的维护和堆操作。
所以,尽可能地使用“纯值”类型(这是我造的词,表示不包含引用类型的值类型,编译器可以实现完全的栈内存管理)。比如以下结构中,有两个String
成员,程序需为它们进行堆操作。
把它改成UUID
和enum
类型
注意:此处的enum
的源数据类型虽然是String
,但是它在内存并不是持有一个String
(虽然没说,我想应该只是个Int)。
所以整个结构变成了纯值类型。
感谢 小刚_aea8 的订正。此处的
Attachment
由于还包含了URL
类型的成员,所以它并没有变成一个“纯值”类型。
在不需要多态时,使用泛型代替protocol
先说下多态的基本实现原理。
上面的代码�是通过override
的方式来实现多态的。编译器为每个对象添加了一个type
成员,里面是这个具体类的信息(称为Virtual Method Table,简称V-Table),其中就包含了它override
的函数指针,也就是各自的draw
函数。下面调用的代码就是通过查询V-Table才找到正确的实现的。
上面的代码把class
改为了protocol
和struct
。
像前一个例子中那样,class
类型的数组的每个成员只占用一个�指针的size,因为只需放入一个指针。而这个例子中,由于struct
是值类型,它被放进数组时应该是拷贝进去的,所以理论上,它要占用struct
自身size的内存。那不同size的struct
如何被放进同一个数组呢?
当需要用protocol
类型指针来�指向struct
时,Swift采用了一个叫Existential Container的结构来保存�struct
的成员变量和方法。如下图:
Existential Container的前3个word
称为value buffer
,是用来保存struct
的数据成员的。对于比较小的struct
可以直接把值塞进去,对于超过3个word
的struct
,则只能分配在堆上,然后在这里保存一个指针。此时每个struct
在栈上占用的空间就一样了(5个word
),这就解答了上面的问题。接下来,为了统一处理不同size的struct
,又在第4个word
增加了一个叫Value Witness Table(简称VWT)的结构,里面包含了一组函数。如下图:
以Point
和Line
为例:
函数 | Point | Line |
---|---|---|
allocate | 没动作 | 在堆上分配内存,并保存指针到value buffer
|
copy | 把值拷到value buffer
|
把值拷到value buffer 中的指针对应的堆内存上 |
destruct | 如果包含引用类型的成员变量,这里需要�引用减1。此处没有,所以没动作 | 也没动作 |
deallocate | 没动作 | 释放堆上的内存 |
注:实际上不止这几个函数,还有allocateBufferAndCopyValue
、projectBuffer
、destructAndDeallocateBuffer
等。
在第5个word
上添加一个Protocol Witness Table(简称PWT)的结构。里面包含protocol
的成员方法的指针。PWT与V-Table很类似,程序也是通过查询这个表来实现多态的。
上图中,左上角是源代码,左下角是编译时产生的代码。可以看到:
- 类型为
Drawable
的参数编译后变成ExistContDrawable
,也就是上面提到的Existential Container。 - 函数首先在创建一个
ExistContDrawable
类型的临时变量,用来放参数的值。 - 拷贝
type
成员(里面包含实现类的信息,图中写错了,应该是 local.type = val.type) - 拷贝
pwt
成员(里面包含struct
实现的protocol
中的方法的函数地址) - 分配空间并赋值
- 调用
projectBuffer
取出数据正确的内存地址(里面判断是否需要堆操作。我想应该是从前面的type
里面取出实现类的size来判断的。) - 调用
draw
函数。声明中的draw
方法虽然没有参数,编译出来后会加上一个参数,就是结构体的实际内存地址。 - 最后调用
destructAndDeallocateBuffer
清理内存(temp写错了,应该是local)
一个简单的调用实际做了这么多事情。这些代价都是花在需要动态判断具体struct
的信息和跳转到�对应的方法上的。如果改成使用泛型,则编译器就可以在编译期知道具体类型了,也就可以进行诸如inline等优化手段。具体实现�参考下面两张图:
但是,这样做的前提是不需要使用多态。如果像上面的例子那样,需要把不同的实现类放进一个数组中,则必须借用多态了。
使用“写时拷贝”
由于struct
是值类型,当它在传递时会发生多次拷贝。如果你的struct
拷贝成本很高或者拷贝发生得很频繁,而修改却很少的话,可以考虑使用“写时拷贝”的方法来优化它,如下:
此处使用一个storage
的class
来包装数据。当Line
发生拷贝时,storage
成员只发生引用计数加一的操作。当需要真正写入时,再调用isUniquelyReferencedNonObjc
判断一下storage
的引用计数是否大于1, 是的话则显式拷贝一份再进行写入。
内置类型String
,Array
,Set
,Dictionary
均使用了这个技术。