IOS基础原理:内存管理(上)

原创:知识点总结性文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、内存布局
    • 1、内存五大区域的介绍
    • 2、Demo演示
  • 二、Tagged Pointer
    • 1、引入原因
    • 2、特点
    • 3、原理
    • 4、面试题
  • 三、NONPOINTER_ISA
    • 1、isa_t 结构
    • 2、对象引用计数保存在哪里
  • 四、SideTables 散列表
    • 1、简介
    • 2、MRC
    • 3、ARC
  • 五、循环引用问题
    • 1、产生循环引用问题的原因
    • 2、循环引用场景:NSTimer
    • 3、探索NSTimer循环引用的解决方案
    • 4、使用Proxy中介者解决Timer的循环引用问题

一、内存布局

内存布局

1、内存五大区域的介绍

栈区
  • 创建临时变量时由编译器自动分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等
  • 在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用
  • 和堆一样,用户栈在程序执行期间可以动态地扩展和收缩,向下增长
堆区
  • 通过newalloc等分配,block经过copy后的对象。
  • 堆可以动态地扩展和收缩,向上增长。

它们的释放系统不会主动去管,由我们的开发者去告诉系统什么时候释放这块内存(一个对象引用计数为0是系统就会销毁该内存区域对象)。

一般一个 new 就要对应一个release。在ARC下编译器会自动在合适位置为OC对象添加release操作,会在当前线程Runloop退出或休眠时销毁这些对象。

NSString的对象就是stack 中的对象,NSMutableString 的对象就是heap 中的对象。前者创建时分配的内存长度固定且不可修改;后者是分配内存长度是可变的。两类对象的创建方法也不同,前者直接创建NSString * str1=@"welcome";,而后者需要先分配再初始化NSMutableString * mstr1=[[NSMutableString alloc] initWithString:@"welcome"];

bss(静态区)

未初始化的全局变量和静态变量,程序运行过程内存的数据一直存在,程序结束后由系统释放。

data(常量区)

已初始化的全局变量、静态变量、常量,在程序结束后由系统释放。

text(代码区)

用于存放程序运行时的代码,是被编译成二进制的程序代码。


2、Demo演示

  • 0x1:静态常量
  • 0x7:栈区
  • 0x6:堆区
  • 如何找到这个对象:指针 (栈)——>堆区内存空间——>读取到值
// 局部变量
int money = 10;
NSLog(@"小学生的零花钱:%p",&money);
NSLog(@"小学生的年龄:%lu",sizeof(money));

// 对象
NSObject *student = [NSObject new];
NSLog(@"中学生:%@,中学生的姓名:%p",student,&student);
NSLog(@"中学生的年龄:%lu",sizeof(&student));

// 数组
NSArray *class = [[NSArray alloc] init];
NSLog(@"班级:%@,几年级几班:%p",class,&class);

// 静态常量
NSLog(@"int == \t%p",&pureInt);
NSLog(@"static int == \t%p",&staticInt);
NSLog(@"static NSString == \t%p",&staticNSString);

输出结果为:

2021-03-01 17:26:46.721875+0800 内存管理[92080:4361603] 小学生的零花钱:0x7ffee3da905c
2021-03-01 17:26:46.721991+0800 内存管理[92080:4361603] 小学生的年龄:4
2021-03-01 17:26:46.722072+0800 内存管理[92080:4361603] 中学生:<NSObject: 0x600001e60590>,中学生的姓名:0x7ffee3da9050
2021-03-01 17:26:46.722135+0800 内存管理[92080:4361603] 中学生的年龄:8
2021-03-01 17:26:46.722222+0800 内存管理[92080:4361603] 班级:(
),几年级几班:0x7ffee3da9048
2021-03-01 17:26:46.722295+0800 内存管理[92080:4361603] int ==  0x10be5c798
2021-03-01 17:26:46.722366+0800 内存管理[92080:4361603] static int ==   0x10be5c7a0
2021-03-01 17:26:46.722425+0800 内存管理[92080:4361603] static NSString ==  0x10be5c7a8

二、Tagged Pointer

内存管理方案
  • Tagged Pointer:存储NSNumberNSDate等小对象
  • NONPOINTER_ISA:非指针型isa
  • SideTable散列表:spinlock自旋锁、refcountMap引用计数表,weak_table_t弱引用表

1、引入原因

通常我们创建对象,对象存储在堆中,对象的指针存储在栈中,如果我们要找到这个对象,就需要先在栈中,找到指针地址,然后根据指针地址找到在堆中的对象。

