内存管理和值类型的性能

最初发布于 swiftrocks.com

您很可能在iOS生涯中至少问过自己一次, structclass之间有什么区别。 实际上,在使用一个或另一个之间的选择总是归结为值语义引用语义 ,但是两者之间的性能差异是可表达的,并且取决于对象的内容,尤其是在处理值类型时,它们之间可能会偏重一个或另一个。

有人可能会说,对于应用程序级别的开发人员而言,内存体系结构的知识是无关紧要的,我对此表示部分赞同。 知道如何在这里和那里节省一些钱不会对新型iPhone产生明显的影响,过早的优化是一个非常不明智的做法。

但是,引用和值类型在滥用时都会严重降低您的应用程序的速度,这些知识将确定您是否可以有效解决问题。

要了解两者之间更深的差异,让我们回顾一下进程的地址空间:(为简单起见,使用单线程)

  |-------------------------|- 
| 说明|
|--------------------------------
| 全球数据
|--------------------------------
| 堆|
|--------------------------------
| 什么都没有(堆栈和堆向此处增长)|
|--------------------------------
| 堆叠
|--------------------------------

在内存体系结构中,堆栈与您已经知道的数据结构没有什么不同,并且堆栈分配是一种简单快速的分配/释放涉及堆栈的内存的方法。

应用程序中的每个“作用域”(就像方法的内部内容一样)将提供它需要运行的内存量,将堆栈指针按此数量移动并运行-将数据添加到它现在构成的空内存地址中。 丢失作用域后,堆栈指针将减少相同的数量,从而安全地重新分配所有作用域的数据。 分配/取消分配堆栈内存的成本实际上就是分配整数的成本。

在堆栈分配中,作用域收集的数据表示归因于该作用域的所有数据,例如方法参数,返回值,但更重要的是: 值类型 。 只要在编译时就知道值类型的大小并且不包含/不包含值类型,就可以按引用类型递归地使用它,就不需要引用计数,并且它的寿命将是静态的 -等于寿命其范围。 它会在堆栈上完全分配,并且在释放范围时,值类型也会分配。 缺少引用计数开销和堆栈分配的存在可以显着提高性能。

PS:所有基准测试均使用-O。 我必须添加一些特殊的逻辑和关键字/属性以防止编译器跳过我的方法,但是为了使代码易于阅读,我将它们隐藏在示例中。

  struct EmptyStruct { 
私人租借号码:Int64 = 1
//默认情况下,空类具有64位存储空间
//指针
//因此我们要在结构中添加64位以进行公平比较。
}

@inline(never)func createABunchOfEmptyStructs(){
for _ in 0 .. <1_000_000 {
让myStruct = EmptyStruct()
}
}

createABunchOfEmptyStructs()//将堆栈指针向上移动一百万个EmptyStructs的大小。
//向移动堆栈指针创建的空地址添加一百万个EmptyEmpty结构。
//将堆栈指针向下移动相同的数量。
//总计:〜0.005秒

如果您的值类型的内容是其他堆栈分配的静态大小值类型,则您的值类型也将是静态大小。 这意味着您的值类型还将利用全部堆栈分配,并提高复制操作的性能。

我们曾经问过一个候选人,为什么他选择对明显不可变的东西使用class ,并打算用值语义来对待。 他的理由是该对象经常作为方法内部的参数发送,因此他担心多次复制该对象可能会对性能产生影响。

将属性分配给大多数值类型确实可以创建对象的完整副本。 但是,这种针对完全堆栈分配的值类型的赋值复制行为是如此之快和廉价,以至于Apple声称它可以在恒定时间内运行:

  struct BigStaticStruct { 
设fp1:Int64 = 1
令fp2:Int64 = 1
设fp3:Int64 = 1
让fp4:Int64 = 1
让fp5:Int64 = 1
}

func createABunchOfCopiesOfHugeStruct(){
让bigStruct = BigStaticStruct()
for _ in 0 .. <1_000_000 {
让副本= bigStruct
}
}

createABunchOfCopiesOfHugeStruct()//〜0.0033秒

//即使将属性数量增加了十倍,运行时也不会改变
//因为复制静态大小的结构是恒定时间操作。

但是,如果您要处理许多递归深度,堆栈分配可能会占用应用程序的内存。 值得庆幸的是,Swift具有尾递归优化功能,这意味着如果您使用尾递归反汇编方法,则会找到算法的迭代版本。

但是,当您需要引入具有可扩展大小的对象并“破坏” 指针概念时会发生什么?

