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_GLOBAL1 25全局 Block不捕获外部变量BLOCK_HAS_COPY_DISPOSE1 25实际为 1 25全局 Block 标志的邻位表示该 Block 有 copy/dispose 辅助函数BLOCK_HAS_SIGNATURE1 30带有方法签名用于类型描述BLOCK_USE_STRET1 29返回值是否使用结构体返回这里要特别区分BLOCK_IS_GLOBAL和BLOCK_HAS_COPY_DISPOSE。全局 Block 因为不捕获外部变量、不涉及内存管理所以它的flags里不会设置BLOCK_HAS_COPY_DISPOSE也没有对应的copy和dispose函数。而一个捕获了__strong对象类型变量的栈 Blockflags里就一定会带上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 会变成堆 Blockcopy 操作的触发时机在 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类型属性myBlockBlock 内部使用了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 内存问题建议从这几个角度再顺一遍。