OC底层知识点之 - Block底层原理

2,184 阅读14分钟

系列文章:OC底层原理系列OC基础知识系列

本文将介绍block的类型,循环引用的解决方法以及block底层分析

Block简介

Block定义:带有自动变量的匿名函数,它是C语言的拓展功能,之所以是扩展,是因为C语言不允许存在这样的匿名函数

  • 匿名函数
    • 匿名函数式指不带函数名称的函数
  • 带有自定变量
    • Block拥有捕获外部变量的功能,在Block中访问一个外部的局部变量,Block会持有它的临时状态,自动捕获变量值,外部局部变量的变化不会影响它的状态(这个下面会讲到)。

Block类型

block主要有三种类型

  • 1.__NSGlobalBlock__:全局block,存储在全局区
    • 在Block内部不使用外部变量,或者只使用静态变量和全局变量

此时block无参也无返回值,属于全局block

  • 2.__NSMallocBlock__:堆区block,因为block既是函数,也是对象
    • 在Block内部使用局部变量或者OC属性,并且赋值给强引用或者Copy修饰的变量

此时block会访问外界变量,即底层拷贝a,所以是堆区block

  • 3.__NSStackBlock__:栈区block
    • 与MallocBlock一样,可以在内部使用局部变量或者OC属性,但是不能赋值给强引用或者Copy修饰的变量

其中局部变量a在没有处理之前(即没有拷贝之前)是 栈区block, 处理后(即拷贝之后)是堆区block ,所以栈区block越来越少了 这个情况下,可以通过__weak不进行强持有,block就还是栈区block