堆栈不适合与大小变化的对象一起使用,指针/动态生存期的概念意味着对象的生存期与其范围无关—毕竟,即使在内存中也存在一个对象如果没有任何反应。

堆与栈一样,与具有相同名称的数据结构没有太大区别,在这种情况下,它应用于动态分配的用户管理的内存。

当进程请求一定数量的内存时,堆将搜索一个满足该请求的内存地址,并将其返回给进程。 当不再使用内存时,该进程必须告诉堆释放该部分内存。

在iOS中,“不再使用”以引用计数的形式工作,幸运的是,ARC的存在意味着,大多数事情将自动为您处理,除非您必须与RawPointer系列打交道。

堆分配比堆栈分配要慢,不仅是因为数据结构更为复杂-它还需要线程安全。 每个线程都有自己的堆栈,但是堆与所有人共享,需要同步。 但是,它允许引用类型和诸如动态大小数组之类的东西存在。

 期末课程EmptyClass {} 

@inline(never)func createABunchOfEmptyClasses(){
for _ in 0 .. <1_000_000 {
让myClass = EmptyClass()
}
}

createABunchOfEmptyClasses()

//将堆栈指针向上移动一百万个EmptyClass指针的大小。
//为一百万个EmptyClasses请求堆中的内存。
//向通过移动堆栈指针创建的空地址添加一百万EmptyClass指针,以指向堆的返回地址。
//(循环结束)减少指针的引用计数。
//每个类的引用计数都降为零,并发送释放其内存地址的请求。
//向下移动堆栈指针。
//总计:〜0.117秒

如果内存管理是二进制的,那就是说值类型进入堆栈,引用类型进入堆,那将是很好的选择,但实际上,值类型的寿命和性能由其内容严格定义。

如果在编译期间无法确定值类型的大小(由于协议/通用要求),或者如果值类型递归地包含/包含在引用类型中(请记住闭包也是引用类型),则它将需要堆分配。 从根本上不成问题到使struct性能指数级变差(范围可能不是类),范围可能很大。

堆栈分配的值类型之所以很棒,是因为它们的寿命与它们的作用域的寿命直接相关,但是如果您的值类型是class的子class ,则要使用它才能超出其作用域。 这种情况在@escaping闭包中很常见,并且此值类型将丢失其堆栈分配属性,以便与引用类型一起完全堆分配 。 从某种意义上说,您甚至可以说这种值类型本身就是引用类型 ,因为生活在堆中意味着几个对象可以指向它-即使它仍然具有值语义

如果您的值类型是堆分配的class的父class ,那么它本身将不会是堆分配的,但它将继承引用计数开销,以便能够使内部引用保持活动状态。 根据值类型的复杂性,这可能导致性能显着下降。

在标准库中,带有子引用的值类型的示例为StringArrayDictionarySet 。 这些值类型包含内部引用类型,这些内部引用类型管理堆中元素的存储,从而允许它们根据需要增加/减小大小。

由于堆操作比堆栈操作更昂贵,因此复制堆分配的值类型不是像堆栈分配的值那样的常量操作。 为了防止这种情况影响性能,标准库的可扩展数据结构为写时复制

使用此功能,仅分配属性将不会复制值类型,而是将创建引用,就像它是常规引用类型一样。 实际复制仅在确实必要时进行。

  //复制作业 
let emptyStruct = EmptyStruct()//地址为A
let copy = emptyStruct //地址B

//写时复制
let array = [1,2,3] //地址C
var notACopy = array //静态地址C
notACopy = [4,5,6] //现在的地址D

请注意,您创建的任何值类型都将是赋值复制 ,但是您可以对它们进行编码以具有写时复制功能。 这不是编译器,标准库本身是在代码级别执行的,您也可以。 这是苹果公司的一个例子。

完全堆栈分配的值类型不需要引用计数,但是不幸的是,具有内部引用的值类型继承此功能。

考虑两个对象:一个充满classesstruct和一个充满相同classes

  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]()
让对象= HugeClass()
for _ in 0 .. <10_000_000 {
array.append(object)
}
}

func createABunchOfCopiesOfStruct(){
var array = [HugeDynamicStruct]()
让对象= HugeDynamicStruct()
for _ in 0 .. <10_000_000 {
array.append(object)
}
}

//每个对象包含十个EmptyClasses

createABunchOfReferencesOfClass()//〜1.71秒
createABunchOfCopiesOfStruct()//〜5.1秒

从前面所说的来看,与仅增加引用计数值的class版本相比, struct版本在赋值复制时会花费更长的时间。

