从值类型复制引发的Swift内存的思考01

2018-02-09 12:42:43来源:https://juejin.im/post/5a7b04c86fb9a0634b4d632a作者:稀土掘金人点击

分享

前不久看了一篇文章,喵神的值类型和引用类型,在阅读的时候有一个结论 值类型被复制的时机是值类型的内容发生改变时... 这个时候本来是想记下来的,后来转念一想,实践出真知,所以我就基于这个问题: 值类型到底是什么时候被赋值的? 做了一些调查和实践,从而有了这系列文章...


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?>.alignment8
//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
}

地址是从栈底一直向上增加的,我画出示意图如下:( Bool size为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>.stride56
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~~


最新文章

123

最新摄影

闪念基因

微信扫一扫

第七城市微信公众平台