这个过程比较繁琐,当存储的对象只是一个很小的东西,比如一个字符串,一个数字。去走这么一个繁琐的过程,无非是耗费性能的,所以苹果就搞出了TaggedPointer这么一个东西。

假设我们要存储一个NSNumber对象,其值是一个整数。正常情况下,如果这个整数只是一个NSInteger的普通变量,那么它所占用的内存是与CPU的位数有关,在32位CPU下占4个字节,在64位CPU下是占8个字节的。而指针类型的大小通常也是与CPU位数相关,一个指针所占用的内存在32位CPU下为4个字节,在64位CPU下也是8个字节。如果没有Tagged Pointer对象,从32位机器迁移到64位机器中后,虽然逻辑没有任何变化,但这种NSNumberNSDate一类的对象所占用的内存会翻倍。

苹果提出了Tagged Pointer对象。由于NSNumberNSDate一类的变量本身的值需要占用的内存大小常常不需要8个字节,拿整数来说,4个字节所能表示的有符号整数就可以达到20多亿(注:2^31=2147483648,另外1位作为符号位),对于绝大多数情况都是可以处理的。


2、特点

TaggedPointer专门用来存储小的对象,针对NSNumberNSDate以及部分NSString的内存优化方案。

Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮囊的普通变量而已。所以,它的内存并不存储在堆中,也不需要mallocfree

Tagged Pointer指针中包含了当前对象的地址、类型、具体数值。

Tagged Pointer指针在内存读取上有着3倍的效率,创建时比普通需要mallocfree的类型快106倍,因为避免了从栈和堆读取值的过程。


3、原理

a、实验代码

Tagged Pointer通过在其最后一个 bit位设置一个特殊标记,用于将数据直接保存在指针本身中。因为Tagged Pointer并不是真正的对象,我们在使用时需要注意不要直接访问其isa变量。

为了存储和访问一个 NSNumber 对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失。

int intNumber = 1;
float floatNumber = 2.5;
long longNumber = 3;
double doubleNumber = 4.9;

NSNumber *number0 = @(intNumber);
NSNumber *number1 = @(2);
NSNumber *number2 = @(floatNumber);
NSNumber *number3 = @(longNumber);
NSNumber *number4 = @(doubleNumber);
NSNumber *numberFFFF = @(0xFFFF);

NSLog(@"number0 pointer is %p", number0);
NSLog(@"number1 pointer is %p", number1);
NSLog(@"number2 pointer is %p", number2);
NSLog(@"number3 pointer is %p", number3);
NSLog(@"number4 pointer is %p", number4);
NSLog(@"numberffff pointer is %p", numberFFFF);

// 最终输出结果
number0 pointer is 0xb000000000000012
number1 pointer is 0xb000000000000022
numberFFFF pointer is 0xb0000000000ffff2

可以看到苹果确实是将值直接存储到了指针本身里面。我们还可以猜测,数字最末尾的 2 以及最开头的 0xb 是否就是苹果对于Tagged Pointer的特殊标记呢?实际上最末尾的数字(这里是2)表示的是数值类型,比如intfloat等,而倒数第二个数字表示的是Tagged Pointer的值(Tagged Pointer不是对象而是值),所以Tagged Pointer存储的小对象的指针不再是一个简单的地址,而是包括了具体值、类型、长度。

我们尝试放一个长于8 字节的整数到NSNumber实例中,对于这样的实例,由于Tagged Pointer无法将其按上面的压缩方式来保存,那么应该就会以普通对象的方式来保存。

NSNumber *bigNumber = @(0xEFFFFFFFFFFFFFFF);
NSLog(@"bigNumber pointer is %p", bigNumber);

// 输出
bigNumber pointer is 0x10921ecc0

验证了我们的猜测,bigNumber的地址更像是一个普通的指针地址。可见,当 8 字节可以承载用于表示的数值时,系统就会以Tagged Pointer的方式生成指针,如果 8 字节承载不了时,则又用以前的方式来生成普通的指针。


b、源码解析

Tagged Pointer对象进行编码(添加特殊标识,说明为Tagged Pointer)和解码(还原,不影响使用)。通过两次异或操作返回原值。

static inline void * _Nonnull _objc_encodeTaggedPointer(uintptr_t ptr)
{
    uintptr_t value = (objc_debug_taggedpointer_obfuscator ^ ptr);
}

