Swift 内存模型

最近有兴趣想要看看 Swift 对象的内存布局,所以尝试研究一下这个话题。这篇文章主要是关注结构体和类的内存模型。

# MemoryLayout

Swift 官方提供了 MemoryLayout 这样的一个类方便我们来分析内存模型,这个类描述了类型的 size, stridealignment

The memory layout of a type, describing its size, stride, and alignment.

举个例子来分别解释一下 sizestridealignment。如下代码

struct Point {
    let x: Double
    let y: Double
    let isFilled: Bool
}

print(MemoryLayout<Point>.size)         //17
print(MemoryLayout<Point>.stride)       //24
print(MemoryLayout<Point>.alignment)    //8

alignment 属性是表示类型的内存对齐。在这个例子里 Double 是占用 8 个字节,isFilled 是占用 1 个字节。结构体总体大小是要能被最大的成员的内存大小整除。所以这里 Point 是按照 8 个字节来进行内存对齐的。

size 是表示结构体真正需要的大小 类型实例占用连续内存字节的大小。这里两个 Double 类型加一个字节的 isFilled 类型就是 17 个字节。

stride 表示结构体真正分配内存的大小,它和 size 的区别在于 stride 是需要考虑内存对齐的,所以即使是只占用 1 个字节的 Bool 类型,也要出于内存对齐的原因占用 8 个字节,剩下的 7 个字节其实是被浪费掉了。

Untitled

值得注意的是 size 的计算方式,如果结构体类型是下面这种的化,尽管实际可能只需要 9 个字节(Int 占用 8 个字节),但是最终的 size 是显示 16 个字节,所以 size 并不是计算需要的内存空间,而是连续占用的内存空间。

struct Point {
    let isFilled: Bool
		let i:Int
}
print("\(MemoryLayout<Point>.size)")        //16个字节
print("\(MemoryLayout<Point>.stride)")      //16个字节
print("\(MemoryLayout<Point>.alignment)")   //8个字节

Untitled

我这里感觉 MemoryLayout 其实有点类似 OC 中的 class_getInstanceSize (opens new window) 的作用。要记得 OC 里面还有 malloc_size 用来计算对象真正在内存中占用的空间呢,所以 Swift 中有没有类似的 API 呢?

其实 Swift 中也是能直接使用 malloc_size 的,不过作为值类型的 Struct 是存储在栈上的。而 malloc_size 统计的是内存分配的信息,是堆上的信息,同时最小分配 16 个字节这种要求也是针对堆上的内存分配。栈上的内存占用只需要满足内存对齐规则就好了。

# Struct

Struct 在 Swift 中被大量使用,比如 String 和 Array 都是结构体类型。值类型的临时变量一般都是存放栈上的。其实看明白上面一段 MemoryLayout 的介绍也就基本上能明白结构体的内存分配了。

举个直观的例子来看下,在结尾打断点,查看 point 实例的内存布局,内存是不会骗人的。

struct Point {
    var isFilled: Bool
    var a:Int
}
var point = Point(isFilled: true, a: 10)
var value = 100

# Xcode 查看内存

这里跑个题,说下Xcode 查看内存的方式

  1. 在 Xcode 变量调试区,右键 point 变量,点击「View Memory of “point”」,然后就能看到对应 point 实例的内存信息。前 8 个字节是 isFilled,后 8 个字节是 a 变量。后面紧跟着的是 value 对象。

    Untitled

    这里内存的字节序是通过小端存储,所以看着还有点费劲

  2. ·如果我们知道 point 实例的内存地址的话,也可以通过 的相关命令来看,详细的文档说明看

    1. LLDB 命令 (opens new window) memory read 0x0000000100008000 打印结果如下

      0x100008000: 01 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00  ................
      0x100008010: 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  d...............
      

      结果不是很直观,可以使用这个命令 memory read --size 8 --format x 0x0000000100008000,这里的展示结果就很好看了。

      0x100008000: 0x0000000000000001 0x000000000000000a
      0x100008010: 0x0000000000000064 0x0000000000000000
      

      xmemory read 命令的简写形式,LLDB 中也有 x 命令,GDB 中也有 x 命令,不过两者对应的参数配置不一样,这里稍微有点让人混淆。

    2. GDB 命令 (opens new window) x/4gx 0x0000000100008000 。检查内存中的 8个字(double word)的内容,并以十六进制格式显示它们的值。打印结果同上面的 memory read 指令打印结果。

      0x100008008: 0x0000000000000001 0x000000000000000a
      0x100008018: 0x0000000000000064 0x0000000000000000
      
    3. p/x point 命令是按照 16 进制格式展示 point 命令。这个是不需要内存打印的。指令说明在这里 (opens new window)

      (TerminalToolSwiftDemo.Point)  (isFilled = true, a = 0x000000000000000a)
      

