在 Swift 中 String 和 Array 本身是一个结构体,那它们是如何存储数据的,它们在内存当中的结构又是什么样的呢,我们通过这篇文章去探讨这两个问题。
一、String
1. Swift 的内存布局
1.1. String 的源码分析
Swift 中的 String 是一个结构体类型,那它是如何存储字符的呢?在源码中它的结构如下:
public struct String {
public // @SPI(Foundation)
var _guts: _StringGuts
@inlinable @inline(__always)
internal init(_ _guts: _StringGuts) {
self._guts = _guts
_invariantCheck()
}
// other
......
}
它的初始化方法要求传一个 _StringGuts 类型。这个 _StringGuts 就是我们接下来要去研究的对象。在源码的 StringGuts.swift 文件中找到了它的定义,如下:
// StringGuts is a parameterization over String's representations. It provides
// functionality and guidance for efficiently working with Strings.
//
@frozen
public // SPI(corelibs-foundation)
struct _StringGuts: UnsafeSendable {
@usableFromInline
internal var _object: _StringObject
@inlinable @inline(__always)
internal init(_ object: _StringObject) {
self._object = object
_invariantCheck()
}
// Empty string
@inlinable @inline(__always)
init() {
self.init(_StringObject(empty: ()))
}
}
注意看,它的初始化方法要求传一个 _StringObject 类型的,我们继续跟进,看看这个 _StringObject 是什么。
@frozen @usableFromInline
internal struct _StringObject {
......
}
这个 _StringObject 也是一个结构体,我们找到 _StringObject(empty: ()) 这个方法,它的实现如下:
@inlinable @inline(__always)
internal init(empty:()) {
// Canonical empty pattern: small zero-length string
#if arch(i386) || arch(arm) || arch(arm64_32) || arch(wasm32)
self.init(
count: 0,
variant: .immortal(0),
discriminator: Nibbles.emptyString,
flags: 0)
#else
self._countAndFlagsBits = 0
self._object = Builtin.valueToBridgeObject(Nibbles.emptyString._value)
#endif
_internalInvariant(self.smallCount == 0)
_invariantCheck()
}
注意看,在这里它调用了一个 init(count: variant: discirminator: flags:),我们来看一下这个方法的实现:
@inlinable @inline(__always)
init(count: Int, variant: Variant, discriminator: UInt64, flags: UInt16) {
_internalInvariant(discriminator & 0xFF00_0000_0000_0000 == discriminator,
"only the top byte can carry the discriminator and small count")
self._count = count
self._variant = variant
self._discriminator = UInt8(truncatingIfNeeded: discriminator &>> 56)
self._flags = flags
self._invariantCheck()
}
这个方法在对当前这个 _StringObject 的成员变量进行赋值。此时,String 的源码结构我们大致的就已经知道了,那 _count,_variant,_discriminator,_flags 分别又代表什么呢?首先 _count 应该是代表当前字符串的大小。_variant 的类型是 Variant,它是一个枚举,我们来看一下它的定义:
internal enum Variant {
case immortal(UInt)
case native(AnyObject)
case bridged(_CocoaString)
// function
......
}
这个枚举代表字符串的三种情况,分别为 immortal、native 以及 bridged。而通过刚才初始化方法的传值,此时的 _variant 类型是 Variant.immortal(0) 类型的,这个代表 Swift 原生的字符串类型。
接下来我们看 _discriminator,这个 _discriminator 外部传进来的是一个 Nibbles.emptyString,我们接着就来看一下 Nibbles,代码如下:
// Namespace to hold magic numbers
@usableFromInline @frozen
enum Nibbles {}
它是一个什么都没有的枚举,那这个时候我们可以找找它的 extension。
extension _StringObject.Nibbles {
// The canonical empty string is an empty small string
@inlinable @inline(__always)
internal static var emptyString: UInt64 {
return _StringObject.Nibbles.small(isASCII: true)
}
}
emptyString 返回的是一个 Nibbles 的 samll(isASCII:) 方法,true 代表是 ASCII,false 代表不是。我们来看这个 samll(isASCII:) 的实现。
@inlinable @inline(__always)
internal static func small(isASCII: Bool) -> UInt64 {
return isASCII ? 0xE000_0000_0000_0000 : 0xA000_0000_0000_0000
}
可以看到,当 isASCII 为 true 的时候,返回是 0xE000_0000_0000_0000,否则返回 0xA000_0000_0000_0000。
1.2. 小字符串的内存布局
那 0xE000_0000_0000_0000 和 0xA000_0000_0000_0000 代表什么意思呢?我们来看一下:
此时我什么都不传,此时我们看到当前的字符串打印出来属于 0xE000_0000_0000_0000,说明这个空字符串属于 ASCII。接下来我传入一个中文:
当传入中文的时候,打印出来的属于 0xA000_0000_0000_0000,说明中文字符不属于 ASCII。
传入中文时 0xa 后面的 3 代表什么?我们来看一下:
当 empty 等于 ab 的时候 0xe 的后面等于 2,接下来我们看看 abc 的时候:
由此得知,这个 0xe 或者 0xa 后面代表的是字符串的大小。而且前面的 636261 是 abc 的编码,也就是 636261 代表的就是 abc。
我们都知道一个字符串的大小为 16 个字节,那这些字符是否都是存储在这 16 个字节中呢,我们来看一下 abcdefgh 是否填满前 8 个字节。
确实填满了前 8 个字节,那如果这个字符串大于 8 个字节呢,我们来看一下 abcdefghijklmno 的内存:
这个字符串取的比较巧,其大小正好为 15,此时这个字符串的值完全就存储在了这 16 个字节里。如果这个字符串的大小超过了 15,这个字符串会如何存储呢?
1.3. 大字符串的内存布局
我们来看一下 abcdefghijklmnoabc 的内存:
此时这 16 个字节存储的内容就变了。为什么呢,此时这 16 个字节代表又是什么,在源码中有这么一段注释:
当字符串的大小小于 15 的时候属于小字符串,比如:abcdefghijklmno。当字符串的大小大于 15 的时候属于大字符串。通过官方的注释得知大字符串可以是原生的、共享的或者是外来的。这里主要探究原生的字符串。根据官方的注释,这个原生的字符串具有尾部分配的存储空间,它从存储对象地址的“nativeBias”偏移量开始。
那也就是意味着当字符串的大小大于 15 的时候,会分配额外的存储空间,用这个额外的存储空间存储字符串的值。我们继续往下看:
首先 nativeBias 的值为 32。接下来我们看一下 discriminator(鉴别器) 和 objectAddr,根据官方给的注释,这个 discriminator 在 64 位中,占据的位置是高 63 位到高 60 位。那高 60 位到低 0 位存储的就是这个额外的存储空间的内存地址。
这个 objectAddr 存储的是这个额外的存储空间的内存地址,但是它是一个相对地址,因为它需要加上 nativeBias,得到的才是这个额外的存储空间的地址值。
此时回到 abcdefghijklmnoabc 的内存布局,前面的 8 个字节(0xd000000000000012)我们先不要管,看后面的 8 个字节(0x8000000100003f60)。0x8 就是这个鉴别器的值,后面的 100003f60 就是额外存储空间的相对地址。所以我们来试着加一下 nativeBias 的到的值后是否就是字符串的存储空间。
100003f60 + 32 = 100003f60 + 0x2 = 100003f80
我们格式化输出一下 0x100003f80:
和我们的猜想一样,0x100003f80 就是字符串的内存地址。那我怎么知道这个 0x8 就是鉴别器的值呢,在源码中有,代码如下:
// Discriminator for large, immortal, swift-native strings
@inlinable @inline(__always)
internal static func largeImmortal() -> UInt64 {
return 0x8000_0000_0000_0000
}
这个 0x8000_0000_0000_0000 代表是原生字符串的大字符串。
接下来我们再来看一下前面的 8 个字节(0xd000000000000012)是什么东西。在官方的注释里有一个大字符串关于标志位相关的描述,如图:
-
isASCII:用来判断当前字符串是否是 ASCII,在高 63 位。
-
isNFC:这个默认为 1,在高 62 位。
-
isNativelyStored:是否是原生存储,在 高 61 位。
-
isTailAllocated:是否是尾部分配的,在 高 60 位。
-
TBD:留作将来使用,在高 59 位到高 48 位。
-
count:当前字符串的大小,在高 47位到低 0 位。
了解到这些含义之后,我们看一下 0xd000000000000012 在 64 位中是如何存储的,如图:
可以看到这个和官方注释的描述是一致的,那么 0x12 的十进制是 18,所以当前字符串的大小为 18。所以当字符串为大字符串的时候,前 8 个字节存储的就是 countsAndFlags。
1.4. 关于字符串内存布局的小结
-
一个 String 变量/常量的大小为 16 个字节。
-
当字符串的大小小于等于 15 的时候为小字符串,当字符串的大小大于 15 的时候为大字符串。
-
当为小字符串的时候,在 16 字节的大小中,前 15 个字节用来存储字符串的值,最后一个字节用来记录当前字符串是否是 ASCII 和字符串的大小。
-
当为大字符串的时候,在 16 字节的大小中,前 8 个字节用来记录字符串的大小和其它的一些信息,比如是否是 ASCII。剩余的 8 个字节中,高 63 位到高 60 位存储的是鉴别器的值,剩余的用来存储字符串的存储空间的地址值。
2. Swift Index
在 Swift 中,获取字符串中某一个字符的值我们可以通过 Index 去获取。
let str = "Hello World"
比如说上面这个字符串,我需要获取 "Hello World" 中的 "e" 字符,那么我们可以通过 index(i:offsetBy:) 方法来获取 "e" 这个字符对应的 Index。这个方法要求传一个这个字符串中某个字符的 Index,然后根据这个 Index 偏移,偏移到要返回的 Index。
比如我们要获取 "e" 这个 Index,那就从这个字符串起始的 Index 开始偏移 1 个 Index,代码如下:
let index = str.index(str.startIndex, offsetBy: 1)
print(str[index]) // e
此时我们通过这个 index 就可以拿到 "e" 字符。那这里我们思考一个问题,为什么不能像数组一样通过 Int 类型的下标直接访问,而是通过这么繁琐的方式去访问呢?
Swift 中的 String 代表的是一系列的 characters(字符),字符的表示方式有很多种,比如我们最熟悉的 ASCII 码,ASCII 码一共规定了 128 个字符的编码,对于英文字符来说 128 个字符已经够用了,但是相 对于其他语言来说,这是远远不够用的。
这也就意味着不同国家不同语言都需要有自己的编码格式,这个时候同一个二进制文件就有可能被翻译成不同的字符。有一种编码能够把所有的符号都纳入其中的方式,就是我们熟悉的 Unicode。但是 Unicode 只是规定了符号对应的二进制代码,并没有详细明确这个二进制代码应该如何存储。
什么意思呢,假设这里有一个字符串:"我是Coder",那这个字符串对应的 Unicode 如下:
我:6211
是:662f
C:0043
o:006f
d:0064
e:0065
r:0072
可以看到,上述的文字每一个对应一个十六进制的数,对于计算机来说能够识别的是二进制,那它们的二进制表示如下:
我:0110 0010 0001 0001
是:0110 0110 0010 1111
C:0000 0000 0100 0011
o:0000 0000 0110 1111
d:0000 0000 0110 0100
e:0000 0000 0110 0101
r:0000 0000 0111 0010
为了能够看清楚中文字符和英文字符的区别,我上面是以 4 的倍数输出,不足用 0 补齐。对于英文字符如果统一采用中文字符这种方式去存储,也就是用和中文字符一样的步长去存储英文字符,必然会有很大的浪费的。
为了解决这个问题,就可以用 UTF-8,UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符 号,根据不同的符号而变化字节长度。这里我们简单说一下 UTF-8 的规则:
-
单字节的字符,字节的第一位设为 0,对于英语文本,UTF-8 码只占用一个字节,和 ASCII 码完全相同;
-
n 个字节的字符(n>1),第一个字节的前 n 位设为 1,第 n+1 位设为 0,后面字节的前两位都设为 10,这 n 个字节的其余空位填充该字符 Unicode 码,高位用 0 补足。
例如:
我:11100110 10001000 10010010
是:11100110 10011000 10101111
C:0100 0011
o:0110 1111
d:0110 0100
e:0110 0101
r:0111 0010
对于 Swift 来说,String 是一系列字符的集合,也就意味着 String 中的每一个元素是不等长的。那也就意味着我们在进行内存移动的时候步长是不一样的,什么意思?比如我们有一个 Array 的数组(Int 类型),当我们遍历数组中的元素的时候,因为每个元素的内存大小是一致的,所以每次的偏移量就是 8 个字节。
但是对于字符串来说不一样,比如访问 str[1],那么是不是要把 "我" 这个字段遍历完成之后才能够确定 "是" 的偏移量?依次内推每一次都要重新遍历计算偏移量,这个时候无疑增加了很多的内存消耗。这就是为什么不能通过 Int 作为下标来去访问 String 的原因。
我们再来看源码关于 String 的 Index 布局的描述:
从注释中我们大致明白了上述表示的意思:
-
position aka encodedOffset:一个 48 bit 值,用来记录码位偏移量。
-
transcoded offset:一个 2 bit 的值,用来记录字符使用的码位数量。
-
grapheme cache:一个 6 bit 的值,用来记录下一个字符的边界。
-
reserved:7 bit 的预留字段 scalar aligned: 一个 1 bit 的值,用来记录标量是否已经对齐过。
所以对于 String 的 Index 的本质是存储了 encodedOffset 和 transcoded offset。当我们构建 String 的 Index 的时候,其实是把 encodedOffset 和 transcoded offset 计算出来存放到 Index 的内存信息里面。而这个 Index 本身就是一个 64 位的位域信息。
二、Array
1. Array 的内存结构
在 Swift 中,Array 是一个结构体,那结构体的大小取决于结构体成员变量的大小。那一个 Array 的变量是多大呢?和平常的结构体一样吗?
先定义一个结构体,我们来看一下它的大小,代码如下:
struct Person {
var age = 18
var height = 180
}
let p = Person()
print(MemoryLayout.stride(ofValue: p)) // 16
可以看到,这个结构体的大小为 8 个字节。我们再来看一下 Array 的:
let nums = [1, 2, 3, 4, 5, 6, 7]
print(MemoryLayout.stride(ofValue: nums)) // 8
这个数组的大小竟然只有 8 个字节,那就说明一个问题,数组的元素不是存储在 nums 变量上的,那数组在中数据存储在什么地方?我们先通过 sil 代码来看 nums 在底层是如何去分配内存的。
我们来看,在 mian 函数中,当我们初始化 nums 的时候,会调用一个 _allocateUninitializedArray 方法,并且将这个数组元素的个数传进去。那么,这个方法在内部都做了什么操作呢,我们来看一下源码中它是如何实现的。
在 ArrayShared.swift 文件中找到这个函数的具体实现:
@inlinable // FIXME(inline-always)
@inline(__always)
@_semantics("array.uninitialized_intrinsic")
public // COMPILER_INTRINSIC
func _allocateUninitializedArray<Element>(_ builtinCount: Builtin.Word)
-> (Array<Element>, Builtin.RawPointer) {
let count = Int(builtinCount)
if count > 0 {
// Doing the actual buffer allocation outside of the array.uninitialized
// semantics function enables stack propagation of the buffer.
let bufferObject = Builtin.allocWithTailElems_1(
_ContiguousArrayStorage<Element>.self, builtinCount, Element.self)
let (array, ptr) = Array<Element>._adoptStorage(bufferObject, count: count)
return (array, ptr._rawValue)
}
// For an empty array no buffer allocation is needed.
let (array, ptr) = Array<Element>._allocateUninitialized(count)
return (array, ptr._rawValue)
}
这个方法返回一个元组,通过 count 判断当前初始化的数组是否需要分配缓冲区。我们来看当 count 大于 0 的情况,当大于 0 的时候,会调用一个 allocWithTailElems_1 方法,分配堆空间来存储数组当中的元素。接着通过 _adoptStorage 来创建数组,我们来看一下 _adoptStorage 方法内部是如何实现的:
@inlinable
@_semantics("array.uninitialized")
internal static func _adoptStorage(
_ storage: __owned _ContiguousArrayStorage<Element>, count: Int ) -> (Array, UnsafeMutablePointer<Element>) {
let innerBuffer = _ContiguousArrayBuffer<Element>(
count: count,
storage: storage)
return (
Array(
_buffer: _Buffer(_buffer: innerBuffer, shiftedToStartIndex: 0)),
innerBuffer.firstElementAddress)
}
注意看,此时返回的这个元组里面有我们需要的 Array 和 firstElementAddress,这个 firstElementAddress 是数组第一个元素的地址。
为什么要返回第一个元素的地址呢?是因为在第一个元素的地址之前,应该还有其它的信息。这里先给出结论,如图:
这个 nums 变量里存储的其实是一个名为 _ContiguousArrayStorage 类的内存地址,而这个类存储这个存储着 元类型,引用计数,元素的个数,容量的大小和标志位,以及元素的内存地址。
我们先通过 lldb 打印一下是否和结论一致,如图:
从图中可以看到,其实和我们之前下的结论是基本一致的,但其实我在源码中并未找到 metadata 和 refCount 相关的代码和文档描述,感兴趣的可以去源码中搜索 _ContiguousArrayStorage 这个类的实现,或者熟悉汇编调试的靓仔可以试试通过汇编来证明这两个东西。
2. Array 数据的拼接
对于 Array 调用 append 去拼接数据其实在文档注释中也有解释,如图:
当你向数组添加元素并且该数组开始超出其保留容量时,该数组会分配更大的内存区域并将其元素复制到新存储中。新存储是旧存储大小的倍数。这种指数增长策略意味着追加一个元素在恒定时间内发生,平均许多追加操作的性能。触发重新分配的追加操作有性能成本,但随着阵列变大,它们发生的频率越来越低。
接下来我们来看一下 append 是如何去扩容的,其实现如下:
@inlinable
@_semantics("array.append_element")
public mutating func append(_ newElement: __owned Element) {
// Separating uniqueness check and capacity check allows hoisting the
// uniqueness check out of a loop.
_makeUniqueAndReserveCapacityIfNotUnique()
let oldCount = _buffer.mutableCount
_reserveCapacityAssumingUniqueBuffer(oldCount: oldCount)
_appendElementAssumeUniqueAndCapacity(oldCount, newElement: newElement)
_endMutation()
}
首先它调用了一个 _makeUniqueAndReserveCapacityIfNotUnique 方法,其实现如下:
@inlinable
@_semantics("array.make_mutable")
internal mutating func _makeUniqueAndReserveCapacityIfNotUnique() {
if _slowPath(!_buffer.beginCOWMutation()) {
_createNewBuffer(bufferIsUnique: false,
minimumCapacity: count + 1,
growForAppend: true)
}
}
如果缓冲区的存储是唯一引用的,将缓冲区置于可变状态,然后去创建新的 buffer。我们接着看这个 _createNewBuffer(bufferIsUnique:minimumCapacity:growForAppend) 的实现,代码如下:
@_alwaysEmitIntoClient
@inline(never)
@_semantics("optimize.sil.specialize.owned2guarantee.never")
internal __consuming func _consumeAndCreateNew(bufferIsUnique: Bool,
minimumCapacity: Int,
growForAppend: Bool ) -> _ArrayBuffer {
let newCapacity = _growArrayCapacity(oldCapacity: capacity,
minimumCapacity: minimumCapacity,
growForAppend: growForAppend)
......
}
在这个方法当中它会去调用 _growArrayCapacity 方法来进行扩容,我们继续来看 _growArrayCapacity 的实现,如图:
所以,一般情况下,一个数组在进行扩容的时候,本质上是在旧的 capacity 上进行 2 倍扩容。