static inline uintptr_t _objc_decodeTaggedPointer_noPermute(const void * _Nullable ptr)
{
    return value ^ objc_debug_taggedpointer_obfuscator;
}

_objc_msgSend函数中Tagged Pointer对象会直接返回,而不会去查询imp

ENTRY _objc_msgSend
#if SUPPORT_TAGGED_POINTERS
    b.le    LNilOrTagged    
LNilOrTagged:
    b.eq    LReturnZero     // nil check
    GetTaggedClass
    b   LGetIsaDone

4、面试题

为什么第二个for会崩溃?答:taggedpointer

- (void)taggedPointerBug
{
    self.queue = dispatch_queue_create("com.xiejiapei", DISPATCH_QUEUE_CONCURRENT);
    
    for (int i = 0; i<100000; I++)
    {
        dispatch_async(self.queue, ^{
            // 文字够短属于小对象符合taggedPointer的存储标准,故未崩溃
            self.nameStr = [NSString stringWithFormat:@"xjp"];
        });
    }
}
- (void)touchTaggedPointer
{
    for (int i = 0; i<100000; i++) {
        dispatch_async(self.queue, ^{
            // 文字太长不符合taggedPointer的存储标准,采用其他方式存储,生成100000个临时变量超负荷故崩溃
            self.nameStr = [NSString stringWithFormat:@"dsggkdashjksda"];
        });
    }
}

执行了objc_release(id obj),由于大量的循环,导致了线程问题,使引用计数<=-1。但是由于第一个循环中的objtaggedpointer类型的string,会直接return obj,并不会release

这里release,那retain的时候咋办呢,引用计数是一直往上增吗?并不是,在objc_retain(id obj)中,同样判断了obj->isTaggedPointer,如果是true,就直接return obj

TaggedPointer对象避免了不断retainrelease操作(引用计数处理)。异步并发中可能导致在开辟的多个线程中一直执行retain操作,未能及时release操作,(或反之,引用计数减为0后再继续减为负数)。

void objc_release(id obj)
{
    // 如果是TaggedPointer对象则直接返回
    if (obj->isTaggedPointerOrNil()) return;
    // 否则进行release操作
    return obj->release();
}

id objc_retain(id obj)
{
    // 如果是TaggedPointer对象则直接返回
    if (obj->isTaggedPointerOrNil()) return obj;
    // 否则进行retain操作
    return obj->retain();
}

三、NONPOINTER_ISA

1、isa_t 结构

struct objc_object {
    isa_t isa;
    ...
}

在OC的类结构中出现的第一个成员变量就是联合体isa_t,其保存了很多有用的信息,以主流的arm64为例,主要包含了:

  • arm64arm64x86_64等不同的架构下,isa的存储结构是不同的
  • nonpointer:表示是否对isa指针开启指针优化。0表示纯isa指针,1表示isa中包含了类对象地址、类信息、对象的引用计数等
  • has_assoc:关联对象标识位。0:没有关联对象 1:存在关联对象
  • has_cxx_dtor:该对象是否有C++或者Objc的析构器,如何有析构函数则需要做析构处理,如果没有则可以更快地释放对象
  • shiftcls:存储类指针的值。开启指针优化的情况下,在arm64架构中有33位用来存储类指针
  • magic:用于调试器判断当前对象是真的对象还是没有初始化的空间
  • weakly_referenced:对象是否被指向或者曾经指向一个弱引用变量,没有弱引用的对象可以更快地释放
  • unused:标识对象是否正在释放内存
  • has_sidetable_rc:当对象引用计数大于10的时候则需要借助该变量存储进位
  • extra_rc:表示该对象的引用计数值,实际上是引用计数-1的值。例如,如果对象的引用计数为10,那么extra_ _rc 为9。如果引用计数大于10, 则需要使用到上面的has_ sidetable_ rc
# if __arm64__
#     define ISA_BITFIELD                                                      \
        uintptr_t nonpointer        : 1;                                       \
        uintptr_t has_assoc         : 1;                                       \
        uintptr_t has_cxx_dtor      : 1;                                       \
        uintptr_t shiftcls          : 33;                                      \
        uintptr_t magic             : 6;                                       \
        uintptr_t weakly_referenced : 1;                                       \
        uintptr_t unused            : 1;                                       \
        uintptr_t has_sidetable_rc  : 1;                                       \
        uintptr_t extra_rc          : 19

2、对象引用计数保存在哪里

引用计数的存储

