深入解析Objective-C __block变量底层内存布局与循环引用问题

📅 发布时间:2026/9/8 5:59:58
深入解析Objective-C __block变量底层内存布局与循环引用问题
很多iOS开发者在刚接触block时都遇到过一个问题在block里明明可以读外部的局部变量但一赋值就报错提示Variable is not assignable (missing __block type specifier)。报错信息已经告诉你解法了——加上__block修饰符但那时候大部分人只是机械地加上并不知道这背后发生了什么。我在早期也是这样直到后来因为一个block的循环引用问题排查了两天才痛下决心把__block变量的内存布局研究明白。这篇文章就把这些底层的知识整理出来。它不是一个API使用手册而是一份从编译器视角、从内存布局视角重新看待__block变量的指南。读完你可以清楚地回答__block变量到底存在哪里为什么加上__block就能改值block从栈拷贝到堆的时候__block变量经历了什么以及ARC下__block变量的循环引用问题到底怎么回事。这篇文章适合所有正在使用Objective-C的开发者尤其是对block的理解还停留在会写、会用层面、遇到内存问题却说不出原因的人。读完我相信你会对block和__block有完全不同的认识。1. 从block里为什么改不了外部变量说起——捕获机制的真相1.1 block捕获普通局部变量时做了什么先回到最基础的问题为什么不加__block就改不了外部局部变量很多人只知道结论但不清楚根因。我用一个最简单的例子说明int a 10; void (^block)(void) ^{ // 这里可以读a但不能写a NSLog(%d, a); }; block();在C语言层面block本质上是一个结构体编译器会把这个block改写成一个类似这样的结构struct __block_impl { void *isa; int flags; int reserved; void *FuncPtr; }; struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0 *Desc; int a; // 捕获进来的变量注意是值拷贝 };也就是说当block捕获一个普通局部变量a时它不是去持有a的引用而是把这个变量的值复制了一份作为block结构体的一个成员变量。你可以理解为block只是给这个值拍了一张照片照片放在block自己的结构体里。之后你在block内部读取a读的其实是block结构体里的那个成员不是外部栈上那个a。所以在block内部给a赋值就相当于尝试修改block结构体里那个被拷贝的成员而这个成员在编译时是作为const拷贝进来的编译器直接就不允许你这样做。1.2 不同变量的捕获规则差异这个捕获规则不是对所有变量都一样的这点很多人容易混淆。我整理一下变量类型block内部能否修改捕获方式普通局部变量否值拷贝block持有副本static局部变量是指针拷贝block持有变量地址全局变量是直接访问不需要捕获__block局部变量是通过特殊结构体间接访问static局部变量之所以可以修改是因为编译器改成传递这个变量的指针地址block内部通过指针去操作原始变量。全局变量更简单全局区地址固定block内部直接按符号访问。那__block呢它走的是另一条完全不同的路——编译器不是把a的值拷贝进block结构体而是创建一个额外的结构体对象来包装这个变量然后再把结构体指针传给block。这条路就是本文的核心。2. 编译器眼里的__block变量__Block_byref结构体逐字段拆解2.1 用clang -rewrite-objc偷看底层想真正搞清楚__block的内存布局最直观的方式是让编译器把Objective-C代码改写为C代码看看它到底生成了什么。先准备这样一段代码int main() { __block int a 10; void (^block)(void) ^{ a 20; NSLog(%d, a); }; block(); return 0; }然后在终端执行clang -rewrite-objc main.m会生成一个main.cpp文件。核心内容在这里我做了一些精简和整理// 这是编译器为 __block int a 生成的结构体 struct __Block_byref_a_0 { void *__isa; __Block_byref_a_0 *__forwarding; int __flags; int __size; int a; // 原始变量 }; // block的结构体 struct __main_block_impl_0 { struct __block_impl impl; struct __main_block_desc_0 *Desc; __Block_byref_a_0 *a; // 指向上面结构体的指针 };看到这张结构图很多事情就清楚了带__block的变量a并没有被直接拷进block而是生成一个__Block_byref_a_0结构体block结构体里只是保存了这个结构体的指针。2.2 四个字段各司其职__Block_byref_a_0结构体里每个字段都有自己的职责我逐个拆开讲__isa这个字段在Objective-C对象里本来是指向类对象的指针在__block变量结构体里它通常为0或者指向NULL。这个字段的存在是为了让结构体在布局上和Objective-C对象兼容但实际使用中我们不依赖它。__forwarding这个是最关键的字段指向真正存储变量的结构体。为什么需要一个额外的跳转后面我专门用一节来讲这里先记住它是用来解决block从栈拷贝到堆之后变量归属问题的。__flags标志位用来标记这个变量有没有被拷贝到堆上以及变量是不是对象类型等信息。具体位含义在不同平台略有差异一般情况下我们不需要手动操作它。__size结构体大小。这个字段非常重要因为block在拷贝或释放时需要知道__Block_byref结构体到底占多少字节才能正确执行内存操作。a这才是真正的原始变量。我们的__block int a在内存里的本体就是结构体里的这个字段。block在访问a的时候不是直接访问而是会走一段类似这样的代码// block内部对a的赋值会被改写成 (a-__forwarding-a) 20;也就是说通过block持有的__Block_byref_a_0 *a指针先找到__forwarding再通过__forwarding找到真正的变量。前面提到的拍照比喻在这里不适用了更准确的理解是block拿着一个地址通过这个地址去访问一个间接层再通过间接层里记录的指针找到真正的变量的位置。2.3 descriptor里的拷贝与销毁函数如果你继续往下看重写生成的代码还会发现一个__main_block_desc_0结构体里面除了常规的reserved和size字段之外多了两个函数指针struct __main_block_desc_0 { size_t reserved; size_t Block_size; void (*copy)(struct __main_block_impl_0 *, struct __main_block_impl_0 *); void (*dispose)(struct __main_block_impl_0 *); };这两个函数指针是干什么的当一个block从栈上被拷贝到堆上时copy函数会被调用它的职责是如果block捕获了__block变量、对象变量或其他需要特殊内存管理的变量就要把这些变量也做相应的拷贝或引用计数调整。dispose函数则在block从堆上释放时被调用负责释放相关资源。这里要特别说明如果__block变量只是一个int、float这样的普通数据类型copy和dispose函数可能是个空的实现或者非常简单的逻辑但如果__block变量持有了Objective-C对象那么copy函数就要负责把对象正确retaindispose函数负责release。所以不要小看这两个函数指针它们是ARC内存管理在block生命周期里的关键入口。3. __forwarding指针栈与堆之间的导航员3.1 为什么要拐一道弯__forwarding的设计在整个__block内存布局里是最精妙的地方也最不容易理解。我当年第一次看到这个字段时也是一头雾水搞不懂为什么明明直接持有一个变量还要搞一个中间指针绕来绕去。答案在于block和__block变量最初都诞生在栈上它们的生命周期本来只属于当前函数作用域。但是block经常需要被存储到堆上或者被传递到其他地方使用比如作为一个属性被持有、被异步操作引用。这时候block会被从栈拷贝到堆。问题是__block变量该怎么办如果__block变量留在栈上而block去了堆上那么block访问__block变量时就可能访问到已经被销毁的栈空间。如果直接把__block变量也一并拷贝到堆上那么原来代码里访问栈上的a的地方和block里访问堆上的a的地方就变成了两个不同的变量——这显然不符合__block的语义因为__block存在的意义就是让block内外共享同一个变量。__forwarding就是把这个矛盾解决掉的机制。3.2 从栈拷贝到堆时发生了什么我画一个完整的过程描述帮助你建立画面感。第一步代码运行在栈上创建__Block_byref_a_0结构体栈上的结构体的__forwarding指向自己。此时block也在栈上block结构体里保存指向这个结构体的指针。第二步block被拷贝到堆上。拷贝的触发时机可能是手动执行[block copy]在ARC下把block赋值给一个strong属性或变量block作为参数传入某些会copy它的APIblock被放入NSArray、NSDictionary等容器拷贝发生时copy函数不仅会把block结构体本身从栈复制到堆也会把__Block_byref_a_0结构体从栈复制到堆。关键来了在拷贝完成后栈上的__Block_byref_a_0的__forwarding指针会被更新指向堆上的那个新拷贝的结构体。也就是说整个流程下来栈上的结构体变成了一个代理它的__forwarding指向堆上的真实结构体。而堆上的结构体的__forwarding指向自己。之后不管是从block内部访问还是从外部原始代码位置访问表达式最终都通过__forwarding-a来定位变量所以访问到的都是堆上那一份这就保证了共享语义。我用一个代码片段来模拟这个过程的核心变化__block int a 0; // 栈上结构体 S 创建S-__forwarding S void (^block)(void) ^{ a; // 实际是 (a-__forwarding-a) }; // 这里block被拷贝假设拷贝到堆上的结构体为 H // 拷贝完成后S-__forwarding HH-__forwarding H // 此时从任何路径访问 a都会访问到 H-a如果没有__forwarding这层跳转当block被拷贝到堆上时你很难保证外部引用和block内部引用指向同一个变量。栈上的结构体不能动了因为其他代码可能还在用它的地址直接改block里的指针也只能影响block自己的访问路径。__forwarding的存在让所有路径都归一化到通过__forwarding找到真正存储位置这个统一的访问方式上问题就迎刃而解了。3.3 没有__forwarding会怎样这里做一个假设推演如果没有__forwardingblock被拷贝到堆上后堆上的block和栈上的__block变量结构体就会脱节。如果函数返回栈上结构体被销毁堆上的block访问到一个悬空的指针轻则值错乱重则崩溃。即便你非常小心地保证在函数返回前一定使用完block但block一旦被拷贝到堆上就脱离了函数的生命周期控制你根本没法保证它什么时候会被执行。所以__forwarding不是可选的优化而是保证block和__block内存安全的基本机制。4. 堆上的内存布局与生命周期验证4.1 用代码实测各阶段变量地址理论讲再多不如跑一段代码来验证。我写了一个小例子在不同阶段打印变量的地址你可以直接跑一下亲眼看内存地址怎么变。#import Foundation/Foundation.h void testBlockVariableAddress() { __block int a 0; int *stackA a; NSLog(1. block执行前a的地址: %p, stackA); void (^block)(void) ^{ int *innerA a; NSLog(2. block内部a的地址: %p, innerA); a; NSLog(3. 修改后的a值: %d, a); }; block(); // 此时block还在栈上block内部地址和外部应该一致 void (^heapBlock)(void) [block copy]; // 拷贝到堆上 NSLog(4. 拷贝后外部a的地址: %p, a); heapBlock(); }从实际执行结果来看前三个日志打印的地址通常是一致的或者至少通过__forwarding访问到的都是同一个结构体。第4步打印的地址在block从前没有被使用过、现在被拷贝到堆上的场景里外部a访问到的是栈结构体但栈结构体的__forwarding指向堆上的结构体所以值仍然是共享的。如果你在block内部打印a经过__forwarding跳转后它指向的就是堆上结构体里的变量地址。这个地址的变化过程其实就证明了前面说的__forwarding跳转机制。4.2 block copy前后内存布局对比我用一张文字化的表格来展示copy前后内存布局的变化阶段栈上堆上访问结果copy前存在block结构体和__Block_byref结构体__forwarding指向自身无访问栈上结构体变量copy后block结构体可能还在__Block_byref结构体的__forwarding指向堆上存在block结构体和__Block_byref结构体__forwarding指向自身访问堆上结构体变量栈结构体销毁后已不存在仍存在通过栈上残留指针或block内部指针经__forwarding访问堆上变量这里有一个非常重要的点block被拷贝到堆上时它捕获的对象类型变量和__block变量的所有权处理是不完全一样的。普通对象变量被捕获时如果block被拷贝对象会被retain而__block变量结构体被拷贝时取决于结构体内部变量的类型如果是对象也会被retain如果是普通类型则只是值拷贝。4.3 __block变量的释放时机__block变量结构体跟随block的生命周期。block在堆上时__block变量结构体也在堆上block被释放时相关联的__block变量结构体也会被释放。也就是说__block变量的生命周期从跟随栈帧变成了跟随block。这是理解循环引用的基础。如果多个block同时捕获同一个__block变量每个block拷贝时都会重新拷贝一份__Block_byref结构体吗答案是否定的。系统会尽量复用已经存在的堆上结构体通过引用计数来管理。这一点在多个block共享同一个__block变量的场景里特别重要不要假设每个block都有自己独立的__Block_byref结构体。5. 内存管理中的连环坑与规避方案5.1 ARC下__block对象变量的循环引用问题这是很多开发者会踩的坑也是最容易搞混的地方。在ARC下__block修饰一个对象类型变量时当block从栈拷贝到堆__Block_byref结构体里的对象指针会被强引用。也就是说只要堆上的block还存在这个对象就不会被释放。考虑这样一个场景typedef void (^MyBlock)(void); implementation MyObject - (void)setupBlock { __block MyObject *weakSelf self; // 注意这里用__block而不是__weak MyBlock block ^{ NSLog(%, weakSelf); }; // 如果block被长期持有 self.block block; }这里的问题是self持有blockblock持有的__Block_byref结构体里强引用了weakSelf而weakSelf指向的就是self本身。这形成了一个完整的循环引用链导致self无法被释放。很多人在这个场景里误以为__block就是用来打破循环引用的这其实是把__block和__weak搞混了。正确的做法是__weak MyObject *weakSelf self; // 真正的打破循环引用的方式 MyBlock block ^{ NSLog(%, weakSelf); }; self.block block;__weak不会retain对象block持有的只是对象的弱引用不参与引用计数循环引用自然就断开了。5.2 __block与__weak/__strong的真实关系我还见过一些人把__block和__weak、__strong放在一起比较问哪个更好。这其实是两个完全不同维度的修饰符。__block是存储类型修饰符核心作用是改变变量被block捕获时的存储方式让变量可以在block内部被修改。__weak和__strong是所有权修饰符核心作用是控制对象的引用计数。一个变量可以同时使用__block和__weak吗可以。比如__block __weak MyObject *obj self;这种情况下obj这个变量本身存储在__Block_byref结构体里但结构体里对obj的引用是弱引用不会影响对象的释放。实际开发中这种写法偶尔会出现在需要在block内把弱引用重新赋值给某个临时变量、又希望在block执行期间对对象的生命周期做精细控制的场景。不过大多数情况下__weak block内部转成__strong的写法已经足够了。5.3 嵌套block中的__block变量传递嵌套block是另一个容易出问题的地方。当一个__block变量被外层block捕获然后外层block内部又创建了一个新的内层block内层block也捕获了这个变量这时候内存布局会怎么变化我遇到过这种场景外层block被拷贝到堆上然后内层block也可能被拷贝。如果内层block又被单独拷贝了一份__Block_byref结构体那整个共享关系是不是就被破坏了答案是不会。因为__forwarding机制保证了所有路径最终都指向堆上的同一个结构体。内层block捕获的不再是栈上那个__Block_byref结构体的地址而是通过外层block访问到的堆上结构体的地址。所以只要理解了__forwarding是全局唯一的访问入口嵌套block的问题就迎刃而解了。不过嵌套block场景下还有一个需要注意的点如果你在block内部对__block变量做修改而这个block在异步执行另外一段代码也在同时修改同一个变量就会产生数据竞争。__block不提供任何线程安全机制它只是一个存储和共享的解决方案。多线程环境下使用__block变量需要自己加锁或者使用原子操作否则会有数据竞争和未定义行为。5.4 block被多次copy时的行为还有一个容易忽略的行为对同一个block多次执行copy操作并不会每次都重新生成一份新的__Block_byref结构体。系统内部会通过引用计数来管理堆上的block结构多次copy只是增加引用计数不会导致__block变量结构体被复制多份。这解决了一个潜在的性能问题也避免了一个逻辑问题如果每次copy都复制一份__Block_byref结构体那么不同block copy之间就变成了不同的变量彻底违背了__block的共享语义。6. 实战经验总结与调试技巧6.1 用lldb查看__block变量真实地址在Xcode里断点调试时直接在lldb里打印a有时候看到的是__Block_byref结构体的地址你会觉得它和外面代码打印的a不一样这是因为编译器在断点环境下可能会对访问做了一些优化或者重写。需要留意的是你在lldb里看到的是一个中间结构体的地址真正的业务变量是通过__forwarding跳转之后才能访问到的对象。如果要在lldb里手动查看__block变量的底层结构可以这样做(lldb) frame variable -L这个命令会输出当前栈帧里所有变量的内存地址包括编译器的重写情况。如果你的代码里有__block int a你可能会看到类似这样的输出0x00007ffeefbff958: (__block int) a 20地址前缀是0x7ffe开头的就是栈地址0x6000开头的是堆地址。当你看到a的地址从栈变成堆就说明block被拷贝了__block结构体也跟着去了堆上。6.2 从崩溃日志里识别__block野指针如果你在处理block相关的崩溃时崩溃栈里出现__Block_byref相关的符号或者EXC_BAD_ACCESS时地址指向一个栈地址就要高度怀疑是不是block被释放了但还有地方在访问。一个常见场景是这样的block被某个对象持有但持有对象已经被释放了block本身也已经被释放了然后某个定时器或者异步回调还在调用这个block的指针。这时候如果block里捕获了__block变量访问__forwarding-a就会读到已经被回收的内存产生野指针。排查这类问题的时候建议优先检查block被持有的链条block被谁持有持有的对象生命周期是不是覆盖到了block的调用时机block捕获的__block变量是否被多个对象共享如果共享释放顺序是什么样的把这三条链路理清楚了block相关崩溃的排查就完成了一大半。6.3 从工程角度如何优雅使用__block虽然__block机制很强大但我的实际经验是尽量少用。__block在大多数场景里的真实需求是在block内部修改外部变量但这种需求往往可以通过设计来规避。比如把需要修改的状态封装成一个可变对象block内部只修改对象的属性就不再需要__block修饰属性本身了。又比如用局部变量在block前面处理完逻辑再把结果传递给block需要用的参数。这样做的优点是后续的内存管理逻辑更简单可读性更高。当然在需要真正共享状态的场景下__block是绕不开的。比如一个网络请求返回的数据需要在多次回调里累加到同一个变量里比如一个计数器需要被多个block共享用__block就是最自然的写法。这时候只要把生命周期和循环引用这两个问题处理干净用__block完全没问题。6.4 我踩过的最后一个坑分享一个我自己犯过的错误。有一段时间我在写一个音频处理工具用__block来保存中间数据指针然后在一个异步block里访问。当时想当然地认为block会被拷贝到堆上所以__block变量也会在堆上应该是安全的。结果忽略了block底层是存储在一个局部变量里的在ARC下block从栈拷贝到堆的时机并不总是立即发生的。在某些场景下block仍然停留在栈上而栈上空间在函数返回后就失效了异步再去访问就出现了玄学崩溃。从那以后我养成了一个习惯凡是block可能被异步调用的我一定显式地调用一次copy或者通过强引用属性让ARC帮我们处理确保block和__block结构体都稳定地在堆上。这些经验分享出来是想提醒你__block不仅仅是加一个修饰符那么简单它背后是一套完整的内存管理和共享机制。理解了它的设计原理再遇到block相关的内存问题会从容得多。