之前在排查一个诡异的崩溃时,最后定位到一个__block变量被提前释放的问题。代码看起来人畜无害:一个对象指针用__block修饰,在block里判断后给另一个变量赋值,结果block执行到一半,那个__block变量已经指向了已经被释放的对象。后来回头翻了一下编译后的结构体,发现一切早就写在了内存布局里。如果你也把__block当成“普通的局部变量升级包”,那你迟早会踩类似的坑。这篇文章专门聊__block变量在底层的内存布局,包括编译器是怎么把它封装成结构体的、__forwarding指针到底在干什么、block从栈拷贝到堆时变量是如何“搬家”的,以及几种常见的坑和排查方法。适合所有写Objective-C的开发者,尤其是想弄清楚Block底层机制的读者。
1. 为什么Block已经能捕获变量,还要专门搞一个__block
先说一个最基础的场景。在OC里,block默认捕获外部变量时,是把它变成一份不可变的副本。也就是说,你在block内部读到的那个变量,和外部声明的变量根本不是同一份内存。所以如果你直接在block里给一个外部变量赋值,编译器会直接报错:Variable is not assignable。
objc复制int counter = 0;
void (^block)(void) = ^{
counter = 10; // 编译错误:Variable is not assignable
};
这个设计其实是合理的。block在创建时,外部变量在栈帧里,block本身也可能在栈上,如果直接让block内部持有外部变量的地址,那外部变量一旦生命周期结束,block就拿到一个悬垂指针。所以C语言风格的编译器选择“拷贝一份值进来”,这样block内部永远操作的是自己的那份数据。但这么做带来另一个问题:block内部修改的值,外部完全感知不到。
objc复制int counter = 0;
void (^block)(void) = ^{
NSLog(@"%d", counter);
};
counter = 100;
block(); // 输出还是0,因为block捕获的是创建时刻的那份拷贝
看到这个结果之后,你应该能理解我们为什么需要__block了。它要做的不是“让外部变量在block里可修改”,而是把这个变量的存储从一个普通的栈变量,变成一个能被block和外部作用域共同引用的共享结构体。这个结构体在编译期被创建,在运行期被block捕获为指针,从而让block内部和外部操作的是同一块内存。
简单说:默认捕获是“值传递”,__block是“引用传递”。从内存布局视角看,__block变量不再只是变量本身,而是一个由编译器包装出来的结构体对象。理解这一点,后面所有细节都顺了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内存布局拆解:编译器把__block变量变成了什么
2.1 编译器把__block变量封装成了结构体
在Clang的底层实现里,每个__block变量都会被重写成一个结构体。变量名通常类似__Block_byref_counter_0,这里的counter是你原来的变量名,0是序号。结构体布局大概是这样的:
c复制struct __Block_byref_counter_0 {
void *__isa;
struct __Block_byref_counter_0 *__forwarding;
int __flags;
int __size;
int counter; // 这才是你真正的变量
};
不同编译版本字段名可能有细微差别,但核心布局是稳定的:前面有4个元数据字段,之后才是你声明的变量本体。当你写__block int counter = 0时,编译后的代码会创建这个结构体的实例,并把counter字段初始化为0。block内部捕获的也不再是一个int变量,而是指向这个结构体的指针。
这个封装结构体在Objective-C的runtime里被称为“byref变量”。它被设计成类似对象的形式,含有__isa指针,所以某些场景下可以像对象一样参与内存管理。不过对于纯基本类型,__isa并不指向真实类对象,它更多是占位作用和用来标记类型。
2.2 结构体里每个字段到底在干什么
逐个看这些字段,你会发现它们没有一个是多余的。
__isa用于标识这个byref变量属于什么类型。在ARC下,如果变量是一个OC对象,某些情况下这个指针会指向一个类,用来管理对象的所有权。如果变量是基本类型,它可能为空或指向一个特殊的占位信息。
__forwarding是这个结构体里最关键的指针。它指向“真正生效”的结构体实例。为什么要多一层转发?因为block和这个byref变量可能被拷贝到堆上,而栈上的旧结构体还保存着同一个变量的旧地址。如果每次都直接使用结构体地址,就可能出现两边不同步的问题。__forwarding的存在就是为了解决这个同步问题,后面我会单独展开。
__flags保存一些标志位,用来告诉runtime这个变量的类型、内存管理方式等。比如是否包含OC对象、是否需要retain/release等。
__size是这个结构体的大小。runtime在把byref变量从栈拷贝到堆时,需要知道复制多少字节,这个字段就是用来做内存拷贝的长度依据。
最后才是真正存储的数据字段。如果你声明了多个__block变量,编译器会为每个变量生成一个独立的结构体。注意,如果是__block NSMutableArray *array这种对象指针,这个字段的类型就是NSMutableArray *,结构体里保存的是指针值,不是对象本身。
2.3 __forwarding:一个容易被忽视的指针
__forwarding的经典使用场景是block和byref结构体之间的转移。在block还停留在栈上时,byref结构体也在栈上,此时结构体的__forwarding指向它自己。访问变量时,会通过(object->__forwarding)->counter来获取值。
当block被拷贝到堆上时,runtime会创建一个新的byref结构体放在堆上,然后把堆上结构体的__forwarding指向堆上结构体自己,同时把栈上旧结构体的__forwarding指向堆上的新结构体。这样一来,无论你在block外通过旧栈结构体访问变量,还是在block内部通过捕获的堆结构体访问变量,最终都会被转发到堆上的最新数据。
如果没有这层间接跳转,block被拷贝后,外部变量和block内部变量就指向两个不同的结构体,修改一处另一处看不到,__block就失去意义了。这个设计说白了就是给变量做了一次“重定位”,确保所有访问路径都收敛到最新的内存副本。
从运行期来看,block里的变量访问代码会被改成类似这样:
c复制counter = 20;
// 实际是:
(block->__forwarding)->counter = 20;
先拿到结构体,再通过__forwarding跳到真正的结构体,然后才读写字段。理解了这一步,__block内存布局的核心就已经拿下了。
3. 运行时迁移:栈上的变量是怎么活到堆上的
3.1 从Block_copy开始看变量搬家
要理解__block变量的完整生命周期,必须看block何时从栈上拷贝到堆上。在MRC时代,block默认创建在栈上,如果函数返回后还继续使用这个block,就需要手动调用Block_copy。在ARC时代,编译器会自动对需要逃逸的block执行拷贝,但底层逻辑和Block_copy是一样的。
给block做拷贝时,runtime会执行一个关键函数_Block_object_assign,这个函数会扫描block捕获的外部变量,根据变量类型做对应处理。对__block变量而言,它会把原始的byref结构体从栈上拷贝一份到堆上,然后重新配置两个结构体的__forwarding指针。
具体流程可以这样理解:
- 在栈上分配一个新的byref结构体,按
__size字段的大小拷贝原有数据。 - 将堆上结构体的
__forwarding指向堆上结构体自己。 - 修改栈上原结构体的
__forwarding,让它指向堆上结构体。 - 如果byref变量内部包含OC对象,还会对对象执行retain(在ARC下)。
这个“复制原数据”的过程并不复杂,复杂的是对象所有权处理。如果__block修饰的是一个OC对象指针,编译器不仅需要转移结构体,还需要决定是否retain这个对象。在默认情况下,block在拷贝时会对__block对象指针进行强引用,避免对象提前销毁。
3.2 从Block_release看变量如何被回收
有拷贝就有释放。当堆上的block被释放时,runtime会调用_Block_object_dispose。它同样会遍历block捕获的变量,对__block变量执行反向操作:释放byref结构体所占的内存,并对其中的OC对象执行release。
这里有个容易踩的细节:如果一个__block变量没有被拷贝到堆上,例如block只在一个函数内部使用,那block和byref结构体都还在栈上,根本不会触发_Block_object_dispose。只有在block被拷贝到堆上之后,block释放时才会执行完整的dispose流程。
所以内存布局要分成两种情况看待:
- 栈上block捕获栈上byref:一切都是临时的,函数返回就死,不需要额外的内存管理。
- 堆上block持有堆上byref:block的生命周期管理了byref的内存,byref再管理OC对象。
如果你在调试时发现__block变量对应的对象释放时机不对,优先怀疑block是否被copy过,以及block本身存储在栈上还是堆上。
3.3 ARC时代:大多数时候你看到的block都是堆block
ARC对block的重要改动是:编译器会自动把需要跨作用域的block从栈上拷贝到堆上。所以在ARC下,你通常不用手动调Block_copy,但你以为的“局部block”如果被赋值给属性、被放入集合、被异步队列持有,它都会被自动拷贝到堆上,同时里面的__block变量也跟着搬去了堆。
这意味着即使在ARC下,只要block被保留到函数返回之后,__block变量也不再保存在原来的栈帧里。你的外部局部变量在block创建后返回前就被编译器“换了一个身份”,变成了一块堆内存。这带来一个反直觉的结果:函数栈帧释放了,__block变量却还活着,直到block被释放才死。
我整理过一张对比表,方便看不同存储区域的生成时机:
| 存储位置 | 生成时机 | 生命周期 | 访问方式 |
|---|---|---|---|
| 栈上byref | block创建于栈上时 | 与所在函数/作用域绑定 | 栈地址 + __forwarding转发 |
| 堆上byref | block被copy到堆上时 | 与堆block生命周期一致 | 堆地址 + __forwarding转发 |
| 全局静态区的byref | block作为全局变量时 | 整个程序生命周期 | 全局地址 |
注意第三种情况。如果block本身是全局的,比如定义在文件作用域且没有捕获自动变量,那么它捕获的__block变量也会分配在全局静态区。这也是一个冷门坑点:全局block里的__block变量生命周期很长,修改它会影响到所有引用它的地方,一定要小心。
4. 实战中的高频坑与调试方法
4.1 循环引用的本质和两种解法
网上聊__block self循环引用的文章很多,但多数都停在“用__weak替代”的结论上。从内存布局看,循环引用的本质是:byref变量被堆block持有,而byref内存储的对象指针又强引用了一个对象,这个对象又持有这个block,从而形成“block -> byref结构体 -> 对象 -> block”的闭环。
在MRC时代,__block修饰self并不会造成循环引用,因为block不会强引用byref。到了ARC,编译器默认对byref内的对象指针做了强引用,所以__block捕获self时,self会被byref强保留,而这个block如果又被self持有,循环就出现了。
解决办法有两个主流方向。第一个是使用__weak:
objc复制__weak typeof(self) weakSelf = self;
self.block = ^{
[weakSelf doSomething];
};
这里weakSelf本身不是__block变量,编译器捕获的是weakSelf的地址,而weakSelf指向对象时不增加引用计数,所以打破循环。
第二个方法是继续用__block,但是在block执行完毕后手动断开引用:
objc复制__block typeof(self) blockSelf = self;
self.block = ^{
[blockSelf doSomething];
blockSelf = nil; // 断开对self的强引用
};
这种方式在需要延迟持有时很有用,但注意block必须执行一次,否则block会一直持有byref,循环就一直在。从内存布局上看,你是在手动重置byref里的blockSelf字段,让它不再指向self,从而斩断保留环。如果你写了一个永远可能不被执行的block,最好别用这个方案。
4.2 __block对象指针在block内被置空时发生了什么
如果你在block内部把一个__block对象指针置nil,需要知道这背后不是简单的赋值,而是对原对象做一次release。比如:
objc复制__block id obj = [NSObject new];
void (^block)(void) = ^{
obj = nil;
};
编译器会在obj = nil时,先对原对象执行release,再更新byref里的指针值。如果你在block外还持有obj,那block外访问的obj也会变成nil,因为两者通过同一个byref结构体共享数据。正是这种“共享”特性,让许多人在排查问题时误以为是多线程问题,其实只是__block的内存布局导致变量被统一更新了。
一个更常见的坑:在block里把__block对象置nil之后,如果后面继续读取这个变量,拿到的就是nil。一旦你没有判空,就会产生空指针操作。
4.3 多线程修改__block变量导致的数据竞争
__block经常被用来传入异步回调,这时候如果多个线程同时读写同一个__block变量,实际上就是在并发读写同一块堆内存。由于没有任何同步机制,数据竞争就出现了。比如经典的数组遍历:
objc复制__block NSInteger count = 0;
dispatch_apply(1000, queue, ^(size_t i) {
count += 1;
});
最终count可能不等于1000,因为count += 1不是原子操作。底层是读byref结构体里的count字段,加上1,再写回去,中间被其他线程打断就会丢数据。
建议:多线程环境不要直接用__block变量做累加或状态切换。要么使用OSAtomic或atomic属性,要么用串行队列/锁来保证原子性。实在要用__block,至少单独加锁:
objc复制__block NSInteger count = 0;
NSLock *lock = [NSLock new];
dispatch_apply(1000, queue, ^(size_t i) {
[lock lock];
count++;
[lock unlock];
});
4.4 用lldb直接观察__block内存布局
纸上谈兵再多,不如亲手看一眼。在Xcode里,给block调用打断点,然后在lldb里输入一些命令,就能直接看到__block变量的真实结构。常见的办法是用frame variable -L显示变量的地址和存储位置。
lldb复制frame variable -L myBlockBlock
或者用expression打印结构体内部字段:
lldb复制expr myBlockBlock->__forwarding->count
如果你在block内部,直接po _cmd之类的没什么用,更好的是把block对象转成类型信息:
lldb复制image lookup -t __Block_byref_count_0
这个命令会显示CC byref结构体的定义,你可以对照字段名访问。有一次我就是用这种方式发现栈上的byref结构体__forwarding指向了堆地址,才确认block已经被拷贝,从而解释了为什么另一个局部变量已经失效但__block变量仍然能被修改。
还有一个更直接的方法是打开Xcode的“Debug -> Debug Workflow -> View Memory”,输入byref结构体地址,直接看内存字节。不过我觉得最实用的还是用expr打字段,效率最高。
4.5 和static变量/普通变量的对比
很多开发者在遇到block需要修改局部变量时,会走捷径:把变量改成static,或者搞成全局变量。这样做确实能绕过编译错误,但内存布局完全不一样。
普通局部变量就是栈上的值拷贝,block内部拿到的是副本,修改不影响外部。static局部变量放在静态存储区,block可以直接引用它的地址,所以能修改外部变量,但代价是变量变成了全局生命周期、多个block共享同一份数据。用static做block间的通信,很容易引入隐含状态和线程安全问题。
另一个不好的做法是用全局可变容器来传数据。比如搞一个NSMutableDictionary单例,block往里面写,外面读。这种做法在没有锁的情况下同样会导致竞争问题,而且破坏封装性。
相比之下,__block变量封装成结构体后,仍然保留局部语义:它的作用域本质是局部的,只是内存被搬到了堆上。它不像static变量那样全局共享,只被捕获它的block和外部作用域共享。这一点在小型异步任务里特别实用,既保留了局部变量的“隔离性”,又允许block内外修改。
5. 我踩过几次坑之后的几点心得体会
我早期写OC时,对__block的理解一直停留在“让block可以修改局部变量”这个结论上,直到被几个线上问题按在地上摩擦之后,才开始认真看编译产物和运行时结构。后面实践下来,有几个心得:
第一,遇到block修改外部变量没生效,不要先怀疑编译器。优先检查内存布局:变量是在栈上还是在堆上,__forwarding指向谁。大多数“改了没反应”的问题,其实是捕获到了旧对象,而不是__forwarding异常。
第二,不要滥用__block。很多人习惯把block里所有要用的变量都加上__block,以为这样“更灵活”,结果一不小心就把外部对象强引用住了,导致循环引用或延迟释放。能用参数传递的就用参数传递,能返回值的就直接返回值,只有必须跨作用域共享的才用__block。
第三,排查内存问题时,最好把block和byref结构体当成“两个对象”看待。block是函数对象,byref是数据容器。它们的生命周期并不完全同步:block在栈上时byref在栈上,block拷贝到堆后byref才跟着去堆。理解了这一层,你再看crash堆栈里那些_Block_object_assign和_Block_object_dispose的调用,就一目了然了。
最后分享一个小技巧:在代码里写__block变量后,如果你想知道编译器到底做了什么,直接在命令行用clang -rewrite-objc转换一下源码,看看生成的__Block_byref_xxx结构体和block内部函数,比任何理论讲解都直观。这一步看完,内存布局的大部分疑问都会瞬间清晰。