extra_rchas_sidetable_rc两个标志就是用来存储对象引用计数的。引用计数的增加主要通过retain()函数来实现。在不支持nonpointer的对象中,引用计数只存储在sidetable中。可以看出在支持nonpointer的情况下:在isa的位域extra_rc足以存储的情况,引用计数会优先选择存储在isaextra_rc位域中。

isa位域extra_rc溢出,则会选择将将引用计数的RC_HALF(如果extra_rc占据8bit,则RC_HALF=2^7)保存在isa中,另一半RC_HALF叠加保存在sidetable中。之所以这样选择是因为isa_t的存取理论上会比sidetable的操作效率上快很多,这样做既可以使超出extra_rc存储范围的引用计数得到有效存储,又可以确保引用计数的增减足够快速(存取都以extra_rc优先)。

引用计数的获取

因为对象的引用计数主要存储在isa位域extra_rc和散列表中,所以要获取对象的引用计数也需要从两个位置进行获取。在开启nonpointer的对象中,对象的引用计数包括两部分:存储在isa中的引用计数;存储在sidetable中的引用计数。在未开启nonpointer的对象中,对象的引用计数全部存储在sidetable中,只需要从sidetable中获取就可以。

其实在创建对象的过程中(例如alloccopy),存储的引用计数并没有进行+1操作,而是默认对象的最小引用计数为1。在release操作时才会判断引用计数-1是否为0,如果为0则进行释放,否则进行引用计数-1


四、SideTables 散列表

1、简介

SideTables是一个64个元素长度的hash数组,里面存储了SideTableSideTableshash键值就是一个对象objaddress。因此可以说,一个obj对应了一个SideTable,但是一个SideTable,会对应多个obj。因为SideTable的数量只有64个,所以会有很多obj共用同一个SideTable

SideTables 散列表
SideTable结构
a、什么是 Spinlock_t 自旋锁?

自旋锁若已被其他线程获取,则当前线程会不断探索该锁是否被释放,如果释放则第一时间获取,适用于轻量访问的情况。

b、为什么是多张SideTable而不是一张哈希表?

一张表的引用计数或者弱引用需要顺序操作,这就需要等前一个解开锁才能继续工作,存在效率问题。所以通过分离锁打散成多张表(共8个),支持并发操作,提高访问效率。

因为改变属性的引用计数需要对属性进行加锁和解锁操作,如果只有一张哈希表那么会由于操作过于频繁而影响性能。自旋锁由于忙等的特性,如果只有一张哈希表就会由于等待耗时影响性能,而如果有多张哈希表则可以分离锁互不影响以减少耗时。

c、怎样实现快速分流,即通过指针快速定位到属于哪一张SideTable表?

SideTable本质是一个哈希表,给定值是对象内存地址,目标值是数组下标索引,哈希查找通过取余存储值和取值,内存地址均匀分布。哈希函数是一个均匀散列函数,不需要通过遍历操作,效率很高。

static StripedMap<SideTable>& SideTables() 
{
    return SideTablesMap.get();
}
class StripedMap 
{
    enum { StripeCount = 64 };

    // 重写下标操作符,用于从SideTables中获取特定SideTable
    T& operator[] (const void *p)
    { 
        return array[indexForPointer(p)].value; 
    }
}    
struct SideTable 
{
    spinlock_t slock;// 自旋锁
    RefcountMap refcnts;// 引用计数表
    weak_table_t weak_table;// 弱引用表
}
struct weak_table_t 
{
    weak_entry_t *weak_entries;
};

struct weak_entry_t 
{
    weak_referrer_t *referrers;
    inline_referrers[0] = newReferrer;
}

id weak_register_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id, WeakRegisterDeallocatingOptions deallocatingOptions)
{
    weak_entry_t new_entry(referent, referrer);
    weak_entry_insert(weak_table, &new_entry);
}

static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry)
{
    weak_entry_t *weak_entries = weak_table->weak_entries;
    weak_entries[index] = *new_entry;
    weak_table->num_entries++;
}

2、MRC

alloc
retain +1
release -1
retainCount 引用计数值
autorelease 结束时候调用release释放
dealloc
a、MRC通过引用计数管理对象生命周期
NSNumber *i = [NSNumber numberWithInteger:2];
[i retainCount];//1

NSMutableArray *array = [NSMutableArray array];
[array addObject:i];
[i retainCount];//2

NSNumber *j = i;//不获得i的所有权
[i retainCount];//2

NSNumber *j = [i retain];//获得
[i retainCount];//3

[i release];//2

[array removeObjectAtIndex:0];
[i retainCount];//1

