Swift进阶(五)—— 内存管理

Swift和OC一样,也是采用了基于引用计数的ARC内存管理方案,在OC中ARC引用主要有强引用弱引用,Swift的ARC引用除了强引用弱引用外,又增加了一个无主引用

在第一篇文章Swift进阶(一) —— 类与结构体中,我们知道Swift本质是一个HeapObject的结构体,而在HeapObject结构体中有两个成员变量:metadatarefCounts。其中metadata指向元数据对象,存储Swift数据结构类、结构体的基本信息、属性和方法列表等。而这个refCounts属性则是和ARC的引用计数相关。

refCounts的本质

我们先从源码上面来查找refCounts具体做了什么。

首先我们先从HeapObject.h去找refCounts属性
截屏2022-02-28 下午5.02.31.png
我们可以看到refCounts的类型是InlineRefCounts,在RefCount.h文件中,我们找到了InlineRefCounts的定义。
截屏2022-02-28 下午5.07.29.png
从定义中,我们可以知道InlineRefCounts是一个模板类,是一个接收了InlineRefCountBits类型参数的RefCounts类。接着我们去查看RefCounts类的数据结构
截屏2022-02-28 下午5.20.52.png
RefCounts类中的API我们可以看到,在这个类里面,都是在操作RefCountsBits这个传进来的泛型参数,我们可以知道RefCounts类本质上是对当前引用计数的一个包装。因此,引用计数的类型取决于传进来的这个参数类型,也就是上面的InlineRefCountBits类。
截屏2022-02-28 下午5.29.40.png
通过查找InlineRefCountBits的定义,我们知道,这又是一个模板函数。我们先看一下RefCountIsInline这个参数。
截屏2022-02-28 下午5.31.21.png
可以看到这个参数是枚举类型,要么是true,要么是false。接下来我们查看RefCountBitsT这个类。
截屏2022-02-28 下午5.36.15.png
RefCountBitsT类中,只有bits这个成员变量,这个bitsRefCountBitsInt类型。因此我们可以确定引用计数类型应该是RefCountBitsInt类型,我们去看一下RefCountBitsInt类。
截屏2022-02-28 下午6.23.14.png
可以看到,Type 的类型是一个 uint64_t 的位域信息,在这个 uint64_t 的位域信息中存储着运行生命周期的相关引用计数

到了这里,我们仍然不知道是如何设置的,我们先来看⼀下,当我们创建⼀个实例对象的时候,当前的引⽤计数是多少?

我们先来查看HeapObject类的初始化方法
截屏2022-02-28 下午6.28.46.png
截屏2022-02-28 下午6.30.28.png
HeapObject类的初始化方法中,我们看到了一个Initialized,这是对refCounts的初始化赋值。我们查找一下这个Initialized,发现它是Initialized_t枚举类型。
截屏2022-02-28 下午6.38.39.png
搜索Initialized_t,我们找到了refCounts的初始化方法
截屏2022-02-28 下午6.40.11.png
通过上面的注释可以看出来,这里是给一个新创建的对象的引用计数置为1。传入的参数为RefCountBits(0, 1),这里的RefCountBits就是我们上面说的RefCountBitsT类。我们去查看RefCountBitsT类的初始化方法,代码如下:
截屏2022-02-28 下午6.52.47.png
从上面的初始化方法我们可以知道,strongExtraCount是0, unownedCount是1,初始化时对这两个数进行了偏移,接下来我们来查找StrongExtraRefCountShift
PureSwiftDeallocBitCountPureSwiftDeallocShift这三个偏移数字是多少?

通过全局搜索StrongExtraRefCountShift,我们找到了这三个常量的定义:
截屏2022-02-28 下午7.02.47.png
其中PureSwiftDeallocShift的值是0,PureSwiftDeallocBitCount的值是1。而StrongExtraRefCountShift则是通过shiftAfterField方法计算出来。我们查找一下shiftAfterField方法的定义
截屏2022-02-28 下午7.10.20.png
根据上面的方法定义,我们可以求出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
复制代码

最后我们通过代码验证一下。
截屏2022-02-28 下午7.16.44.png
通过lldb命令打印地址,我们可以看到:
截屏2022-02-28 下午7.17.34.png
打印出来的结果和我们预想的一样。

强引用

在Swift中,默认情况下,对一个实例对象的引用是强引用。当我们创建一个实例对象后,它的refcount属性会是0x0000000000000003,当我们对这个实例对象进行强引用后,refcount属性会发生什么变化呢?

代码如下:
截屏2022-03-01 下午7.02.01.png
然后分别在t1、t2、print(“end”)处分别设置断点,然后使用lldb命令x/8g查看t的内存结构,每次过一个断点打印内存结构一次。打印结果如下:
截屏2022-03-01 下午7.05.11.png
我们可以看到,当对实例变量t进行强引用的过程中,trefcount属性从0x0000000000000003-> 0x0000000200000003 ->0x0000000400000003。通过计算器对这几个地址进行解析,查看它们的变化。
截屏2022-03-01 下午7.11.13.png
截屏2022-03-01 下午7.12.28.png
通过计算器我们可以看到,当引用次数增加时,refcount属性在高位进行位移操作。

