1. 内容整体设计与思路拆解
1.1 为什么说 Block 的内存布局是绕不过去的坎
在 iOS 开发里,Block 这个东西属于“天天用,但未必真懂”的典型。你用UIView的动画接口要写 Block,用 URLSession 的回调要写 Block,用DispatchQueue.async还是要写 Block。大多数情况下,你只要记得“用 weakSelf 避免循环引用”就够了,代码照样能跑,项目照样能上线。
但一旦遇到真正刁钻的问题,这套“口诀式”的理解就会立刻露馅。比如:为什么在低版本系统上,栈 Block 在异步回调后偶尔会崩溃?为什么__block变量在 Block 内部修改后,外面的值有时变了有时没变?为什么把一个 Block 存进NSArray或者作为属性保存时,必须手动copy一下?这些问题的答案,不约而同地指向同一个地方——Block 在内存里到底是怎么摆的。
这篇文章想把 Block 的内存布局彻底讲透。我会从最底层的内存结构体开始,逐步拆解 Block 在栈、堆、全局区三种位置的存放方式,然后再把Block_copy这条命令的前世今生解释清楚。中间会穿插大量我实际调试中踩过的坑和验证过的结论。这篇文章适合谁看?如果你刚接触 Block 不久,只想把内存管理搞清楚,那你需要这篇;如果你已经写过两年以上 OC,但遇到 Block 崩溃问题仍然只能靠猜,那你更需要这篇。
1.2 从一次真实崩溃说起,理清 Block 的三种存放区域
先说个我去年实际遇到的案例。当时在做一款社交 App,聊天页面里有一个发送语音消息的功能。语音录制完成后,需要在一个 Block 回调里把音频文件写入本地,然后刷新 UI。代码乍一看没有任何问题,回调写得很规范,但在 iOS 12 的真机上,偶现一个EXC_BAD_ACCESS,而且崩溃堆栈直接指向 Block 内部对捕获变量的访问。
排查了很久,最后定位到原因:这个 Block 是在栈上创建的,被异步任务持有并执行,但栈 Block 的生命周期只到当前函数返回为止。函数返回后,栈帧被回收,Block 所在的内存区域已经变成“悬空”状态,再访问它捕获的变量自然就崩溃了。解决办法很简单——在将 Block 传给异步接口之前,手动执行一次copy,把它从栈区搬到堆区,由堆来管理它的生命周期。
这个案例引出了理解 Block 内存布局的第一个关键点:Block 不一定在堆上,它有三种可能的存放位置。第一种是全局区,类似 OC 里的全局变量,只要这个 Block 没有捕获任何外部变量,它就被编译器放在全局数据段,整个程序运行期间只有一份,生命周期跟 App 一样长。第二种是栈区,如果 Block 捕获了外部变量,并且是在函数内部创建的,那么默认情况下它就被分配在栈上,函数返回时它的生命周期就结束了。第三种是堆区,对栈 Block 执行copy操作之后,它会被迁移到堆上,之后由引用计数来管理它的存亡。
理解这三种位置,是理解 Block 内存布局的地基。接下来的篇幅,我会把这个地基一砖一瓦地拆开来看。
2. 核心细节解析:Block 对象本身的内存结构
2.1 Block 本质上是一个结构体,而不是“一段代码”
很多开发者对 Block 有个直觉上的误解,觉得它跟 C 语言里的函数指针差不多,就是一个指向代码的地址。但实际上,Block 在底层是一个实实在在的结构体对象,它既包含指向代码的指针,也包含它捕获的变量数据。
我们可以借助 runtime 源码来看 Block 的结构。在苹果开源的Block.h和Block_private.h中,Block 结构体的核心定义大致是这样:
struct Block_layout { void *isa; // 指向类的指针,标记 Block 类型 int flags; // 标志位,记录 Block 的形态和附加信息 int reserved; // 保留字段,当前未使用 void (*invoke)(void *, ...); // 函数指针,指向 Block 实际执行的代码 struct Block_descriptor *descriptor; // 描述信息,包含 copy/dispose 函数等 // 捕获的变量从这里开始排列 };这段结构体就是 Block 内存布局的骨架。isa指针和 OC 对象一样,说明 Block 在运行时也是以对象的形式存在的,只不过它的类比较特殊。flags标志位决定了这个 Block 的性质,比如它是不是全局 Block,需不需要处理捕获变量的内存管理。invoke是真正的函数指针,Block 内部的代码编译后就在这个地址上。descriptor则是一个附加的描述结构体,里面记录了 Block 的大小、可选参数,以及两个重要的函数指针copy和dispose——这两个函数是捕获变量内存管理的核心,后面会详细展开。
注意:Block 捕获的变量并不是藏在
invoke函数内部,而是按声明顺序直接排列在结构体尾部。也就是说,Block 在内存里是一个“代码指针 + 捕获变量副本”的复合体,这跟函数指针有本质区别。
2.2 flags 标志位:决定 Block 行为和内存管理策略的分水岭
flags字段是理解 Block 内存布局的一把钥匙。在Block_private.h中定义了一组枚举值,其中最重要的几个如下:
| 标志位 | 数值 | 含义 |
|---|---|---|
| BLOCK_IS_GLOBAL | 1 << 25 | 全局 Block,不捕获外部变量 |
| BLOCK_HAS_COPY_DISPOSE | 1 << 25(实际为 1 << 25,全局 Block 标志的邻位) | 表示该 Block 有 copy/dispose 辅助函数 |
| BLOCK_HAS_SIGNATURE | 1 << 30 | 带有方法签名,用于类型描述 |
| BLOCK_USE_STRET | 1 << 29 | 返回值是否使用结构体返回 |
这里要特别区分BLOCK_IS_GLOBAL和BLOCK_HAS_COPY_DISPOSE。全局 Block 因为不捕获外部变量、不涉及内存管理,所以它的flags里不会设置BLOCK_HAS_COPY_DISPOSE,也没有对应的copy和dispose函数。而一个捕获了__strong对象类型变量的栈 Block,flags里就一定会带上BLOCK_HAS_COPY_DISPOSE,因为当 Block 被拷贝到堆上时,它需要通知系统对这些捕获的对象执行retain;当 Block 被释放时,需要执行release。
还有一个实际调试中会用到的点:BLOCK_HAS_SIGNATURE标志位决定了descriptor结构体里是否包含方法签名。很多动态化框架或者调试工具需要解析 Block 的类型信息,就是通过这个标志位去定位签名所在的偏移量。我在写某些运行时工具时,就踩过这个坑——如果直接按固定结构体读取descriptor,而不检查flags,读出来的内容会对不齐。
2.3 descriptor 结构体:Block 描述信息的内存排布
descriptor是紧接着invoke之后的一个辅助结构体。在早期的 runtime 版本中,它的结构比较简单:
struct Block_descriptor { unsigned long int reserved; unsigned long int size; // Block 结构体总大小 void (*copy)(void *dst, void *src); // 拷贝辅助函数 void (*dispose)(void *); // 释放辅助函数 };size字段很重要,它记录了整个 Block 结构体占用的字节数。当我们手动计算 Block 的大小、或者在调试器中查看某段内存时,size字段是判断 Block 边界的关键依据。copy和dispose函数指针则关系着捕获变量的生命周期管理——但注意,只有BLOCK_HAS_COPY_DISPOSE标志位被设置时,这两个函数指针才会存在于descriptor中,否则的结构体里只有reserved和size两个字段。
在更新一些的 runtime 版本里,descriptor被拆成了Block_descriptor_1、Block_descriptor_2、Block_descriptor_3三个部分,分别承载基础信息、签名信息和 layout 信息。无论怎么拆分,核心思路没变:Block 在内存中的布局是“结构体头 + 描述信息 + 捕获变量存储区”,理解这个总框架,后续分析任何具体问题都不会迷路。
3. 深入捕获变量机制与 __block 底层原理
3.1 值捕获的本质:被捕获的变量进入了 Block 内部成为副本
Block 捕获变量的规则,很多文章总结成一句话:“基本类型是按值捕获,对象类型是按引用捕获”。这句话大致对,但不够精确。更准确的说法是:Block 内部的代码,访问的是结构体尾部“捕获变量区”里的那份数据,而不是原始变量本身。
举个例子:
int age = 10; void (^block)(void) = ^{ NSLog(@"%d", age); }; age = 20; block(); // 输出是 10,不是 20原因很直接:Block 结构体的捕获变量区在创建时,已经把age的值 10 拷贝了一份进去。后续外面的age怎么变,跟 Block 内部那份副本毫无关系。这是“值捕获”的典型表现。
那为什么对象类型的变量看起来像是“引用捕获”呢?比如:
NSMutableArray *array = [NSMutableArray array]; void (^block)(void) = ^{ [array addObject:@"1"]; }; array = nil; // ?外部把指针置空,Block 里的 array 会变吗?在 MRC 时代,Block 捕获对象类型变量时,捕获的是指针的值,也就是对象地址的副本,但并没有对对象做retain。所以如果外部把array置空并释放,Block 内部持有的地址就是悬空指针。但在 ARC 时代,编译器对捕获对象类型变量的栈 Block,会默认加入copy/dispose辅助函数,当 Block 被拷贝到堆上时,它会retain捕获的对象,从而保证 Block 生命周期内对象不会提前释放。
实操结论:ARC 下,把 Block 从栈拷贝到堆时,对捕获的
__strong对象执行的是retain。这属于编译器的隐含行为,不需要手动干预,但你理解它之后,就能解释很多看似“玄学”的内存问题。
3.2 __block 变量的底层真相:变量被包装成了一个结构体
__block是 OC 中一个让人又爱又恨的修饰符。数组、字典、可变对象能在 Block 内部直接修改,是因为修改的是对象内部数据;但想要修改基本类型变量,比如int count,就必须在count前面加上__block。这是为什么?
因为普通捕获是拷贝。如果 Block 内部修改的是副本,外面看不到变化。而__block的作用,是让编译器把这个变量包装成一个结构体对象,然后 Block 捕获的是这个结构体的地址(指针),无论是 Block 内部还是外部,操作的其实是同一份数据。
底层结构大致长这样:
struct __Block_byref_count_0 { void *__isa; struct __Block_byref_count_0 *__forwarding; // 关键字段 int __flags; int __size; int count; // 原始变量的真正存储位置 };看到这里你应该明白了:__block int count在编译后,count不再是一个简单的整型变量,而是一个__Block_byref_count_0结构体中的字段。Block 和外部代码通过结构体指针访问count,相当于间接共享了这块内存。
这里最值得关注的是__forwarding指针。它的存在解决了一个棘手的问题:当__block变量所在的 Block 从栈被拷贝到堆上时,__block结构体也要跟着搬家。如果只把 Block 挪到堆上,而__block结构体还留在栈里,Block 访问它就可能访问到已失效的内存。__forwarding指针的解决方案是:栈上结构体的__forwarding指向堆上的结构体,堆上结构体的__forwarding指向自己。这样无论从哪个入口访问,最终都会被引导到堆上那一份数据,保证了 Block 在堆上执行时,访问的__block变量是稳定有效的。
3.3 为什么有些场景必须用 __block,有些场景用 static 也能绕过去
之前提到,__block是让 Block 内部修改外部变量的标准姿势。但还有一个“野路子”——用static变量。比如:
static int count = 0; void (^block)(void) = ^{ count += 1; };这段代码在 Block 内部可以直接修改count,甚至不需要__block。原因在于static变量存放在全局静态区,地址固定,Block 捕获的是这个固定地址,修改它自然就是修改全局数据。但这种做法有一个致命问题:static变量是全局共享的,如果某个方法被多个对象同时调用,它们操作的是同一份数据,很容易产生状态错乱。所以在实际开发中,除非你明确知道自己在干什么,否则不要去用static代替__block。__block变量是跟随 Block 生命周期走的,语义清晰得多。
补充:
static变量的生命周期跟整个程序一致,不会被 ARC 影响,因此 Block 捕获 static 变量时不需要 retain,也不存在循环引用风险。这是它的优点,但代价是全局共享。权衡之下,我还是建议在需要“Block 内外共享修改”的场景里使用__block。
4. 实操过程:Block_copy 到底做了什么,核心环节全拆解
4.1 什么时候栈 Block 会变成堆 Block:copy 操作的触发时机
在 ARC 时代,编译器会在很多需要延长 Block 生命周期的场景下自动插入copy操作。比如把 Block 赋值给一个strong类型的属性,把 Block 存入数组、字典等容器,或者把 Block 作为参数传给一个可能异步执行的接口。这些情况下,编译器会默默地对栈 Block 执行一次copy,让它变成堆 Block,确保在使用时它还是“活着”的。
但有一个常见的坑是:有些接口并不保证会 copy Block。比如你自己写的一个方法,接收 Block 参数,然后在方法内部把它存到一个属性里:
@property (nonatomic, copy) void (^myBlock)(void); - (void)setBlock:(void (^)(void))block { _myBlock = [block copy]; // 或者依赖属性 copy 修饰符 }如果这里的属性修饰符不是copy而是strong,并且你没有手动 copy,那么当传入的 Block 是栈 Block 时,它会在方法返回后失效,后续调用_myBlock()就可能在访问悬空内存。这是 ARC 下仍然需要手动copy的少数场景之一。所以我的建议是:凡是把 Block 作为属性保存的,一律用copy修饰;凡是把 Block 放进容器的,尽量让框架层自动处理,自己不要再画蛇添足地手动 copy——重复 copy 虽然不会崩溃,但也属于无谓消耗。
4.2 Block_copy 的执行流程:从栈到堆,编译器帮你做了什么
手动调用Block_copy或者运行时执行_Block_copy函数,底层做的事情可以拆解成三步。理解了这三步,你对 Block 内存布局的理解会有一个质变。
第一步,判断 Block 的类型。如果flags里带有BLOCK_IS_GLOBAL,说明它本来就是全局 Block,直接返回原指针,不需要任何内存迁移。全局 Block 就像一个不可变常量,没有“栈上都堆上”的区分。
第二步,如果不是全局 Block,就在堆上申请一块新的内存空间,大小由descriptor->size字段决定。然后做一次memmove,把原来栈上的 Block 结构体原封不动地搬到堆上。这时候旧址还在,但已经不重要了。
第三步,调用descriptor->copy辅助函数,处理捕获变量和__block变量的迁移。这一步是最核心的内存管理环节:对于捕获的__strong对象,对它执行retain;对于__block变量,需要把栈上的__block结构体也搬到堆上,并调整__forwarding指针;如果__block变量内部还捕获了其他对象,也需要一并处理。
可以用一个简化的伪代码来表达这整个过程:
static void *_Block_copy(const void *arg) { struct Block_layout *aBlock = (struct Block_layout *)arg; if (aBlock->flags & BLOCK_IS_GLOBAL) { return aBlock; // 全局 Block 直接返回 } struct Block_layout *result = malloc(aBlock->descriptor->size); memmove(result, aBlock, aBlock->descriptor->size); // 整体拷贝 result->flags &= ~BLOCK_IS_GLOBAL; // 标记不再是栈 Block if (result->flags & BLOCK_HAS_COPY_DISPOSE) { result->descriptor->copy(result, aBlock); // 调用辅助函数处理捕获变量 } return result; }注意,这段代码是高度简化后的逻辑,实际 runtime 远比这个复杂。但它足以让你看清一个核心思想:Block_copy干的不是“给 Block 引用计数加一”,而是把 Block 从栈“搬运”到堆,并在搬运过程中处理好所有捕获变量的内存归属。
4.3 __block 变量随 Block 迁移到堆上的完整过程
前面提到__block结构体里有__forwarding指针,现在结合Block_copy的执行流程,把它的内存迁移过程讲完整。假设你在函数里定义了一个__block int count,并且创建了一个捕获它的栈 Block。内存中大致是这样的画风:
- 栈上有一个
__Block_byref_count_0结构体,里面存着count的值; - 栈 Block 的结构体捕获变量区里,存着指向这个结构体的指针;
__Block_byref_count_0内部的__forwarding指向它自己,表示“当前这份数据在栈上是有效的”。
当Block_copy执行时,runtime 发现 Block 捕获了一个__block变量,于是做两件事:
- 在堆上申请一块新空间,把
__Block_byref_count_0结构体完整拷贝一份过去; - 调整指针关系:栈上结构体的
__forwarding指向堆上的结构体,堆上结构体的__forwarding指向它自己。
这样设计的好处是,Block 被复制到堆上后,它访问__block变量的方式是通过指针 ->__forwarding-> 真正的数据。如果在复制之后,外部代码还拿着栈上结构体的指针访问count,由于__forwarding已经指向堆上那份,它也会感知到堆上的数据变化。这就是为什么__block变量在 Block copy 前后,Block 内外看到的始终是同一份数据的原因。
4.4 内存布局的典型现场:用 lldb 实测确认 Block 结构
理论讲了这么多,如果不实际看一眼内存布局,总觉得不踏实。我在日常调试时,经常用 lldb 直接打印 Block 的内部信息来验证猜想。给你一个可以直接实操的检查方法。
先写一段测试代码:
int x = 10; __block int y = 20; NSObject *obj = [NSObject new]; void (^block)(void) = ^{ x = x + y; NSLog(@"%@", obj); };在block()调用处打一个断点,然后在 lldb 里执行以下命令:
po block frame variable block你会看到类似下面的输出:
(void (*)(void)) block = 0x0000000100508f20这个地址是 Block 结构体的首地址。继续用内存读取命令查看:
memory read 0x0000000100508f20 0x0000000100508f40如果 Block 已经被 copy 到堆上,你会在isa偏移位置看到类似__NSGlobalBlock__、__NSStackBlock__或__NSMallocBlock__的类名指针。在堆上时通常是__NSMallocBlock__,在栈上时是__NSStackBlock__,全局时是__NSGlobalBlock__。这是最直观的判断 Block 当前所处位置的依据。
我在把这个方法分享给团队新同学的时候,经常说一句话:不要只靠直觉去猜 Block 在哪,用 lldb 打印一次类名,你就再也不会忘。那次语音录制崩溃的排查,最终我也是通过打印 Block 类名,确认了崩溃前的 Block 仍然是__NSStackBlock__,于是果断在所有异步接口的入口处补了copy,问题彻底消失。
5. 常见问题与排查技巧实录
5.1 问题:Block 偶现崩溃,真机上容易出现,模拟器上没事
这是很典型的现象,原因也很简单:模拟器和真机的栈地址空间、栈帧回收策略不同。栈 Block 在模拟器上可能因为栈帧没有被复用,侥幸还能访问到残留数据;真机上栈帧一旦被重用,悬空指针就会指向完全无关的内存。所以遇到 Block 偶现崩溃,而且模拟器复现不了,第一反应就应该去检查这个 Block 是不是被异步任务持有了,以及持有它的 API 是否保证了 copy。
排查手段可以先用符号断点,在_Block_copy和_Block_release处打断点,观察这个 Block 是否被 copy,以及 copy 的发生时机。如果某个 Block 从创建到执行都没有走到_Block_copy,但它的执行又发生在函数返回之后,那基本可以断定是栈 Block 悬空问题。
5.2 问题:__block 变量在异步回调里读到的值是 0
有读者跟我反馈过一个场景:在方法 A 里创建__block NSInteger count,然后在一个异步队列里把count赋值,方法 A 返回后读取count,发现一直是 0,不知道是哪一步出了问题。
这个问题的根源在于:__block变量绑定的生命周期受 Block 副本的影响。如果你的异步 Block 没有被 copy,或者你读取count的时机早于 Block 执行完毕,结果自然不对。还有一个容易忽略的细节:__block变量在 Block 被 copy 到堆上之后,它的存储位置会跟随 Block 副本走。如果你在方法 A 返回后直接读取原始栈上的count,由于栈结构体已经被回收或覆写,读到的值无法保证。正确做法是,通过__forwarding指针访问,或者确保读取操作也发生在堆 Block 的生命周期内,最好是直接通过 Block 回调拿值,而不要在外层直接读取__block变量。
注意:
__block不是“线程同步”工具,它只解决“变量捕获的可变性”问题。多个线程同时访问和修改__block变量,仍然存在数据竞争。别把__block当atomic用。
5.3 问题:Block 捕获了 self,到底会不会循环引用
这是面试里被问到最多的问题。先说结论:不一定。Block 捕获self导致循环引用的前提是:self持有这个 Block,而 Block 又捕获了self,形成一个互相持有的闭环。常见场景是:一个ViewController有一个copy类型属性myBlock,Block 内部使用了self。此时self->myBlock->self,双方引用计数都降不下来,内存泄漏。
但如果你把 Block 传给一个第三方框架,这个框架执行完就释放 Block,不持有它,那 Block 捕获self并不会形成循环引用。关键不是“Block 里能不能用 self”,而是“持有 Block 的人是谁”。这也是我在代码 review 时反复强调的一点:先判断 Block 的持有关系,再决定要不要 weakSelf。
为了安全起见,当 Block 作为属性被 self 持有时,无论场景看起来多安全,我都建议在 Block 内用一个 weak 修饰的 self 弱引用,然后在 Block 内部再用强引用接一下:
__weak typeof(self) weakSelf = self; self.myBlock = ^{ __strong typeof(self) strongSelf = weakSelf; if (strongSelf == nil) return; [strongSelf doSomething]; };这种写法既避免了循环引用,又保证了在 Block 执行过程中 self 不会中途被释放。这个“weak 弱引用 + strong 局部强引用”的组合,是我个人最推荐的做法。
5.4 问题:明明调用过一次 copy,为什么 Block 还会崩溃
有开发者跟我说,他手动copy过 Block,存储在NSMutableArray里,但后续取出调用时仍然崩溃。我第一反应是:copy过不等于存进去了。很多人会在局部变量持有 Block 时执行[block copy],但 copy 后的返回值没有保存,或者在使用时使用了原 Block 而不是 copy 后的副本。
// 错误示例 void (^block)(void) = ^{ ... }; [block copy]; // 返回值被丢弃,原 Block 还是栈 Block [array addObject:block]; // 存入的还是栈 Block // 正确示例 void (^block)(void) = [^{ ... } copy]; [array addObject:block]; // 存入的是堆 Block这个错误相当隐蔽,因为你的意图是“我已经 copy 过了,应该安全”,但实际上根本没有把 copy 后的对象保存下来。所以这里给出一个硬性习惯:执行copy之后,立刻用等号左边接收返回值,不要写裸的[block copy]。类似这种问题,往内存布局上想,往往很快就能找到原因。
5.5 常见问题速查表:一眼定位 Block 内存问题
| 现象 | 最可能原因 | 解决方向 |
|---|---|---|
| 异步回调时 Block 偶现崩溃 | 栈 Block 悬空 | 确保 Block 被 copy 后再传给异步接口 |
| Block 类名是NSStackBlock且执行时机晚于创建函数 | 未 copy | 使用 copy 修饰属性,或显式 copy |
| __block 变量值不一致 | 栈结构体已回收 / 未通过 forwarding 访问 | 使用 Block 回调传值,避免外层直接访问 |
| 保存 Block 到容器后崩溃 | copy 返回值被丢弃 | 将[block copy]结果赋值给变量 |
| self 和 Block 互相影响无法释放 | 循环引用 | 用 weakSelf + strongSelf 解除闭环 |
| Block 不捕获任何变量,但传入异步后仍崩溃 | 通常不会发生 | 全局 Block 生命周期同 App,优先检查其他地方 |
6. 结尾:一点个人体会与调试建议
Block 的内存布局这个话题,内容并不轻松,但它是 iOS 开发中少有的“一旦弄懂,整个内存管理认知都会提升一个台阶”的知识点。我见过很多开发者,Block 用了好几年,遇到崩溃还是靠全局断点瞎猜,然后给代码加上各种莫名其妙的copy和dispatch_after来“碰运气”解决问题。这种处理方式不仅不能根治问题,还会给后续维护埋雷。
根据我自己的经验,排查 Block 内存问题,核心就抓三个东西:一是 Block 当前的类名,确认它是在栈上还是在堆上;二是 Block 捕获了哪些变量,这些变量的内存归属在 Block copy 时发生了什么变化;三是 Block 的持有链,到底是谁在什么时机持有,生命周期到哪结束。把这三点想明白,绝大多数 Block 崩溃和内存泄漏问题都能迎刃而解。如果你在实际开发中遇到了我上面没覆盖到的 Block 内存问题,建议从这几个角度再顺一遍。