ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

iOS Block底层原理深度解析:从结构体到内存管理

iOS Block底层原理深度解析:从结构体到内存管理 1. 从一道面试题说起Block到底是不是对象很多人准备iOS面试时看到Block的本质这几个字第一反应就是背答案Block是带有自动变量值的匿名函数是OC对象底层是结构体。背得滚瓜烂熟面试官稍微追问一句那它为什么能捕获外部变量立刻就卡壳了。我自己当年也是这样直到有一次被问Block捕获的变量存在哪里我才意识到光背结论根本没用。这篇内容就是把我这些年对Block的理解重新梳理一遍。核心围绕三件事Block在底层到底是什么结构、它怎么截获外部变量、__block又做了什么手脚。这三件事是连在一起的搞懂一条线面试里不管怎么问都能接得住。适合已经会写Block、但对底层机制一知半解的iOS开发者也适合正在准备面试、想把这块彻底吃透的人。先说结论Block本质是一个结构体里面存了函数指针和捕获的变量同时它继承了一个isa指针所以它也是一个对象。但是对象这件事只是表象真正决定它行为的是它的内存布局和捕获规则。下面我按结构体长什么样→变量怎么被截获→__block改了什么→内存怎么管理这条线展开。2. Block的底层结构一个结构体加一个函数指针2.1 用clang把Block翻译成C看真相想看清Block的本质最直接的办法是用clang -rewrite-objc把OC代码转成C代码。这是我在排查Block相关崩溃时最常用的手段比看任何博客都靠谱。写一段最简单的代码int main(int argc, char * argv[]) { void (^myBlock)(void) ^{ printf(hello block); }; myBlock(); return 0; }用命令clang -rewrite-objc main.m转换后核心结构会变成这样省略了部分系统定义struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0* Desc; __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags0) { impl.isa _NSConcreteStackBlock; impl.Flags flags; impl.FuncPtr fp; Desc desc; } }; struct __block_impl { void *isa; int Flags; int Reserved; void *FuncPtr; };看到没一个Block变量在底层就是一个__main_block_impl_0结构体实例。它内部包含三样关键东西isa指针、FuncPtr函数指针、Desc描述信息。isa指向_NSConcreteStackBlock这就是为什么说Block是对象——它有isa能响应消息。FuncPtr指向的是真正执行的那段代码也就是Block体被编译成的C函数。这个函数的名字通常是__main_block_func_0它接收一个参数就是Block结构体自身的指针。所以Block调用的时候本质是impl.FuncPtr(block自身指针)把结构体自己传进去这样函数内部才能访问到捕获的变量。2.2 三种Block类型与isa的指向关系isa指向什么决定了这个Block是什么类型。这是面试高频考点也是理解内存管理的前提。系统里有三个关键的类Block类型isa指向存储位置生命周期全局Block_NSConcreteGlobalBlock全局数据区程序运行期间一直存在栈Block_NSConcreteStackBlock栈区随栈帧销毁堆Block_NSConcreteMallocBlock堆区引用计数管理全局Block出现在两种情况Block内部没有捕获任何外部变量或者只捕获了全局变量、静态变量。这种Block不依赖任何运行时状态编译器直接把它放到全局区连引用计数都不需要。栈Block是默认状态。只要Block捕获了局部变量它就会被创建在栈上。这里有个坑栈Block一旦离开作用域就会被销毁如果你把它赋值给一个强引用属性或者放进数组程序可能崩溃或者行为异常。这就是为什么很多老代码里Block属性要用copy而不是strong——copy会把栈Block复制到堆上。堆Block是copy之后的结果。一旦Block被复制到堆上它的引用计数就开始工作捕获的变量也会跟着一起被复制到堆上管理。ARC环境下编译器会自动帮你做很多copy操作但理解这个机制对排查循环引用、野指针问题至关重要。提示判断一个Block是不是栈Block可以在赋值后打印它的isa或者用__bridge转成void *看地址范围。栈地址通常很大接近0x7开头堆地址相对小一些。2.3 为什么Block调用要传自己进去这一点很多人没想过。FuncPtr指向的C函数签名是这样的static void __main_block_func_0(struct __main_block_impl_0 *__cself) { // Block体代码 }调用时是impl.FuncPtr(block指针)。为什么要传自己因为Block体里用到的所有捕获变量都作为结构体的成员存在。函数内部要访问这些变量必须通过__cself-成员名的方式。这就像C的成员函数需要this指针一样Block的成员函数也需要一个指向自己的指针。理解了这一点就能明白为什么Block捕获变量是值拷贝——因为变量被复制成了结构体的成员函数访问的是成员不是原来的变量。这个设计直接引出了下一节要讲的截获特性。3. 变量截获为什么改不了外面的值3.1 局部变量被值拷贝进结构体先看一段会让人困惑的代码int age 10; void (^myBlock)(void) ^{ printf(%d, age); }; age 20; myBlock(); // 输出 10不是 20很多人第一次看到这个结果会愣一下明明调用前把age改成了20为什么Block里还是10答案就在结构体里。转换后的代码大致是struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0* Desc; int age; // 捕获的变量作为成员 __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags0) : age(_age) { // ... } };Block初始化的时候把当时的age值10拷贝给了结构体的age成员。之后外面怎么改改的都是原来的局部变量跟结构体里的副本没关系。所以Block里读到的永远是捕获那一刻的值。这就是截获这个词的准确含义——不是引用是拷贝。面试里如果被问Block为什么修改不了外部局部变量标准答案就是局部变量被值拷贝进Block结构体Block内部操作的是副本不是原变量。3.2 静态变量、全局变量为什么能改同样是变量静态变量和全局变量在Block里就能改这又是为什么static int staticValue 10; int globalValue 20; void (^myBlock)(void) ^{ staticValue 30; // 可以改 globalValue 40; // 可以改 };原因很简单静态变量和全局变量本来就存在全局数据区它们的地址在程序运行期间是固定的。Block捕获它们的时候捕获的是地址或者说直接通过符号访问不是值拷贝。既然访问的是同一块内存当然能改。这里有个细节值得注意静态变量在Block结构体里存的是指针不是值。转换后的代码里会看到int *staticValue这样的成员。所以严格来说Block对静态变量也是捕获但捕获的是指针效果上等同于引用。变量类型捕获方式能否在Block内修改原因局部自动变量值拷贝否操作的是副本静态局部变量捕获指针是访问同一块内存全局变量直接访问是地址固定成员变量捕获self指针是通过self访问的是对象属性3.3 成员变量和self的关系成员变量是个特殊情况。Block里访问_name或者self.name实际上捕获的是self指针。转换后结构体里会多一个ViewController *self成员。这也是循环引用的根源——Block强引用了self如果self又强引用了Block就形成环。self.myBlock ^{ NSLog(%, self.name); // 捕获了self };这段代码里Block结构体持有了self而self假设是ViewController通过属性持有了Block两者互相强引用谁都释放不了。解决办法是用__weak__weak typeof(self) weakSelf self; self.myBlock ^{ NSLog(%, weakSelf.name); };weakSelf本身是个局部变量被值拷贝进Block但它是弱引用不会增加self的引用计数。这样就打破了循环。注意用weakSelf之后如果Block是异步执行的执行时self可能已经被释放weakSelf会变成nil。需要多次访问self时建议在Block内部用__strong typeof(weakSelf) strongSelf weakSelf;转成强引用保证执行期间self存活。4. __block修饰符让局部变量变得可修改4.1 __block做了什么把变量包装成结构体既然局部变量是值拷贝那有没有办法让Block内部修改它、并且外部也能看到修改有就是加__block。__block int age 10; void (^myBlock)(void) ^{ age 20; }; myBlock(); printf(%d, age); // 输出 20加了__block之后age不再是普通局部变量编译器会把它包装成一个结构体struct __Block_byref_age_0 { void *__forwarding; int __flags; int __size; int age; // 真正的值 };Block结构体里存的不再是int age而是一个指向__Block_byref_age_0的指针。Block内部修改age改的是这个结构体里的age成员。因为Block和外部变量访问的是同一个结构体通过指针所以修改能同步。这里的关键是__forwarding指针。它指向这个结构体自己在栈上时当Block被复制到堆上时栈上的__forwarding会指向堆上的副本。这样无论从栈还是堆访问最终都落到同一份数据上。这个设计非常巧妙解决了栈Block复制到堆之后变量访问的一致性问题。4.2 __block变量的内存管理__block变量如果是个对象类型内存管理会复杂一些。看这段__block NSString *name hello; void (^myBlock)(void) ^{ name world; };__Block_byref_name_0结构体里会多出copy和dispose两个函数指针分别对应__Block_byref_id_object_copy_0和__Block_byref_id_object_dispose_0。当Block从栈复制到堆时会调用copy函数对name做一次retainBlock销毁时调用dispose做一次release。这套机制保证了__block对象在Block生命周期内的正确管理。如果__block修饰的是基本类型就没有这两个函数因为基本类型不需要引用计数。4.3 __block和__weak能一起用吗经常有人问__block和__weak能不能同时修饰一个变量答案是能但要想清楚目的。__block __weak typeof(self) weakSelf self;这种写法在某些场景下有用比如需要在Block内部重新赋值weakSelf。但大多数情况下单纯用__weak就够了。__block在这里的作用是让变量可被重新赋值__weak是让它弱引用。两个修饰符解决的是不同问题不要混为一谈。还有一个经典误区用__block来打破循环引用。早期MRC时代确实有人这么做因为__block变量不会被Block retain。但在ARC下__block对象会被Block强引用用它来打破循环引用是无效的必须用__weak。修饰符作用是否解决循环引用适用场景__weak弱引用不增加计数是打破self循环引用__strong强引用默认否保证对象存活__block允许Block内修改局部变量ARC下否需要在Block内改变量值__unsafe_unretained不安全管理是但危险极少使用5. 从栈到堆Block复制时发生了什么5.1 什么时候会触发copy栈Block离开作用域就没了所以只要Block需要在当前作用域之外存活就必须复制到堆上。触发copy的常见场景Block作为函数返回值返回Block赋值给__strong修饰的变量或属性Block作为参数传给dispatch_async等系统APIBlock被放进数组、字典等集合ARC下编译器会自动插入objc_retainBlock也就是Block_copy。所以大多数时候你不需要手动copy。但理解这个过程对排查问题很重要。5.2 复制时捕获变量怎么处理Block从栈复制到堆时它捕获的变量也会被复制。基本类型直接拷贝一份到堆上的结构体对象类型会执行retain__block变量则通过__forwarding指针做特殊处理。这里有个容易踩的坑如果Block捕获了一个栈上的对象指针复制到堆后这个指针指向的对象可能已经释放了。比如- (void)test { NSObject *obj [[NSObject alloc] init]; self.block ^{ NSLog(%, obj); }; }obj是局部变量Block捕获它并复制到堆时会对obj做retain所以obj不会释放。这是ARC的功劳。但在MRC下如果不手动copyBlock还在栈上obj释放后Block里的指针就是野指针一调用就崩。5.3 __forwarding指针的妙用前面提到__forwarding这里展开说一下。当__block变量在栈上时__forwarding指向栈上的结构体自己。Block被复制到堆时堆上会生成一份新的__Block_byref结构体然后栈上那份的__forwarding被改成指向堆上的新结构体。这样做的目的是如果Block内部和外部的代码都通过__forwarding访问变量那么无论Block在栈上还是堆上访问的都是同一份数据。复制之后外部代码通过栈上的__forwarding找到堆上的值Block通过堆上的结构体访问两边保持一致。这个设计解决了一个核心矛盾Block复制到堆后原来的栈变量可能已经失效但外部代码可能还在用。__forwarding就像一根绳子把栈和堆连起来保证数据同步。6. 面试里那些容易翻车的追问6.1 Block的isa为什么是_NSConcreteStackBlock面试官问Block是对象吗你答是他接着问那它的isa指向什么很多人就答不上来了。默认情况下捕获了局部变量的Blockisa指向_NSConcreteStackBlock。没捕获变量的指向_NSConcreteGlobalBlock。被copy过的指向_NSConcreteMallocBlock。这三个类都是系统私有的你没法直接创建它们的实例但可以通过isa判断Block的状态。这在调试内存问题时很有用。6.2 为什么Block属性要用copy这是老生常谈但面试官喜欢追问用strong会怎样。答案用strong修饰Block属性如果赋值的是栈Blockstrong只是让指针指向栈上的Block不会复制到堆。栈Block随栈帧销毁后属性指向的就是野指针调用必崩。copy会触发Block_copy把Block复制到堆上安全。ARC下strong和copy对Block的效果其实一样因为编译器会自动copy。但为了语义清晰和兼容MRC还是推荐用copy。6.3 循环引用的三种排查思路循环引用是Block面试的必考题。我总结的排查思路第一看Block里有没有直接用self或者成员变量。用了就大概率有循环引用。第二看Block是不是被self强引用。如果Block只是临时用一下没被self持有那即使捕获了self也不会循环。第三用__weak打破。如果Block内部需要多次访问self用__strong临时提升。__weak typeof(self) weakSelf self; self.block ^{ __strong typeof(weakSelf) strongSelf weakSelf; if (!strongSelf) return; [strongSelf doSomething]; [strongSelf doAnotherThing]; };这段代码是标准写法面试里能写出来基本就稳了。6.4 __block变量在ARC下的引用计数变化这个比较偏底层但高级面试会问。__block修饰的对象变量在Block复制到堆时会被retainBlock销毁时被release。如果这个对象同时被外部强引用引用计数会加一。所以__block对象在Block生命周期内是强引用的这一点和__weak完全不同。有个细节__block变量在MRC下不会被retain这是历史遗留差异。ARC下统一了行为都retain。面试时如果被问到MRC和ARC的区别可以提这一点。7. 我在实际项目里踩过的Block坑说几个真实踩过的坑都是文档里不会写的。第一个坑在for循环里创建Block并捕获循环变量。如果循环变量是局部变量每个Block捕获的是各自的值拷贝没问题。但如果捕获的是__block变量所有Block共享同一份最后执行时拿到的都是最终值。这个坑在异步任务里特别隐蔽。第二个坑Block里用了weakSelf但没做nil判断。异步回调时self可能已经释放weakSelf是nil给nil发消息虽然不崩但逻辑会静默失败排查起来很费劲。所以关键路径上一定要加if (!strongSelf) return;。第三个坑在Block里修改了__block数组同时又在另一个线程遍历它。__block变量本身不是线程安全的多线程访问需要加锁。这个坑在并发场景下偶发很难复现。第四个坑把栈Block赋值给assign属性。assign不复制栈Block销毁后属性变野指针。这个在MRC时代很常见ARC下编译器会警告但还是要留意。提示调试Block问题时可以在关键位置打印Block的isa和捕获变量的地址对比栈堆变化。用po命令配合__bridge转换能快速定位问题。8. 把Block机制串起来理解回到最开始的问题Block的本质是什么我的理解是它是一个自带上下文的函数对象。结构体负责存上下文捕获的变量函数指针负责执行逻辑isa让它具备对象的身份__forwarding和copy机制负责生命周期管理。截获变量的特性本质是值拷贝和指针捕获的区别。局部变量值拷贝所以改不了静态和全局变量捕获地址所以能改__block把变量包装成结构体通过指针共享所以既能改又能同步。__block修饰符的核心价值是让局部变量获得跨作用域共享的能力。它通过__Block_byref结构体和__forwarding指针解决了栈堆复制时的数据一致性问题。理解了这个就能明白为什么__block对象在ARC下会被retain以及为什么它不能用来打破循环引用。面试里遇到Block的题不要急着背结论。先想清楚这个Block捕获了什么、在栈上还是堆上、有没有循环引用然后顺着结构体的内存布局去推答案自然就出来了。这套思路比死记硬背管用得多也是我这些年排查Block相关问题最依赖的方法。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表