我们下面通过源码去分析一下。首先通过汇编代码来查看一下强引用操作怎么实现:
截屏2022-03-01 下午7.20.50.png
从汇编代码中可以看到,当对一个实例变量强引用时,调用了一个swift_retain方法。接下来我们通过源码查看swift_retain如何实现。
截屏2022-03-01 下午7.25.04.png
通过查看swift_retain函数实现,我们可以看到,refCounts.increment(1),我们继续查看increment函数的实现。
截屏2022-03-01 下午7.28.33.png
在这里面我们看到了incrementStrongExtraRefCount这个方法,从字面意思就可以看出,这个方法是用来增加强引用计数的。我们深入到这个方法里面,查看怎么增加强引用计数。
截屏2022-03-02 下午2.50.11.png
我们可以看到,强引用计数的增加是先把要增加的引用计数参数往左移动33位,然后在和原来的引用计数相加,也就是说每做一次强引用,refcount属性就会加上0x200000000

4e1783a97bc5427da5705f368791c956_tplv-k3u1fbpfcp-watermark.webp
上面这张图是refcount属性64位域信息下面的存储方式,我们可以看到在高位33-62位用于存储强引用计数。而在第32位isDeinitingMask用来标识这个实例是否可以析构,如果当一个实例不再被强引用,那么它就会被释放掉,我们来验证一下。代码如下所示:
截屏2022-03-03 下午6.14.59.png
通过lldb命令打印内存地址,显示如下:
截屏2022-03-03 下午6.16.00.png
t = nil后,refcount地址显示为0x100000003,通过计算器进行解析后,我们发现在32位上标识为1。也就是说这个实例可以被释放掉。
截屏2022-03-03 下午6.17.12.png

循环引用

我们在使用OC开发时,经常会遇到使用强引用出现循环引用导致实例对象无法释放的问题,在Swift中,也会出现这种情况,比如以下代码所示:
截屏2022-03-03 下午6.32.55.png
t = nil时,不会触发deinit方法,因为这两个实例对象之间出现了循环引用。

Swift 提供了两种办法⽤来解决你在使⽤类的属性时所遇到的循环强引⽤问题:弱引⽤( weak reference )⽆主引⽤(unowned reference )

弱引用

弱引⽤不会对其引⽤的实例保持强引⽤,因⽽不会阻⽌ ARC 释放被引⽤的实例。这个特性阻⽌了引⽤ 变为循环强引⽤。声明属性或者变量时,在前⾯加上 weak 关键字表明这是⼀个弱引⽤。 

由于弱引⽤不会强保持对实例的引⽤,所以说实例被释放了弱引⽤仍旧引⽤着这个实例也是有可能的。 因此,ARC 会在被引⽤的实例被释放是⾃动地设置弱引⽤为 nil 。由于弱引⽤需要允许它们的值为nil,它们⼀定得是可选类型。

我们通过一个案例来看弱引用在内存的存储方式
截屏2022-03-03 下午6.43.11.png
通过lldb命令查看存储属性
截屏2022-03-03 下午6.43.49.png
可以看到,当对一个实例实行弱引用后,refcount地址发生了很大的变化,那这个变化是怎么来的?

首先我们先去查看汇编代码:
截屏2022-03-03 下午6.47.26.png
可以看到,当使用了弱引用后,汇编代码里面调用了swift_weakInit方法。我们去源码里面查看这个方法。
截屏2022-03-03 下午6.50.16.png
这个方法里面调用了nativeInit方法,我们再去查看nativeInit方法

截屏2022-03-03 下午6.51.40.png
这个代码里面主要是创建了一个side,然后把这个side存储起来。所以我们继续查看formWeakReference这个方法。
截屏2022-03-03 下午6.54.48.png
在这个方法里面,通过allocateSideTable创建一个散列表,然后返回一个HeapObjectSideTableEntry
最后我们查看一下allocateSideTable方法。
截屏2022-03-03 下午7.02.26.png
我们可以看一下这个函数是怎么创建散列表的:

1.首先获取原先的引用计数refcounts属性。

2.判断refcounts属性有没有散列表,如果有,则直接返回散列表。

3.如果没有散列表,则重新创建一个散列表,并以此创建一个新的refcounts属性。

4.对原来的散列表做一些析构处理。

接下来我们来看一下HeapObjectSideTableEntry这个类,从源码里面去搜索这个类,发现了苹果官方关于引用计数的一些注释:

截屏2022-03-03 下午7.14.31.png
从注释里面我们可以知道,当对实例对象强引用的时候,使用了InlineRefCounts,引用计数计算规则是
strong RC + unowned RC + flags,而对实例对象就行弱引用后,则变成了HeapObjectSideTableEntry,引用计数计算规则是strong RC + unowned RC + weak RC + flags

