iOS-Swift 独孤九剑:十一、String、Array 的内存结构

2,474 阅读16分钟

在 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.png

此时我什么都不传,此时我们看到当前的字符串打印出来属于 0xE000_0000_0000_0000,说明这个空字符串属于 ASCII。接下来我传入一个中文:

0xA000_0000_0000_0000.png

当传入中文的时候,打印出来的属于 0xA000_0000_0000_0000,说明中文字符不属于 ASCII。

传入中文时 0xa 后面的 3 代表什么?我们来看一下:

0xe200000000000000.png

当 empty 等于 ab 的时候 0xe 的后面等于 2,接下来我们看看 abc 的时候:

0xe300000000000000.png

由此得知,这个 0xe 或者 0xa 后面代表的是字符串的大小。而且前面的 636261 是 abc 的编码,也就是 636261 代表的就是 abc。

我们都知道一个字符串的大小为 16 个字节,那这些字符是否都是存储在这 16 个字节中呢,我们来看一下 abcdefgh 是否填满前 8 个字节。

abcdefgh 的内存.png

确实填满了前 8 个字节,那如果这个字符串大于 8 个字节呢,我们来看一下 abcdefghijklmno 的内存:

abcdefghijklmno 的内存.png

这个字符串取的比较巧,其大小正好为 15,此时这个字符串的值完全就存储在了这 16 个字节里。如果这个字符串的大小超过了 15,这个字符串会如何存储呢?

1.3. 大字符串的内存布局

我们来看一下 abcdefghijklmnoabc 的内存:

abcdefghijklmnoabc.png

此时这 16 个字节存储的内容就变了。为什么呢,此时这 16 个字节代表又是什么,在源码中有这么一段注释:

native.png

当字符串的大小小于 15 的时候属于小字符串,比如:abcdefghijklmno。当字符串的大小大于 15 的时候属于大字符串。通过官方的注释得知大字符串可以是原生的、共享的或者是外来的。这里主要探究原生的字符串。根据官方的注释,这个原生的字符串具有尾部分配的存储空间,它从存储对象地址的“nativeBias”偏移量开始。

那也就是意味着当字符串的大小大于 15 的时候,会分配额外的存储空间,用这个额外的存储空间存储字符串的值。我们继续往下看:

nativeBias.png

首先 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.png

和我们的猜想一样,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)是什么东西。在官方的注释里有一个大字符串关于标志位相关的描述,如图:

大字符串标识位描述.png

  • isASCII:用来判断当前字符串是否是 ASCII,在高 63 位。

  • isNFC:这个默认为 1,在高 62 位。

  • isNativelyStored:是否是原生存储,在 高 61 位。

  • isTailAllocated:是否是尾部分配的,在 高 60 位。

  • TBD:留作将来使用,在高 59 位到高 48 位。

  • count:当前字符串的大小,在高 47位到低 0 位。

了解到这些含义之后,我们看一下 0xd000000000000012 在 64 位中是如何存储的,如图:

0xd000000000000012 的位域信息.png

可以看到这个和官方注释的描述是一致的,那么 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 布局的描述:

String's Index.png

从注释中我们大致明白了上述表示的意思:

  • 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 在底层是如何去分配内存的。

Array sil 代码.png

我们来看,在 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 是数组第一个元素的地址。

为什么要返回第一个元素的地址呢?是因为在第一个元素的地址之前,应该还有其它的信息。这里先给出结论,如图: Array 的内存结构.png

这个 nums 变量里存储的其实是一个名为 _ContiguousArrayStorage 类的内存地址,而这个类存储这个存储着 元类型,引用计数,元素的个数,容量的大小和标志位,以及元素的内存地址。

我们先通过 lldb 打印一下是否和结论一致,如图: lldb 打印 Array 的内存结构.png

从图中可以看到,其实和我们之前下的结论是基本一致的,但其实我在源码中并未找到 metadata 和 refCount 相关的代码和文档描述,感兴趣的可以去源码中搜索 _ContiguousArrayStorage 这个类的实现,或者熟悉汇编调试的靓仔可以试试通过汇编来证明这两个东西。

2. Array 数据的拼接

对于 Array 调用 append 去拼接数据其实在文档注释中也有解释,如图:

Array append 注释.png

当你向数组添加元素并且该数组开始超出其保留容量时,该数组会分配更大的内存区域并将其元素复制到新存储中。新存储是旧存储大小的倍数。这种指数增长策略意味着追加一个元素在恒定时间内发生,平均许多追加操作的性能。触发重新分配的追加操作有性能成本,但随着阵列变大,它们发生的频率越来越低。

接下来我们来看一下 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 的实现,如图:

Array扩容代码.png

所以,一般情况下,一个数组在进行扩容的时候,本质上是在旧的 capacity 上进行 2 倍扩容。