Question
前不久看了一篇文章, 喵神的值类型和引用类型, 在阅读的时候有一个结论
值类型被复制的时机是值类型的内容发生改变时...
这个时候本来是想记下来的, 后来转念一想, 实践出真知, 所以我就基于这个问题: 值类型到底是什么时候被赋值的? 做了一些调查和实践, 从而有了这系列文章...
Answer
我在 iOS Playground 中写了如下示例, 初始化了
Int String Struct Array
并且立刻进行了赋值操作:
- struct Me {
- let age: Int = 22 // 8
- let height: Double = 180.0 // 8
- let name: String = "XiangHui" // 24
- var hasGirlFriend: Bool ? // 1
- }
- var a = 134
- var cpa = a
- var b = "JoJo"
- var cpb = b
- var me = Me() var secondMe = me
- var likes = ["comdy", "animation", "movies"]
- var cpLikes = likes
并且随后使用一个 swift 指针方法来输出值类型在内存中的地址:
- withUnsafeBytes(of: &T, {
- bytes in print("T: \(bytes)")
- })
那么其实我们可以猜测一下, 如果是在值类型发生改变的时候才去赋值的话(写时复制), 那么以上复制的变量的地址应该和原变量是一样的, 结果如下:
- a: UnsafeRawBufferPointer(start: 0x00007ffee3500ef8, count: 8)
- cpa: UnsafeRawBufferPointer(start: 0x00007ffee3500f00, count: 8)
- b: UnsafeRawBufferPointer(start: 0x00007ffee3500f18, count: 24)
- cpb: UnsafeRawBufferPointer(start: 0x00007ffee3500ee0, count: 24)
- me: UnsafeRawBufferPointer(start: 0x00007ffee3500fa8, count: 41)
- secondMe: UnsafeRawBufferPointer(start: 0x00007ffee3500f40, count: 41)
- likes: UnsafeRawBufferPointer(start: 0x00007ffee3500f30, count: 8)
- cpliles: UnsafeRawBufferPointer(start: 0x00007ffee3500f08, count: 8)
显然, 值类型的值并非是在改变的时候才去复制, 而是在赋值的时候就会进行复制!
Deep in
当这个问题解决之后又不禁有了新的疑问:
在系统中内存究竟是如何分配的?
栈中的数据到底是如何存储的?
堆上的数据又是如何存储的?
针对我的这三个简单但是宽泛的问题, 我做了大量的阅读和实践, 然后有了下面的一些思考和总结:
Concept
在进行更抽象的内存理论之前, 得了解几个基本的概念, 首先是可操作内存区域, 在程序中我们使用的内存区域就是图中的绿色区域:
在这块区域中我们可以简要的分为三个区域堆, 栈, 全局区在现代的 CPU 每次读取数据的时候, 都会读取一个 word, 在 64 位上, 也就是 8 个字节
Stack 存储方法调用; 局部变量(Method invocation; Locial variables)
Heap 存储对象(all objects!)
Global 存储全局变量; 常量; 代码区
这样一看其实有一点豁然开朗的感觉, 其实基本只有方法或者特定类型如结构体中出现的变量才是局部变量, 也就是说在方法中声明的变量都是分配在栈上的, 然而在类中声明一个基本类型作为对象属性, 其实是在堆上分配的
- class Test {
- let a = 4 // 分配在堆上
- func printMyName() {
- let myName = "JoJo" // 分配在栈上
- print("\(myName)")
- }
- }
- MemoryLayout
- // 值类型
- MemoryLayout < Int > .size //8
- MemoryLayout < Int > .alignment //8
- MemoryLayout < Int > .stride //8
- MemoryLayout < String > .size //24
- MemoryLayout < String > .alignment //8
- MemoryLayout < String > .stride //24
- // 引用类型 T
- MemoryLayout < T > .size //8
- MemoryLayout < T > .alignment //8
- MemoryLayout < T > .stride //8
- // 指针类型
- MemoryLayout < unsafeMutablePointer < T >> .size //8
- MemoryLayout < unsafeMutablePointer < T >> .alignment //8
- MemoryLayout < unsafeMutablePointer < T >> .stride //8
- MemoryLayout < unsafeMutableBufferPointer < T >> .size //16
- MemoryLayout < unsafeMutableBufferPointer < T >> .alignment //16
- MemoryLayout < unsafeMutableBufferPointer < T >> .stride //16
MemoryLayout<Type > 是一个泛型, 通过它的三个属性可以获取具体类型在内存中的分配: size 表明该类型实际使用了多少个字节; alignment 表明该类型必须对齐多少字节(如为 8, 意味着地址的起点地址可以被 8 整除);stride 表明从开始到结束一共需要占据多少字节 Swift 中基本类型的 size 和 stride 在内存中是一样的 (可选型如 Double? 实际使用了 9 个字节, 但是却需要占据 16 个字节) 内存对齐的好处这里针对内存对齐的好处有了比较详尽的描述, 主要是速度快
Struct Stack Memory
从一个栈的实例来看栈中内存的分配情况:
- struct Me {
- let age: Int = 22
- let height: Double? = 180.0
- let name: String = "XiangHui"
- var hasGirlFriend: Bool = false
- }
- //MemoryLayout<Double?>.size 9
- //MemoryLayout<Double?>.alignment 8
- //MemoryLayout<Double?>.stride 16
- class MyClass {
- func test() {
- var me = Me()
- print(me)
- }
- }
- let myClass = MyClass()
- myclass.test()
在方法里打个断点使用调试器输出栈中的内存, 在这之前可以猜想一下, Int 类型占 8 个字节, Double? 虽然 size 是 9 个字节, 但是它的 stride 是 16 字节, 所以占据了 16 字节, String 类型占据了 24 个字节, 最后 Bool 类型占据 8 个字节, 一共
8 + 16 + 24 + 8 = 56
字节, 也就是说这个结构体在栈上占据 56 字节的内存, 打印如下:
- (lldb) po MemoryLayout.size(ofValue: me)
- 49
- (lldb) po MemoryLayout.stride(ofValue: me)
- 56
奇怪, 为什么 size 是 49 呢? 因为 size 是从开始到实际结束所占据的内存, 即 Bool 的 size 和 stride 都是为 1 个字节, 这样的话, 当前 word 还有 7 个字节是没有使用的内存, 所以实际大小为 49 字节再看详细地址打印:
- (lldb) frame variable -L me
- 0x00007ffeea2cda50: (MemorySwiftProject.Me) me = {
- 0x00007ffeea2cda50: age = 22
- 0x00007ffeea2cda58: height = 180
- 0x00007ffeea2cda68: name = "XiangHui"
- 0x00007ffeea2cda80: hasGirlFriend = false
- }
地址是从栈底一直向上增加的, 我画出示意图如下:(Boolsize 为 1)
原来在结构体中栈的存储如此简单, 那么如果结构体中有声明引用类型呢? 结果是引用类型占一个 word(指针所占空间为 8 个字节); 那么如果在结构体中有方法体呢? 结论是结构体中即使有方法实现依然不占据内存, 这个问题留待下篇文章来解决! 但是可以有一个初步的猜测, 我觉得应该是和方法的静态调用有关, 也即是和编译器的编译相关
- // 方法体在结构体中并不占据内存
- struct Test {
- let a = 1
- func test01() {}
- }
- let test = Test()
- MemoryLayout.size(ofValue: test) // 8
- struct Test2 {
- func test01() {}
- }
- let test2 = Test2()
- MemoryLayout.size(ofValue: test2) // 0
- Method Stack Memory
本来应该是要了解了解堆的, 结果在方法调用断点输出的时候, 发现了一些值得一提的点, 所以就决定聊一聊关于方法栈中的内存! 关于方法的调度, 其实就是一个一个方法的入栈, 栈顶方法执行完之后出栈, 然后新的栈顶方法执行完之后出栈如果是在一个递归方法的执行过程中, 这个就感觉看起来很有意思
但是呢, 现在不聊方法的调度, 而是聊一聊当执行一个方法的时候, 方法的内部是如何进行内存分配的, 首先一点, 方法在执行过程中内存是分配在栈上的!
- struct Me {
- let age: Int = 22 // 8
- let height: Double? = 180.0 // size: 9 stride: 16
- let name: String = "XiangHui" // 24
- let a = MemoryClass() // 8
- let hasGirlFriend = false // 1
- }
- // MemoryLayout<Me>.stride 56
- func test() {
- var number = 134 // stride: 8
- var name = "JoJo" // stride: 8
- var me = Me() // stride: 64
- var likes = ["comdy", "animation", "movies"] // stride: 8
- withUnsafeBytes(of: &number, { bytes in
- print("number: \(bytes)")
- })
- withUnsafeBytes(of: &name, { bytes in
- print("name: \(bytes)")
- })
- withUnsafeBytes(of: &me, { bytes in
- print("me: \(bytes)")
- })
- withUnsafeBytes(of: &likes, { bytes in
- print("likes: \(bytes)")
- })
- }
在这里首先解释一下为什么结构体的 stride 是 64 个字节吗? 通过上述讲了这里应该很明了了吧, 在这个结构体中有
Int Double ? String Class Bool
类型, 一共 8 + 16 + 24 + 8 + 8 = 56 字节还有一个小细节为什么数组 likes 的 stride 是 8 个字节呢? 因为在栈上分配的依然是一个数组指针而已, 它指向内存中的另一块存储空间, 至于实际数组所存储的内存空间是如何分配呢? 留待下篇文章解决~ 代码输出结果如下:
- 0x00007ffee46f2ac0: (Int) number = 134
- 0x00007ffee46f2aa8: (String) name = "JoJo"
- 0x00007ffee46f2a68: (MemorySwiftProject.Me) me = {
- 0x00007ffee46f2a68: age = 22
- 0x00007ffee46f2a70: height = 180
- 0x00007ffee46f2a80: name = "XiangHui"
- scalar: a = 0x000060c00001de10 {} // 引用类型在堆中的具体地址
- 0x00007ffee46f2aa0: hasGirlFriend = false
- }
- 0x00007ffee46f2a20: ([String]) likes = 3 values {
- 0x00007ffc9d780500: [0] = "comdy"
- 0x00007ffc9d721710: [1] = "animation"
- 0x00007ffc9d6443d0: [2] = "movies"
- }
通过
withUnsafeBytes(of: &T) {}
方法, count 输出的是 Size 那么接下来开始分析了: 首先有一点值得注意, 输出的内存居然是依次递减的, 也就是说栈底的元素反而内存地址较高, 而后入栈的元素, 地址是依次变小的, 所以结构体如下:
奇怪, 为什么会多出 64 个字节呢? 而且还是和结构体的 size 一样大针对这个情况一开始我以为是数组的问题, 以为这个和数组有关系, 然后做出了大量的测试, 如果没有数组的话, 将数组变量换成一个 Int 类型, 结果还是一样多出 64 字节, 那我就想, 就应该是结构体的原因了, 结果去掉结构体变量后, 发现一切正常, 所有变量按照 stride 和 alignment 一一入栈, 无异常
然后接下来我改变结构体的大小结果发现, 在方法栈中多出的这块内存依旧和结构体实例的 size 一样大, 为什么呢? 为什么在方法栈中给结构体分配内存的时候会多出一块内存呢, 而且 size 还和它的 size 一样大? 同样留着这个问题吧!
Heap Memory
在我们看完栈上的内存之后, 堆上的内存其实也是一样的, 代码实例如下:
- class MemoryClass {
- static let name = "Naruto"
- let ninjutsu = "rasengan" // 24
- let test = TestClass() // 8
- let age = 22 // 8
- func beatSomeone() {
- let a = ninjutsu + ninjutsu
- print(a)
- }
- }
- func heapTest() {
- let myClass = MemoryClass()
- print(myClass)
- }
- heapTest()
在 heapTest( )方法中打个断点可以得到以下输出:
- (lldb) frame variable -L myClass
- scalar: (MemorySwiftProject.MemoryClass) myClass = 0x000060400027ca80 {
- 0x000060400027ca90: ninjutsu = "rasengan"
- scalar: test = 0x00006040004456d0 {
- 0x00006040004456e0: name = "Hui"
- }
- 0x000060400027cab0: age = 22
- }
- (lldb) po malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque())
- 64
那么根据输出的结果可以得出以下结论:
在这里有三个地方是多出来的三个字节, 他们分别存什么呢? 我从最后一个 word 开始分析
堆上的每次内存分配
为什么从最后一个 word 开始分析呢? 因为每次新建一个 object,object 的属性都是从第 16 个字节开始分配的, 所以在每个对象的前两个 word 都必然存储一些其他的信息, 因为之前的 OC 基础, 所以可以猜测应该是存储的一个 isa 指针之类的信息但是最后 8 个字节就不一定出现了, 接下来我的测试方式是在 MyClass 中增加 Bool 类型的成员变量, 结果通过
malloc_size(UnsafeRawPointer)
方法我得到的内存大小为 64 80 96 ... 每次都以 16 个字节递增, 所以我可以初步确定这是堆分配内存的特性, 每次都会分配 16 个字节的倍数的内存, 回到上图, 我如果增加一个 Int 成员变量, 它的内存大小为 64 字节, 而计算大小正好也是 64, 符合; 我如果再增加一个 Bool 型的成员变量, 它的内存大小为 80 字节, 也正如推测所以结论是: 至少在 iOS 64 系统上, 堆上对对象分配内存时, 每次都是分配的 16 个字节的倍数
- class MemoryClass {
- static let name = "Naruto"let ninjutsu = "rasengan" // 24
- let test = TestClass() // 8
- let age = 22 // 8
- let age2 = 22 // 8
- }
- // malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque()) 64
- class MemoryClass {
- static let name = "Naruto"let ninjutsu = "rasengan" // 24
- let test = TestClass() // 8
- let age = 22 // 8
- let age2 = 22 // 8
- let a = false // 1 (只多了一个 Bool 类型)
- }
- // malloc_size(Unmanaged.passUnretained(myClass as AnyObject).toOpaque()) 80
消失的类型变量
使用 static 修饰的 name 属性, 在初始化类实例的时候并没有出现堆上的内存中, 这在开篇第二幅图中就解释了这个问题, 在整个内存区域可以分为栈区; 堆区; 全局变量, 静态变量区; 常量区; 代码区下面是我画的图:
类型变量并不会分配在堆上, 而是会在编译的时候就分配在 Global Data 区域中, 所以这也是在堆上为什么类型变量没有分配内存的原因.
对象的第一个 Word 是什么?
其实这个问题呢我也思考了很久, 感觉上应该就是 OC 中的 isa 指针指向它的类, 结果也是如此, 这篇文章有很明确的解释: C++ 中对象的 isa 指针指向的是 VTable, 它只是单纯的方法列表, 而在 swift 中更复杂一些, 实际上所以的 Swift 类都是 Objective-C 类, 如果添加了 @obj 或者继承 NSObject 的类会更直观, 但是即使是纯粹的 Swift 类依然在本质上就是 Objective-C 类针对这个问题我专门在 twitter 上询问了大神 @mikeash, 他回复的原话:
Yes, they subclass a hidden SwiftObject class.
所以第一个 word 其实就是一个 isa 指针, 指向的就是 Class; 但是更准确的说, 不一定是 isa 指针, 有时候是 isa 指针和其他的东西, 比如说和当前对象相关联的其他对象(当前对象释放时它也需要清理)... 但是通常意义上我们可以理解为就是 isa 指针
我们可以做一个实验, 改变当前对象的 isa 指针, 指向其他的类型, 那么会发生什么呢?
- class Cat {
- var name = "cat"
- func bark() {
- print("maow")
- }
- // 可变原始指针(当前实例的指针)
- func headerPointerOfClass() -> UnsafeMutableRawPointer {
- return Unmanaged.passUnretained(self as AnyObject).toOpaque()
- }
- }
- class Dog {
- var name = "dog"
- func bark() {
- print("wangwang")
- }
- // 可变原始指针(当前实例的指针)
- func headerPointerOfClass() -> UnsafeMutableRawPointer{
- return Unmanaged.passUnretained(self as AnyObject).toOpaque()
- }
- }
- func heapTest() {
- let cat = Cat()
- let dog = Dog()
- let catPointer = cat.headerPointerOfClass()
- let dogPointer = dog.headerPointerOfClass()
- catPointer.advanced(by: 0)
- .bindMemory(to: Dog.self, capacity: 1)
- .initialize(to: dogPointer.assumingMemoryBound(to: Dog.self).pointee, count: 1)
- cat.bark() // wangwang
- }
因为 cat 实例的 isa 指针指向了 Dog 类型, 所以调用方法的时候会动态的根据方法名在 Dog 类型的方法列表中找到对应的方法然后执行, 从这里可以知道类中的方法是动态派发的, 在 runtime 的时候找到对应的方法然后执行!
既然提到了 isa 指针, 那么接下来有会有疑惑了 isa 指向的 Class 的结构到底是怎样的呢? 因为之前已经提到了 Swift 类本质上是 OC 类, 所以我们看 OC 类的定义就可以了, 因为 Objective-C 类定义是开源的, 所以就看下图呗:
- Class isa
- Class super_class
- const char *name
- long version
- long info
- long instance_size
- struct objc_ivar_list *ivars
- struct objc_method_list **methodLists
- struct objc_cache *cache
- struct objc_protocol_list *protocols
内存中的 Class 存储了类名; 它的实例大小; 属性列表; 方法列表; 协议列表; 缓存 (加快了方法调度) 等等... 但是, 这毕竟是一个 Objective-C Class 中的结构, 事实上 Swift Class 拥有 Objective-C Class 里的所有内容而且还添加了一些东西, 但是本质上, Swift Class 只是拥有更多东西的 Objective-C Class
- uint32_t flags;
- uint32_t instanceAddressOffset;
- uint32_t instanceSize;
- uint16_t instanceAlignMask;
- uint16_t reserved;
- uint32_t classSize;
- uint32_t classAddressOffset;
- void * description;
对象里的第二个 Word
好吧, 第一个 Word 存储的可以简单地说就是指向 Class 的指针, 那么第二个 Word 呢? 其实第二个 Word 存放的是引用计数, 在 Swift 是使用的引用计数来管理对象的生命周期的, Swift 中有两种引用计数, 一种是强引用, 一种是弱引用, 而在两者都在这个 Word 中, 每一种引用计数的大小 31 个字节! 那么接下来那张图就可以完善了:
总结
其实这一篇下来还是学了挺多东西的, 接下来我来捋一捋脉络:
首先值类型到底是在什么时候进行复制: 值类型在赋值的时候就复制, 而不是在改变的时候, 也就是说并非写时复制
然后介绍一些基本的关于内存的基本概念: MemoryLayout 三属性等
通过一些实例来说明了 Struct 在栈中的存储结构, 要注意栈底位置和地址增加方向
接着说明了在方法栈中 Method 的存储结构, 栈底在顶部, 地址是从栈底向栈顶递减的, 如果方法栈中有结构体也正好是可以符合存储结构的
最后讲了对象在 Heap 中的存储结构, 第一个 Word 是存放 isa 指针, 第二个 Word 是存放的 retain counts; 以及在针对对象分配内存的时候, 内存是以 16 个字节的倍数递增的
但是呢, 也给自己留下了一些问题, 这些问题就留待在下篇文章解答吧:
数组的内存到底怎么分配的?
结构体中没有方法的存储空间, 那么是如何调用结构体中的方法呢?
类中的方法又是如何调度的呢?
方法栈中如果出现结构体, 会多出和结构体大小一致的空间, 这是为什么呢?
协议又是如何存储的? 结构体继承协议会怎样? 类继承协议会怎样?
参考文章:
- Unsafe Swift: Using Pointers And Interacting With C
- Exploring Swift Memory Layout
Swift 对象内存模型探究(一)
Swift 进阶之内存模型和方法调度
Printing a variable memory address in swift
最后附上我的 Blog 地址, 如果觉得写得不错欢迎关注我的掘金, 或者常来逛我的 Blog~~
来源: https://juejin.im/post/5a7b04c86fb9a0634b4d632a