[i release];//销毁

//释放动态分配的内存和关闭文件
- (void)dealloc{[super dealloc];}

b、alloc实现

经过一系列调用,最终调用了C函数的calloc,但此时并没有将引用计数+1


c、retainCount实现

alloc对象引用计数值为0,之所以 retainCount能获取到1,是因为添加了局部变量size_t refcnt_result令其值为1,再让其实现相加操作。

SideTable &table = sideTables()[this];
size_t refcnt_result = 1;
RefcountMap::iterator it = table.refcnts.find(this);
refcnt_resount+= it->second>>SIDE_TABLE_RC_SHIFT

d、retain实现

经过两次哈希查找,第一次从SideTables中查找到对象所在的SideTable,第二次从引用计数表中查找到该对象的引用计数值实现+1(实际是通过地址偏移量来实现的)。

SideTable& table = SideTables()[This];
size_t& refcntStorage = table.refcnts[This];
refcntStorage += SIZE_TABLE_RC_ONE

如果溢出就将启用散列表中的引用计数,并将extra_rc大小置为满载的一半。

ALWAYS_INLINE id objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant)
{
    // 是否溢出
    uintptr_t carry;
    // extra_rc中的引用计数+1
    newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);
    
    // 如果溢出就将启用散列表中的引用计数,并将extra_rc大小置为满载的一半
    if (slowpath(carry))
    {
        // 将extra_rc大小置为满载的一半,另外一半存到散列表中
        newisa.extra_rc = RC_HALF;
        // 启用散列表中的引用计数
        newisa.has_sidetable_rc = true;
    }
}

e、release实现

经过两次哈希查找,第一次从SideTables中查找到对象所在的SideTable,第二次从引用计数表中查找到该对象的引用计数值实现-1(实际是通过地址偏移量来实现的)。

SideTable& table = SideTables()[This];
size_t& refcntStorage = table.refcnts[This];
refcntStorage -= SIZE_TABLE_RC_ONE;

如果extra_rc下溢出了,就会从散列表中去减少一些引用计数。

objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant)
{
    // 是否向下溢出(多减为负)
    uintptr_t carry;
    // 将extra_rc中的引用计数-1
    newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);
    
    // 如果extra_rc下溢出了,就会从散列表中去减少一些引用计数
    auto borrow = sidetable_subExtraRC_nolock(RC_HALF);
    
    // 调用析构函数
    if (performDealloc)
    {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, @selector(dealloc));
    }
}

f、内存泄漏问题

Analyzeproduct-->Analyze)是静态分析工具 , Instrumentsproduct-->profile)是动态分析工具( LeaksAllocations)。静态分析方法能发现大部分的问题,但是仅仅使用静态内存泄漏分析得到的结果并不是非常可靠,因为有的内存泄露是在运行时,用户操作时才产生的,所以我们需要将对项目进行更为完善的内存泄漏分析和排查。那就需要用到我们下面要介绍的动态内存泄漏分析方法Instruments中的Leaks方法进行排查。

系统工具analyze:设置为YES后会在编译期开启内存泄露的检查
instrument工具里的leak
// 创建两个对象a和b
MemoryObject *a = [MemoryObject new];
MemoryObject *b = [MemoryObject new];

// 互相引用对方
a.obj = b;
b.obj = a;

点击左上角的红色圆点,这时项目开始启动了,由于leaks是动态监测,所以手动进行一系列操作,可检查项目中是否存在内存泄漏问题。如图所示,橙色矩形框中所示绿色为正常,如果出现如右侧红色矩形框中显示红色,则表示出现内存泄漏。

  • 监控内存分布情况,随着时间的变化内存使用的折线图,有红色菱形图标 O 出现 , 则有内存泄漏
  • 显示泄漏的对象,列出了它们的内存地址 、 占用字节 、 所属框架和响应方法等信息
  • RetCt是引用计数列,最后的引用计数不为零 ,这说明该对象内存没有释放
  • 点击右边的跟踪栈信息按钮,可以定位到泄露点在项目中的代码位置

调用者没有这个对象的所有权而释放它 ,都会造成过度释放,从而产生僵尸对象(Zombies分析模板),试图调用僵尸对象 ,则会崩溃(应用直接跳出),并抛出EXC_BAD_ACCESS异常,僵尸对象的引用计数变化是 : 1 (创建) --->0 (释放)--->-1(僵尸化)。


3、ARC

什么是ARC?

