ps:
本文所有测试基于64位计算机
由于计算机内部是小端读取内存的(因此都是向后读取内存)
本文基于Swift4.2, Xcode10.0
1 | MemoryLayout<T>.size // 类型T需要的内存大小 |
这是研究的总结:
Swift-MemoryLayout
底下是研究的过程(有些结论没有及时更新,上面的结论链接会及时更新)
Struct
struct的内存结构比较简单,和C以及C++的struct内存布局非常类似。
// 测试代码
1 | struct Foo { |
foo的内存结构图
可以看到foo的内存结构,每一个属性都会根据对应的类型分配好对应的内存大小。虽然Foo只需要分配15个字节,但由于有内存对齐原则,还是给Foo分配了16个字节。对于结构体内部的方法,并不会在结构体内部储存函数的信息。其实在编译后,在调用的地方(foo.foo()), 会直接跳转到具体的函数实现的位置(call foo(伪编译后名字)),对于遵循协议的方法,会通过witness_table得到函数指针去后,在跳转到具体的实现地址。(具体分析参考我的下篇文章“Swift方法调用”)
String
接下来考虑下这种情况
1 | struct Foo2 { |
根据内存对齐原则,字符串占用的是16个字节,顾foo2也会分配16个字节。但是str需要的字节数明显要大于16字节。那这是不是内存溢出了?(要是这么容易溢出,那长字符串怎么储存呢。。。) 别忘了还有指针这个东西。
foo2的内存结构图
(小端读取内存)
可以看到foo2的前面字节数据是个指针,指向就是字符串str真正的地址。
当然如果字符串的实际占用内存如果小于分配的内存(16字节),如字符串”hello”,那么这个字符串的值就不是以指针方式储存了,是直接储存在结构体foo分配的内存中
// 测试代码
1 | struct FooStr { |
fooStr内存结构图
可以看到01字节值就是a的值了,中间的00 00是内存对齐空出来的空间。还能看到hello,wo以及rld, 你可能要问了,这个字符串为什么被分开呢? hello,wo占用了8个字节,下一个8字节空间储存的就是rld(没储存完的用00表示). 其次,栈区(Swift结构体在栈区)内存分配是从高地址向低地址分配。所以就会有先显示rld 00 00… 接着显示hello,wo
Array
既然Swift中的String是这种情况,那么Swift中的Array应该也一样(Array类型分配8个字节,但存储的数据远远大于8个字节,还包括Array的信息数据)。
// 测试代码
swift
let arr = ["Tanner", "Jin"]
arr内存结构图(前面的字节值是指向数组内存地址的指针)
输入指针,跳转到数组内存地址
[“Tanner”, “Jin”]数组真正的内存结构图
可以看到元素Tanner以及Jin在32个字节之后。至于这个32个字节的储存是什么信息呢?(肯定是数组的信息)
(目前我知道16~24个字节储存的是数组个数的信息)
可以试试下面的的代码~
1 | let array = [1, 2] |
每个数组初始化后都会有一个固定的内存大小来储存元素,当数组执行insert或者append操作时,超过了这个数组的固定的内存大小,就会创建一个新的更大的内存的数组来储存元素数据,并将指向原来数组的指针重新指向这个新的数组地址
Dictionary
字典和数组一样,也是一个指针,可以通过下面代码将变量转成指针类型,然后查看指针值的内存(不是指针变量),就能看到字典的内存布局了
1 | let dic = ["Tanner": "Swift"] |
布局如下图
如图可以看到元素key和value的内容,但是前面的字节是啥意思,以及key和value的布局是怎么分布的呢?
这就得翻Swift源码了,翻了之后,立马就知道了,截图如下
可以看出来,Swift原生字典内部是一个类,这个类的属性映射着字典的的属性,并且字典的key和value是按照数组的方式储存的,其实再翻下__RawDictionaryStorage这个类的源码,会发现这个类还有个属性 _HashTable,通过 _HashTable的hash算法来维护key和value的映射offset关系
Protocol
Pure Protocol
// 测试代码
1 | protocol Foo { |
结果图如下
可以看到协议的类型占用40个字节,但对于继承协议的结构体A是根据自身属性来分配内存大小,和之前分析的结构体内存结构一致。可以注意看,foo2有一个witness_ tables属性的指针,指向的就是协议Foo的witness_table(类型于C++的vtable), witness _ table的具体分析可以看我的下篇文章Swift方法调用.
- 对于上面demo的情况,a按照结构体A分配内存。foo虽然是A,但是在编译的时候是按照协议类型分配内存的,即分配40个字节。前面的是数据,后面8个字节是witness_table的指针。如下图所示
- 但如果给结构体A在加上两个字符串属性,那么A占用了48个字节,但是Foo类型只能分配40个字节,怎么办呢?还是指针。可以看到foo2类型有三个playload_data的指针属性,可以指向真正的数据。
Class Protocol
继承于class的协议占用16个字节, 前8个字节是实例对象的地址(指针),后8个字节是witness_ table的地址(指针)
Enum
// 测试代码
1 | enum Foo: String { |
foo1内存结构图如下
foo2内存结构图如下
可以看到foo1分配了2个字节的内存,并且值是0和1.
而foo2占用了17个字节的内存,一个是a(16 bytes), 一个是b(1 byte)。事实上,对于Foo2的属性a,其实已经是一个字符串类型了,不是枚举类型了。
而对于枚举来说,编译器分配的值是0, 1, 2, 3, 4…(按照定义的顺序),即占用一个字节空间。而对于枚举的rawValue, 编译器会根据rawValue具体的类型分配具体的内存大小。
Enum with Associated Objects
// 测试代码
1 | enum Foo<T> { |
打印结果
通过打印信息可以看到,具有关联对象的枚举,每一个枚举值分配的内存大小是枚举类型中枚举的关联值类型占用的空间大小加上一个字节(事实上是枚举关联值类型的最大类型,即一个枚举关联了Int8类型,另一个枚举关联了Int64类型,那么就按照Int64类型占用的大小)。
而对于foo2, 实际上并有具体的值,他其实是一个Block,而Swift中的Block占用的是16个字节。
而对于枚举值内存中储存的是什么呢?我们接着看:
// 测试代码
1 | import Foundation |
通过Xcode的View Memory功能可以得到指针指向的内存空间信息为:
fooaPointer: 00 03
foobPointer: 05 00 (关联值为5 Int8)
foocPointer: 01 03
foodPointer: 04 01 (关联值为4 Int8)
fooePointer: 02 03
foofPointer: 03 03
foogPointer: 03 02 (关联值为3 Int8)
将有关联值的枚举和没有关联值的枚举分开看,就能看到一些规律了:
没有关联值
fooaPointer: 00 03
foocPointer: 01 03
fooePointer: 02 03
foofPointer: 03 03
具有关联值
foobPointer: 05 00 (关联值为5 Int8)
foodPointer: 04 01 (关联值为4 Int8)
foogPointer: 03 02 (关联值为3 Int8)
可以看到没有关联值的枚举值中,00, 01, 02, 03 是正常枚举的值(按照顺序定义赋值),而03是该枚举类型中具有关联值的枚举值的个数(这个是我猜测的,不过也是通过许多的例子推测出来的)。
对于有关联值的枚举,05,04,03是关联值的值。00,01,02是有关联值的枚举中的值(也是按照定义顺序)
顺便一提,Swift中的可选类型也是枚举,一个case是none(nil),另一个case是some(T) (value). 这也能解释Int?类型占用了9个字节。
Class
Swift中的Class在内存中其实就是一个Objective-C的Class,也就是说Swift的Class和Objective-C的Class一样也是runtime的机制的一部分,即Swift中的Class的也是一个对象(类对象,和Objective-C一样)。
(所以不要说Swift没有runtime机制了,Swift的Class也还是runtime机制的一部分,只不过Swift的方法调用并不像Objective-C那样遵循runtime机制中的消息机制,Swift方法调用比较复杂,可以期待我的下篇文章”Swift方法调用”)
其实在objc源码中也能发现些信息(Swift类对象也是继承于objc_class,不过多了些参数):
总结以及论证
Swift中的实例对象和Objective-C中的实例对象一样是一个objc_object结构体,第一个参数是isa指针,指向Swift的类对象
// 测试代码
1 | class Foo { |
lldb打印Foo对象foo内存结构如下
(小段读取内存)
(1) 可以看到对象foo的前面8个字节的值为0x0000000100582870,正是fooClass的地址0x100582870。即Swift的实例对象和Objective-C 一样,第一个参数是isa指针,指向实例的类对象。
(2) 接着可以看到foo的a属性,分配了8个字节,b属性分配了1个字节。
(3) 而中间那8个字节是什么呢?其实是引用计数(继承于NSObject的类没有这8个字节的数据的)。包括强引用计数和弱引用计数。当强引用计数变成0后,对象会被摧毁(会走deinit方法),但该对象占用的内存不会释放,当强引用计数和弱引用计数都变成0后,这个对象所占用的内存才会被释放。
// 测试代码(在最后一行代码下断点)
1
2
3
4
5
6
7
8
9
10
11
class Foo {
let a = 6
let b: Int8 = 9
deinit {
print("hello")
}
}
weak var foo = Foo()
// foo = nil // 用来观察弱引用数变成0后 foo对象是否在内存中存在
print("===end===")
// 注释了foo = nil的结果图
可以看到强引用计数为0,弱引用计数不为0时,对象已经摧毁了,但占用的内存还未释放
// foo = nil结果图
可以看到强引用计数为0,弱引用计数为0时,对象已经摧毁了,占用的内存也被释放了
结论:
- Swift的类的实例对象在内存中是一个objc_object结构体(和Objective-C一致)
- Swift类的实例对象的第一个参数是isa指针,指向类对象。第二个参数是引用计数(和Objective-C一致)
- Swift类的实例对象引用计数,在强引用计数为0时,对象会析构(走deinitf方法)。在弱引用计数为0时,对象占用的内存才会释放
Swift中的Class在内存和一个Objective-C的Class是相同的, 即Swift中的Class编译后都会生成Runtime的objc_class结构体对象.
// Swift定义类的两种方式
class ClassA {}
class ClassB: NSObject {}
对于ClassB来说,继承自NSObject,就和Objective-C的类继承与NSObject类似(其实两者NSObject实现也类似),编译后ClassB都会生成对应的objc_ class结构体。
但是对于ClassA,它没有继承任何类,最后也会编译成objc_ class这个结构体吗?没错是的!
其实ClassA在内部是继承于一个SwiftObject类的,而SwiftObject是用Objective-C实现的另一个“NSObject”类 SwiftObject源码
接下来就来验证看ClassA是不是继承于SwiftObject
// 测试代码
1 | class ClassA {} |
ClassA的内存地址如下
SwiftObject的内存地址如下
通过lldb打印ClassA内存结构
由runtime源码可以知道类对象前面的8个字节指向的是ClassA的元类,后8个字节指向的是ClassA的父类
由于计算机内部是小端读取内存的(因此向后读取内存),因此ClassA的元类的地址应该为0x1d8001005827e9, 而ClassA的父类的地址为0x00000001005824c0即0x1005824c0,这个地址不就是SwiftObject类所在的内存地址嘛!!!,由此可知ClassA继承于SwiftObject
接下来我们接着来看看SwiftObject类继承于那个类
可以看到SwiftObject类的元类地址是0x1d8001005824e9(和ClassA的元类地址相近),而SwiftObject父类的地址是0x00,即父类为nil
现在再来看看继承于NSObject的ClassB类对象的内存结构
// 测试代码
1
2
3
4
5
class ClassB: NSObject {}
let objectClass = objc_getClass("NSObject")!
let swiftObjectClass = objc_getClass("SwiftObject")!
let classB = objc_getClass("_TtC12SwiftRuntime6ClassB")! // or SwiftRuntime.ClassB
lldb读取classB的内存结构
前面8个字节还是ClassB元类的地址, 后面8个字节是父类的地址0x00007fff92988140(小端模式读取), 可以看到父类的内存地址和objectClass地址是一样的。
lldb读取NSObject的内存结构
可以看到NSObject类也是和SwiftObject一样是没有父类的
结论:
- Swift的Class编译后也是生成objc_class结构体对象(和Objective-C一致)
- Swift有两个根类,一个是SwiftObject,一个是NSObject,而且两者实现基本一致
- 自定义的Class在编译后都会继承于SwiftObject,而SwiftObject没有父类
- 继承于NSObject的类编译后的父类是NSObject,而NSObject也一样没有父类
Swift中类的扩展(extension)能关联对象,因为它是一个id类型(objc_class继承于objc_object)
源码如图所示:
如果有兴趣看如何实现的关联对象功能的可以看看以下源码(本文主要分析的是Swift的内存布局,就不分析啦~)
简单来说就是有一个关联对象管理者的一个单例(AssociationsManager), 这个单例有一个属性 是关联对象hash表(AssociationsHashMap)。这个hash表的key就是id(objc_object)的指针,value就是这个id(objc_object)对应的对象关联表(ObjectAssociationMap)。通过参数value生成一个关联对象(ObjcAssociation), 然后给id对应的对象关联表(ObjectAssociationMap)通过参数key设置生成的关联对象(ObjcAssociation).
ps: 如果你试过给Swift的Array,String…这些结构体去关联对象,会发现也能关联上。这是因为当被需要时,Swift的值类型会自动转化成对象类型(我和同事Andrew的提问以及苹果员工的回答)。
至于怎么转的,我发现了他们其实是遵循了_ObjectiveCBridgeable这个协议,这个协议会关联一个object类型(Array关联的是NSArray),协议里的有一些相互转化的方法(自己实现),实现了值类型和关联的对象类型相互转化。
例子
1.将某个实例a的isa指针替换成另一个类,再调用a的方法
// 测试代码
1 | class Foo { |
运行结果图
这个时候也不要激动,不要立马下结论说这不就是Objective-C消息机制,把类对象的方法表(objc _method _list)替换嘛。No,No,No~ 方法表替换确实是替换了,但根本原因是替换了类对象的vtable(C++中的虚函数表,其实类对象objc _class就是C++写的)。具体原因以及分析可以期待我的下篇文章”Swift方法调用” (下篇文章我将直接替换类对象vtable中的某个具体的函数指针)
2. 提个问题,自己可以去实践下。
1 | class A<T> { |
对象a和a1是同一个类的实例对象吗?