美文网首页iOS面试系列
一道Block面试题的深入挖掘

一道Block面试题的深入挖掘

作者: iOS最新面试题收录 | 来源:发表于2020-05-19 15:21 被阅读0次

    0. 序言

    最近看到了一道Block的面试题,还蛮有意思的,来给大家分享一下。

    本文从一道Block面试题出发,层层深入到达Block原理的讲解,把面试题吃得透透的。

    题外话:

    很多人觉得Block的定义很怪异,很难记住。但其实和C语言的函数指针的定义对比一下,你很容易就可以记住。

    // Block
    returnType (^blockName)(parameterTypes)
    
    // 函数指针
    returnType (*c_func)(parameterTypes)
    复制代码
    

    例如输入和返回参数都是字符串:

    (char *) (*c_func)(const char *);
    (NSString *) (^block)(NSString *);
    复制代码
    

    好了,下面正式开始~

    1. 面试题

    1.1 问题1

    以下代码存在内存泄露么?

    • 不存在
    • 存在
    - (void)viewDidLoad {
        [super viewDidLoad];
        NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
        id token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                       object:nil
                                        queue:[NSOperationQueue mainQueue]
                                   usingBlock:^(NSNotification * _Nonnull note) {
            [self doSomething];
            [center removeObserver:token];
        }];
    }
    
    - (void)doSomething {
    
    }
    复制代码
    

    答案是存在

    作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:761407670 进群密码'亮子',不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!

    另附上一份各好友收集的大厂面试题,进群可自行下载!
    image
    1.1.1 分析
    • block中,我们使用到的外部变量有selfcentercenter使用了__weak说明符肯定没问题。

    • center持有tokentoken持有blockblock持有self,也就是说token不释放,self肯定没法释放。

    • 我们注意到[center removeObserver:token];这步会把tokencenter中移除掉。按理说,centerself是不是就可以被释放了呢?

    我们来看看编译器怎么说:

    编译器告诉我们,token在被block捕获之前没有初始化[center removeObserver:token];是没法正确移除token的,所以self也没法被释放!

    为什么没有被初始化?

    因为token在后面的方法执行完才会被返回。方法执行的时候token还没有被返回,所以捕获到的是一个未初始化的值!

    1.2 问题2

    以下代码存在内存泄露么?

    • 不存在
    • 存在
    - (void)viewDidLoad {
        [super viewDidLoad];
        NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
        id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                               object:nil
                                                queue:[NSOperationQueue mainQueue]
                                           usingBlock:^(NSNotification * _Nonnull note) {
            [self doSomething];
            [center removeObserver:token];
        }];
    }
    
    - (void)doSomething {
    
    }
    
    复制代码
    

    这次代码在token之前加入了__block说明符。

    提示:这次编译器没有警告说token没有被初始化了。

    答案是还是存在

    1.2.1 分析

    首先,证明token的值是正确的,同时大家也可以看到token确实是持有block的。

    那么,为什么还会泄露呢?

    因为,虽然centertoken的持有已经没有了,token现在还被block持有。

    可能还有同学会问:

    加入了__block说明符,token对象不是还是center返回之后才能拿到么,为什么加了之后就没问题了呢?

    原因会在Block原理部分详细说明。

    1.3 问题3

    以下代码存在内存泄露么?

    • 不存在
    • 存在
    - (void)viewDidLoad {
        [super viewDidLoad];
        NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
        id __block token = [center addObserverForName:UIApplicationDidEnterBackgroundNotification
                                               object:nil
                                                queue:[NSOperationQueue mainQueue]
                                           usingBlock:^(NSNotification * _Nonnull note) {
            [self doSomething];
            [center removeObserver:token];
            token = nil;
        }];
    }
    
    - (void)doSomething {
    
    }
    
    - (void)dealloc {
        NSLog(@"%s", __FUNCTION__);
    }
    复制代码
    

    答案是不存在

    1.3.1 分析

    我们可以验证一下:

    可以看到,我们添加token = nil;之后,ViewController被正确释放了。这一步,解除了tokenblock之间的循环引用,所以正确释放了。

    有人可能会说:

    使用__weak typeof(self) wkSelf = self;就可以解决self不释放的问题。

    确实这可以解决self不释放的问题,但是这里仍然存在内存泄露!

    2. Block的原理

    虽然面试题解决了,但是还有几个问题没有弄清楚:

    • 为什么没有__block说明符token未被初始化,而有这个说明符之后就没问题了呢?
    • tokenblock为什么会形成循环引用呢?

    2.1 Block捕获自动变量

    刚刚的面试题比较复杂,我们先来看一个简单的:

    Block转换为C函数之后,Block中使用的自动变量会被作为成员变量追加到 __X_block_impl_Y结构体中,其中 X一般是函数名, Y是第几个Block,比如main函数中的第0个结构体: __main_block_impl_0

    typedef void (^MyBlock)(void);
    
    int main(int argc, const char * argv[])
    {
      @autoreleasepool
      {
         int age = 10;
         MyBlock block = ^{
             NSLog(@"age = %d", age);
         };
         age = 18;
         block();
      }
      return 0;
    }
    复制代码
    

    顺便说一下,这个输出:age = 10

    在命令行中对这个文件进行一下处理:

    clang -w -rewrite-objc main.m
    复制代码
    

    或者

    xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
    复制代码
    

    区别是下面指定了SDK和架构代码会少一点。

    处理完之后会生成一个main.cpp的文件,打开后会发现代码很多,不要怕。搜索int main就能看到熟悉的代码了。

    int main(int argc, const char * argv[])
    {
      /* @autoreleasepool */
      { __AtAutoreleasePool __autoreleasepool; 
         int age = 10;
         MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));
         age = 18;
         ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
      }
      return 0;
    }
    复制代码
    

    下面是main函数中涉及到的一些结构体:

    struct __main_block_impl_0 {
      struct __block_impl impl; //block的函数的imp结构体
      struct __main_block_desc_0* Desc; // block的信息
      int age; // 值引用的age值
      __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags=0) : age(_age) {
        impl.isa = &_NSConcreteStackBlock; // 栈类型的block
        impl.Flags = flags;
        impl.FuncPtr = fp; // 传入了函数具体的imp指针
        Desc = desc;
      }
    };
    
    struct __block_impl {
      void *isa; // block的类型:全局、栈、堆
      int Flags;
      int Reserved;
      void *FuncPtr; // 函数的指针!就是通过它调用block的!
    };
    
    static struct __main_block_desc_0 { // block的信息
      size_t reserved;
      size_t Block_size; // block的大小
    } __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};
    复制代码
    

    有了这些信息,我们再看看

    MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, age));
    复制代码
    

    可以看到,block初始化的时候age是值传递,所以block结构体中age=10,所以打印的是age = 10

    2.2 __block说明符

    Block中修改捕获的自动变量有两种方法:

    • 使用静态变量、静态全局变量、全局变量

      从Block语法转化为C语言函数中访问静态全局变量、全局变量,没有任何不同,可以直接访问。而静态变量使用的是静态变量的指针来进行访问。

      自动变量不能采用静态变量的做法进行访问。原因是,自动变量是在存储在栈上的,当超出其作用域时,会被栈释放。而静态变量是存储在堆上的,超出作用域时,静态变量没有被释放,所以还可以访问。

    • 添加 __block修饰符

      __block存储域类说明符。存储域说明符会指定变量存储的域,如栈auto、堆static、全局extern,寄存器register。

    比如刚刚的代码加上 __block说明符:

    typedef void (^MyBlock)(void);
    
    int main(int argc, const char * argv[])
    {
    @autoreleasepool
    {
       int __block age = 10;
       MyBlock block = ^{
           age = 18;
       };
       block();
    }
    return 0;
    }
    复制代码
    

    在命令行中对这个文件进行一下处理:

    xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc main.m
    复制代码
    

    我们看到main函数发生了变化:

    • 原来的age变量:int age = 10;

    • 现在的age变量:__Block_byref_age_0 age = {(void)0,(__Block_byref_age_0 )&age, 0, sizeof(__Block_byref_age_0), 10};

    int main(int argc, const char * argv[])
    {
      /* @autoreleasepool */
      { __AtAutoreleasePool __autoreleasepool; 
         __Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 10};
         MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 570425344));
         ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
      }
      return 0;
    }
    复制代码
    

    原来我们知道添加 __block说明符,我们就可以在block里面修改自动变量了。

    恭喜你,现在你达到了第二层!__block说明符,其实会把自动变量包含到一个结构体中。

    这也就解释了问题1为什么加入__block说明符,token可以正确拿到值。

    MyBlock block = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 570425344));
    复制代码
    

    这次block初始化的过程中,把age这个结构体传入到了block结构体中,现在就变成了指针引用

    struct __Block_byref_age_0 {
      void *__isa; //isa指针
      __Block_byref_age_0 *__forwarding; // 指向自己的指针
      int __flags; // 标记
      int __size; // 结构体大小
      int age; // 成员变量,存储age值
    };
    
    struct __main_block_impl_0 {
      struct __block_impl impl;
      struct __main_block_desc_0* Desc;
      __Block_byref_age_0 *age; // 结构体指针引用
      __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_age_0 *_age, int flags=0) : age(_age->__forwarding) {
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
      }
    };
    复制代码
    

    我们再来看看block中是如何修改age对应的值:

    static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
        __Block_byref_age_0 *age = __cself->age; // 通过结构体的self指针拿到age结构体的指针
        (age->__forwarding->age) = 18; // 通过age结构体指针修改age值
    }
    复制代码
    

    看到这里可能不明白__forwarding的作用,我们之后再讲。现在知道是age是指针引用修改成功的就可以了。

    2.3 Block存储域

    从C代码中我们可以看到Block的是指是Block结构体实例__block变量实质是栈上__block变量结构体实例。从初始化函数中我们可以看到,impl.isa = &_NSConcreteStackBlock;,即之前我们使用的是栈Block。

    其实,Block有3中类型:

    • _NSConcreteGlobalBlock类对象存储在程序的数据区(.data区)。
    • _NSConcreteStackBlock类对象存储在栈上。
    • _NSConcreteMallocBlock类对象存储在堆上。
    void (^blk)(void) = ^{
      NSLog(@"Global Block");
    };
    
    int main() {
      blk();
      NSLog(@"%@",[blk class]);//打印:__NSGlobalBlock__
    }
    复制代码
    

    全局Block肯定是存储在全局数据区的,但是在函数栈上创建的Block,如果没有捕获自动变量,Block的结构实例还是 _NSConcreteGlobalBlock,而不是 _NSConcreteStackBlock

    void (^blk0)(void) = ^{ // 没有截获自动变量的Block
        NSLog(@"Stack Block");
    };
    blk0();
    NSLog(@"%@",[blk0 class]); // 打印:__NSGlobalBlock__
    
    int i = 1;
    void (^blk1)(void) = ^{ // 截获自动变量i的Block
        NSLog(@"Capture:%d", i);
    };
    blk1();
    NSLog(@"%@",[blk1 class]); // 打印:__NSMallocBlock__
    复制代码
    

    可以看到没有捕获自动变量的Block打印的类是NSGlobalBlock,表示存储在全局数据区。 但为什么捕获自动变量的Block打印的类却是设置在堆上的NSMallocBlock,而非栈上的NSStackBlock?这个问题稍后解释。

    设置在栈上的Block,如果超出作用域,Block就会被释放。若 __block变量也配置在栈上,也会有被释放的问题。所以, copy方法调用时,__block变量也被复制到堆上,同时impl.isa = &_NSConcreteMallocBlock;。复制之后,栈上 __block变量的__forwarding指针会指向堆上的对象。因 此 __block变量无论被分配在栈上还是堆上都能够正确访问。

    编译器如何判断何时需要进行copy操作呢?

    在ARC开启时,自动判断进行 copy

    • 手动调用copy
    • 将Block作为函数参数返回值返回时,编译器会自动进行 copy
    • 将Block赋值给 copy修饰的id类或者Block类型成员变量,或者__strong修饰的自动变量。
    • 方法名含有usingBlockCocoa框架方法或GCD相关API传递Block。

    如果不能自动 copy,则需要我们手动调用 copy方法将其复制到堆上。比如向不包括上面提到的方法或函数的参数中传递Block时。

    ARC环境下,返回一个对象时会先将该对象复制给一个临时实例指针,然后进行retain操作,再返回对象指针。runtime/objc-arr.mm提到,Block的retain操作objc_retainBlock函数实际上是Block_copy函数。在实行retain操作objc_retainBlock后,栈上的Block会被复制到堆上,同时返回堆上的地址作为指针赋值给临时变量。

    2.4 __block变量存储域

    当Block从栈复制到堆上时候,__block变量也被复制到堆上并被Block持有。

    • 若此时 __block变量已经在堆上,则被该Block持有。
    • 若配置在堆上的Block被释放,则它所持有的 __block变量也会被释放。
    __block int val = 0;
    void (^block)(void) = [^{ ++val; } copy];
    ++val;
    block();
    复制代码
    

    利用 copy操作,Block和 __block变量都从栈上被复制到了堆上。无论是{ ++val; }还是++val;都转换成了++(val->__forwarding->val);

    Block中的变量val为复制到堆上的 __block变量结构体实例,而Block外的变量val则为复制前栈上的 __block变量结构体实例,但这个结构体的__forwarding成员变量指向堆上的 __block变量结构体实例。所以,无论是是在Block内部还是外部使用 __block变量,都可以顺利访问同一个 __block变量。

    3. 面试题C代码

    下面我们看看面试题的C代码。

    @interface Test : NSObject
    @end
    @implementation Test
    - (void)test_notification {
        NSNotificationCenter *__weak center = [NSNotificationCenter defaultCenter];
        id __block token = [center addObserverForName:@"com.demo.perform.once"
                                               object:nil
                                                queue:[NSOperationQueue mainQueue]
                                           usingBlock:^(NSNotification * _Nonnull note) {
            [self doSomething];
            [center removeObserver:token];
            token = nil;
        }];
    }
    - (void)doSomething {
    
    }
    @end
    复制代码
    

    3.1 重写

    在命令行中对这个文件进行一下处理,因为用到了 __weak说明符,需要额外指定一些参数:

    xcrun -sdk iphoneos clang -arch arm64 -w -rewrite-objc -fobjc-arc -mios-version-min=8.0.0 -fobjc-runtime=ios-8.0.0 main.m
    复制代码
    

    这个会更复杂一些,但我们只看重要的部分:

    struct __Block_byref_token_0 {
      void *__isa;
    __Block_byref_token_0 *__forwarding;
     int __flags;
     int __size;
     void (*__Block_byref_id_object_copy)(void*, void*);
     void (*__Block_byref_id_object_dispose)(void*);
     __strong id token; // id类型的token变量 (strong)
    };
    
    struct __Test__test_notification_block_impl_0 {
      struct __block_impl impl;
      struct __Test__test_notification_block_desc_0* Desc;
      Test *const __strong self; // 被捕获的self (strong)
      NSNotificationCenter *__weak center; // center对象 (weak)
      __Block_byref_token_0 *token; // token结构体的指针
      __Test__test_notification_block_impl_0(void *fp, struct __Test__test_notification_block_desc_0 *desc, Test *const __strong _self, NSNotificationCenter *__weak _center, __Block_byref_token_0 *_token, int flags=0) : self(_self), center(_center), token(_token->__forwarding) {
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
      }
    };
    复制代码
    

    现在我们看到block结构体 __Test__test_notification_block_impl_0中持有token,同时之前我们看到token也是持有block的,所以造成了循环引用。

    这也就回答了问题2。

    下面我们看看blockIMP函数是如何解决循环引用问题的:

    static void __Test__test_notification_block_func_0(struct __Test__test_notification_block_impl_0 *__cself, NSNotification * _Nonnull __strong note) {
        __Block_byref_token_0 *token = __cself->token; // bound by ref
        Test *const __strong self = __cself->self; // bound by copy
        NSNotificationCenter *__weak center = __cself->center; // bound by copy
    
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("doSomething"));
        ((void (*)(id, SEL, id  _Nonnull __strong))(void *)objc_msgSend)((id)center, sel_registerName("removeObserver:"), (id)(token->__forwarding->token));
        (token->__forwarding->token) = __null;
    }
    复制代码
    

    可以看到,token = nil;被转换为了(token->__forwarding->token) = __null;,相当于block对象对token的持有解除了!如果你觉得看不太明白,我再转换一下:

    (__cself->token->__forwarding->token) = __null; // __cself为block结构体指针
    复制代码
    

    3.2 Block的类型

    细心的同学可能发现:

    impl.isa = &_NSConcreteStackBlock;
    复制代码
    

    这是一个栈类型block呀,声明周期结束不是就该被系统回收释放了么。我们使用了ARC同时我们调用是方法名中含有usingBlock,会主动触发 copy操作,将其复制到堆上。

    4. 总结

    Block最常问的就是循环引用、内存泄露问题。

    注意要点:

    • __weak说明符的使用
    • __block说明符的使用
    • 谁持有谁
    • 如何解除循环引用

    另外,需要再强调一下的是:

    • 面试题中的block代码如果一次都没有执行也是会内存泄露的!

    • 可能有人会说使用__weak typeof(self) wkSelf = self;就可以解决self不释放的问题。

      确实这可以解决self不释放的问题,但是这里 仍然存在内存泄露! 我们还是需要从根上解决这个问题。

    补充:

    上面讲的时候集中在说tokenblock的循环引用,ViewController的问题我简单带过了,可能同学们看的时候没有注意到。

    我在这里专门拎出来说一下:

    tokenblock循环引用,同时block持有self(ViewController),导致ViewController也没法释放。

    如果希望优先释放ViewController(不管block是否执行),最好给ViewController加上__weak说明符。

    此外,破除tokenblock的循环引用,实际有两种方法:

    • 手动设置token = nil;
    • token也使用__weak说明符id __block __weak token

    注意:

    以下说法不够严谨,也可能存在问题:

    最简单粗暴的解决办法:大家都__weak

    NSNotificationCenter *__weak wkCenter = [NSNotificationCenter >defaultCenter];
    __weak typeof(self) wkSelf = self;
    id __block __weak wkToken = [wkCenter addObserverForName:UIApplicationDidEnterBackgroundNotification
                                          object:nil
                                           queue:[NSOperationQueue mainQueue]
                                      usingBlock:^(NSNotification * _Nonnull note) {
       [wkSelf doSomething];
       [wkCenter removeObserver:wkToken];
    }];
    复制代码
    

    这个问题具体要看NSNotificationCenter具体是怎么实现的。token使用__weak说明符,但是如果NSNotificationCenter没有持有token,在函数作用域结束时,token会被销毁。虽然不会有循环引用问题,但是可能导致无法移除这个观察者的问题。

    如果觉得本文对你有所帮助,给我点个赞吧~

    作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:761407670 进群密码'亮子',不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!

    摘自原文:https://juejin.im/post/5eaa2a87e51d454db7436726

    相关文章

      网友评论

        本文标题:一道Block面试题的深入挖掘

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