ARCLLVMRuntime协同作用的结果,在这种模式下,程序员不需要清楚地了解获得/放弃对象所有权的时机,ARC 会自动在适当的位置插入retainreleaseautorelease 函数,这样程序员能将更多的精力用于开发程序的核心功能。

ARC有什么好处?
  • 不需要再在意对象的所有权
  • 可以删除程序中内存管理部分的大部分代码,使程序看起来更清爽
  • 可以避免手动内存管理时的错误(内存泄漏等)
  • 可以使多线程环境下的编程更简单。例如:不用担心不同的线程之间可能出现的所有权冲突
ARC有什么特点?
  • ARC会禁止调用引用计数的相关函数retainreleaseautoreleaseretainCount
  • 可以重写dealloc方法,但是不能显示调用super.dealloc
  • ARC中新增weakstrong属性关键字

五、循环引用问题

1、产生循环引用问题的原因

当双方都在等待对方释放的时候,就形成了循环引用,结果两个对象都不会被释放,只有打破循环引用关系才能够释放内存。ARC代码中的内存泄漏多半是由于强引用循环引起的 ,从而导致一些内存无法释放,这就会导致dealloc()方法无法被调用, Leaks模板提供了查看引用循环视图,选择Cycles & Roots菜单项即可查看。通过使用弱引用既可以防止生成循环引用,实例对象被释放后自动变成nil,又可以防止对象被释放后形成野指针。


2、循环引用场景:NSTimer

a、NSTimer的创建
方法一:使用timerWithTimeInterval,但需要手动加入Runloop
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer timerWithTimeInterval:1
                                     target:weakSelf
                                   selector:@selector(fireHome)
                                   userInfo:nil
                                    repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer
                                  forMode:NSDefaultRunLoopMode];
方法二:使用scheduledTimerWithTimeInterval,会自动加入Runloop
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1
                                              target:weakSelf
                                            selector:@selector(fireHome)
                                            userInfo:nil
                                             repeats:YES];
调用的方法
- (void)fireHome 
{
    num++;
    NSLog(@"hello word - %d",num);
}

b、NSTimer循环引用分析

发现页面消失之后,定时器并没有停止输出,deinit(swift)方法也没有执行,这就是循环引用了。这个TimeraddTargetUIButtonaddTarget有什么不同呢?buttontarget是弱引用,Timertarget是强引用。我们能直接看出来self 强持有timertarget方法中timerself对象进行了强持有,因此造成了循环引用。


3、探索NSTimer循环引用的解决方案

a、weakSelf

按照惯例用weakSelf去打破强引用的时候,发现weakSelf没有打破循环引用,timer仍然在运行。变成了self -> timer -> weakSelf -> selftimer之所以无法打破循环关系是因为timer创建时target是对weakSelf的对象强持有操作,而weakSelfself虽然是不同的指针但是指向的对象是相同的,也就相当于间接的强持有了self,所以weakSelf并没有打破循环引用关系。

那么block使用weakSelf为什么可以打破循环引用呢?

