今天我们通过查看内存、汇编以及 Swift 源码等多途径来探究一下 Swift 中的 String 的内存布局及底层实现。
空字符串
首先创建一个最简单的字符串,空字符串str1:
从图中可以看到,
String 内部有个 _StringGuts,_StringGuts 内部有个 _StringObject,_StringObject 内部有个 Builtin.BridgeObject 类型的_object 和一个 UInt64 类型的 _countAndFlagsBits。
找到 StringGuts[1] 源码,可以看到 _StringGuts 是一个结构体,里面有个 _StringObject 类型的成员 _object,跟上面 Xcode 打印的一致。
搜索关键词 empty,可以轻松找到创建空字符串的初始化方法:调用 _StringObject的方法empty:() 生成一个空的 _StringObject 对象后传入到自身默认初始化方法:init(_ object: _StringObject) 中。
进一步查看 StringObject[2] 源码,同样搜索关键词 empty,找到方法:init(empty:())。因为我们的设备是64位,所以这个方法会进入到第一个分支中,分别初始化成员:_countAndFlagsBits 和 _object(也跟图一的打印保持一致)。
Nibbles 是个枚举,源码中给它加了多个extension。进一步查看源码可以看到 Nibbles.emptyString,调用方法:small(isASCII: Bool)
enum Nibbles{}
extension_StringObject.Nibbles{
// The canonical empty string is an empty small string
@inlinable@inline(__always)
internalstaticvar emptyString:UInt64{
return_StringObject.Nibbles.small(isASCII:true)
}
}
extension_StringObject.Nibbles{
// Discriminator for small strings
@inlinable@inline(__always)
internalstaticfuncsmall(isASCII:Bool)->UInt64{
#if os(Android) && arch(arm64)
return isASCII ?0x00E0_0000_0000_0000:0x00A0_0000_0000_0000
#else
return isASCII ?0xE000_0000_0000_0000:0xA000_0000_0000_0000
#endif
}
非空字符串
由上面的分析,我们可以猜想到一个字符串变量至少占用了16字节。用 MemoryLayout 工具进行验证也确实是16个字节。
var str1 = ""
print(MemoryLayout.size(ofValue: str1)) //16
小字符串
那么这16个字节是如何分配的呢?先把空字符 str1 的内容稍微改为:"1",并且借助 MJ 的内存小工具Mems[3] 直接打印变量地址及内容
var str1 = "1"
print(Mems.ptr(ofVal: &str1))
print(Mems.memStr(ofVal: &str1))
/*
0x000000010000c1c8
0x0000000000000031 0xe100000000000000
(lldb) x 0x000000010000c1c8
0x10000c1c8: 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 e1 1...............
0x10000c1d8: 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ................
*/
当然,我们也可以打断点查看
以上我们可以看到,字符串其中一个字节内容为0x31("1"的十六进制ASCII值)。此时(Builtin.BridgeObject) _object = 0xe100000000000000,对比之前空字符串的(Builtin.BridgeObject) _object = 0xe000000000000000,我们可以猜想_object的其中一位可能存放字符串的长度。带着这种猜想,我们去进一步验证一下。
var str1 = "0123456789ABCDE"
print(Mems.memStr(ofVal: &str1))
//0x3736353433323130 0xef45444342413938
我们发现当字符串的长度不超过15时,打印结果跟猜想的一致。
大字符串
var str1 = "0123456789ABCDEF"
print(Mems.memStr(ofVal: &str1))
//0xd000000000000010 0x8000000100007930
当字符串长度大于15时,打印结果显示前8个字节的其中一个字节内容为:0x10,也就是字符串的长度16,后8个字节内容为:0x8000000100007930。16个字节没有直接保存字符串内容,那么很有可能其中一部分内容保存着字符串的地址。带着这种猜想,我们结合断点跟汇编一起分析。
通过第5、6行汇编计算得到字符串内容地址0x100007950,并读取内容,确实存放着0123456789ABCDEF。断点处也可以看到(Builtin.BridgeObject) _object = 0x8000000100007930,也就是说字符串变量地址的其中8个字节内容的恰好是(Builtin.BridgeObject) _object的地址。
不难发现0x8000000100007930的后面一串跟0x100007950很接近。是否存在某种联系呢?
0x100007950 = 0x100007930 + 0x20
0x20,即十进制的32,其实就是地址偏移。这点在源码中可以找到。综上,我们可以初步得出结论,当字符串长度大于15时,字符串变量的其中8个字节保存着字符串长度等信息,另外8个字节保存着字符串内容的地址等信息。
extension _StringObject{
@inlinable@inline(__always)
internalstaticvar nativeBias:UInt{
#if _pointerBitWidth(_64)
return 32
#elseif _pointerBitWidth(_32)
return 20
#elseif _pointerBitWidth(_16)
// TODO: we need to revisit all of this when we decide on efficient
// structures for storing String on 16-bit platforms
return 12
#else
#error("Unknown platform")
#endif
}
我们也可以通过工具MachOView直接查看字符串 0123456789ABCDEF 在 Mach-O 文件中的位置。在__TEXT的__cstring中(常量区)找到了它。
StringObject
继续查看 StringObject的源码,一步步撕开它的面纱。开宗明义,注释直接说明StringObject抽象了 String struct 位级别的解释和创建。在64位平台上,有个4位的重要鉴别器。标识是否小字符串、大字符串、桥接、ASCII、原生、共享、外来等。
接下来简化一下结构体_StringObject中主要内容:
struct _StringObject{
enum Nibbles{}
struct CountAndFlags {
var _storage:UInt64
}
#if$Embedded
publictypealias AnyObject=Builtin.NativeObject
#endif
#if _pointerBitWidth(_64)
var _countAndFlagsBits:UInt64
var _object:Builtin.BridgeObject
#elseif _pointerBitWidth(_32) || _pointerBitWidth(_16)
enum Variant{
case immortal(UInt)
casenative(AnyObject)
case bridged(_CocoaString)
}
var _count:Int
var _variant:Variant
var _discriminator:UInt8
var _flags:UInt16
var _countAndFlagsBits:UInt64
#else
#error("Unknown platform")
#endif
}
由上我们可以看到:
-
• 在64位平台,
_StringObject中有_countAndFlagsBits和_object两个成员。允许小字符串内容自然地矢量对齐。 -
• 在32或者16位平台,
_StringObject中有一个枚举_variant成员和_count、_discriminator、_flags、_countAndFlagsBits。 -
• 枚举
Variant中有三个成员:immortal、native、bridged。很显然这些取值会影响鉴别器_discriminator的状态。
第201行-325行主要根据平台对 _StringObject 进行相应的初始化操作。后面开始介绍大字符串,也就是第341行开始,第二小节第4张图片中所示。
-
• 大字符串可以是:原生的、共享的和外来的
-
• 原生字符串具有尾部分配的存储空间,该存储空间从偏移量开始
nativeBias来自存储对象的地址 -
• 大字符串字面量存储在常量区,这点在上面
MachOView小节也得到了验证 -
• 原生字符串始终由
Swift运行时管理 -
• 共享字符串没有尾部分配的存储,但提供对连续
UTF-8的访问 -
• 外来字符串无法提供对连续
UTF-8的访问。外来字符串仅包含不能被视为“共享”的延迟桥接的NSString,可以提供对UTF-16的访问 -
• 8 字节存储
_object,其中b63:b60用于存储鉴别器,剩下60位存储大字符串内容的地址(真实地址还需要加上偏移,64位平台是0x20)。如上面字符串0123456789ABCDEF的0x8000000100007930中的8为鉴别器,剩下的都为地址,字符串真实地址 = 0x0000000100007930 + 0x20
这段及后续部分主要介绍鉴别器的一些工作,使用掩码、位操作等技术(ObjC的runtime中常见这种操作),鉴别小字符串、大字符串、原生字符串、外来字符串、providesFastUTF8、桥接字符串等。
这里开始介绍小字符串和空字符串(很显然,空字符串是一种特殊的小字符串)。
-
• 64位平台,小端模式,第一个字符存储在最低位,高位字符和计数器存储在高地址。例如最初的字符串
1,0x0000000000000031 0xe100000000000000 -
• 32位平台,存储空间变少,但仍然采用类似布局
这里是非small,也就是大字符串的布局:
-
•
_object和非对象部分对半共享一个字的存储单元,也就是说各8个字节 -
• 非对象部分的8个字节,高5位,即
b63:b59是标志位,b58:b48是保留位,剩下部分存储着字符串的长度
源码剩余部分主要是一些初始化器、查询器、访问器、前置检查、辅助器以及聚合查询与抽象等。
总结
-
•
struct String->struct _StringGuts->struct _StringObject -
• 64位平台,
_StringObject包含_countAndFlagsBits和_object -
• 32及16位平台,
_StringObject包含_count、_variant、_discriminator、_flags和_countAndFlagsBits -
• 在我们iOS开发中,一个
Swift字符串变量占用16个字节内存 -
• 当字符串的长度
count<= 15 时,即small string,字符串变量地址的前15个字节直接存储着字符串内容,后一个字节的高4位,存储着一些标志位,低4位存储着字符串的长度count -
• 当字符串的长度
count> 15 时,即large string,字符串变量地址的其中8个字节存储着_countAndFlagsBits,8个字节存储着_object。其中_countAndFlagsBits8字节中的高5位是标志位,即b63:b59;b47:b0存储着大字符串的长度count;剩下的b58:b48是保留位。而_object8字节的高4位是标志位,即b63:b60;剩下60位间接存储大字符串内容的地址address(字符串的真实地址 =address+ 偏移nativeBias,64位平台是32,32位平台是20,16位平台是12)。
引用链接
[1] StringGuts: github.com/swiftlang/s…
[2] StringObject: github.com/swiftlang/s…
[3] Mems: github.com/CoderMJLee/…
本文使用 文章同步助手 同步