这里其实还涉及到一个问题,我们如何通过代码获取结构体实例的内存地址?可以使用如下 API,打印出来的值就是结构体实例对应的地址,可以通过 Xcode IDE 查看内存变量来验证。

var point = Point(isFilled: true, a: 10)
withUnsafeMutablePointer(to: &point) {
    print("\($0)")
}

好的,回到正题,我们通过内存查看的方式,可以确认 point 结构体实例在真实内存中也是占用 16 个字节。

# Class

类和结构体不同,类是引用类型,在堆上分配内存空间,然后栈上仅保留指向堆上内存空间的指针。

我们通过下面这个例子来尝试分析一下类实例的内存布局

class Foo {
    var a = 1
    var b = 2
}
func test() {
    var foo = Foo()
    var baz:Int = 4

    print(MemoryLayout<Foo>.size)        //8
    print(MemoryLayout<Foo>.stride)      //8
    print(MemoryLayout<Foo>.alignment)   //8

    withUnsafeMutablePointer(to: &foo) { print("\($0)") } // 0x000000016fdff1a8
    withUnsafeMutablePointer(to: &baz) { print("\($0)") } // 0x000000016fdff1a0
}

打印出来的 foobaz 地址都是栈上指针的地址,因为栈上的内存空间分配是从高地址到低地址分配,所以先声明的类实例 foo 的栈指针所在的内存地址更高,并且占用 8 个字节,所以 baz 的所在内存地址就等于 foo 的指针地址减去 8 个字节。

通过命令来看下 0x000000016fdff1a0 处的内存信息。

(lldb) x/2gx 0x000000016fdff1a0
0x16fdff1a0: 0x0000000000000004 0x0000600002f4b080
(lldb) x/4gx 0x0000600002f4b080
0x600002f4b080: 0x0000000100008118 0x0000000000000003
0x600002f4b090: 0x0000000000000001 0x0000000000000002

大概就是下面这个样子的

Untitled

foo 实例分配的堆上的内存空间中,除了我们赋值的 1,2 之外,还有起始的两个8字节的内容:

  • 首个 8 字节(0x0000000100008118)的内容,应该和 OC 中的 isa 类似,保存着指向类信息的指针。
  • 第二个8 字节(0x0000000000000003)中的内容参考网上的文章,大家认为是存储的内容是引用计数(这个我并没有深入研究,感觉讲明白还需要单独写一篇博客,这里先存疑)。

所以真正属性内容是从第 16 个字节起始的,这里类实例真正占用的内存空间应该是 32 个字节,而我们代码当中通过 MemoryLayout 打印的 foo 相关的 size 就是不正确的,它返回的是指针的大小,而并非是对应堆上内存空间的大小。

我们之前说过 Swift 也可以通过 malloc_size 的方法去获取内存大小,于是有了下面获取内存实例大小的方法,打印结果也是 32 字节。

(lldb) p malloc_size(Unmanaged.passUnretained(foo).toOpaque())
(Int) 32

参考地址:

  1. 腾讯Bugly-Swift 对象内存模型探究(一) (opens new window)
  2. Swift内存模型与指针的使用 (opens new window)
  3. Swift 拾遗- struct & class | kingcos (opens new window)