但是,考虑一下当我们增加每个对象内部的EmptyClasses的数量时会发生什么:

  //每个对象现在包含TWENTY EmptyClasses 

createABunchOfReferencesOfClass()//〜1.75秒
createABunchOfCopiesOfStruct()//〜14.5秒

HugeClass添加更多的类对算法的运行时间没有任何影响,但是HugeDynamicStruct的版本运行所需的时间是它的两倍多!

由于所有引用类型都需要引用计数,因此增加一class classes的属性数量不会改变该算法的运行时间,因为仅增加父引用的引用计数就足以使其内部引用保持活动状态。

但是,值类型自然没有引用计数。 如果您的值类型包含内部引用,则对其进行复制将需要增加其子级的引用计数-不是第一个,不是第二个,而是从字面上逐个引用。

 最终课程ClassOfClasses { 
让emptyClass = EmptyClass()
让emptyClass2 = EmptyClass()
让emptyClass3 = EmptyClass()
}

让classOfClasses = ClassOfClasses()
让参考= classOfClasses
让reference2 = classOfClasses
让reference3 = classOfClasses

CFGetRetainCount(classOfClasses)// 4
CFGetRetainCount(classOfClasses.emptyClass)// 1
CFGetRetainCount(classOfClasses.emptyClass2)// 1
CFGetRetainCount(classOfClasses.emptyClass3)// 1

struct StructOfClasses {
让emptyClass = EmptyClass()
让emptyClass2 = EmptyClass()
让emptyClass3 = EmptyClass()
}

让structOfClasses = StructOfClasses()
让复制= structOfClasses
让copy2 = structOfClasses
让copy3 =​​ structOfClasses

CFGetRetainCount(structOfClasses)//不编译,结构本身没有引用计数。
CFGetRetainCount(structOfClasses.emptyClass)// 4
CFGetRetainCount(structOfClasses.emptyClass2)// 4
CFGetRetainCount(structOfClasses.emptyClass3)// 4

值类型中包含的引用类型越多,复制时引用计数所涉及的开销就越大,从而导致潜在的讨厌的性能问题。

您可以通过将不必要的引用与适当的静态大小值类型交换来提高应用程序的性能。

考虑以下具有内部引用的值类型:

  struct DeliveryAddress { 
let标识符:字符串
让类型:字符串
}

如果identifier表示一个UUID,则可以用FoundationUUID结构安全地替换它,该结构是静态大小的。

以类似的方式, type可以轻松地成为预定义的enum

  struct DeliveryAddress { 
枚举AddressType {
案例家
个案工作
}
让标识符:UUID
让类型:AddressType
}

通过这些更改,此结构现在已静态调整大小。 不仅消除了引用计数开销,而且现在也更加类型安全。

如果您的值类型比这更复杂(并且您有性能问题),请问自己是否真的不应该将其作为具有写时复制功能的class

从Apple Docs:

作为一般准则,请考虑在以下一个或多个条件适用时创建结构:

该结构的主要目的是封装一些相对简单的数据值。

合理的是,当您分配或传递该结构的实例时,将封装的值复制而不是引用。

结构存储的任何属性本身都是值类型,也应该期望将其复制而不是引用。

该结构不需要从另一个现有类型继承属性或行为。

良好的结构候选人包括:

尺寸为Double的几何形状的大小,可能封装了width属性和height属性。

引用系列中范围的一种方法,可能封装了Int类型的start属性和length属性。

3D坐标系中的一个点,可能封装了x,y和z属性,每个属性都是Double类型。

在所有其他情况下,定义一个类,并创建该类的实例以通过引用进行管理和传递。 实际上,这意味着大多数自定义数据构造应该是类,而不是结构。

即使此处显示的示例过于夸张,小错误也可能并且很快就会加起来,将来会给您带来麻烦。 切记:人们希望玩得开心,而且大多数人都不会接受柔和流畅的60 fps体验。 等待/冻结非常令人讨厌,如果移动网站的加载时间超过3秒,则53%的访问将被放弃,并且当您的应用开始显示随机打ic /减速时,尤其是在滚动内容时,应牢记这一点。

性能取决于几个因素,在structsclasses之间进行选择只是其中之一。 如果您对此主题感兴趣,我强烈建议您观看有关方法分派和见证表的WWDC视频。

操作系统:三个简单的部分
WWDC:了解Swift性能
WWDC:优化Swift性能
WWDC:在Swift中使用值类型构建更好的应用程序
苹果:优化技巧


最初发布于 swiftrocks.com