11 KiB
Swift 结构体和类的内存布局
结构体初始化器
实验1:在 struct 内部自己实现 init
struct Point {
var x: Int
var y: Int
init () {
x = 0
y = 0
}
}
var point = Point()
在init 方法内第一行处加 断点,如下所示
实验2:struct 内不自己加 init
struct Point {
var x: Int = 0
var y: Int = 0
}
var point = Point()
在var point = Point()处加 断点,如下所示
现象:可以看到加不加自定义初始化器的汇编代码基本相同。
结论:如果没有为结构体声明初始化器,编译器会自动生成1个初始化器。目的是保证所有成员都有初始值。
结构体内存布局
struct CustomDate {
var year: Int
var month: Int
var isLeapYear: Bool
}
var date = CustomDate(year: 2024, month: 3, isLeapYear: false)
print(MemoryLayout<CustomDate>.size) // 17
print(MemoryLayout<CustomDate>.stride) // 24
print(MemoryLayout<CustomDate>.alignment) // 8
print(Mems.memStr(ofVal: &date)) // 0x00000000000007e8 0x0000000000000003 0x0000000000000000
Int 占8 Byte,Bool 占1 Byte,共 2*8 + 1 = 17 Byte,由于存在内存对齐,所以17向上到24 Byte。
- 值语义:
struct是值类型,这意味着当你将一个struct赋值给另一个变量或传递给函数时,会创建一个新的副本。每个副本都有其自己的内存空间,对其中一个副本的修改不会影响其他副本。 - 内存连续性:
struct的成员变量在内存中是连续存储的,没有额外的内存开销(如对象指针或元数据)。这使得访问struct的成员变量非常高效。 - 内存对齐:为了确保访问效率,编译器可能会对
struct的成员变量进行内存对齐。这意味着某些成员变量之间可能会有未使用的内存空间(填充字节)。这种对齐通常是基于目标平台的硬件架构和访问性能考虑。 - 嵌套结构体:如果
struct包含其他struct或枚举作为成员,那么这些嵌套的类型也会按照它们自己的内存布局规则进行排列。 - 可变大小结构体:在某些情况下,
struct的大小可能不是固定的。例如,如果struct包含可变长度的数组或字符串,那么它的实际大小将取决于这些成员的大小。然而,即使在这种情况下,struct的内存布局仍然是紧凑的,并且遵循相同的访问规则。 - 与类的比较:与
class(类)不同,struct不需要额外的内存来存储类型信息、引用计数或其他元数据。这使得struct通常比class更轻量级,并且在某些情况下具有更好的性能。
类的内存布局
类和结构体类似,但是编译器不会为类自动生成可以传入成员值的初始化器。
// 写法1
class CustomDate {
var year: Int = 2024
var month: Int = 3
}
// 写法2
class CustomDate {
var year: Int
var month: Int
init () {
year = 2024
month = 3
}
}
上面2个写法是等价的。
结构体和类的区别:
- 结构体是值类型(枚举也是值类型),类是引用类型(指针类型)
值类型赋值给 var、let 或者给函数传参,是直接将所有内容拷贝一份。产生了全新副本,属于深拷贝。
值类型
func test() {
struct Point {
var x: Int
var y: Int
}
var point1 = Point(x: 10, y: 20)
var point2 = point1
point2.x = 11
point2.y = 22
print(point1.x) // 10
print(point1.x) // 20
print("over")
}
test()
因为是在函数内部变量,所以是在栈上分布
| 内存地址 | 内存数据 | 说明 |
|---|---|---|
| 0x10000 | 10 ----赋值改变------> 11 | point2.x |
| 0x10008 | 20 ----赋值改变------> 22 | point2.y |
| 0x10010 | 10 | point1.x |
| 0x10018 | 20 | point1.y |
断点打在 var point1 = Point(x: 10, y: 20) 处,查看汇编
乍一看如果不认识的话,先找字面量(立即数),比如红色框内的 0xa,就是 10,0x14 就是20。之前学过寄存器的设计,64位寄存器是兼容32位寄存器的。红色框内将 0xa,也就是 10 保存到 %edi 寄存器内部,也就是保存到 %rdi 中,将 0x14 也就是20,保存到 %esi 也就是保存到 %rsi 寄存器中。
LLDB 模式下输入 si 进入 init 方法内部。
可以查看到将 %rsi 里的10 保存到 %rdx 中了;将 %rdi 里的20 保存到 %rax 中了。这也就是 struct init 方法做的事情。
LLDB 模式下输入 finish 结束 init 方法。
可以看到下面几句汇编
0x1000035ac <+44>: movq %rax, -0x10(%rbp) // rbp - 0x10
0x1000035b0 <+48>: movq %rdx, -0x8(%rbp) // rbp - 0x8
0x1000035b4 <+52>: movq %rax, -0x20(%rbp) // rbp - 0x20
0x1000035b8 <+56>: movq %rdx, -0x18(%rbp) // rbp - 0x18
0x1000035bc <+60>: movq $0xb, -0x20(%rbp)
0x1000035c4 <+68>: movq $0x16, -0x18(%rbp)
可以看到分别将 %rax 里的10赋值给内存地址为 %rbp - 0x10 ,%rdx 里的20赋值给内存地址为 %rbp - 0x8 了。
可与看到 0x10 和 0x8 地址相差8,且地址连续,也就是 point1 的内存地址。同样下面的 0x20 和 0x18 地址相差8,且地址连续,也就是 point2 的内存地址。
第五行将 0xb 也就是11 赋值给 %rbp - 0x20的地址,0x16 也就是22赋值给 %rbp-0x18的地址,也就是 point2 的 x、y
COW 机制
值类型的赋值操作:Swift 标准库中的 String、Array、Dictionary 和 Set 确实采用了 Copy-On-Write(COW,写时复制) 技术,这是一种内存优化策略,旨在提升性能并减少不必要的内存复制
核心思想:
- 延迟复制:当多个变量引用同一份数据时,它们共享底层存储,直到某个变量尝试修改数据时,才会真正复制一份独立的副本。
- 节省资源:避免对不可变数据进行冗余复制,减少内存占用和计算开销
仅当有“写”操作时,才会真正执行拷贝操作:
- 对于标准库值类型的赋值操作,Swift 能确保最佳性能,所以没必要为了保证最佳性能来避免赋值
- 自定义的值类型,比如结构体,在赋值的时候,就会立马发生深拷贝
举个例子
var array1 = [1, 2, 3]
var array2 = array1 // 此时共享底层存储
array2.append(4) // 触发 COW:array1 和 array2 的存储分离
工作过程:
- 赋值时:
array2与array1共享同一块内存 - 修改时:当
array2被修改时,检查引用计数。如果引用计数 > 1(即存在多个所有者),则复制底层存储,确保修改不影响其他变量
写操作触发检查机制:
-
修改前检查:执行写操作(删除、添加、修改)时,检查缓冲区的引用计数
-
唯一性检查:若引用计数为1,则直接修改缓冲区;否则,复制缓冲区并修改新副本
伪代码
// 伪代码逻辑 mutating func append(_ element: Element) { if !isUniquelyReferenced(&buffer) { buffer = buffer.copy() // 复制缓冲区 } buffer.append(element) // 修改新副本 }
什么是缓冲区
Array 结构体(值类型)
+-------------------+
| 指向缓冲区的指针 |-----→ Buffer 类(引用类型)
| | +----------------+
| 其他元数据(长度、容量) | | 存储元素的内存块 |
+-------------------+ | [1, 2, 3, ...] |
+----------------+
- 结构体轻量级:
Array结构体本身只包含一个指针和少量元数据(如长度、容量),占用固定大小(如 8 字节指针 + 8 字节长度 + 8 字节容量 = 24 字节)。 - 缓冲区动态分配: 实际存储元素的连续内存块由缓冲区动态分配在堆上,容量可扩展。
- 共享与复制:
- 赋值时:仅复制结构体的指针(浅拷贝),多个数组共享同一缓冲区。
- 修改时:通过 COW(写时复制)机制,仅在需要时复制缓冲区。
引用类型
引用赋值给 var、let 或者给函数传参,是将内存地址拷贝一份。属于浅拷贝
func testReferenceType() {
class Size {
var width: Int
var height: Int
init(width: Int, height: Int) {
self.width = width
self.height = height
}
}
var size1 = Size(width: 10, height: 20)
var size2 = size1
size2.width = 11
size2.height = 22
}
testReferenceType()
下断点,可以看到下面的汇编:
在调用(汇编的 call)完 allocating_init 方法后,方法返回值用 %rax 保存的。然后打印出 %rax 寄存器的值,查看内存信息如下
红色框代表类信息的地址,蓝色框代表引用计数,绿色框代表10,黄色框代表20.
汇编的第17行,movq %rdi, -0x50(%rbp) 应该就是将 %rdi 里面的 对象内存地址赋值到 %rbp - 0x50 中去,也就是 size1 指针地址。
汇编的第20行,movq %rdi, -0x10(%rbp) 应该就是将 %rdi 里面的 对象内存地址赋值到 %rbp - 0x10 中去,也就是 `size12 指针地址。
再接下去的汇编
0x100003525 <+133>: movq -0x50(%rbp), %rax
0x100003529 <+137>: movq $0xb, 0x10(%rax)
0x100003531 <+145>: callq 0x100007434 ; symbol stub for: swift_endAccess
0x100003536 <+150>: movq -0x50(%rbp), %rdi
0x10000353a <+154>: callq 0x100007476 ; symbol stub for: swift_release
0x10000353f <+159>: movq -0x68(%rbp), %rdx
0x100003543 <+163>: movq -0x60(%rbp), %rcx
0x100003547 <+167>: movq -0x50(%rbp), %rdi
0x10000354b <+171>: addq $0x18, %rdi
0x10000354f <+175>: leaq -0x48(%rbp), %rsi
0x100003553 <+179>: movq %rsi, -0x58(%rbp)
0x100003557 <+183>: callq 0x100007410 ; symbol stub for: swift_beginAccess
0x10000355c <+188>: movq -0x58(%rbp), %rdi
0x100003560 <+192>: movq -0x50(%rbp), %rax
0x100003564 <+196>: movq $0x16, 0x18(%rax)
可以看到将 %rbp - 0x50 的值赋值给 %rax ,然后将 oxb 也就是 11 保存到 %rax 也就是 size1 指针的所指向的内存的 0x10处,为什么是前面空了16位?因为前8位保存类信息、后8位保存引用计数信息,所以从16位开始。
movq $0x16, 0x18(%rax) 将 0x16 也就是 22 保存到 %rax 也就是 size1 指针的所指向的内存的 0x18处,为什么是前面空了24位?因为前8位保存类信息、中间8位保存引用计数信息,后8位保存 Int 的 width,所以从24位开始。