Swift的内存布局与方法调度

一个值就是一块内存区域,理解内存布局,就理解Swift中的值与类型。

alignment-padding-bytes

内存都是一系列的01,通常我们称之为位(bit),二进制位是数字电路的基础。

内存、Stack与Heap

通常我们把8个bit称之为1个byte,我们将与系统位宽相同的字节称为一个word字长,对于64位的系统也就是8个byte。前面介绍过虚拟内存和内存分段,我们这里需要了解的是,存储器中的数据都是经地址总线与数据总线一个word一个word的存取的,一个读取的时钟周期读取一个word。这是内存布局一些基础的前置知识。

Swift是类C的语言,Swift本身是C++编写的。在数据的内存布局上,Swift与C、C++是有些类似的,在后面我们会看到。Swift应用程序的Mach-O结构也与之前是大体一致。

程序的内存分段主要包括

  • 栈区
  • 堆区
  • 代码区
  • 全局静态区

内存的栈与堆是数据的主要存储区域,也是两种不同策略管理内存的分区:

  • 栈区:内存操作比较简单,申请或销毁内存只需要移动栈顶指针即可,速度快。因为栈区内存会随着栈帧的回收而回收,因此只能存储值类型的数据,比如临时变量,比如Swift中的基本数据类型以及enumstruct等,并且内存都是在编译器就可确定的。

  • 堆区:内存的申请及销毁需要发起系统调用向系统申请的,虚拟内存的申请及访问速度肯定是会比栈区的慢的,但堆区内存可以由进程自主申请创建或销毁,不会因为栈帧的销毁而销毁,因此引用类型的数据都是存储在堆区的,比如Swift中的class类型数据。

Size、Stride与Alignment

Swift提供了MemoryLayout的enum来帮助我们查看类型的内存布局信息。

比如对于如下的一个只有简单数据元素的struct结构

1
2
3
4
struct YearMonth {
let year: Int
let month: Int
}

可以通过let size = MemoryLayout<YearMonth>.size来获得YearMonth这个结构的内存实际大小。

MemoryLayout支持查看内存的3个信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// The contiguous memory footprint of `T`, in bytes.
///
/// A type's size does not include any dynamically allocated or out of line
/// storage. In particular, `MemoryLayout<T>.size`, when `T` is a class
/// type, is the same regardless of how many stored properties `T` has.
///
/// When allocating memory for multiple instances of `T` using an unsafe
/// pointer, use a multiple of the type's stride instead of its size.
public static var size: Int { get }

/// The number of bytes from the start of one instance of `T` to the start of
/// the next when stored in contiguous memory or in an `Array<T>`.
///
/// This is the same as the number of bytes moved when an `UnsafePointer<T>`
/// instance is incremented. `T` may have a lower minimal alignment that
/// trades runtime performance for space efficiency. This value is always
/// positive.
public static var stride: Int { get }

/// The default memory alignment of `T`, in bytes.
///
/// Use the `alignment` property for a type when allocating memory using an
/// unsafe pointer. This value is always positive.
public static var alignment: Int { get }

简单来说我们可以理解为,size是值连续占用的内存位置,stride是考虑了内存对齐后占用的内存位置,alignment则是类型的内存对齐大小。

比如下面的例子:

1
2
3
4
5
6
7
8
print(MemoryLayout<Bool>.size)      // 1
print(MemoryLayout<Bool>.stride) // 1
print(MemoryLayout<Bool>.alignment) // 1


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

对于Swift的基本数据类型,sizestride是一样的,对于复合类型sizestride往往是不一样的,就是涉及内存对齐问题。

1
2
3
4
5
┌──────────────────────────┬──────┬───────────────────────────┐
│ ... │ 4b │ ... │
├──────────────────────────┴───┬──┴───────────────────────────┤
│ 32 bits │ 32 bits │
└──────────────────────────────┴──────────────────────────────┘

如上图所见,假如数据是按上面那行的布局存放的,那第一次读只能读到10bit中的一部分,第二部分还需要另一次读操作。对于10bit的数据需要两次的读操作不但是低效的,并且是不安全的——操作系统对于不对齐的内存访问也会报内存访问错误。

关于内存对齐,更多内容可以参考这里

struct的内存布局

1
2
3
4
5
6
7
8
struct Demo {
let a: Int // 8
let b: Bool // 1
}

print(MemoryLayout<Demo>.size) // 9
print(MemoryLayout<Demo>.stride) // 16
print(MemoryLayout<Demo>.alignment) // 8

在这个例子中,内存布局是这样的,其实跟C是一样的

1
2
3
4
5
6
7
┌─────────────────────────────────────┬─────────────────────────────────────┐
│ 16 bytes stride (8x2) │ 16 bytes stride (8x2) │
├──────────────────┬──────┬───────────┼──────────────────┬──────┬───────────┤
│ 8 bytes │ 1b │ 7 bytes │ 8 bytes │ 1b │ 7 bytes │
├──────────────────┴──────┼───────────┼──────────────────┴──────┼───────────┤
│ 9 bytes size (8+1) │ padding │ 9 bytes size (8+1) │ padding │
└─────────────────────────┴───────────┴─────────────────────────┴───────────┘

