做 iOS 和 macOS 开发的人,基本都绕不开 __block。哪怕只是在一个 Block 里改一个计数、一个开关状态,只要它被 Block 捕获了,编译器就会要求你加上这个修饰符。但如果你只停留在“加个 __block 就能改”这个层面,遇到内存问题、循环引用、嵌套 Block 共享状态的时候,依然会一头雾水。这篇文章从 __block 变量的内存布局入手,把编译器到底做了什么、变量在栈和堆之间怎么搬、搬的过程中哪些地方最容易踩坑,用源码和分析一起讲清楚。目标读者是已经写过 Block、想搞懂背后原理的开发者,内容会尽量贴近实际调试场景,不堆空洞概念。
1. 先说结论:Block不能直接修改外部变量,是“捕获拷贝”在作祟
1.1 按值捕获的约束:从一次编译报错说起
先看一段再常见不过的代码:
objectivec复制int count = 0;
void (^block)(void) = ^{
count++; // 编译报错:Variable is not assignable, missing __block specifier
};
第一次看到这个报错的人,大概率会一脸疑惑:我明明就是在 count 所在作用域里修改它,凭什么不行?这里的核心原因在于,局部变量被 Block 捕获时,默认发生的是“按值捕获”,也就是 Block 内部拿到的不是 count 本身,而是 count 的那份快照值。
你可以做一个最简单的实验验证这一点:
objectivec复制int count = 0;
void (^block)(void) = ^{
NSLog(@"%d", count);
};
count = 99;
block(); // 输出 0,而不是 99
这个结果很多人第一次看到都会愣一下。原因就在于 Block 在创建时已经把 count 当时的数值拷贝进了自己的结构体,count = 99 改的是原作用域里的变量,Block 内部那份快照纹丝不动。这其实是 C 语言函数参数按值传递的延伸:Block 本质上就是一个带捕获环境的结构体对象,局部变量会被拷贝到这个结构体里保存。
这种设计在大多数场景下是合理的,它保证了 Block 不会因为外部变量的变化而影响到内部逻辑。但问题来了:如果我们确实需要 Block 内部修改外部变量,并且让外部也能感知到这个修改呢?这时候编译器给出的解决方案就是 __block。
注意:这个“按值拷贝”对对象类型同样适用。不过对象类型拷贝的是指针,所以 block 内部仍然能通过指针访问到对象内部属性,这也就是为什么你可以在 block 里修改
NSMutableArray的内容而不需要__block,但不能直接重新赋值给这个数组变量。
1.2 编译器遇到 __block 之后究竟改了什么
很多人以为 __block 只是把一个变量标记为“可修改”,好像加了个魔法属性。实际上编译器做的事情远比这复杂:它会把一个普通的 int count 转换成一个结构体对象,然后所有对这个变量的访问都变成对结构体字段的访问。
用 LLVM 自带的源码重写工具可以看得一清二楚。假设有这样一个文件:
objectivec复制__block int count = 0;
void (^block)(void) = ^{
count++;
};
block();
在终端执行:
bash复制clang -rewrite-objc main.m -o main.cpp
打开生成的 main.cpp,你会发现 __block int count 变成了下面这样的结构体:
c复制struct __Block_byref_count_0 {
void *__isa;
struct __Block_byref_count_0 *__forwarding;
int __flags;
int __size;
int count;
};
而 Block 内部原本那句 count++,会被改写成:
c复制((struct __Block_byref_count_0 *)__forwarding->count)++;
看到这里你应该明白了:__block 让原本的普通变量“升级”成了一个对象,Block 捕获的不再是数值快照,而是这个结构体对象的指针。通过指针去修改结构体里的字段,自然就能实时反映到外部变量上。
这就是 __block 变量内存布局的核心起点:编译器把变量包装成了一个结构体,而不是简单地在 Block 内部放一个可写副本。后面我们讨论的所有生命周期、迁移、循环引用问题,都是围绕这个结构体展开的。
1.3 一个最小实验:验证普通捕获与 __block 捕获的区别
为了加深印象,我建议你亲手跑一下这个对比实验。在 Xcode 里新建一个命令行工程,写两段代码,分别用普通变量和 __block 变量,观察它们的打印结果和地址变化:
objectivec复制int normal = 0;
__block int blocked = 0;
NSLog(@"normal 地址: %p", &normal);
NSLog(@"blocked 地址: %p", &blocked);
void (^block)(void) = ^{
NSLog(@"block 内 normal: %d", normal);
NSLog(@"block 内 blocked: %d", blocked);
blocked = 5;
};
normal = 3;
block();
NSLog(@"外部 blocked: %d", blocked);
你会发现,blocked 的地址和 normal 的地址分布区域明显不同,Block 内部读出 normal 仍然是创建时的 0,而 blocked 已经实时读到了 3,并且 Block 内部改成 5 后,外部也变成了 5。这都是结构体指针捕获带来的效果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆解 __Block_byref 结构体:这就是 __block 变量的真实内存形态
2.1 结构体的五个字段,逐一过一遍
clang -rewrite-objc 生成的那个结构体,就是 __block 变量的完整内存形态。每个字段都不是摆设,理解它们才能理解后面的坑。
| 字段 | 作用 | 说明 |
|---|---|---|
void *__isa |
类型标识 | 让 __block 变量具备对象形态,参与内存管理 |
struct __Block_byref_count_0 *__forwarding |
指向实际存储 | 栈上指向堆上副本或自己,是搬迁机制的核心 |
int __flags |
状态标记 | 记录引用计数、所有权修饰符等 |
int __size |
结构体大小 | 用于 copy 时计算需要迁移的内存字节数 |
int count |
真正的业务字段 | 也就是被包装的原始变量,名字随变量名变化 |
__isa 字段会让很多人惊讶:原来 __block 变量本身也是一个“对象”。在非 ARC 时代,这个 __isa 指向的是一个专门的类,用来处理 retain/release。在 ARC 下,编译器也会借助它完成内存管理逻辑。我们平时开发不需要直接操作这个字段,但理解它的存在,有助于理解为什么 __block 变量会有和普通对象类似的生命周期行为。
__flags 则更像一张“状态位标签”。它记录了结构体的内存管理策略,比如变量是否被堆上 Block 引用、是否需要特殊处理等。具体的位含义会随编译器版本变化,但我们不需要关心二进制层面,只要知道它的存在让编译器能在运行时快速判断结构体状态就够了。
__size 极其重要。当 Block 被从栈拷贝到堆上时,编译器需要知道 __block 结构体占多大空间,才能正确地分配内存并完成字段拷贝。如果这个字段算错,轻则数据丢失,重则内存越界。
2.2 __forwarding 指针的设计哲学
__forwarding 是整个结构体里最精妙的设计,也是内存布局中最核心的机制。它的本质是一个“指路牌”:无论是原始栈上结构体还是后来拷贝到堆上的新结构体,通过它都能找到真正存储变量的那一个。
先说最简单的情况:Block 从未被拷贝到堆上,此时结构体在栈上,__forwarding 指向它自己。也就是说,在 Block 创建之初,变量就存在栈上,Block 内部直接通过 self->__forwarding 找到自己并读写 count。
一旦 Block 被 copy 到堆上,情况就变了。栈上结构体已经被复制了一份到堆上,如果不做处理,于是就有两份 count 存在。此时编译器执行一个关键操作:
- 在堆上分配一个新的
__Block_byref_count_0结构体; - 把原栈上结构体的内容拷贝到堆上结构体;
- 将栈上结构体的
__forwarding指针改成指向堆上结构体; - 将堆上结构体的
__forwarding指针指向它自己。
这样,栈上代码如果还想通过原变量访问,__forwarding 会把它带到堆上副本;堆上 Block 内部的访问,也直接落到同一份数据上。两份结构体只存在一个逻辑状态,修改任何一处,所有访问者都能看到一致的值。
你可以把 __forwarding 想象成搬家后老地址门上的那张纸条:纸上写着新家地址,来访的人都按这个纸条找到新家,不会发生“去了老房子却发现人不在”的情况。
提示:很多人画 Block 内存图时只画一个结构体,这是不对的。只要经历过 copy,内存里就存在两个结构体:一个留在栈上(它的
__forwarding指向堆),一个在堆上(它的__forwarding指向自己)。栈上那个结构体虽然还在,但已经只是一个跳板。
2.3 对象字段与内存所有权:不是所有 __block 变量都长一样
上面我们分析的是 __block int count,结构体里只有一个 int count。但如果你写的是 __block NSMutableArray *arr,生成的结构体就不一样了。在 ARC 下,结构体的尾部会带上所有权修饰符:
c复制struct __Block_byref_arr_0 {
void *__isa;
struct __Block_byref_arr_0 *__forwarding;
int __flags;
int __size;
__strong NSMutableArray *arr;
};
注意最后那个 __strong。这说明该字段是一个强引用。当 Block 被 copy 到堆上时,编译器会保留这个对象引用,确保对象不会提前释放;当 __block 结构体释放时,也会对 arr 做一次 release。这套逻辑和普通对象属性管理是一致的。
这里最容易踩的坑是 ARC 下对象型 __block 变量带来的循环引用。在 MRC 时代,__block 捕获对象不会对对象进行 retain,所以很多人习惯用 __block id blockSelf = self; 来打破循环引用。但是 ARC 启用后,编译器为了保证堆上 Block 中的变量安全,会对对象字段执行强引用。如果此时 self 又持有这个 Block,一个环就形成了:
objectivec复制__block id blockSelf = self;
self.block = ^{
[blockSelf doSomething];
};
这段代码在 ARC 下会形成 self -> block -> blockSelf -> self 的引用环,不会被提前释放。除非你在 Block 执行完手动把 blockSelf 置为 nil,否则这个环会一直存在。后面第 4 节我会详细说循环引用的正确解法。
3. 变量的栈上出生、堆上搬迁:完整生命周期回顾
3.1 栈上初始状态:Block 和变量都在临时区域
在很多初学者眼里,Block 是“在堆上分配的对象”。这个印象来自 ARC 下的常见用法,但严格来说并不准确。Block 有三种类型:
| Block 类型 | 存储区域 | 特点 |
|---|---|---|
__NSGlobalBlock__ |
全局区 | 不捕获局部变量,类似函数指针 |
__NSStackBlock__ |
栈区 | 捕获局部变量时默认创建,作用域结束后失效 |
__NSMallocBlock__ |
堆区 | 栈 Block 被 copy 后生成,由引用计数管理 |
当你写下一段捕获局部变量的 Block 并且没有调用 copy 时,Block 对象和 __block 变量结构体都栖息在栈上。此时 Block 的生命周期和当前作用域绑定,一旦函数返回或作用域结束,栈上的 Block 和变量结构体都会被自动清理。
来看一个危险的操作:
objectivec复制void (^block)(void);
if (1) {
__block int count = 0;
block = ^{
count++;
};
}
block(); // 未定义行为:block 可能已经失效
在这个例子里 block 捕获了一个栈上的变量结构体,if 作用域结束后,这个结构体指针理论上已经悬空。虽然很多时候打印出来还能访问到旧数据,但那只是因为栈内存还没有被覆盖,一旦有新的函数调用覆盖这块栈空间,数据就会变得不可预测。这种问题在 Release 模式下尤其隐蔽,我见过不少现场崩溃都长这样。
3.2 Block 被 copy 到堆上时,变量结构体会经历什么
把 Block 从栈上搬到堆上的操作,核心是 Block_copy 函数。这个函数做的事情可以拆成几步:
- 在堆上分配一块内存,大小根据 Block 描述信息计算;
- 把栈上的 Block 结构体拷贝到堆上;
- 对 Block 捕获的每个变量执行搬运或保留操作;
- 如果捕获的是
__block变量,就按照前面说的步骤,把变量结构体也复制到堆上; - 更新
__forwarding指针,让栈上变量结构体指向堆上副本。
第 4 步和第 5 步是 __block 变量迁移的关键。ARC 下,Block 被赋值给属性、被放进数组、被返回给调用方等操作都会触发自动 copy。这就是为什么你在 ARC 下很少感知到“栈 Block”的存在,因为编译器已经在需要的时候帮你把它搬到了堆上。
有一点值得注意:Block 拷贝过程中,__forwarding 指针在所有相关 Block 内部也会被更新。多个 Block 如果共享同一个 __block 变量,那么它们内部的访问都是通过堆上那份结构体的 __forwarding 落点。这也意味着,堆上的 __block 变量结构体实际上被多个 Block 持有,它的生命周期由引用计数控制。
__size 字段在这里扮演了“搬运工清单”的角色。Block_copy 在搬迁 __block 结构体时,需要精确知道复制多少字节,__size 正是为此服务的。如果结构体里包含对象字段,拷贝后还需要对对象执行一次 retain,这个工作也由 __size 连同编译器的辅助函数一起完成。
3.3 三种 Block 类型下,__block 变量的去向对比
前面提到 Block 有三种类型,那么在三种类型下 __block 变量结构体又分别在什么位置?
__NSGlobalBlock__:不捕获任何局部变量,所以不存在__block变量的问题。如果你在全局作用域声明一个__block全局变量,Block 直接通过地址访问全局区,不涉及结构体包装,这是特例。__NSStackBlock__:Block 和__block变量结构体都在栈上。适用于 Block 只在当前作用域内临时使用、不会被保存到别处的场景。__NSMallocBlock__:Block 在堆上,__block变量结构体也被迁移到堆上或引用堆上副本。这是大多数实际业务场景下的最终形态。
从内存布局的角度看,__NSStackBlock__ 转成 __NSMallocBlock__ 的过程,本质上就是一次“从栈到堆”的迁移,而 __forwarding 指针保证了迁移前后的访问一致性。理解这张对应关系,对排查“为什么变量值在两个地方不一样”这类问题非常有帮助。
4. 实践中真正的 Battle:循环引用、状态同步与排查方法
4.1 ARC 下的循环引用:__block 已经不背这个锅了
前面已经提到,ARC 下对象型 __block 变量会持有对象,因此用它来打破循环引用是不行的。最典型的场景就是在一个类中持有 Block,而 Block 内部使用了 self:
objectivec复制@implementation MyClass {
void (^_block)(void);
}
- (void)setup {
__weak __typeof(self) weakSelf = self;
_block = ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf doSomething];
}
};
}
这里用 __weak 打破循环引用,Block 内部再用 __strong 临时持有,避免 weakSelf 在 doSomething 执行期间被提前释放。这是 ARC 下的标准姿势。
有些人会问:那 __block 是不是彻底不能用来打破循环引用了?也不是。如果你确实想用,必须在 Block 执行完毕后手动断环:
objectivec复制__block MyClass *blockSelf = self;
_block = ^{
[blockSelf doSomething];
blockSelf = nil; // 手动断环
};
这个写法有两个问题:第一,doSomething 执行完之前这个环一直存在,如果 doSomething 永远不执行(比如 Block 被保存在一个从不调用的地方),环就永远解不开;第二,一旦 Block 被 copy,__block 变量结构体指向堆,断环操作还要求 Block 必须被执行一次。所以我个人强烈建议:ARC 下,循环引用一律用 __weak + __strong 组合,不要再用 __block。
注意:
__weak只适用于 ARC。如果在 MRC 下,__block不 retain 对象的机制确实可以用来打破循环引用,但那是过去式了,新项目基本都在 ARC 下开发,这个经验已经不再适用。
4.2 多个 Block 共享同一个 __block 变量的正确姿势
__block 变量一个被忽视的优点,是它天然支持多个 Block 共享状态。因为所有捕获同一个 __block 变量的 Block,在 copy 之后都会访问堆上同一个结构体,所以各个 Block 之间看到的值是一致的。
举个例子,做下载任务时可能需要多个回调 Block 共享一个进度变量:
objectivec复制__block int64_t totalBytes = 0;
void (^progressBlock)(int64_t) = ^(int64_t bytes) {
totalBytes += bytes;
NSLog(@"当前进度: %lld", totalBytes);
};
void (^finishedBlock)(void) = ^{
NSLog(@"总进度: %lld", totalBytes);
};
这种用法在 GCD 并发执行多个任务时也很有用。多个并发 Block 同时累加一个 __block int64_t 变量,虽然不能保证原子性,但可以保证它们访问的确实是同一个变量。如果需要原子性,记得配合 os_unfair_lock 或 atomic 等同步机制,单纯靠 __block 不提供线程安全。
不过要注意:如果你在 Block 内部把 __block 变量重新指向另一个 Block 捕获的变量,两个 Block 的共享关系会变得复杂。有一种典型误用是:
objectivec复制__block int value = 0;
void (^block1)(void) = ^{ value = 10; };
void (^block2)(void) = ^{ value = 20; };
这样写,两个 Block 持有的 __forwarding 最终都指向堆上同一个 value 结构体,所以 block1 先执行、block2 后执行,value 最终是 20。这是符合预期的。但如果你在 Block 内部对 value 重新赋值为另一个结构体指针(对象类型场景),比如:
objectivec复制__block NSMutableArray *arr = [NSMutableArray array];
block1 = ^{
arr = [NSMutableArray array]; // 重新赋值
};
block2 = ^{
[arr addObject:@1]; // 访问的是新的 arr
};
那么 block2 访问的 arr 是 block1 重新赋值后的数组,这是共享同一个 __block 变量的自然结果,不算 bug,但需要理解清楚:共享的永远是结构体里保存的那份字段值,而不是某个特定对象。
4.3 用 clang 和 LLDB 验证内存布局的调试流程
遇到 __block 相关的诡异问题,光靠猜很难定位。最快的方式是把编译器的中间产物拿出来看,然后在断点处用 LLDB 验证内存地址。
第一步,生成结构体源码。我习惯建一个最小测试文件,只包含出问题的 __block 变量和 Block,然后执行:
bash复制clang -rewrite-objc test.m -o test.cpp
打开 test.cpp,搜索 __Block_byref_,就能看到变量结构体定义以及 Block 内部访问变量的完整代码路径。这一步可以确认编译器到底有没有生成 __forwarding 跳转,以及在哪个字段上做读写。
第二步,在 Xcode 断点处观察变量地址。如果看到变量地址以 0x7... 开头,通常说明变量在栈上;以 0x6... 或 0x1... 开头通常说明在堆上。这时候再配合打印 __forwarding 指向的地址,就能快速判断变量结构体当前是否已经完成堆迁移。
LLDB 里可以这么操作,首先获取变量地址:
lldb复制po &count
假设输出 0x7ffeefbff4a0,说明结构体在栈上。然后打印结构体内容:
lldb复制p *(struct __Block_byref_count_0 *)0x7ffeefbff4a0
观察输出里 __forwarding 字段的值。如果它等于 0x7ffeefbff4a0 自己,说明结构体没有被拷贝过;如果它指向一个 0x6... 或 0x1... 开头的地址,说明已经发生过 copy,栈上这块只是一个跳板。
我在实际项目中靠这个方法定位过一次问题:Block 在某个工具函数里被临时保存,后来在其他线程调用时读到的 __block 变量值忽变。打印后发现问题出在 Block 被存到了 dispatch 队列的 block 里,队列在持有时自动 copy,而工具函数里又手动释放了栈上的 Block,导致 __forwarding 指向堆上结构体的时候,堆上结构体已经因为引用计数归零被释放。顺着这个思路排查,很快就找到了真正的原因。
4.4 访问性能盘点和优化建议
__block 变量虽然方便,但访问代价比普通局部变量要高。普通局部变量直接读栈寄存器或者内存地址,一次访存就完成。__block 变量则经历了“读取变量结构体地址 → 通过 __forwarding 找到真正存储 → 解引用字段”这样一个链路,至少多一次间接寻址。
在循环里频繁访问 __block 变量时,这个成本会被放大:
objectivec复制__block int total = 0;
for (int i = 0; i < 1000000; i++) {
total += i;
}
这段代码在 Release 模式下也许优化得很好,但在 Debug 模式或开启低优化等级时,每次循环都会做多级跳转,性能差距可能达到数倍。如果这是热点代码,更推荐先拷贝到局部变量:
objectivec复制__block int total = 0;
int temp = 0;
for (int i = 0; i < 1000000; i++) {
temp += i;
}
total = temp;
当然,这里讨论的是最细颗粒度的优化,绝大多数开发场景不需要为了这种差异牺牲可读性。但理解了这个机制,你就能明白为什么有些性能敏感代码里会出现“先取临时变量,最后写回 __block 变量”的写法,这不是玄学,是给编译器减负。
最后再聊一个我实际遇到的细节
我曾经在排查一个诡异崩溃时,花了整整半天,最后发现原因竟然是一个 __block 变量被两个不同的 Block 捕获,其中一个 Block 在创建时没有 copy,另一个 Block 在另一个线程里先 copy 了。两个 Block 同时修改同一个 __block 变量时,因为栈上结构体还没来得及更新 __forwarding,导致一个 Block 写到了栈上的旧副本,另一个 Block 读到了堆上的新副本,状态直接分裂。从那以后,我对所有跨作用域保存 Block 的地方都会多留一个心眼:只要 Block 要跳出当前函数或长期持有,就得保证它已经被 copy 到堆上,并且在赋值时保持引用。如果你想彻底吃透这块内容,强烈建议自己跑一遍 clang -rewrite-objc,把生成的结构体代码对着源码读一遍,再在 LLDB 里打几个断点观察地址变化,比看十篇博客都有用。
