Swift内存布局:大小,步幅,对齐
Swift结构实例的内存布局的基础知识。
Swift类型在内存中处理时要考虑三个属性:大小,步幅和对齐。
尺寸
让我们从两个简单的结构开始:
struct Year {
let year: Int
}
struct YearWithMonth {
let year: Int
let month: Int
}
我的直觉告诉我, YearWithMonth
的实例比YearWithMonth
的实例更大(它占用更多的内存空间)。 但是我们是这里的科学家。 我们如何用硬数字验证直觉?
内存布局
我们可以使用MemoryLayout
类型来检查一些有关类型在内存中的外观的属性。
要从结构的类型中查找结构的size
,请使用size
属性以及通用参数:
let size = MemoryLayout.size
如果您有该类型的实例,请使用size(ofValue:)
静态函数:
let instance = Year(year: 1984)
let size = MemoryLayout.size(ofValue: instance)
在这两种情况下,大小均报告为8个字节。
毫不奇怪,我们的结构YearWithMonth
的大小为16个字节。
回到尺寸
结构的大小似乎很直观-计算每个属性的大小之和。 对于这样的结构:
struct Puppy {
let age: Int
let isTrained: Bool
}
大小应与其属性的大小相匹配:
MemoryLayout.size + MemoryLayout.size
// returns 9, from 8 + 1
MemoryLayout.size
// returns 9
似乎可以工作! [ 旁白:是吗? 😈]
大步走
当您在单个缓冲区(例如数组)中处理多个实例时,类型的跨度变得很重要。
如果我们有一组连续的幼犬,每个幼犬大小为9个字节,那么在内存中会是什么样?
事实并非如此。 ❌
步幅确定两个元素之间的距离,该距离将大于或等于大小。
MemoryLayout.size
// returns 9
MemoryLayout.stride
// returns 16
因此,布局实际上如下所示:
也就是说,如果您有一个指向第一个元素的字节指针,并且想移至第二个元素,则跨度就是您需要使指针前进的字节距离数。
为什么大小和步幅会有所不同? 这使我们达到了内存布局的最终魔数。
对准
想象一下,计算机一次获取了8位或一个字节的内存。 询问字节1或字节7所需的时间相同。
然后,您升级到16位计算机,该计算机可以16位字访问数据。 您仍然有旧软件想要按字节访问数据,但可以想象这里可能存在的魔术:如果该软件要求字节0和字节1,则计算机现在可以对字0进行单个内存访问并拆分16位结果。
在这种理想情况下,字节级内存访问速度是原来的两倍! 🎉
现在说一个流氓程序,将这样的16位值放入:
然后,您要求计算机在字节位置3处输入16位字。问题是该值未对齐 。 要读取它,计算机需要在位置1读取单词,将其切成两半,在位置2读取单词,将其切成两半,然后将两半粘贴在一起。 那是两个单独的16位内存读取,以访问单个16位值-速度应该是它的两倍! 😭
在某些系统上,未对齐的访问比慢要糟糕-完全不允许这样做,它会使程序崩溃。
简单的Swift类型
在Swift中,简单类型(如Int
和Double
与其大小具有相同的对齐值。 一个32位(4字节)整数的大小为4个字节,需要对齐到4个字节。
MemoryLayout.size // returns 4
MemoryLayout.alignment // returns 4 MemoryLayout.stride // returns 4
跨度也是4,这意味着连续缓冲区中的值相隔4个字节。 无需填充。
复合类型
现在回到我们的Puppy
结构,它具有Int
和Bool
属性。 再次考虑在缓冲区中值彼此相对的情况:
由于Bool
值的alignment=1
,因此很高兴。 但是第二个整数未对齐。 它是一个alignment=8
的64位(8字节)值,并且其字节位置不是 8的倍数。
记住这种类型的跨度为16,这意味着缓冲区实际上看起来像这样:
我们保留了struct内部所有值的对齐要求:第二个整数位于字节16处,是8的倍数。
这就是为什么该结构的步幅可以大于其大小的原因:添加足够的填充来满足对齐要求。
计算对齐
因此,在此过程的最后, Puppy
结构类型的对齐方式是什么?
MemoryLayout.alignment // returns 8
结构类型的对齐方式是其所有属性中的最大对齐方式。 在Int
和Bool
, Int
的对齐值较大,为8,因此该结构使用它。
然后跨度变为将大小四舍五入到对齐的下一个倍数。 在我们的情况下:
- 大小是9
- 9不是8的倍数
- 9之后的8的下一个倍数是16
- 因此,步幅为16
最后的并发症
考虑我们原始的Puppy
并将其与AlternatePuppy
对比:
struct Puppy {
let age: Int
let isTrained: Bool
} // Int, Bool
struct AlternatePuppy {
let isTrained: Bool
let age: Int
} // Bool, Int
AlternatePuppy
结构仍然具有8的对齐方式和16的步幅,但是:
MemoryLayout.size
// returns 16
什么?! 我们所做的就是更改属性的顺序。 为什么现在大小不同? 它仍然应该是9,不是吗? 一个Bool
后面跟一个Int
,就像这样:
也许您在这里看到了问题:8字节整数不再对齐! 这实际上在内存中是这样的:
结构本身必须对齐, 并且结构内部的属性必须保持对齐。 填充在元素之间移动,并且整个结构的大小会扩展。
在这种情况下,步幅仍为16,因此从Puppy
到AlternatePuppy
的有效更改是填充的位置。 这些结构呢?
struct CertifiedPuppy1 {
let age: Int
let isTrained: Bool
let isCertified: Bool
} // Int, Bool, Bool
struct CertifiedPuppy2 {
let isTrained: Bool
let age: Int
let isCertified: Bool
} // Bool, Int, Bool
这两个结构的大小,步幅和对齐度是多少? 🤔(剧透)
结束括号
最后,假设您有一个UnsafeRawPointer
(在C中为void *
)。 您知道它指向的事物的类型。 尺寸,步幅和对齐方式在哪里出现?
- 大小是从指针读取以到达所有数据的字节数。
- 跨步是要前进到缓冲区中下一项的字节数。
- 对齐是每个实例必须位于的“均匀可除数”数字。 如果要分配内存以将数据复制到其中,则需要指定正确的对齐方式(例如,
allocate(byteCount: 100, alignment: 4)
)。
对于我们大多数人而言,大多数时候,我们可能会处理诸如数组和集合之类的高级集合,而无需考虑底层的内存布局。
在其他情况下,您可以在平台上使用较低级别的API或与C代码互操作。 如果您有一个Swift结构数组,并且需要C代码来读取它(反之亦然),则需要担心分配具有正确对齐方式的缓冲区,确保结构内部的填充对齐,并确保正确的步幅值,以便您可以正确解释数据。
正如我们所看到的,即使计算大小也不像看起来那样简单-每个大小和每个属性的对齐之间存在一些相互作用,这些相互作用决定了结构的整体大小。 因此,了解所有这三个方面意味着您将成为内存大师。
}