深入解析Objective-C __block变量底层内存布局与循环引用问题
2026/9/8 5:59:51 网站建设 项目流程

很多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结构体,里面除了常规的reservedsize字段之外,多了两个函数指针:

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函数就要负责把对象正确retain,dispose函数负责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它的API
  • block被放入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 = &H,H->__forwarding = &H // 此时从任何路径访问 a,都会访问到 H->a

如果没有__forwarding这层跳转,当block被拷贝到堆上时,你很难保证外部引用和block内部引用指向同一个变量。栈上的结构体不能动了,因为其他代码可能还在用它的地址;直接改block里的指针也只能影响block自己的访问路径。__forwarding的存在让所有路径都归一化到"通过__forwarding找到真正存储位置"这个统一的访问方式上,问题就迎刃而解了。

3.3 没有__forwarding会怎样

这里做一个假设推演:如果没有__forwarding,block被拷贝到堆上后,堆上的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持有block,block持有的__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被持有的链条:

  1. block被谁持有?
  2. 持有的对象生命周期是不是覆盖到了block的调用时机?
  3. 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相关的内存问题,会从容得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询