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 flags=0) { 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 flags=0) : 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做一次retain;Block销毁时调用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等系统API - Block被放进数组、字典等集合
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下,如果不手动copy,Block还在栈上,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指向什么",很多人就答不上来了。默认情况下,捕获了局部变量的Block,isa指向_NSConcreteStackBlock。没捕获变量的,指向_NSConcreteGlobalBlock。被copy过的,指向_NSConcreteMallocBlock。
这三个类都是系统私有的,你没法直接创建它们的实例,但可以通过isa判断Block的状态。这在调试内存问题时很有用。
6.2 为什么Block属性要用copy
这是老生常谈,但面试官喜欢追问"用strong会怎样"。答案:用strong修饰Block属性,如果赋值的是栈Block,strong只是让指针指向栈上的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复制到堆时会被retain,Block销毁时被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相关问题最依赖的方法。