深入解析Block底层原理:结构体、内存管理与循环引用

发布时间:2026/10/15 8:05:49
深入解析Block底层原理:结构体、内存管理与循环引用 1. 从一次崩溃日志说起为什么需要搞懂block的底层很多人第一次对block产生“这玩意儿不简单”的感觉往往不是在看什么底层原理书的时候而是在调试一个崩溃日志的时候。我印象很深的一次是排查一个异步回调里的野指针崩溃堆栈里出现了一串__NSMallocBlock__相关的符号当时我盯着那个符号看了半天心里想的是我明明写的是一个普通的闭包怎么底层变成了一个带Malloc字样的对象这个疑问其实就是理解block的起点。你在代码里写的^{ ... }编译器并不把它当成一段“内联的代码片段”简单处理而是把它当作一个匿名结构体 函数指针 捕获变量的组合体来对待。换句话说block在编译期就已经被“对象化”了它有自己的内存布局、有自己的isa指针、有自己的生命周期管理规则。这篇文章想做的事情是把block从“会用”到“用明白”之间那段路补上。适合的读者是已经能熟练写block回调、知道__block和__weak大概怎么用但遇到循环引用、变量捕获异常、block跨线程传递时心里没底的人。我会从编译器怎么改写block讲起把结构体布局、三种block类型、变量捕获机制、循环引用的真实成因、以及实际排查中容易踩的坑一层一层拆开。看完之后你再看崩溃日志里的__NSMallocBlock__应该能立刻反应过来它为什么在那儿。需要提前说明的是下面涉及的编译器改写结果是基于Clang在常见优化级别下的典型输出做的合理还原不同编译器版本、不同优化选项下细节会有出入但核心机制是一致的。我尽量把“为什么是这样”讲清楚而不是只丢结论。2. 编译器到底把block改写成了什么2.1 一个最简block的等价结构体先看一段最简单的代码void example(void) { int a 10; void (^myBlock)(void) ^{ printf(%d, a); }; myBlock(); }编译器看到这段代码后大致会做三件事生成一个描述block的结构体类型、生成一个真正的函数block的调用体、生成一个全局的结构体实例来描述这个block的元信息。还原出来大概长这样struct __block_impl { void *isa; int Flags; int Reserved; void *FuncPtr; }; struct __example_block_impl_0 { struct __block_impl impl; struct __example_block_desc_0 *Desc; int a; };这里有几个关键点值得停下来看。第一__block_impl里第一个字段是isa这说明block本身就是一个对象它遵循Objective-C对象的内存布局规则这也是为什么block能被retain、copy、release。第二FuncPtr指向真正执行block代码的那个C函数。第三捕获的变量a被直接作为结构体的一个字段塞了进来。所以当你写^{ printf(%d, a); }的时候编译器实际生成的调用体函数签名类似static void __example_block_func_0(struct __example_block_impl_0 *__cself)函数体里访问a的地方被改写成了__cself-a。这就是“捕获”的本质——值拷贝进结构体函数通过结构体指针访问。2.2 为什么捕获是值拷贝而不是引用很多人会疑惑我在block外面改了a为什么block里读到的还是旧值原因就在上面这个结构体布局里。block初始化的时候a的值被复制进了结构体的a字段之后外部变量a和结构体里的a就是两个独立的内存空间了。这跟函数传参是同一个道理。你调用foo(a)的时候foo内部拿到的是a的一份拷贝你在foo里改参数不影响外面的a。block捕获变量本质上就是“把变量作为参数打包进一个结构体在创建block的那一刻完成传参”。理解这一点之后__block修饰符的作用就顺理成章了。__block做的事情是不再把变量本身拷进结构体而是把变量包装成一个单独的结构体block结构体里只存这个包装结构体的指针。这样外部和block内部操作的是同一块内存修改才能互相可见。后面第4节会详细拆这个包装过程。2.3 block描述符里藏着什么除了__block_impl还有一个__example_block_desc_0结构它主要包含两个信息结构体总大小以及一个可选的copy/dispose函数指针。当block捕获了需要内存管理的对象比如Objective-C对象时这两个函数指针就会被填上负责在block被拷贝到堆上时retain捕获的对象在block释放时release它们。这就是为什么捕获一个对象类型的变量即使你没写__weak在block被拷贝到堆之后也不会立刻出问题——描述符里的copy函数帮你做了retain。但这也正是循环引用的根源之一第5节会展开。3. 三种block类型栈、堆、全局以及它们什么时候互相转换3.1 全局block最省心的那一种如果一个block没有捕获任何外部变量编译器会把它标记成全局blockisa指向_NSConcreteGlobalBlock。这种block存放在数据段不涉及栈和堆的拷贝生命周期跟程序一致。你可以把它理解成一个“静态的、只读的代码块对象”。void (^globalBlock)(void) ^{ printf(no capture); };这种block你随便传、随便存不用担心内存问题因为它压根不在栈上也不需要拷贝到堆。3.2 栈block最容易被忽视的“短命鬼”捕获了外部变量的block默认创建在栈上isa指向_NSConcreteStackBlock。它的生命周期跟所在函数栈帧绑定函数一返回栈帧销毁这个block就失效了。这里有个非常经典的坑把栈block赋值给一个超出当前作用域的变量或者作为返回值返回或者丢进一个异步队列延后执行。栈帧没了block结构体所在的内存被复用你再调用它访问的就是垃圾数据崩溃或者读到莫名其妙的值。// 危险示例 void (^getBlock)(void); { int x 5; getBlock ^{ printf(%d, x); }; // 栈block } getBlock(); // x的内存可能已经被覆盖实测下来这种问题在Debug模式下有时“看起来正常”因为栈内存还没被覆盖但Release模式下优化一开立刻现原形。这也是为什么很多block相关的bug只在线上出现。3.3 堆block真正能“活下来”的形态当栈block被copy操作时它会被拷贝到堆上isa变成_NSConcreteMallocBlock这就是崩溃日志里那个__NSMallocBlock__的来源。堆block有自己的引用计数捕获的对象会被retain生命周期由引用计数管理。在ARC环境下编译器会在很多场景自动帮你做这个copy把block赋值给强引用变量、作为函数返回值、传给dispatch_async这类需要延后执行的API时都会自动拷贝到堆。所以日常开发中你写的大多数block最终都是堆block。但“自动”不等于“无条件”。有一个容易忽略的点把block赋值给__weak变量不会触发拷贝因为weak引用不持有对象编译器认为没必要拷贝。如果你把一个栈block赋给weak变量然后栈帧销毁weak变量会变成nil如果实现正确或者指向已释放内存。这个细节在排查“block莫名其妙不执行”的问题时很有用。3.4 三种类型的对照类型isa存储位置生命周期触发拷贝的条件全局block_NSConcreteGlobalBlock数据段程序级不需要拷贝栈block_NSConcreteStackBlock栈随栈帧赋值给强引用、返回、传参给延后执行API堆block_NSConcreteMallocBlock堆引用计数已经是堆blockcopy只增加引用计数这张表建议记牢排查block问题时第一件事就是判断“这个block现在是什么类型”。4.__block修饰符背后的包装机制4.1 不加__block为什么改不了外部变量前面说过不加__block时变量是值拷贝进block结构体的。所以下面这段代码编译直接报错int a 10; void (^b)(void) ^{ a 20; // 编译错误variable is not assignable };编译器报错的原因是block结构体里的a是一个只读的拷贝你改它没有意义编译器干脆禁止你改。这个设计其实很合理——如果允许你改你会以为改了外面的a但实际上改的只是拷贝反而制造了隐蔽的bug。4.2__block把变量搬进了独立的结构体加上__block之后编译器会为这个变量生成一个包装结构体struct __Block_byref_a_0 { void *__forwarding; int __flags; int __size; int a; };block结构体里不再直接存a而是存一个指向这个包装结构体的指针。外部代码访问a时也被改写成通过__forwarding指针访问。这里最精妙的是__forwarding字段。当block还在栈上时__forwarding指向栈上的包装结构体自己。当block被拷贝到堆上时栈上的包装结构体会被拷贝一份到堆上然后栈上那份的__forwarding被改成指向堆上的新副本。这样无论你从栈上还是堆上访问最终都通过__forwarding找到堆上那份保证数据一致。这个设计解决了一个很实际的问题block拷贝到堆之后外部变量可能还在栈上被访问如果两边各改各的数据就分裂了。__forwarding相当于一个“永远指向最新有效副本”的跳转指针。4.3__block对象的生命周期管理如果__block修饰的是一个Objective-C对象包装结构体里还会多出copy和dispose函数指针负责在block拷贝到堆时retain对象、block释放时release对象。这也是为什么__block修饰的对象在ARC下能正确管理内存。有一个实际经验__block修饰对象时在MRC时代常被用来打破循环引用因为__block对象不会被retain但在ARC下这个技巧失效了__block对象会被正常retain。所以现在打破循环引用基本都用__weak__block更多是用在需要修改外部变量的场景。5. 循环引用不是block的错是引用链没理清5.1 循环引用的真实成因循环引用经常被说成“block的坑”但严格来说这是对象之间互相强引用造成的block只是其中一环。典型场景self.completionBlock ^{ [self doSomething]; // 捕获selfblock强引用self };这里self强引用completionBlock通过属性completionBlock捕获self又强引用self形成环。只要这个环不断两个对象都不会释放。关键在于block捕获对象时默认是强引用。这个默认行为本身没错因为大多数情况下你确实希望block执行期间对象还活着。问题出在当block被对象自己持有时这个强引用就变成了环的一部分。5.2__weak和__strong的配合套路标准解法是用__weak打破环__weak typeof(self) weakSelf self; self.completionBlock ^{ __strong typeof(weakSelf) strongSelf weakSelf; if (!strongSelf) return; [strongSelf doSomething]; };这里有两层考虑。第一层weakSelf不持有self环断了。第二层block内部先把weakSelf转成strongSelf是为了防止执行过程中self被释放导致访问野指针。strongSelf在block执行期间临时持有self执行完自动释放既安全又不会造成长期环。注意strongSelf的声明必须放在block内部放在外面就变成强引用了环又回来了。这个细节我见过不止一个人写错。5.3 什么时候不需要weak不是所有block都需要weak。如果block不是被self持有的比如传给dispatch_async一次性执行完就释放的那捕获self不会形成环直接用self反而更安全避免self提前释放。判断标准很简单看block是否被self或self持有的对象长期持有。是就用weak不是就不用。我见过有人为了“保险”到处写weakSelf结果在异步回调里self已经释放逻辑静默失败排查半天。所以weak不是越多越好要理解它解决的是什么问题。6. 实际排查中容易踩的几个坑6.1 在block里直接改捕获的变量前面讲过不加__block改不了。但有一种情况更隐蔽捕获的是对象的属性你改属性看起来“成功了”因为改的是对象内部状态不是引用本身。这容易让人误以为“block能改外部变量”然后在需要改变量本身时踩坑。NSMutableArray *arr [NSMutableArray array]; void (^b)(void) ^{ [arr addObject:x]; // 可以改的是arr指向的对象 // arr [NSMutableArray array]; // 不行改引用本身需要__block };6.2 block作为属性时的copy语义在MRC时代block属性必须用copy而不是retain因为retain不会把栈block拷到堆上栈帧一销毁属性就悬空了。ARC下编译器会自动处理但如果你在写一些底层代码或者跟C结构体打交道还是要记得手动copy。6.3 在block里访问可能已经释放的weak对象weakSelf转strongSelf之后要判空这个判空不是形式主义。如果block执行时self已经释放weakSelf是nilstrongSelf也是nil不判空直接发消息虽然不会崩OC给nil发消息是安全的但如果后面有解引用或者下标访问就会出问题。6.4 循环引用的排查方法遇到疑似循环引用我一般用两个手段。一是Xcode的内存图调试器看对象之间的引用关系环一眼就能看出来。二是在dealloc里打日志如果对象该释放的时候没打印基本就是被环住了。定位到环之后顺着引用链找哪个block捕获了self针对性加weak。7. 把block当成对象来理解很多问题就通了绕了一圈回到开头那个崩溃日志。__NSMallocBlock__之所以出现在堆栈里是因为那个block被拷贝到了堆上它的isa指向了堆block的类。它捕获的对象在拷贝时被retain释放时被release这一切都由block描述符里的函数指针驱动。block的底层原理说到底就三件事结构体布局决定了变量怎么捕获isa决定了它是什么类型、怎么管理内存__forwarding决定了跨栈堆访问怎么保持一致。把这三件事想清楚循环引用、变量捕获异常、栈block悬空这些问题都能从原理上找到解释而不是靠背结论。我个人在实际项目里的体会是block用得出问题十有八九不是语法不熟而是没意识到“它是个对象有生命周期捕获就是强引用”。每次写block之前花三秒钟想一下这个block会被谁持有、捕获了谁、什么时候释放。这三秒钟能省掉后面几个小时的排查。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询