Swift和OC一样,也是采用了基于引用计数的ARC内存管理方案,在OC中ARC引用主要有强引用
和弱引用
,Swift的ARC引用除了强引用
和弱引用
外,又增加了一个无主引用
。
在第一篇文章Swift进阶(一) —— 类与结构体中,我们知道Swift本质是一个HeapObject
的结构体,而在HeapObject
结构体中有两个成员变量:metadata
和refCounts
。其中metadata
指向元数据对象,存储Swift数据结构类、结构体的基本信息、属性和方法列表等。而这个refCounts
属性则是和ARC的引用计数相关。
refCounts的本质
我们先从源码上面来查找refCounts具体做了什么。
首先我们先从HeapObject.h
去找refCounts
属性
我们可以看到refCounts
的类型是InlineRefCounts
,在RefCount.h
文件中,我们找到了InlineRefCounts
的定义。
从定义中,我们可以知道InlineRefCounts
是一个模板类,是一个接收了InlineRefCountBits
类型参数的RefCounts
类。接着我们去查看RefCounts
类的数据结构
从RefCounts
类中的API我们可以看到,在这个类里面,都是在操作RefCountsBits
这个传进来的泛型参数,我们可以知道RefCounts
类本质上是对当前引用计数的一个包装。因此,引用计数的类型取决于传进来的这个参数类型,也就是上面的InlineRefCountBits
类。
通过查找InlineRefCountBits
的定义,我们知道,这又是一个模板函数。我们先看一下RefCountIsInline
这个参数。
可以看到这个参数是枚举
类型,要么是true
,要么是false
。接下来我们查看RefCountBitsT
这个类。
在RefCountBitsT
类中,只有bits
这个成员变量,这个bits
是RefCountBitsInt
类型。因此我们可以确定引用计数类型应该是RefCountBitsInt
类型,我们去看一下RefCountBitsInt
类。
可以看到,Type
的类型是一个 uint64_t
的位域信息,在这个 uint64_t
的位域信息中存储着运行生命周期的相关引用计数。
到了这里,我们仍然不知道是如何设置的,我们先来看⼀下,当我们创建⼀个实例对象的时候,当前的引⽤计数是多少?
我们先来查看HeapObject
类的初始化方法
在HeapObject
类的初始化方法中,我们看到了一个Initialized
,这是对refCounts
的初始化赋值。我们查找一下这个Initialized
,发现它是Initialized_t
枚举类型。
搜索Initialized_t
,我们找到了refCounts
的初始化方法
通过上面的注释可以看出来,这里是给一个新创建的对象的引用计数置为1。传入的参数为RefCountBits(0, 1)
,这里的RefCountBits
就是我们上面说的RefCountBitsT
类。我们去查看RefCountBitsT
类的初始化方法,代码如下:
从上面的初始化方法我们可以知道,strongExtraCount
是0, unownedCount
是1,初始化时对这两个数进行了偏移,接下来我们来查找StrongExtraRefCountShift
、
PureSwiftDeallocBitCount
、PureSwiftDeallocShift
这三个偏移数字是多少?
通过全局搜索StrongExtraRefCountShift
,我们找到了这三个常量的定义:
其中PureSwiftDeallocShift
的值是0,PureSwiftDeallocBitCount
的值是1。而StrongExtraRefCountShift
则是通过shiftAfterField
方法计算出来。我们查找一下shiftAfterField
方法的定义
根据上面的方法定义,我们可以求出StrongExtraRefCountShift
的值,如下所示:
static const size_t StrongExtraRefCountShift = shiftAfterField(IsDeiniting)
= IsDeinitingShift + IsDeinitingBitCount
= shiftAfterField(UnownedRefCount) + 1
= UnownedRefCountShift + UnownedRefCountBitCount + 1
= shiftAfterField(PureSwiftDealloc) + 31 + 1
= PureSwiftDeallocShift + PureSwiftDeallocBitCount + 32
= 0 + 1 + 32
= 33
static const size_t UnownedRefCountShift = PureSwiftDeallocShift + PureSwiftDeallocBitCount = 0 + 1 = 1
复制代码
我们可以知道,每一个对象创建后,refcounts的属性值如下所示。
refcount = 0 << 33 | 1 << 0 | 1 << 1 = 0x0000000000000003
复制代码
最后我们通过代码验证一下。
通过lldb命令打印地址,我们可以看到:
打印出来的结果和我们预想的一样。
强引用
在Swift中,默认情况下,对一个实例对象的引用是强引用。当我们创建一个实例对象后,它的refcount
属性会是0x0000000000000003
,当我们对这个实例对象进行强引用后,refcount
属性会发生什么变化呢?
代码如下:
然后分别在t1、t2、print(“end”)处分别设置断点,然后使用lldb命令x/8g
查看t的内存结构,每次过一个断点打印内存结构一次。打印结果如下:
我们可以看到,当对实例变量t
进行强引用的过程中,t
的refcount
属性从0x0000000000000003
-> 0x0000000200000003
->0x0000000400000003
。通过计算器对这几个地址进行解析,查看它们的变化。
通过计算器我们可以看到,当引用次数增加时,refcount
属性在高位进行位移操作。
我们下面通过源码去分析一下。首先通过汇编代码来查看一下强引用操作怎么实现:
从汇编代码中可以看到,当对一个实例变量强引用时,调用了一个swift_retain
方法。接下来我们通过源码查看swift_retain
如何实现。
通过查看swift_retain
函数实现,我们可以看到,refCounts.increment(1)
,我们继续查看increment
函数的实现。
在这里面我们看到了incrementStrongExtraRefCount
这个方法,从字面意思就可以看出,这个方法是用来增加强引用计数的。我们深入到这个方法里面,查看怎么增加强引用计数。
我们可以看到,强引用计数的增加是先把要增加的引用计数参数往左移动33位,然后在和原来的引用计数相加,也就是说每做一次强引用,refcount
属性就会加上0x200000000
。
上面这张图是refcount
属性64位域信息下面的存储方式,我们可以看到在高位33-62位用于存储强引用计数。而在第32位isDeinitingMask
用来标识这个实例是否可以析构,如果当一个实例不再被强引用,那么它就会被释放掉,我们来验证一下。代码如下所示:
通过lldb命令打印内存地址,显示如下:
当t = nil
后,refcount
地址显示为0x100000003
,通过计算器进行解析后,我们发现在32位上标识为1。也就是说这个实例可以被释放掉。
循环引用
我们在使用OC开发时,经常会遇到使用强引用出现循环引用导致实例对象无法释放的问题,在Swift中,也会出现这种情况,比如以下代码所示:
当t = nil
时,不会触发deinit
方法,因为这两个实例对象之间出现了循环引用。
Swift 提供了两种办法⽤来解决你在使⽤类的属性时所遇到的循环强引⽤问题:弱引⽤( weak reference )
和⽆主引⽤(unowned reference )
。
弱引用
弱引⽤不会对其引⽤的实例保持强引⽤,因⽽不会阻⽌ ARC
释放被引⽤的实例。这个特性阻⽌了引⽤ 变为循环强引⽤。声明属性或者变量时,在前⾯加上 weak
关键字表明这是⼀个弱引⽤。
由于弱引⽤不会强保持对实例的引⽤,所以说实例被释放了弱引⽤仍旧引⽤着这个实例也是有可能的。 因此,ARC
会在被引⽤的实例被释放是⾃动地设置弱引⽤为 nil
。由于弱引⽤需要允许它们的值为nil
,它们⼀定得是可选类型。
我们通过一个案例来看弱引用在内存的存储方式
通过lldb
命令查看存储属性
可以看到,当对一个实例实行弱引用后,refcount
地址发生了很大的变化,那这个变化是怎么来的?
首先我们先去查看汇编代码:
可以看到,当使用了弱引用后,汇编代码里面调用了swift_weakInit
方法。我们去源码里面查看这个方法。
这个方法里面调用了nativeInit
方法,我们再去查看nativeInit
方法
这个代码里面主要是创建了一个side
,然后把这个side
存储起来。所以我们继续查看formWeakReference
这个方法。
在这个方法里面,通过allocateSideTable
创建一个散列表,然后返回一个HeapObjectSideTableEntry
。
最后我们查看一下allocateSideTable
方法。
我们可以看一下这个函数是怎么创建散列表的:
1.首先获取原先的引用计数refcounts
属性。
2.判断refcounts
属性有没有散列表,如果有,则直接返回散列表。
3.如果没有散列表,则重新创建一个散列表,并以此创建一个新的refcounts
属性。
4.对原来的散列表做一些析构处理。
接下来我们来看一下HeapObjectSideTableEntry
这个类,从源码里面去搜索这个类,发现了苹果官方关于引用计数的一些注释:
从注释里面我们可以知道,当对实例对象强引用的时候,使用了InlineRefCounts
,引用计数计算规则是
strong RC + unowned RC + flags
,而对实例对象就行弱引用后,则变成了HeapObjectSideTableEntry
,引用计数计算规则是strong RC + unowned RC + weak RC + flags
。
我们来看一下HeapObjectSideTableEntry
类:
这个类里面存储了当前实例对象object
和SideTableRefCounts
类型的refcounts
属性。我们看一下
SideTableRefCounts
是什么类。
可以看到它也是RefCountBits
的模板类。
看了SideTableRefCounts
的具体实现,我们可以知道,它是继承了RefCountBitsT
这个类,所以它除了有64位域信息外,还多了一个32位的weakBits
属性,初始化的时候,weakBits
为1,新增一个弱引用,weakBits
加1。
在强引用分析中,InlineRefCountBits
最终通过RefCountBitsT
这个类来实现,找到和HeapObjectSideTableEntry
相关的初始化方法。
通过查看源码,可以知道UseSlowRCShift
为63,SideTableMarkShift
为62,SideTableUnusedLowBits
值为3。
所以当对一个实例变量进行弱引用后,refCounts
存储方式是这样的:先创建一个散列表,同时把散列表的存储地址右移3位,再把高位63、62位地址置为1,最后把这个地址存储到refCounts
属性中。
接下来,我们开始对弱引用后的地址进行解析,得到散列表的地址。首先看一下代码
接下来使用lldb命令获取refCounts
的内存地址。
把获取到的内存地址的高63位、62位置为0
再往左移动3位,得到散列表的内存地址。
使用lldb命令解析这个内存地址
所以,当对一个实例对象进行弱引用的时候,本质上是建立了一个散列表。
无主引用
和弱引⽤类似,⽆主引⽤unowned
不会牢牢保持住引⽤的实例。但是不像弱引⽤,总之,⽆主引⽤unowned
假定是永远有值的。
根据苹果的官⽅⽂档的建议。当我们知道两个对象的⽣命周期并不相关,那么我们必须使⽤weak
。相反,⾮强引⽤对象拥有和强引⽤对象同样或者更⻓的⽣命周期的话,则应该使⽤unowned
。
如果两个对象的⽣命周期完全和对⽅没关系(其中⼀⽅什么时候赋值为nil,对对⽅都没影响),请⽤weak
如果你的代码能确保:其中⼀个对象销毁,另⼀个对象也要跟着销毁,这时候,可以(谨慎)⽤unowned
Weak VS unowned
- 如果两个对象的⽣命周期完全和对⽅没关系(其中⼀⽅什么时候赋值为nil,对对⽅都没影响),请⽤
weak
- 如果你的代码能确保:其中⼀个对象销毁,另⼀个对象也要跟着销毁,这时候,可以(谨慎)⽤
unowned
闭包的循环引用
在Swift中,创建一个闭包会⼀般默认捕获我们外部的变量,如下代码所示:
从打印结果可以看出来,闭包内部对变量的修改将会改变外部原始变量的值。这样我们就会遇到一个问题,如果我们在class
的内部定义⼀个闭包,当前闭包访问属性的过程中,就会对我们当前的实例对象进⾏捕获:
如上图所示,打印的结果没有deinit
方法,也就是说明这个实例对象和闭包形成了循环引用,程序结束后无法进行释放。
那我们应该如何解决循环引用呢?
1.使用闭包的捕获列表,在捕获列表中声明对引用的实例对象为weak
引用。
从打印结果可以看到,deinit
方法有被调用到,也就没有了循环引用。
2.使用闭包的捕获列表,在捕获列表中声明对引用的实例对象为unowned
引用。
捕获列表
什么是闭包的捕获列表呢?
- 默认情况下,闭包表达式从其周围的范围捕获常量和变量,并强引⽤这些值。您可以使⽤捕获列表来显式控制如何在闭包中捕获值。
- 在参数列表之前,捕获列表被写为⽤逗号括起来的表达式列表,并⽤⽅括号括起来。如果使⽤捕获列表,则即使省略参数名称,参数类型和返回类型,也必须使⽤in关键字。
闭包的捕获列表应用如下:
创建闭包时,将初始化捕获列表中的条⽬。对于捕获列表中的每个条⽬,将常量初始化为在周围范围内具有相同名称的常量或变量的值。捕获列表的常量有以下几个特点:
- 捕获列表中的常量是值拷贝,而不是引用
- 捕获列表中的常量的相当于复制了变量的值
- 捕获列表中的常量是只读的,即不可修改
创建闭包时,内部作⽤域中的age
会⽤外部作⽤域中的age
的值进⾏初始化,但它们的值未以任何特殊⽅式连接。这意味着更改外部作⽤域中的age
的值不会影响内部作⽤域中的age
的值,也不会更改封闭内部的值,也不会影响封闭外部的值。相⽐之下,只有⼀个名为height
的变量,既外部作⽤域中的height
,在闭包内部或外部进⾏的更改在两个地⽅均可⻅。