总结

  • 1.block是直接存储在全局区
  • 2.block如果访问外界变量,并进行block相应copy
    • 如果此时的block是强引用,则block存储在堆区,即堆区block(堆区block在出了作用域,会执行free操作进行销毁
    • 如果此时的block通过——weak变成了弱引用,则block存储在栈区,即栈区block(出了作用域引用计数会-1,只有引用计数为0是才会销毁

Block循环引用

【正常释放】:当A持有B,当A调用dealloc方法,给B发送release信号,B收到release信号,如果此时B的引用计数为0时,则会调用B的dealloc方法,此时A,B都能正常释放 【循环引用】:当A持有B,B同时也持有A时,此时A销毁需要B先销毁,而B销毁同样需要A先销毁,就导致相互等待销毁,此时A,B的引用计数都不为0,所以A,B此时都无法释放

解决循环引用

举个循环引用的例子:如下图

上面代码发生了循环引用,因为在block内部使用了self的name变量,导致block持有self,而self本来就持有block,就导致了self和block相互持有

下面来解决循环引用

  • 1.weak-strong-dance(最常用的方法)
  • 2.__block修饰对象,同时置nil
  • 3.传递对象self作为block的参数,提供给block内部使用
  • 4.使用NSProxy

weak-strong-dance(弱强共舞)

  • 1.如果block内部并未嵌套block,直接使用__weak修饰self即可
@interface ViewController ()
@property (nonatomic, copy) void (^block)(void);
@property (nonatomic, copy) NSString *name;
@end

- (void)viewDidLoad {
    [super viewDidLoad];  
    self.name = @"man";
    __weak typeof(self) weakSelf = self;
    self.block = ^(void){
        NSLog(@"%@",weakSelf.name);
    };
    self.block();
}

由于此时的weakSelf和self指向同一片内存空间,而且使用__weak不会导致self的引用计数发生变化,可以通过打印weakSelf和self的指针地址,以及self的引用计数来验证

  • 2.如果block内部嵌套block,则需要同时使用__weak和__strong 如果只用weak修饰,则可能出现block内部持有的对象被提前释放,为了防止block内部变量被提前释放,使用strong对引用计数+1,防止提前释放 其中strongSelf是一个临时变量,在block的作用域内,当block执行完就会释放strongSelf,这种方式属于打破self对block的强引用,依赖于中间者模式,属于自动置为nil,也就是自动释放

__block修饰变量

这种方式同样依赖于中介者模式,属于手动释放,是通过__block修饰对象,主要是因为__block修饰的对象是可以改变的

这里的block必须调用,如果不调用blockvc就不会置空,那么依旧是循环引用,self和block都不会释放

对象self作用参数

主要是将对象self作用参数提供给block内部使用不会有引用计数问题

使用NSProxy虚拟类

  • OC是只能单继承的语言,但它是基于运行时的机制,所以可以通过NSProxy来实现伪多继承,填补多继承的空白
  • NSProxyNSObject是同级的类,是个虚拟类,只是实现了NSObject的协议
  • NSProxy其实是一个消息重定向封装的一个抽象类,类似一个代理人,中间件,可以通过继承它,并重新写下面的两个方法来实现消息转发到另一个实例

使用场景

  • 1.实现多继承功能
  • 2.解决NSTimer&CADisplayLink创建时对self强引用问题,这个在YYKit中YYWeakProxy有所使用的

循环引用解决原理

主要是通过自定义的NSProxy类的对象来代替self,并使用方法实现消息转发,下面是NSProxy子类的实现以及使用的场景

@interface LjProxy ()

@property(nonatomic, weak, readonly) NSObject *objc;

@end

@implementation LjProxy

- (id)transformObjc:(NSObject *)objc{
   _objc = objc;
    return self;
}

+ (instancetype)proxyWithObjc:(id)objc{
    return  [[self alloc] transformObjc:objc];
}

// 有了方法签名之后就会调用方法实现
- (void)forwardInvocation:(NSInvocation *)invocation{
    SEL sel = [invocation selector];
    if ([self.objc respondsToSelector:sel]) {
        [invocation invokeWithTarget:self.objc];
    }
}

// 查询该方法的方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel{
    NSMethodSignature *signature;
    if (self.objc) {
        signature = [self.objc methodSignatureForSelector:sel];
    }else{
        signature = [super methodSignatureForSelector:sel];
    }
    return signature;
}

- (BOOL)respondsToSelector:(SEL)aSelector{
    return [self.objc respondsToSelector:aSelector];
}

@end

自定义Man和Teacher类

@implementation Man

- (void)likeFood {
    NSLog(@"%@-->牛肉", self);
}

@end

@implementation Teacher

- (void)likeWork {
    NSLog(@"%@->教书育人", self);
}

@end

通过LjProxy实现多继承功能 通过LjProxy解决定时器中self的强引用问题 运行打印:

总结

循环引用解决的根本方式:

  • 1.打破self对block的强引用,这需要对block进行声明的时候使用weak修饰,但是这会导致block提前释放,所以这种方式不可行
  • 2.打破block对self的强引用,主要就是self的作用域block作用域数据交换问题,我们可以通过代理通知传值等几种方式,用于解决循环,我们对上面讲的列一下
    • weak-strong-dance(弱强共舞)
    • __block修饰变量
    • 对象self作用参数使用
    • 使用NSProxy子类代替self 上面介绍了block的定义用法以及如何解决循环引用,下面我们来探寻下block的C++实现

Block C++实现

研究底层可以先从C++,断点调试开始

本质

创建block.c文件 通过xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc block.c -o block.cpp,将block.c编译成block.cpp,其中block在底层被编译成了以下的形式 相当于block等于__main_block_impl_0,是一个函数。下面查看__main_block_impl_0

通过上图我们可以知道__main_block_impl_0是一个结构体,同时可以说明block是一个__main_block_impl_0类型的对象,这也是为什么block能够%@打印的原因

我们用一张图来说明他们之间的联系 下面我们来解释几个问题

block为什么需要调用

底层block的类型__main_block_impl_0结构体,通过其同名构造函数创建,第一个传入的block的内部实现代码块,即__main_block_func_0,用fp表示,然后赋值给impl的FuncPtr属性,然后在main中进行了调用,这也是block为什么需要调用的原因。如果不调用block内部实现的代码块将无法执行,可以总结为以下两点

  • 1.函数声明:即block内部实现声明成了一个函数__main_block_func_0
  • 2.执行具体的函数实现:通过调用block的FuncPtr指针,调用block执行

block是如何获取外界变量的

我们将上面的代码当中调用block 再将它编译成.cpp文件

__main_block_func_0中的a是值拷贝,如果此时在block内部实现中作 a++操作是有问题的,会造成编译器的代码歧义,即此时的a是只读的

【总结】:block捕获外界变量时,在内部会自动生成同一个属性来保存

__block原理

将上面代码的局部变量a使用__block修饰 再将它编译成.cpp文件 通过上面的截图我们可以得出以下结论:

  • 1.main中的a是以__Block_byref_a_0结构体的形式出现的,是封装的对象
  • 2.在结构体__Block_byref_a_0中,a的值存在int a中
  • 3.在__main_block_impl_0中,将对象a的地址&a给构造函数
  • 4.在__main_block_func_0内部对a的处理时指针拷贝,此时创建的对象a传入对象的a指向的是同一片内存空间

总结

  • 1.外界变量通过__block生成__Block_byref_a_0结构体
  • 2.结构体用来保存原始变量的指针和值
  • 3.将变量生成的结构体对象的指针地址传递给block,然后在block内部就可以对外界变量进行操作了 上面__block和非__block修饰局部变量产生两种不同的拷贝
  • 非__block修饰:值拷贝 - 浅拷贝,只是拷贝数值,且拷贝的值不可更改指向不同内存空间,非__block修饰的变量a就是值拷贝
  • __block修饰:指针拷贝 - 深拷贝生成的对象指向同一片内存空间,通过__block修饰的变量a就是指针拷贝

Block底层原理

确定block源码位置

在main函数中写如下代码 通过在block处打断点,运行block 我们发现走到了objc_retainBlock,我们加符号断点objc_retainBlock 打印符号断点后,我们发现执行了_Block_copy,我们再加符号断点_Block_copy 此时我们需要看_Block_copy实现,它在libsystem_blocks.dylib源码中,我们去苹果官方下载下源码libclosure-74,在源码中搜索_Block_copy 通过查看_Block_copy的源码实现,发现block在底层的真正类型Block_layout

Block真正类型Block_layout

我们查看下Block_layout底层实现 说明:

  • 1.isa:指向的是block类型的类
  • 2.flags:标识符,按bit位表示一些block附加信息,类似于isa中的位域,其中flags种类有以下几种,主要重点关注BLOCK_HAS_COPY_DISPOSEBLOCK_HAS_SIGNATUREBLOCK_HAS_COPY_DISPOSE 决定是否有Block_descriptor_2BLOCK_HAS_SIGNATURE 决定是否有Block_descriptor_3
    • 第一位:BLOCK_DEALLOCATING,释放标记,一般常用 BLOCK_NEEDS_FREE位与操作,一同传入Flags,告知该block可释放
    • 第十六位:BLOCK_REFCOUNT_MASK,存储引用计数的值;是一个可选用参数
    • 第二十四位:BLOCK_NEEDS_FREE,第16是否有有效的标志,程序根据它来决定是否增加或是减少引用计数位
    • 第二十五位:BLOCK_HAS_COPY_DISPOSE,是否拥有拷贝辅助函数(a copy helper function)
    • 第二十六位:BLOCK_HAS_CTOR,是否拥有block析构函数
    • 第二十七位:BLOCK_IS_GC,标志是否有垃圾回收 //OS X
    • 第二十八位:BLOCK_IS_GLOBAL,标志是否是全局block
    • 第三十位位:BLOCK_HAS_SIGNATURE,与BLOCK_USE_STRET相对,判断当前block是否拥有一个签名。用于runtime时动态调用
  • 3.reserved:保留信息,可以理解预留位置,猜测是用于存储block内部变量信息
  • 4.invoke:是一个函数指针,指向block的执行代码
  • 5.descriptor:block的附加信息,比如保留变量数block的大小进行copy或dispose的辅助函数指针。有三类
    • Block_descriptor_1必选
    • Block_descriptor_2Block_descriptor_3可选 我们再看下他们底层实现

从上图可以知道:Block_descriptor_2Block_descriptor_3都是通过Block_descriptor_1的地址,经过内存平移得到的

Block内存变化

根据符号断点

我们打断点运行,走到objc_retainBlock,我们打印寄存器x0

我们发现此时的block全局block,即__NSGlobalBlock__类型

我们增加外部变量a,再次运行,在相同的位置再次打印x0

此时读取block发现是栈block__NSStackBlock__ 执行到符号断点objc_retainBlock时,我们发现还是栈区block

我们在增加符号断点_Block_copy,继续往下走,来到_Block_copy断点,此时打印

此时的x0地址不变,说明此时的block还是栈区block,我们在_Block_copy尾部ret处打断点,执行到断点处,再次打印 发现经过_Block_copy之后x0地址发生了变化,我们打印x0地址后发现block栈区block变为堆区block,即__NSMallocBlock__

同样上面的结论我们也可以通过读寄存器地址来得出

根据寄存器地址

我们重新运行项目,继续前面的断点,运行前面的断点,打印x0,x8,x9

此时我们看到x0x8指向的是同一块内存空间,用于存储__NSStackBlock__,此时的x9存储的是_block_invoke 我们将代码运行到41行,在次打印上面的地址 此时的x8_block_invokeblr就是跳转进入的意思,也就是要进入_block_invoke 当我们进入_block_invoke中,可以得出是通过内存平移得到block内部实现

前面提到的Block_layout结构体源码中知道其有个属性invoke,即block的执行者,是从isa首地址平移16字节得到invoke,然后进行调用执行的。

Block签名

最开始我们拿到了block的地址,前面底层我们知道block底层Block_layout的结构体 通过上图我们知道descriptor是附加信息,我们打印下它的内容

找到block地址,通过内存平移找到descriptor,然后x/8gx查看descriptor内存情况,我们前面说了descriptor会有_Block_descriptor_2或者_Block_descriptor_3,只有_Block_descriptor_3存在签名判断是否存在_Block_descriptor_2,即flags的BLOCK_HAS_COPY_DISPOSE(拷贝辅助函数)是否有值

  • 1.先通过p/x 1<<25,即1左移25位得到BLOCK_HAS_COPY_DISPOSE
  • 2.再拿flags与上BLOCK_HAS_COPY_DISPOSE(flags是block首地址平移8字节,即:0x00000000c1000002 看到打印结果为0,表示没有Block_descriptor_2

判断是否存在Block_descriptor_3,即flags的BLOCK_HAS_SIGNATURE(是否有签名)是否有值

  • 1.先通过p/x 1<<30,即1左移30位得到BLOCK_HAS_SIGNATURE
  • 2.再拿flags与上BLOCK_HAS_SIGNATURE(flags是block首地址平移8字节,还是:0x00000000c1000002 看到打印的结果不为0,说明有值说明是Block_descriptor_3存在签名,看descriptor,其中第三个0x0000000104d63e87表示签名。我们将签名打印出来了 下面我们通过[NSMethodSignature signatureWithObjCTypes:"v8@?0"]看下签名具体内容 下面我们具体来看下签名:
return value: -------- -------- -------- --------
        type encoding (v) 'v'
        flags {}
        modifiers {}
        frame {offset = 0, offset adjust = 0, size = 0, size adjust = 0}
        memory {offset = 0, size = 0}
    argument 0: -------- -------- -------- --------
        type encoding (@) '@?' // 类型是否是@
        flags {isObject, isBlock} // @是isObject ,?是isBlock,代表 isBlockObject
        modifiers {}
        frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}
        memory {offset = 0, size = 8} // 所在偏移位置是8字节

block的签名信息类似于方法的签名信息,主要体现block返回值参数以及类型等信息上。

Block的三次copy分析

Block_copy源码分析

  • 进入_Block_copy源码,将block栈区拷贝至堆区
    • 如果需要释放,如果需要则直接释放
    • 如果是globalBlock,则不需要copy直接返回
    • 反之,只有两种情况:栈区blockor堆区block,由于堆区block需要申请空间,前面并没有申请空间的相关代码,所以只能是栈区block
      • 通过malloc申请内存空间用于接收block
      • 通过memmoveblock拷贝至新申请的内存中
      • 设置block对象的类型为堆区block,即result->isa = _NSConcreteMallocBlock

_Block_object_assign分析

要分析block的三层copy,首先需要知道外部变量的种类有哪些,在__block的cpp文件中,对block修饰__main_block_desc_0_DATA,而__main_block_desc_0_DATA用的__main_block_copy_0,最后对a的修饰_Block_object_assign。对block修饰其中用的最多的是BLOCK_FIELD_IS_OBJECTBLOCK_FIELD_IS_BYREF 而_Block_object_assign是在底层编译代码中,外部变量拷贝是调用的方法就是它。看下_Block_object_assign的源码

  • 1.如果是普通对象,则交给ARC处理,并拷贝对象指针,即引用计数+1,所以外界变量不能释放
  • 2.如果是block类型的,则通过_Block_copy操作,将block从栈区拷贝到堆区
  • 3.如果是__block修饰的变量,调用_Block_byref_copy函数,进行内存拷贝以及常规处理 我们看下_Block_byref_copy源码实现
  • 1.将传入对象,强转Block_byref结构体类型对象,保存一份
  • 2.没有将外界变量拷贝到堆,需要申请内存进行拷贝
  • 3.如果已经拷贝过了,则进行处理并返回
  • 4.其中copysrcforwarding指针都指向同一片内存,这也是为什么__block修饰的对象具有修改能力的原因

代码验证

写如下代码: 进行clang编译结果如下

  • 1.编译后lj_name比普通变量多了__Block_byref_id_object_copy_131和__Block_byref_id_object_dispose_131
  • 2.__Block_byref_lj_name_0结构体中多了__Block_byref_id_object_copy和__Block_byref_id_object_dispose

通过上面的分析,我们可以知道这些方法的执行顺序_Block_copy->_Block_byref_copy->_Block_object_assign,正好对应上述的三层copy 综上所述,那么block是如何拿到lj_name的呢?

  • 1.通过__block_copy方法,将block拷贝至堆区
  • 2.通过_Block_object_assign方法正常拷贝,因为__block修饰的外界变量在底层是Block_byref结构体
  • 3.发现外部变量还存在一个对象,从bref中取出相应的对象lj_name,拷贝至block控件,才能使用(相同空间才能使用,不同则不能使用)。最后通过内存平移得到lj_name,此时的lj_name和外界lj_name是同一片内存空间(从_Block_object_assign方法中的*dest = object;看出)

三层copy总结

通过上面我们看出,block的三层拷贝指的是以下三层:

  • 【第一层】通过_Block_copy实现对象的自身拷贝,从栈区拷贝至堆区
  • 【第二层】通过_Block_byref_copy方法,将对象拷贝Block_byref结构体类型
  • 【第三层】调用_Block_object_assign方法,对__block修饰当前变量的拷贝 【注意】只有__block修饰的对象才三层copy

拓展

_Block_object_dispose 分析

__Block_byref_id_object_dispose_131实现中调用的就是_Block_object_dispose,下面我们看下_Block_object_dispose的底层实现:

通过源码我们可以知道_Block_object_dispose是进行release操作,通过不同分区的block,进行不同的释放操作。而_Block_object_assign是进行retain操作的,

下面看看_Block_byref_release实现 下面我们画图来更容易的了解Block的三层copy的流程

写到最后

写的内容比较多,由于本人能力有限,有些地方可能解释的有问题,请各位能够指出,同时对Block有关的疑问,欢迎大家留言。希望大家能够相互交流、探索,一起进步!