Files
knowledge-kit/Chapter1 - iOS/1.113.md
2025-06-23 01:18:55 +08:00

11 KiB
Raw Blame History

Swift 结构体和类的内存布局

结构体初始化器

实验1在 struct 内部自己实现 init

struct Point {
    var x: Int
    var y: Int
    init () {
        x = 0
        y = 0
    }
}
var point = Point()

init 方法内第一行处加 断点,如下所示

实验2struct 内不自己加 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 ByteBool 占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,就是 100x14 就是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 了。

可与看到 0x100x8 地址相差8且地址连续也就是 point1 的内存地址。同样下面的 0x200x18 地址相差8且地址连续也就是 point2 的内存地址。

第五行将 0xb 也就是11 赋值给 %rbp - 0x20的地址,0x16 也就是22赋值给 %rbp-0x18的地址,也就是 point2 的 x、y

COW 机制

值类型的赋值操作Swift 标准库中的 String、Array、Dictionary 和 Set 确实采用了 Copy-On-WriteCOW写时复制 技术,这是一种内存优化策略,旨在提升性能并减少不必要的内存复制

核心思想:

  • 延迟复制:当多个变量引用同一份数据时,它们共享底层存储,直到某个变量尝试修改数据时,才会真正复制一份独立的副本。
  • 节省资源:避免对不可变数据进行冗余复制,减少内存占用和计算开销

仅当有“写”操作时,才会真正执行拷贝操作:

  • 对于标准库值类型的赋值操作Swift 能确保最佳性能,所以没必要为了保证最佳性能来避免赋值
  • 自定义的值类型,比如结构体,在赋值的时候,就会立马发生深拷贝

举个例子

var array1 = [1, 2, 3]
var array2 = array1  // 此时共享底层存储

array2.append(4)     // 触发 COWarray1 和 array2 的存储分离

工作过程:

  • 赋值时:array2array1 共享同一块内存
  • 修改时:当 array2 被修改时,检查引用计数。如果引用计数 > 1即存在多个所有者则复制底层存储确保修改不影响其他变量

写操作触发检查机制:

  • 修改前检查:执行写操作(删除、添加、修改)时,检查缓冲区的引用计数

  • 唯一性检查若引用计数为1则直接修改缓冲区否则复制缓冲区并修改新副本

    伪代码

    // 伪代码逻辑
    mutating func append(_ element: Element) {
        if !isUniquelyReferenced(&buffer) {
            buffer = buffer.copy()  // 复制缓冲区
        }
        buffer.append(element)     // 修改新副本
    }
    

什么是缓冲区

     Array 结构体(值类型)
     +-------------------+
     | 指向缓冲区的指针  |-----→  Buffer 类(引用类型)
     |                   |        +----------------+
     | 其他元数据(长度、容量) |        | 存储元素的内存块    |
     +-------------------+        | [1, 2, 3, ...] |
                                  +----------------+
  1. 结构体轻量级 Array 结构体本身只包含一个指针和少量元数据(如长度、容量),占用固定大小(如 8 字节指针 + 8 字节长度 + 8 字节容量 = 24 字节)。
  2. 缓冲区动态分配 实际存储元素的连续内存块由缓冲区动态分配在堆上,容量可扩展。
  3. 共享与复制
    • 赋值时:仅复制结构体的指针(浅拷贝),多个数组共享同一缓冲区。
    • 修改时:通过 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()

下断点,可以看到下面的汇编:

在调用(汇编的 callallocating_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位开始。