在您iOS生涯中很可能至少问过自己一次,struct
和class
之间有什么区别? 实际上,在使用一个或另一个之间的选择总是归结为值语义和引用语义,但是两者之间的性能差异是可表达的,并且取决于对象的内容,尤其是在处理值类型时,它们之间可能会偏重一个或另一个。
有人可能会说,对于应用程序级别的开发人员而言,内存体系结构的知识是无关紧要的,我对此表示部分赞同。知道如何在这里和那里节省一些内存不会对新型iPhone产生明显的影响,过早的优化是一个非常不明智的做法。
但是,引用和值类型在滥用时都会严重降低您的应用程序的速度,这些知识将确定您是否可以有效解决问题。
performance
要了解两者之间更深的差异,让我们回顾一下进程的地址空间:(为简单起见,使用单线程)
代码语言:javascript复制|- - - - - - - - - - - - - - - - - - - - - - - - - -- |
| 指令 Instructions
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| 全局数据 Global Data
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| 堆 Heap
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| 空地址 (栈和堆地址向此处增长)
| Nothing (Stack and heap grow towards here)
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| 栈 Stack
|- - - - - - - - - - - - - - - - - - - - - - - - - - -|
栈分配
在内存体系结构中,栈与您已经知道的数据结构没有什么不同,并且栈分配是一种简单快速的分配/释放涉及栈的内存的方法。
应用程序中的每个“作用域”(就像方法的内部内容一样)将提供它需要运行的内存量,将栈指针按此数量移动并运行——将数据添加到它现在构成的空内存地址中。范围丢失后,栈指针将减少相同的数量——安全地重新分配所有范围的数据。分配/取消分配栈内存的成本实际上就是分配整数的成本。
栈分配的值类型
在栈分配中,作用域收集的数据意味着它的所有内容,例如方法参数,返回值,但更重要的是:值类型 value types
。只要在编译时就知道值类型的大小并不被递归地包含/包含在引用类型中,那么它将不需要使用引用计数,并且它的生命周期将是静态的 static
——等于其作用域的生命周期。它会在栈上完全分配,并且在释放作用域时,值类型也会被释放。没有引用计数开销和栈分配的存在可以显着提高性能。
PS:所有基准测试均使用 -O。我必须添加一些特殊的逻辑和关键字/属性以防止编译器跳过我的方法,但是为了使代码易于阅读,我将它们隐藏在示例中。
代码语言:javascript复制struct EmptyStruct {
private let number: Int64 = 1
//默认情况下,空类的指针具有64位存储空间
//因此我们要在结构体中添加64位以进行公平比较。
}
@inline(never) func createABunchOfEmptyStructs() {
for _ in 0..<1_000_000 {
let myStruct = EmptyStruct()
}
}
createABunchOfEmptyStructs()
//将栈指针向上移动一百万个 EmptyStructs 的大小。
//向移动栈指针创建的空地址添加一百万个 EmptyEmpty 结构体。
//将栈指针向下移动相同的数量。
//总计:〜0.005秒
如果您的值类型的内容是其他栈分配的静态大小值类型,则您的值类型也将是静态大小。这意味着您的值类型还将全部利用栈分配,并提高复制操作的性能。
我们曾经问过一个候选人,为什么他选择对明显不可变的东西使用类 class
,并打算用值语义来对待。他的理由是该对象经常作为方法内部的参数发送,因此他担心多次复制该对象可能会对性能产生影响。
为大多数值类型分配属性确实会创建对象的完整副本。但是,这种针对完全栈分配的值类型的赋值复制 copy-on-assignment
行为是如此之快和廉价,以至于Apple声称它可以在恒定时间内运行:
struct BigStaticStruct {
let fp1: Int64 = 1
let fp2: Int64 = 1
let fp3: Int64 = 1
let fp4: Int64 = 1
let fp5: Int64 = 1
}
func createABunchOfCopiesOfHugeStruct() {
let bigStruct = BigStaticStruct()
for _ in 0..<1_000_000 {
let copy = bigStruct
}
}
createABunchOfCopiesOfHugeStruct() // ~0.0033 secs
//即使将属性数量增加了十倍,运行时也不会改变
//因为复制静态大小的结构体是恒定时间操作。
但是,如果您要处理许多递归深度,栈分配可能会占用应用程序的内存。值得庆幸的是,Swift具有尾递归优化功能,这意味着如果您使用尾递归反汇编方法,则会找到算法的迭代版本。
堆分配
但是,当您需要引入具有可扩展大小的对象并“破坏” 指针的概念时会发生什么?
栈不适合与大小会变化的对象一起使用,指针/动态生存周期的概念意味着对象的生存周期与其作用域无关——毕竟,即使什么也没有发生,也有可能在内存中存在一个对象。
堆与栈一样,与具有相同名称的数据结构没有太大区别,在这种情况下,它应用于动态分配的用户管理的内存。
当进程请求一定数量的内存时,堆将搜索一个满足该请求的内存地址,并将其返回给进程。当不再使用内存时,该进程必须告诉堆释放该部分内存。
在 iOS 中,“不再使用” 以引用计数的形式工作,而且幸运的是ARC的存在意味着大多数事情将自动为您处理,除非您必须与RawPointer系列打交道。
堆分配比栈分配要慢,不仅是因为数据结构更加复杂——它还需要线程安全。每个线程都有自己的栈,但是堆与所有人共享,需要同步。但是,它允许引用类型和诸如动态大小数组之类的东西存在。
代码语言:javascript复制final class EmptyClass {}
@inline(never) func createABunchOfEmptyClasses() {
for _ in 0..<1_000_000 {
let myClass = EmptyClass()
}
}
createABunchOfEmptyClasses()
//将栈指针向上移动一百万个 EmptyClass 指针的大小。
//为一百万个 EmptyClasses 请求堆中的内存。
//向通过移动栈指针创建的空地址添加一百万 EmptyClass 指针,以指向堆的返回地址。
//(循环结束)减少指针的引用计数。
//每个类的引用计数都降为零,并发送释放其内存地址的请求。
//向下移动栈指针。
//总计:〜0.117秒
如果内存管理是二进制的,那就是说值类型进入栈,引用类型进入堆,那将是很好的选择,但实际上,值类型的生命周期和性能由其内容严格定义。
堆分配的值类型
如果在编译期间无法确定值类型的大小(由于协议/通用要求),或者如果值类型递归地包含/包含在引用类型中(请记住闭包也是引用类型),则它将需要堆分配。这时候使用struct
很可能让您的性能相对于使用class
来说成指数级的恶化。
栈分配的值类型之所以很棒,是因为它们的生命周期与它们的作用域的生命周期直接相关,但是如果您的值类型是类的子级,那么要引用它才能使它超出它的作用域。这种情况在@escaping
闭包中很常见,并且此值类型将丢失其栈分配属性,以便与引用类型一起完全由堆分配。在某种程度上,您甚至可以说这种值类型本身就是引用类型,因为存在于堆中意味着多个对象可以指向它——即使它仍然具有值语义。
如果您的值类型是堆分配的类的父类,那么它本身将不会是堆分配的,但是它将继承引用计数开销,以便使内部引用保持活动状态。根据值类型的复杂性,这可能导致性能显着下降。
在标准库中,带有子引用的值类型的示例为String
,Array
,Dictionary
和Set
。这些值类型包含内部引用类型,这些内部引用类型管理堆中元素的存储,从而允许它们根据需要增加/减小大小。
由于堆操作比栈操作更昂贵,因此复制堆分配的值类型不是像栈分配的值那样的常量操作。为了防止这种情况影响性能,标准库的可扩展数据结构为写时复制 copy-on-write
。
使用此功能,仅分配属性不会复制值类型——而是像创建常规引用类型一样创建引用。实际复制仅在确实必要时进行。
代码语言:javascript复制//赋值复制
let emptyStruct = EmptyStruct() //address A
let copy = emptyStruct //address B
//写时复制
let array = [1,2,3] //address C
var notACopy = array //still address C
notACopy = [4,5,6] //now address D
请注意,您创建的任何值类型都将是赋值复制,但是您可以对它们进行编码以具有写时复制功能。标准库本身是在代码级别执行的,所以您也可以。这是苹果公司的一个例子。
具有内部引用的值类型中的引用计数的相关问题
完全栈分配的值类型不需要引用计数,但是不幸的是,具有内部引用的值类型将继承此功能。
考虑两个对象:一个充满类的结构体和一个充满相同类的类:
代码语言:javascript复制struct HugeDynamicStruct {
var emptyClass = EmptyClass()
var emptyClass2 = EmptyClass()
var emptyClass3 = EmptyClass()
var emptyClass4 = EmptyClass()
var emptyClass5 = EmptyClass()
var emptyClass6 = EmptyClass()
var emptyClass7 = EmptyClass()
var emptyClass8 = EmptyClass()
var emptyClass9 = EmptyClass()
var emptyClass10 = EmptyClass()
}
class HugeClass {
var emptyClass = EmptyClass()
var emptyClass2 = EmptyClass()
var emptyClass3 = EmptyClass()
var emptyClass4 = EmptyClass()
var emptyClass5 = EmptyClass()
var emptyClass6 = EmptyClass()
var emptyClass7 = EmptyClass()
var emptyClass8 = EmptyClass()
var emptyClass9 = EmptyClass()
var emptyClass10 = EmptyClass()
}
以下代码段将检查创建HugeClass
所需的时间,将其引用一千万次,将所有这些引用添加到数组中,然后重新分配所有内容。然后它将对struct
变体执行相同的操作。
func createABunchOfReferencesOfClass() {
var array = [HugeClass]()
let object = HugeClass()
for _ in 0..<10_000_000 {
array.append(object)
}
}
func createABunchOfCopiesOfStruct() {
var array = [HugeDynamicStruct]()
let object = HugeDynamicStruct()
for _ in 0..<10_000_000 {
array.append(object)
}
}
//每个对象包含10个 EmptyClasses
createABunchOfReferencesOfClass() // ~1.71 seconds
createABunchOfCopiesOfStruct() // ~5.1 seconds
从前面所说的来看,与仅增加引用计数值的类版本相比,结构体版本在赋值复制时会花费更长的时间。
但是,考虑一下当我们增加每个对象内部的EmptyClass
的数量时会发生什么:
//每个对象现在包含20个 EmptyClasses
createABunchOfReferencesOfClass() // ~1.75 seconds
createABunchOfCopiesOfStruct() // ~14.5 seconds
向HugeClass
添加更多的类对算法的运行时间毫无影响,但是HugeDynamicStruct
的版本运行的时间是后者的两倍多!
由于所有的引用类型需要引用计数,增加的属性的数量级的等级不会改变该算法的运行时间,仅仅是增加了父类参考的引用计数将足以保持它的内部引用。
但是,值类型本身没有引用计数。如果您的值类型包含内部引用,则复制它将需要增加其子级的引用计数——不是第一个,不是第二个,而是从字面上逐个引用。
代码语言:javascript复制final class ClassOfClasses {
let emptyClass = EmptyClass()
let emptyClass2 = EmptyClass()
let emptyClass3 = EmptyClass()
}
let classOfClasses = ClassOfClasses()
let reference = classOfClasses
let reference2 = classOfClasses
let reference3 = classOfClasses
CFGetRetainCount(classOfClasses) // 4
CFGetRetainCount(classOfClasses.emptyClass) // 1
CFGetRetainCount(classOfClasses.emptyClass2) // 1
CFGetRetainCount(classOfClasses.emptyClass3) // 1
struct StructOfClasses {
let emptyClass = EmptyClass()
let emptyClass2 = EmptyClass()
let emptyClass3 = EmptyClass()
}
let structOfClasses = StructOfClasses()
let copy = structOfClasses
let copy2 = structOfClasses
let copy3 = structOfClasses
CFGetRetainCount(structOfClasses) // 不会编译,结构体本身没有引用计数。
CFGetRetainCount(structOfClasses.emptyClass) // 4
CFGetRetainCount(structOfClasses.emptyClass2) // 4
CFGetRetainCount(structOfClasses.emptyClass3) // 4
值类型中包含的引用类型越多,复制时引用计数所涉及的开销就越大,从而导致潜在的讨厌的性能问题。
避免值类型中的引用计数过多
您可以通过将不必要的引用与适当的静态大小值类型交换来提高应用程序的性能。考虑以下具有内部引用的值类型:
代码语言:javascript复制struct DeliveryAddress {
let identifier: String
let type: String
}
如果identifier
表示一个UUID,则可以用Foundation的UUID结构体安全地替换它,该结构体是静态大小的。
以类似的方式,类型可以轻松地成为预定义的枚举。
代码语言:javascript复制struct DeliveryAddress {
enum AddressType {
case home
case work
}
let identifier: UUID
let type: AddressType
}
通过这些更改,此结构体现在已静态调整大小。不仅消除了引用计数开销,而且现在也更加类型安全。
如果您的值类型比这更复杂(并且您有性能问题),请问自己是否真的不应该将其使用具有写时复制 copy-on-write
功能的类替代。
从苹果的文档中可以了解到:
作为一般准则,请考虑在以下一个或多个条件适用时创建结构体:
- 该结构体的主要目的是封装一些相对简单的数据值。
- 合理的是,当您分配或传递该结构体的实例时,将封装的是值复制而不是引用。
- 结构体存储的任何属性本身都是值类型,也应该期望将其复制而不是引用。
- 该结构体不需要从另一个现有类型继承属性或行为。
良好的结构体示例有:
-
Size
为几何形状的大小,可能封装了width
属性和height
属性,它们都是Double
类型。 - 引用一系列范围的一种方法,可能封装了
Int
类型的start
属性和length
属性。 - 3D坐标系中的一个点,可能封装了
x
,y
和z
属性,每个属性都是Double
类型。
在所有其他情况下,定义一个类,并创建该类的实例以通过引用进行管理和传递。实际上,这意味着大多数自定义数据构造应该是类,而不是结构体。
还有什么?
即使此处显示的示例过于夸张,小错误也可能并且很快就会叠加起来,将来会给您带来麻烦。切记:人们希望玩得开心,而且大多数人都不会接低于流畅的60 fps体验。等待/冻结非常令人讨厌,如果移动网站的加载时间超过3秒,则53%的访问将被放弃,并且当您的应用开始卡顿时,尤其是在滚动内容时,应牢记这一点。
性能取决于几个因素,在结构和类之间进行选择只是其中之一。如果您对此主题感兴趣,我强烈建议您观看有关方法分发(Method Dispatching) 和见证表(Witness Tables)的WWDC视频。
参考文献和优秀读物
Operating Systems: Three Easy Pieces WWDC: Understanding Swift Performance WWDC: Optimizing Swift Performance WWDC: Building Better Apps with Value Types in Swift Apple: Optimization Tips
译自
Memory Management and Performance of Value Types