我们来看一下HeapObjectSideTableEntry类:
截屏2022-03-03 下午7.20.14.png
这个类里面存储了当前实例对象objectSideTableRefCounts类型的refcounts属性。我们看一下
SideTableRefCounts是什么类。
截屏2022-03-03 下午7.23.34.png
可以看到它也是RefCountBits 的模板类。
截屏2022-03-03 下午11.26.10.png
看了SideTableRefCounts的具体实现,我们可以知道,它是继承了RefCountBitsT这个类,所以它除了有64位域信息外,还多了一个32位的weakBits属性,初始化的时候,weakBits为1,新增一个弱引用,weakBits加1。

在强引用分析中,InlineRefCountBits 最终通过RefCountBitsT这个类来实现,找到和HeapObjectSideTableEntry相关的初始化方法。

截屏2022-03-03 下午11.37.01.png
通过查看源码,可以知道UseSlowRCShift为63,SideTableMarkShift为62,SideTableUnusedLowBits值为3。

所以当对一个实例变量进行弱引用后,refCounts存储方式是这样的:先创建一个散列表,同时把散列表的存储地址右移3位,再把高位63、62位地址置为1,最后把这个地址存储到refCounts属性中。

接下来,我们开始对弱引用后的地址进行解析,得到散列表的地址。首先看一下代码

截屏2022-03-03 下午11.56.54.png
接下来使用lldb命令获取refCounts的内存地址。
截屏2022-03-03 下午11.57.46.png
把获取到的内存地址的高63位、62位置为0
截屏2022-03-03 下午11.58.28.png
再往左移动3位,得到散列表的内存地址。
截屏2022-03-03 下午11.59.37.png
使用lldb命令解析这个内存地址
截屏2022-03-04 上午12.00.19.png
所以,当对一个实例对象进行弱引用的时候,本质上是建立了一个散列表。

无主引用

和弱引⽤类似,⽆主引⽤unowned不会牢牢保持住引⽤的实例。但是不像弱引⽤,总之,⽆主引⽤unowned假定是永远有值的。

根据苹果的官⽅⽂档的建议。当我们知道两个对象的⽣命周期并不相关,那么我们必须使⽤weak。相反,⾮强引⽤对象拥有和强引⽤对象同样或者更⻓的⽣命周期的话,则应该使⽤unowned

如果两个对象的⽣命周期完全和对⽅没关系(其中⼀⽅什么时候赋值为nil,对对⽅都没影响),请⽤weak 

如果你的代码能确保:其中⼀个对象销毁,另⼀个对象也要跟着销毁,这时候,可以(谨慎)⽤unowned 

Weak VS unowned 

  • 如果两个对象的⽣命周期完全和对⽅没关系(其中⼀⽅什么时候赋值为nil,对对⽅都没影响),请⽤ weak
  • 如果你的代码能确保:其中⼀个对象销毁,另⼀个对象也要跟着销毁,这时候,可以(谨慎)⽤unowned

闭包的循环引用

在Swift中,创建一个闭包会⼀般默认捕获我们外部的变量,如下代码所示:
截屏2022-03-04 上午8.42.59.png

从打印结果可以看出来,闭包内部对变量的修改将会改变外部原始变量的值。这样我们就会遇到一个问题,如果我们在class的内部定义⼀个闭包,当前闭包访问属性的过程中,就会对我们当前的实例对象进⾏捕获:
截屏2022-03-04 上午8.54.57.png
如上图所示,打印的结果没有deinit方法,也就是说明这个实例对象和闭包形成了循环引用,程序结束后无法进行释放。

那我们应该如何解决循环引用呢?

1.使用闭包的捕获列表,在捕获列表中声明对引用的实例对象为weak引用。
截屏2022-03-04 上午9.02.09.png
从打印结果可以看到,deinit方法有被调用到,也就没有了循环引用。

2.使用闭包的捕获列表,在捕获列表中声明对引用的实例对象为unowned引用。
截屏2022-03-04 上午9.03.56.png

捕获列表

什么是闭包的捕获列表呢?

  • 默认情况下,闭包表达式从其周围的范围捕获常量和变量,并强引⽤这些值。您可以使⽤捕获列表来显式控制如何在闭包中捕获值。
  • 在参数列表之前,捕获列表被写为⽤逗号括起来的表达式列表,并⽤⽅括号括起来。如果使⽤捕获列表,则即使省略参数名称,参数类型和返回类型,也必须使⽤in关键字。

闭包的捕获列表应用如下:
截屏2022-03-04 上午9.16.20.png

创建闭包时,将初始化捕获列表中的条⽬。对于捕获列表中的每个条⽬,将常量初始化为在周围范围内具有相同名称的常量或变量的值。捕获列表的常量有以下几个特点:

  • 捕获列表中的常量是值拷贝,而不是引用
  • 捕获列表中的常量的相当于复制了变量的值
  • 捕获列表中的常量是只读的,即不可修改

创建闭包时,内部作⽤域中的age会⽤外部作⽤域中的age的值进⾏初始化,但它们的值未以任何特殊⽅式连接。这意味着更改外部作⽤域中的age的值不会影响内部作⽤域中的age的值,也不会更改封闭内部的值,也不会影响封闭外部的值。相⽐之下,只有⼀个名为height的变量,既外部作⽤域中的height,在闭包内部或外部进⾏的更改在两个地⽅均可⻅。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享