void _Block_object_assign(void *destArg, const void *object, const int flags) {
    const void **dest = (const void **)destArg; 
    switch (os_assumes(flags & BLOCK_ALL_COPY_DISPOSE_FLAGS)) {
      case BLOCK_FIELD_IS_OBJECT:
        _Block_retain_object(object);
        *dest = object; // 实际上是指针赋值

.cpp文件中我们可以看到如上代码段,虽然weakself对象传入进来,但是内部实际操作的是对象的指针,也就是weakself的指针,我们知道weakselfself虽然内存地址相同,但指针地址是不一样的,也就是block中并没有直接持有self,而是通过weakSelf指针操作,所以就打破了self -> block -> weakSelf -> selfself这一层的循环引用,变成了self -> block -> weakSelf (临时变量的指针地址)来打破循环引用。


b、使用weak关键字修饰

一说到循环引用很容易就想到weak,但是这里用weak不行。这要从weak修饰的对象的释放时机说起,用了weak关键字修饰之后,系统会在一个hash表中增加一对key valuekey就是这个对象的hash值,value就是这个对象的指针地址。

我们都知道每一个runloop都有自己的一个autorelease pool,在一次runloop结束之后会销毁这个autorelease pool。在释放这个autorelease pool的时候,也会到hash表中找到weak的对象把它和它的指针都释放掉。那么问题来了,这个timer是在runloop里面重复执行的,换而言之,这个runloop是一直在执行的,所以这个池子根本不会释放。


c、使用invalidate结束timer运行

我们第一时间想到[self.timer invalidate]不就可以了吗?当然这是正确的思路,那么我们调用时机是什么呢?viewWillDisAppear还是viewDidDisAppear?实际上在我们实际操作中,如果当前页面有push操作而并没有pop即还在栈里面,这时候我们释放timer肯定是错误的,所以这时候我们可以用到下面的方法。但发现无论push进来还是pop出去计时器都能正常运行,就算继续push到下一层再pop回去计时器还是能够继续运行。为什么不在deinit(swift)方法里面释放?因为denint方法都不执行了,写了也没用。

- (void)didMoveToParentViewController:(UIViewController *)parent
{
    if (parent == nil)
    {
       [self.timer invalidate];
        self.timer = nil;
        NSLog(@"timer 走了");
    }
}

4、使用Proxy中介者解决Timer的循环引用问题

a、使用Timer时的循环引用问题
❶ 使用selector的方式
func timerCircularReference()
{
    self.timer = Timer.init(timeInterval: 1, target: self, selector: #selector(timerFire), userInfo: nil, repeats: true)
    RunLoop.current.add(self.timer!, forMode: .common)
}

@objc func timerFire()
{
    print("火箭🚀发射")
}

deinit
{
    print("\(self) 界面销毁了")
}

我们进入了详情界面后启动了计时器会一直打印火箭🚀发射,这时候点击返回按钮进入列表页面,发现计时器并没有停止,会继续输出火箭🚀发射,详情界面整个过程中也没有被销毁掉,这就是计时器的循环引用问题。

火箭🚀发射
火箭🚀发射
......

造成计时器的循环引用问题的原因:self -> timer -> selfself.timer会让self详情界面强持有timer,而target: self会让timer强持有详情界面,这就造成了第一重循环引用。还有一点也会导致timer无法释放,就是RunLoop.current -> timer,即RunLoop强持有timer,让timer一直存活。


❷ 使用block的方式
func timerCircularReference()
{
    self.timer = Timer.init(timeInterval: 1, repeats: true, block:
    { (timer) in
        print("火箭🚀发射 \(timer)")
    })
    
    RunLoop.current.add(self.timer!, forMode: .common)
}

使用block闭包的方式因为没有了target: selftimer不会再强持有详情界面,打破了第一重循环引用。但是从详情界面返回到列表界面的时候还是会持续打印火箭🚀发射,说明计时器仍然存活着,但是详情界面却顺利被销毁掉了。这是因为RunLoop还强持有timer,让timer一直存活。况且,我虽然想要解决循环引用问题,但是我不喜欢使用闭包的方式,我更倾向于selector的方式,这又该如何解决呢?

火箭🚀发射 <__NSCFTimer: 0x6000015246c0>
火箭🚀发射 <__NSCFTimer: 0x6000015246c0>
<RxSwiftSourceCodeAnalysis.ViewController: 0x7fcaa8e0bd60> 界面销毁了
<RxSwiftSourceCodeAnalysis.Proxy: 0x60000206ec80> 销毁了
火箭🚀发射 <__NSCFTimer: 0x6000015246c0>
......

b、创建Proxy中介者工具类
class Proxy: NSObject
{
    // target要实现NSObjectProtocol协议里面的isProxy方法
    weak var target: NSObjectProtocol?
    // 调用的方法
    var selector: Selector?
    // 创建计时器
    var timer: Timer? = nil
    ......
    }
}

c、工具类提供给外界使用的计时器方法
func scheduledTimer(timeInterval interval: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool)
{
    // 在Proxy里创建计时器,计时器的target为aTarget(vc)
    self.timer = Timer.init(timeInterval: interval, target: aTarget, selector: aSelector, userInfo: userInfo, repeats: yesOrNo)
    RunLoop.current.add(self.timer!, forMode: .common)
    
    // 将外界传入的aTarget赋给Proxy中的target
    self.target = aTarget as? NSObjectProtocol
    self.selector = aSelector

    // self.target虽然是Proxy中的target,但其实是外界传入的aTarget
    // 所以如果外界的Target对象能够响应selector方法则可以调用,否则直接返回
    guard self.target?.responds(to: self.selector) == true else
    {
        return
    }
}

d、工具类的使用方式

现在的引用关系变成了:vc -> proxy -weak-> target -> vc,通过weak var target打破了对target对象的强引用。

func proxySolveCircularReference()
{
    self.proxy.scheduledTimer(timeInterval: 1, target: self, selector: #selector(timerFire), userInfo: nil, repeats: true)
}

但是运行时发现从详情界面返回到列表界面的时候还是会持续打印火箭🚀发射,说明计时器仍然存活着,同样详情界面也没有被销毁掉。什么鬼?为什么还会有这样的问题?

火箭🚀发射
火箭🚀发射
火箭🚀发射
...

仔细分析引用关系发现仍然存在着循环引用的怪圈,就像马尔克斯的《百年孤独》里面历史的循环怪圈一样难以打破。虽然proxytarget对象是弱引用了,但是在timer中仍然对target对象产生了强引用,这就是说除了上面的vc -> proxy -> target -weak-> vc这条引用关系路线外,还存在着另外一条引用关系路线:vc -> proxy -> timer -> target -> vc,这条路线导致了循环引用问题仍然存在。

self.timer = Timer.init(timeInterval: interval, target: aTarget......

e、将timer的target对象变为proxy

计时器本来应该调用外界的selector(timerFire),但是经过runtime的方法实现交换之后调用了Proxy里面的proxyTimerFire方法。

func scheduledTimer(timeInterval interval: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool)
{
    // 在Proxy里创建计时器,计时器的target为self(Proxy)
    self.timer = Timer.init(timeInterval: interval, target: self, selector: aSelector, userInfo: userInfo, repeats: yesOrNo)
    RunLoop.current.add(self.timer!, forMode: .common)
    
    // 将外界传入的aTarget赋给Proxy中的target
    self.target = aTarget as? NSObjectProtocol
    self.selector = aSelector

    // self.target虽然是Proxy中的target,但其实是外界传入的aTarget
    // 所以如果外界的Target对象能够响应selector方法则可以调用,否则直接返回
    guard self.target?.responds(to: self.selector) == true else
    {
        return
    }
    
    // 将外部的selector和Proxy里面的#selector实现进行交换
    let method = class_getInstanceMethod(self.classForCoder, #selector(proxyTimerFire))
    class_replaceMethod(self.classForCoder, self.selector!, method_getImplementation(method!), method_getTypeEncoding(method!))
}

当外界传入的aTarget为空的时候将计时器销毁掉,存在的时候就调用外界的selector(timerFire)

@objc fileprivate func proxyTimerFire()
{
    if self.target != nil
    {
        self.target!.perform(self.selector)
    }
    else
    {
        self.timer?.invalidate()
        self.timer = nil
    }
}

Proxy销毁的时候调用。

deinit
{
    print("\(self) 销毁了")
}

输出结果为:

火箭🚀发射
火箭🚀发射
火箭🚀发射
<RxSwiftSourceCodeAnalysis.ViewController: 0x7fca5cc0c210> 界面销毁了
<RxSwiftSourceCodeAnalysis.Proxy: 0x6000025837c0> 销毁了

计时器和详情界面都成功被销毁掉了。


f、针对NSSelectorFromString找不到Selector导致奔溃问题的特殊处理

找到selector就正常响应,否则给出错误提示并可以进行处理(比如消息二次转发或者添加方法)。

func proxySolveCircularReference()
{
    let selector = NSSelectorFromString("timerFire")
    self.proxy.scheduledTimer(timeInterval: 1, target: self, selector: selector, userInfo: nil, repeats: true)
}
override func forwardingTarget(for aSelector: Selector!) -> Any?
{
    if self.target?.responds(to: self.selector) == true
    {
        return self.target
    }
    else
    {
        print("老大,老大,你不要为难我呀,我不想浪费时间给你填坑呀!")
        return super.forwardingTarget(for: aSelector)
    }
}

找到selector正常响应时候的输出结果为:

火箭🚀发射
火箭🚀发射
火箭🚀发射
<RxSwiftSourceCodeAnalysis.ViewController: 0x7fca5cc0c210> 界面销毁了
<RxSwiftSourceCodeAnalysis.Proxy: 0x6000025837c0> 销毁了

找不到selector给出崩溃提示的时候输出结果为:

老大,老大,你不要为难我呀,我不想浪费时间给你填坑呀!
2021-01-20 11:37:18.157544+0800 RxSwiftSourceCodeAnalysis[22879:1193311] -[RxSwiftSourceCodeAnalysis.Proxy timerFir]: unrecognized selector sent to instance 0x600003a96620

Demo

Demo在我的Github上,欢迎下载。
BasicsDemo

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
禁止转载,如需转载请通过简信或评论联系作者。

推荐阅读更多精彩内容