这是简单元素struct的内存模型,数据都在栈区

如果我们简单做个修改,调整两个属性的前后位置:

1
2
3
4
5
6
7
8
struct Demo {
let b: Bool // 1
let a: Int // 8
}

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

此时的size由9增加到了16,原因也在于内存对齐。不能将8字节的a紧接着存放在一字节的b之后,因为这样会使得Int类型数据不对齐。

因为struct作为值类型,也不支持继承,所以struct中定义的方法是直接静态派发的,也就是编译时确定函数指针位置。struct的内存布局就只有相关的属性,内存对齐即可。

相对而言,class内存布局就复杂一些。

class的内存布局

如果我们将上述例子Demo的类型修改为class

1
2
3
4
5
6
7
8
class Demo {
let bar: Bool = true // 1
let foo: Int = 0 // 8
}

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

你会发现Demosize以及stride都改变了,而且只有8字节。

classstruct的差别就在于,struct是值类型,而class是引用类型。值类型的数据都存放于栈区内存,而引用类型的数据存放在堆区内存,如前文所述。因此Demo这样的一个实例在内存中只需要保留一个指针来引用堆区的内存即可,因此它的size以及stride都只有固定8个字节(64位系统)。


(示例图片)

对于class实例在堆区的内存占用,可以使用class_getInstanceSize来获取:
1
2
3
4
5
6
7
8
class Demo {}
print(class_getInstanceSize(Demo.self)) // 16

class Demo {
let a: Bool = true // 1 + 7 padding
let b: Int = 0 // 8
}
print(class_getInstanceSize(Demo.self)) // 32 (16 + 16)


Swift中的class需要区分是继承于NSObject类还是SwiftObject类,SwiftObject是与NSObject类似的根类。对于SwiftObject派生类实例对象的内存布局大概如下:

内存的头部都有一个isa指针,与NSObject不同的是,SwiftObject单独占用8个字节来存储引用计数,而不是利用isa指针。随后存储的是从根类到当前类的实例变量,这跟Objective-C是一样的。

swift_classobjc_class也有差异,主要新增了实例内存大小及偏移等变量。

Protocol的内存布局

Swift对于协议类型的采用如下的内存模型 - Existential Container。

Existential Container包括3部分:

  1. Value Buffer:前3个word,用来存储inline的值,如果属性占用内存超过3个word,则使用堆内存,此处存储堆内存的指针
  2. vwt:第4个word,Value Witness Table,每个类型都对应这样的一个表,用来存储方法指针
  3. pwt:第5个word(偏移32字节,0x20),Protocol Witness Table, 用来存储协议的方法

因此协议类型的内存需要占用40个字节。

泛型

Swift支持泛型实现静态多态。

1
2
3
func foo<T: Thing>(local : T) {
local.draw()
}

范型并不采用Existential Container,但是原理类似。VWT和PWT作为隐形参数,传递到范型方法里。临时变量仍然按照ValueBuffer分配3个word,如果数据大小超过3个word,则在堆上开辟内存。

作为编译优化,泛型会为具体的类生成具体的方法,但同时也会对代码进行压缩。

方法调度

Swift中protocolenumstructclass类型都支持方法定义。

Swift中的方法调度包括几种:

  • 静态调度,其实就是编译时已明确的函数位置
  • 基于Witness Table动态调度,类似C++虚函数表的方式,虚函数表存放方法函数表,通过函数表偏移来获取函数指针进行调用
  • 基于Objective-C消息机制动态调度,继承于NSObject的类可使用的,跟Objective-C一致

struct

如上文所述,因为struct作为值类型,也不支持继承,所以struct中定义的方法是直接静态派发的。

class实例对象

对于继承于NSObject并且override的方法,是按Objective-C的消息机制来调度的,也就是objc_msgSend;非override的方法则是vtable方式调度。

  1. 没加@objc的实例方法,编译后在类对象中的vtable以指针的方式指向该函数的具体实现。
  2. 加了@objc的实例方法,编译后该方法函数指针会在vtable中存在,并且还存在于类对象中的method_list中。

调用上,

  1. 对于没加@objc的方法,以vtable方式调度
  2. 对于加了@objc的方法,如果不以selector来调用则是vtable方式,否则是走消息机制
  3. 对于加了dynamic的方法,则直接走消息机制
  4. final修饰的类与方法,因为不存在被继承的情况,直接静态调度

extension定义方法

对于class的extension中定义的方法,同样对于非重写的OC类的方法,是直接静态调度的,所以基类中extension定义的方法是不会被改变的,方法并不会如Objective-C那样记录到类的方法列表中。

  1. 对于没加@objc的类扩展中的方法,走的直接派发的方式,方法不暴露给Objc的调用方式,包括performSelector:
  2. 对于加了@objc的类扩展中的方法,都走的消息机制。

Comments