美文网首页iOS备忘录杂乱
block底层实现原理

block底层实现原理

作者: sun_glory | 来源:发表于2020-07-20 17:02 被阅读0次

    block的本质

    block在开发中的使用频率非常高.

    block本质上是一个OC对象,它内部也有isa指针,这个对象封装了函数调用地址以及函数调用环境(函数参数、返回值、捕获的外部变量等)。当我们定义一个block,在编译后它的底层存储结构是怎样的呢,以下面这个简单的block为例

    int age = 20;
    void (^block)(void) = ^ {
         NSLog(@"age == %d",age);
    };
    

    如果想探究它的底层实现的话,可以在命令行运行xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m将这个main.m文件转成编译后的c/c++文件,然后在这个文件搜索__main_block_impl_0就可以找到这个block的结构体。整体结构如下图:

    16f8481974bfbfae.png
    • impl->isa:就是isa指针,可见它就是一个OC对象。
    • impl->FuncPtr:是一个函数指针,也就是底层将block中要执行的代码封装成了一个函数,然后用这个指针指向那个函数。
    • Desc->Block_size:block占用的内存大小。
    • age:捕获的外部变量age,可见block会捕获外部变量并将其存储在block的底层结构体中。

    当我们调用block()时,实际上就是通过函数指针FuncPtr找到封装的函数并将block的地址作为参数传给这个函数进行执行,把block传给函数是因为函数执行中需要用到的某些数据是存在block的结构体中的(比如捕获的外部变量)。如果定义的是带参数的block,调用block时是将block地址和block的参数一起传给封装好的函数。

    block的变量捕获机制

    block外部的变量是可以被block捕获的,这样就可以在block内部使用外部的变量了。不同类型的变量的捕获机制是不一样的。下面我们来看一个示例:

    int c = 1000; // 全局变量
    static int d = 10000; // 静态全局变量
    
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
    
            int a = 10; // 局部变量
            static int b = 100; // 静态局部变量
            void (^block)(void) = ^{
                 NSLog(@"a = %d",a);
                 NSLog(@"b = %d",b);
                 NSLog(@"c = %d",c);
                 NSLog(@"d = %d",d);
             };
             a = 20;
             b = 200;
             c = 2000;
             d = 20000;
             block();
        }
        return 0;
    }
    
    //*打印结果*
    a = 10
    b = 200
    c = 2000
    d = 20000
    
    

    c文件中的结构体如下:

    struct __main_block_impl_0 {
      struct __block_impl impl;
      struct __main_block_desc_0* Desc;
      int a;
      int *b;
    };
    

    只有2个局部变量被捕获了,而且2个局部变量的捕获方式还不一样。

    • 2.1 全局变量的捕获
      不管是普通全局变量还是静态全局变量,block都不会捕获。因为全局变量在哪里都可以访问,所以block内部不捕获也是可以直接访问全局变量的,所以外部更改全局变量的值时,block内部打印的就是最新更改的值。
    • 2.2 静态局部变量的捕获
      我们发现定义的静态局部变量b被block捕获后,在block结构体里面是以int *b;的形式来存储的,也就是说block其实是捕获的变量b的地址,block内部是通过b的地址去获取或修改b的值,所以block外部更改b的值会影响block里面获取的b的值,block里面更改b的值也会影响block外面b的值。所以上面会打印b = 200。
    • 2.3 普通局部变量的捕获
      普通局部变量就是在一个函数或代码块中定义的类似int a = 10;的变量,它其实是省略了auto关键字,等价于auto int a = 10,所以也叫auto变量。和静态局部变量不同的是,普通局部变量被block捕获后再block底层结构体中是以int a;的形式存储,也就是说block捕获的其实是a的值(也就是10),并且在block内部重新定义了一个变量来存储这个值,这个时候block外部和里面的a其实是2个不同的变量,所以外面更改a的值不会影响block里面的a。所以打印的结果是a = 10。

    为什么普通局部变量要捕获值,跟静态局部变量一样捕获地址不行吗?
    是的,不行。因为普通局部变量a在出了大括号后就会被释放掉了,这个时候如果我们在大括号外面调用这个block,block内部通过a的指针去访问a的值就会抛出异常,因为a已经被释放了。而静态局部变量的生命周期是和整个程序的生命周期是一样的,也就是说在整个程序运行过程中都不会释放b,所以不会出现这种情况。
    那有人又有疑问了,既然静态局部变量一直都不会被释放,那block为什么还要捕获它,直接拿来用不就可以了吗?这是因为静态局部变量作用域只限制在这个大括号类,出了这个大括号,虽然它还存在,但是外面无法访问它。而前面已经介绍过,block里面的代码在底层是被封装成了一个函数,那这个函数肯定是在b所在的大括号外面,所以这个函数是无法直接访问到b的,所以block必须将其捕获。

    block捕获变量小结

    • 全局变量--不会捕获,是直接访问。
    • 静态局部变量--是捕获变量地址。
    • 普通局部变量--是捕获变量的值。

    block的3种类型

    • NSGlobalBlock ( _NSConcreteGlobalBlock )
    • NSStackBlock ( _NSConcreteStackBlock )
    • NSMallocBlock ( _NSConcreteMallocBlock )

    通过一个简单的代码查看一下block在什么情况下其类型会各不相同

    int main(int argc, const char * argv[]) {
        @autoreleasepool {
            // 1. 内部没有调用外部变量的block
            void (^block1)(void) = ^{  __NSGlobalBlock__
                NSLog(@"Hello");
            };
            // 2. 内部调用外部变量的block
            int a = 10;
            void (^block2)(void) = ^{  __NSStackBlock__
                NSLog(@"Hello - %d",a);
            };
           // 3. 直接调用的block的class 
            NSLog(@"%@ %@ %@", [block1 class], [block2 class], [^{  __NSMallocBlock__
                NSLog(@"%d",a);
            } class]);
        }
        return 0;
    }
    
    block是如何定义其类型

    在MRC的环境下,可以得到下图:


    1637de34b6966052.png

    没有访问auto变量的block是NSGlobalBlock类型的,存放在数据段中。
    访问了auto变量的block是NSStackBlock类型的,存放在栈中。
    NSStackBlock类型的block调用copy成为NSMallocBlock类型并被复制存放在堆中。

    block在内存中的存储

    通过下面一张图看一下不同block的存放区域

    1637de34c0579805.png
    上图中可以发现,根据block的类型不同,block存放在不同的区域中。
    数据段中的NSGlobalBlock直到程序结束才会被回收,不过我们很少使用到NSGlobalBlock类型的block,因为这样使用block并没有什么意义。
    NSStackBlock类型的block存放在栈中,我们知道栈中的内存由系统自动分配和释放,作用域执行完毕之后就会被立即释放,而在相同的作用域中定义block并且调用block似乎也多此一举。
    NSMallocBlock是在平时编码过程中最常使用到的。存放在堆中需要我们自己进行内存管理。
    各类型block调用copy
    1637de351bcee494.png

    所以在平时开发过程中MRC环境下经常需要使用copy来保存block,将栈上的block拷贝到堆中,即使栈上的block被销毁,堆上的block也不会被销毁,需要我们自己调用release操作来销毁。而在ARC环境下系统会自动调用copy操作,使block不会被销毁。

    ARC环境下的block

    ARC环境下,编译器会根据情况自动将栈上的block进行一次copy操作,将block复制到堆上。

    什么情况下ARC会自动将block进行一次copy操作? 以下代码都在ARC环境下执行。

    1. block作为函数返回值时
    typedef void (^Block)(void);
    Block myblock()
    {
        int a = 10;
        // 上文提到过,block中访问了auto变量,此时block类型应为__NSStackBlock__
        Block block = ^{
            NSLog(@"---------%d", a);
        };
        return block;
    }
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
            Block block = myblock();
            block();
           // 打印block类型为 __NSMallocBlock__
            NSLog(@"%@",[block class]);
        }
        return 0;
    }
    
    1. 将block赋值给__strong指针时
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
            // block内没有访问auto变量
            Block block = ^{
                NSLog(@"block---------");
            };
            NSLog(@"%@",[block class]);
            int a = 10;
            // block内访问了auto变量,但没有赋值给__strong指针
            NSLog(@"%@",[^{
                NSLog(@"block1---------%d", a);
            } class]);
            // block赋值给__strong指针
            Block block2 = ^{
              NSLog(@"block2---------%d", a);
            };
            NSLog(@"%@",[block1 class]);
        }
        return 0;
    }
    
    1. block作为Cocoa API中方法名含有usingBlock的方法参数时
    NSArray *array = @[];
    [array enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                
    }];
    
    
    1. block作为GCD API的方法参数时
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
                
    });        
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                
    });
    

    block对对象型的局部变量的捕获

    block对对象类型和对基本数据类型变量的捕获是不一样的,对象类型的变量涉及到强引用和弱引用的问题,强引用和弱引用在block底层是怎么处理的呢?
    如果block是在栈上,不管捕获的对象时强指针还是弱指针,block内部都不会对这个对象产生强引用。所以我们主要来看下block在堆上的情况。
    首先来看下强引用的对象被block捕获后在底层结构体中是如何存储的。

    // OC代码
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
    
            Person *person = [[Person alloc] init];
            person.age = 20;
    
            void (^block)(void) = ^{
                NSLog(@"age--- %ld",person.age);
             };
            block();
    
        }
        return 0;
    }
    
    // 底层结构体
    struct __main_block_impl_0 {
      struct __block_impl impl;
      struct __main_block_desc_0* Desc;
      Person *__strong person;
    };
    

    可以看到和基本数据类型不同的是,person对象被block捕获后,在结构体中多了一个修饰关键字__strong

    我们再来看下弱引用对象被捕获后是什么样的:

    // OC代码
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
    
            Person *person = [[Person alloc] init];
            person.age = 20;
    
            __weak Person *weakPerson = person;
            void (^block)(void) = ^{
                NSLog(@"age--- %ld",weakPerson.age);
             };
            block();
    
        }
        return 0;
    }
    
    // 底层block
    struct __main_block_impl_0 {
      struct __block_impl impl;
      struct __main_block_desc_0* Desc;
      Person *__weak weakPerson;
    };
    

    可见此时block中weakPerson的关键字变成了__weak
    在block中修饰被捕获的对象类型变量的关键字除了__strong__weak外还有一个__unsafe_unretained。那这结果关键字起什么作用呢?
    当block被拷贝到堆上时是调用的copy函数,copy函数内部会调用_Block_object_assign函数,_Block_object_assign函数就会根据这3个关键字来进行操作。

    • 如果关键字是__strong,那block内部就会对这个对象进行一次retain操作,引用计数+1,也就是block会强引用这个对象。也正是这个原因,导致在使用block时很容易造成循环引用。
    • 如果关键字是__weak__unsafe_unretained,那block对这个对象是弱引用,不会造成循环引用。所以我们通常在block外面定义一个__weak__unsafe_unretained修饰的弱指针指向对象,然后在block内部使用这个弱指针来解决循环引用的问题。

    block从堆上移除时,则会调用block内部的dispose函数,dispose函数内部调用_Block_object_dispose函数会自动释放强引用的变量。

    __block修饰符的作用

    下面这段代码:

    - (void)test{
        int age = 10;
        void (^block)(void) = ^{
            age = 20;
        };
    }
    

    编译器会直接报错。
    因为age是一个局部变量,它的作用域和生命周期就仅限在是test方法里面,而前面也介绍过了,block底层会将大括号中的代码封装成一个函数,也就相当于现在是要在另外一个函数中访问test方法中的局部变量,这样肯定是不行的,所以会报错。
    如果我想在block里面更改age的值要怎么做呢?我们可以将age定义成静态局部变量static int age = 10;。虽然静态局部变量的作用域也是在test方法里面,但是它的生命周期是和程序一样的,而且block捕获静态局部变量实际是捕获的age的地址,所以block里面也是通过age的地址去更改age的值,所以是没有问题的。
    但我们并不推荐这样做,因为静态局部变量在程序运行过程中是不会被释放的,所以还是要尽量少用。那还有什么别的方法来实现这个需求呢?这就是我们要讲的__block关键字。

    - (void)test1{
        __block int age = 10;
        void (^block)(void) = ^{
            age = 20;
        };
        block();
        NSLog(@"%d",age);
    }
    

    当我们用__block关键字修饰后,底层到底做了什么让我们能在block里面访问age呢?下面我们来看下上面代码转成c++代码后block的存储结构是什么样的。

    struct __main_block_impl_0 {
      struct __block_impl impl;
      struct __main_block_desc_0* Desc;
      __Block_byref_age_0 *age; // by ref
    };
    
    struct __Block_byref_age_0 {
      void *__isa; // isa指针
    __Block_byref_age_0 *__forwarding; // 如果这block是在堆上那么这个指针就是指向它自己,如果这个block是在栈上,那这个指针是指向它拷贝到堆上后的那个block
     int __flags;
     int __size; // 结构体大小
     int age; // 真正捕获到的age
    };
    

    我们可以看到,age用__block修饰后,在block的结构体中变成了__Block_byref_age_0 *age;,而__Block_byref_age_0是个结构体,里面有个成员int age;,这个才是真正捕获到的外部变量age,实际上外部的age的地址也是指向这里的,所以不管是外面还是block里面,修改age时其实都是通过地址找到这里来修改的。

    所以age用__block修饰后它就不再是一个test1方法内部的局部变量了,而是被包装成了一个对象,age就被存储在这个对象中。之所以说是包装成一个对象,是因为__Block_byref_age_0这个结构体的第一个成员就是isa指针。

    __block修饰变量的内存管理

    __block不管是修饰基础数据类型还是修饰对象数据类型,底层都是将它包装成一个对象,然后block结构体中有个指针指向这个对象。既然是一个对象,那block内部如何对它进行内存管理呢?

    • 当block在栈上时,block内部并不会对这个对象产生强引用。
    • 当block调用copy函数从栈拷贝到堆中时,它同时会将这个对象也拷贝到堆上,并对这个对象产生强引用。
    • 当block从堆中移除时,会调用block内部的dispose函数,dispose函数内部又会调用_Block_object_dispose函数来释放这个对象。

    相关文章

      网友评论

        本文标题:block底层实现原理

        本文链接:https://www.haomeiwen.com/subject/nplykktx.html