☰
08-03-优化-闭包与匿名函数的GC分配
2026/10/28 19:05:18 网站建设 项目流程

闭包与匿名函数的 GC 分配

篇章:08-核心技术-性能优化篇 |阅读时间:约 30 分钟


一、引言

闭包(Closure)是 C# 编译器的"语法糖",它让 lambda 表达式可以"捕获"外部变量。但这份语法糖不是免费的:编译器为了实现捕获,生成了一个隐藏的类实例来存储被捕获的变量,这就是 GC 分配。

在 Unity 项目里,这件事的严重性完全由调用频率决定。配置加载时写一个捕获变量的 lambda,整局游戏也执行不了几次,分配多少都无所谓;同样的写法放进 Update、事件回调、协程热路径,就是每帧、每次点击、每场战斗都在给 GC 喂新对象。Boehm 不分代也不压缩(见 03 篇增量式 GC 架构),临时对象涨到阈值就是一次全堆标记清扫,几十毫秒的停顿直接砸穿帧预算。

本文回答四个问题:编译器为闭包生成了什么、这些生成物各值多少字节、Unity 里哪些常见写法在持续制造闭包、以及怎么把这类分配从热路径里清出去。第二节拆机制,第三节讲委托这另一半成本,第五、六节给出陷阱地图,第七节是可以直接照抄的改造方案,第八节给量级估算表。

二、闭包分配机制

2.1 捕获局部变量:显示类被提升到堆上

lambda 捕获的是"变量"而不是"值",这是 C# 的语义约定:闭包看到的是同一个存储位置,外部改了闭包里也跟着变。编译器要维持这个语义,唯一的办法是把局部变量从栈搬到一个同时对 lambda 方法可见的地方,它选的地方是一个编译器生成的隐藏类,社区惯称显示类(display class)。

// 代码 Action CreateCounter() { int count = 0; // 被捕获的变量 return () => count++; } // 编译器生成的代码(简化) class <>c__DisplayClass0_0 { public int count; public void <CreateCounter>b__0() { count++; } } Action CreateCounter() { var closure = new <>c__DisplayClass0_0(); // 堆分配! closure.count = 0; return new Action(closure.<CreateCounter>b__0); // 委托分配 }

逐段解读这段反编译示意。第一段是业务代码:CreateCounter声明了一个局部变量count,返回的 lambda 对它执行自增。第二段是编译器实际生成的形态:count不再是栈上的局部变量,而是显示类<>c__DisplayClass0_0的实例字段;lambda 的方法体被搬成这个类上的实例方法<CreateCounter>b__0。第三段还原了调用时的真实开销:方法体一进来就new一个显示类实例,把局部变量的值拷进字段,然后new一个Action委托,委托的 target 指向显示类实例、method 指向那个实例方法,最后返回。

所以每次调用 CreateCounter 都创建一个闭包类实例(堆分配)+ 一个委托实例(堆分配)。两次分配,一次都省不掉,而且它们的生命周期从这一行就开始了:只要返回的Action还被人握着,显示类实例就活着;显示类实例活着,它字段里那份"局部变量"也就一直活着,哪怕原方法早已返回、栈帧早已销毁。

把同一份机制再用"反编译视角"看一遍,这次连循环一起看:

// 编译器实际生成的代码(反编译) class <>c__DisplayClass0_0 { // 闭包类 public int i; // 被捕获的变量提升为字段 public void <Example>b__0() { Console.WriteLine(i); } } void Example() { var closure = new <>c__DisplayClass0_0(); // 堆分配! for (closure.i = 0; closure.i < 10; closure.i++) { // 每次迭代都使用同一个闭包实例(第1次迭代创建) // 但不是所有闭包都能被编译器优化为单实例 } }

逐段解读这段可视化。Example方法一进来就new出闭包实例,循环变量i被搬进closure.i,循环本体每次改的其实是那个堆对象的字段。循环十次只创建一个闭包实例,因为显示类实例的创建点在循环外侧的方法体入口,不在循环内部;注释里那句"不是所有闭包都能被编译器优化为单实例"要认真对待:单实例的前提是"显示类创建点只执行一次",一旦这个方法本身被多次调用,每次调用都会重新走一遍入口,实例数随之翻倍。

2.2 关键认知:方法被调用 N 次就创建 N 个实例

显示类实例不是全局一份,它跟随包含 lambda 的方法调用而创建。关键认知:编译器生成的闭包类在调用方法体时创建。如果包含闭包的方法被调用 N 次,则创建 N 个闭包实例。把这句话翻译成 Unity 语言:CreateCounter写在 Update 里,一秒钟 60 次调用就是每秒 60 个显示类实例加 60 个委托对象,一分钟 3600 份,直到某次 GC 把它们批量清掉。

有个例外值得单独记:同一次方法调用内,同一个 lambda 表达式被转换多次委托时,显示类实例只创建一次,委托在多数实现里也只创建一次,后续转换复用已有的 target 与方法指针。换句话说,"每次 new 委托"的成本按 lambda 表达式出现的位置算,不按转换次数线性爆炸。真正线性增长的是方法调用次数:调用一次,一套显示类加委托。

2.3 捕获 this 与捕获局部变量的差异

捕获形态不止一种,代价也不同,这是本节最值得记住的分野。

第一种,捕获局部变量(含参数),如 2.1 所示:生成独立的显示类,每次方法调用 new 一个实例,显示类实例加委托,两笔分配。

第二种,只捕获this(即 lambda 体内只访问实例字段或实例方法,不碰任何局部变量)。这种情况下编译器不需要引入新的类,它直接在宿主类上生成一个实例方法,lambda 编译成宿主类.<方法名>b__序号,委托的 target 就是this自己。没有显示类,只剩委托这一笔分配。很多"我写了 lambda 为什么没看到 DisplayClass"的困惑都源于此:Profiler 里找不到显示类,不代表没有分配,委托对象照样在堆上。

第三种,什么都不捕获。编译器把它编进一个缓存用的嵌套类(通常叫<>c),整个程序集每种 lambda 签名一份单例,单例缓存在静态字段里,委托实例也缓存在单例的字段里。第一次执行到该 lambda 时创建单例与委托,之后每次转换直接复用缓存的委托,稳态下零分配。这是"不捕获变量的 lambda 可以被缓存"这句话的完整含义:缓存的不是魔法,是编译器替你做的单例加字段缓存。

三种形态的内存关系画成图:

形态 A:捕获局部变量(最贵) 方法调用栈 堆 ─────────────────────────────────────────────────────────── Example() ┌─────────────────────┐ ├─ new <>c__DisplayClass0_0 ─────────────► │ 显示类实例 │ ~24B(1字段,64位估算) │ closure.i = 0 │ ├ 对象头 ~16B │ │ │ └ int i(提升的局部量)│ ├─ new Action(closure.b__0) ────────────► │ Action 委托 │ ~40B(估算) │ │ ├ target → 显示类实例 │ │ │ └ method → 方法指针 │ └─ return action └─────────────────────┘ 形态 B:只捕获 this(中等) ┌────────────────────────────┐ │ 宿主类实例(本来就有) │ 无显示类 │ ├ 实例字段被 lambda 直接读写 │ 只多一个 Action 委托,target 指向自己 │ └ <Update>b__3 方法 │ └────────────────────────────┘ 形态 C:不捕获任何变量(最省) 静态字段 <>c.<>9 ──► 单例缓存类实例(全程序一份) └─ <>9__0_0 ──► 缓存的 Func<...> 委托(全程序一份,复用) 稳态零分配;首次执行有一次性的创建成本

(对象头与委托大小为 64 位 Mono/Boehm 布局下的典型估算,非实测常数;不同 Unity 版本、Mono 与 IL2CPP 之间会有浮动。)

2.4 IL2CPP 下同样成立

有人以为闭包分配是 Mono JIT 的产物,IL2CPP 会好一些。事实相反:IL2CPP 把显示类照样翻译成 C++ 类,把委托照样翻译成堆上对象,分配一次不少;变化的只是这些对象由 IL2CPP 的分配器经 Boehm 管理。也就是说,本文所有优化建议对 Mono 与 IL2CPP 两个后端同等有效,不存在"换个后端闭包就免费"的选项。

三、委托对象本身:另一半分配成本

把显示类干掉不等于零分配,委托对象自己就是一次堆分配。Action、Func<...>都是类(继承自MulticastDelegate),new Action(obj.Method)的本质是创建一个堆对象,内部至少持有目标对象引用与方法指针两块内容,64 位下典型估算 40B 上下,多播链还会追加节点。

关于委托有四条实用规则。其一,方法组转换与等价 lambda 的分配行为一致:list.Sort(Compare)写成方法组还是写成 lambda,只要不捕获变量,都走 2.3 里形态 C 的缓存路径。其二,委托实例不可变但可组合,+=会创建新的委托对象挂上新链,热路径里反复+=等于反复分配加反复拷贝链表。其三,staticlambda(C# 9 起,Unity 2021.2 起的编译器支持该语言版本)给编译器一个承诺:这个 lambda 绝不捕获任何变量,编译期直接报错来保证,缓存路径因此对它是确定性的,而不是"恰好没捕获"的巧合。其四,缓存委托要缓存对的对象:实例方法的委托持有 target,把带this的委托缓存进静态字段会把整个实例钉在堆上,这在 MonoBehaviour 上就是场景对象泄漏(第六节展开)。

Unity 版本与语言特性对应关系一句话带过:Unity 2021.2 之后的编译器链支持 C# 9,staticlambda、函数指针语法都可用;更老的项目用"显式缓存到字段"达到同样效果,思路完全一样,只是要手写。写法上的差别直接看这组对照:

// C# 9+ 的 static lambda 不捕获任何变量, 可被缓存 var predicate = static (int x) => x > 10; // 静态委托, 零分配 // 与普通 lambda 对比: Func<int, bool> f1 = x => x > 10; // 可能产生委托分配(热路径) Func<int, bool> f2 = static (int x) => x > 10; // 编译器缓存为静态字段

逐段解读这组对比。f1是普通 lambda,它是否分配取决于上下文与实现细节:编译器完全有能力把不捕获的它也编进缓存类,但这段代码没有向编译器承诺任何事,读代码的人同样无从判断,注释里"可能产生委托分配"描述的就是这种不确定性。f2加了static,承诺由编译器强制:一旦它试图引用任何外部变量,编译直接失败,于是缓存行为变成硬保证,热路径里写static就是在给性能上锁。第一条语句展示的写法与f2等价,区别只在f2把委托存进了变量,复用关系更明显。

四、捕获变量 vs 不捕获:热路径对照

// ❌ 捕获变量 → 闭包分配 int threshold = 10; var predicate = (int x) => x > threshold; // new 闭包类 // ✅ 不捕获变量 → 可缓存(静态委托) var predicate = (int x) => x > 10; // 编译器缓存为静态字段

逐段解读这对代码。上半部分捕获了局部变量threshold,编译器必须生成显示类,每次执行到这一行就是一次显示类分配加一次委托分配;值为 10 这个事实救不了它,因为语义上threshold是可变的,编译器必须假设它会在 lambda 执行前被改掉。下半部分把常量直接写进 lambda 体,没有任何变量可捕获,编译器把它编进<>c缓存类,委托在首次执行后全程复用,稳态零分配。

两行代码的可读性几乎相同,GC 行为却是天壤之别。这条对照落到工程里有个直接推论:热路径上的 lambda,先问一句"它捕获了什么"。捕获列表为空,随便写;捕获了任何局部变量,就去查这份捕获在当前场景里每秒产生多少次。

再给一组把捕获改成参数传递的对照,这是消除捕获的通用手术:

// ❌ 每帧创建捕获 threshold 的闭包 void Update() { var targets = enemies.Where(e => e.Hp > threshold).ToList(); // 闭包 + 迭代器,见 08-02 } // ✅ 阈值不进闭包:委托不捕获,比较逻辑提成静态方法 static readonly Func<Enemy, bool> s_aboveThreshold = e => e.Hp > s_cachedThresholdField; // ↑ 若 threshold 是实例字段,静态 lambda 不能碰,改为静态方法 + 显式参数 static bool AboveThreshold(Enemy e, float th) => e.Hp > th; void Update() { for (int i = 0; i < enemies.Count; i++) if (AboveThreshold(enemies[i], threshold)) { /* ... */ } }

上半部分的代价是三笔起步:显示类、委托、外加 LINQ 迭代器与结果容器,后两笔由 08-02 专门清算,这里只算闭包账。下半部分把判断提成静态方法,比较逻辑直接以参数收值,委托可以缓存甚至整个消掉;用手动循环替代 LINQ 后连迭代器也一并消失。注意第二段注释里的提醒:静态 lambda 访问不到实例字段,这不是缺陷而是设计,它逼着你把"隐式捕获"改成"显式传参",热路径代码因此变得更可测、更少隐藏依赖。

五、Unity 中的常见陷阱

// 陷阱 1:事件中的闭包 for (int i = 0; i < 10; i++) { buttons[i].onClick.AddListener(() => Debug.Log(i)); // 每个按钮创建一个闭包,闭包中都持有 i 的引用 } // 修复:缓存到局部变量 foreach (var btn in buttons) { int index = btn.index; btn.onClick.AddListener(() => Debug.Log(index)); } // 陷阱 2:协程中的闭包 IEnumerator Coroutine() { // WaitForSeconds 每次 new → GC yield return new WaitForSeconds(1f); } // 修复:缓存 WaitForSeconds static readonly WaitForSeconds s_wait = new WaitForSeconds(1f);

逐段解读陷阱 1。循环体里每个按钮都执行一次AddListener(() => Debug.Log(i)),lambda 捕获了循环变量i,于是每次迭代创建一套显示类加委托。更隐蔽的是语义问题:在 C# 5 之后的foreach里,迭代变量本身每次迭代都是新的存储位置,捕获它是安全的;但老式for循环的i是同一个变量,十个闭包共享同一个i,全部指向循环结束后的值,点击任何按钮打印的都是 10。修复代码引入index局部变量做每份闭包自己的快照,既修语义又让分配次数与按钮数量绑定而非与点击次数绑定:初始化一次分配十个,之后点击不再产生新闭包。

逐段解读陷阱 2。yield return new WaitForSeconds(1f)每次执行到这一行都新建一个WaitForSeconds。协程恢复机制决定了它每次从 yield 返回都要对返回值做一次类型检查,而检查发生在对象创建之后,这个分配躲不掉,只能让它不发生第二次。缓存成static readonly字段后,全程序一份WaitForSeconds(1f)反复复用。这条修复对所有可缓存的 yield 指令通用:WaitForSeconds、WaitForFixedUpdate、WaitForEndOfFrame这类无状态对象都该缓存;WaitForSecondsRealtime同理。带参数的变体(比如等待时长可配)用Dictionary<float, WaitForSeconds>做个小池子即可,时长种类通常是有限集合。

陷阱 3,协程里的 lambda 捕获this。协程方法里写item.OnDone += () => MarkDirty(),这个 lambda 访问实例方法,按 2.3 属于形态 B:没有显示类,但每次注册都 new 一个委托,委托 target 持有当前 MonoBehaviour。若这个事件源是全局单例或静态事件,场景销毁后单例还活着,委托链上的旧 MonoBehaviour 就被整条拽着不能回收,这是跨场景泄漏的经典成因,第六节展开。

陷阱 4,Update 里顺手写的 lambda。典型形态是delayCall = () => ApplyBuff(buffData)然后每帧判断、偶尔赋值,看起来只在"需要时"创建。问题出在"每帧判断"这行本身如果写成list.ForEach(x => x.Tick())或者把 lambda 当作比较器临时传出去,分配就回到每帧一次的节奏。Update 里的每一行都应假定自己一秒执行 60 次,这是本篇所有判断的基准频率。

陷阱 5,闭包加字符串的叠加放大。onClick.AddListener(() => ShowTip($"玩家 {name} 得分 {score}"))一行里同时踩闭包、字符串拼接、插值装箱三个来源,量级是闭包的几十字节乘以插值产生的字符串几百字节再乘以点击频率。字符串那部分的账在 08-01,这里只提醒一句:闭包经常不是孤立作案,它把其他分配一起带进热路径。

六、事件订阅中的闭包:泄漏与重复订阅

闭包导致的泄漏和普通对象泄漏机制不同:它不靠悬空引用,靠的是引用链太长。只要事件源(按钮、全局事件总线、静态事件)还活着,委托就活着;委托的 target 是闭包实例或 MonoBehaviour,闭包字段里又有被捕获的场景对象。整条链的根在事件源,源不动,链上一个都别想回收。

// ❌ 静态事件 + 捕获 this 的 lambda:场景销毁后无法解除 void OnEnable() => GlobalEvents.OnScoreChanged += () => RefreshUI(score); void OnDisable() => GlobalEvents.OnScoreChanged -= () => RefreshUI(score); // ↑ -= 的是另一个新 lambda,等于没退订;旧 lambda 永远挂在静态事件上 // ✅ 持有委托实例,成对增删;或直接用静态方法 + 显式传参 System.Action _handler; void OnEnable() { _handler = RefreshUI; GlobalEvents.OnScoreChanged += _handler; } void OnDisable() { GlobalEvents.OnScoreChanged -= _handler; _handler = null; } void RefreshUI(int score) { /* ... */ }

逐段解读坏代码。两处 lambda 各自编译成独立的委托对象,语义相等的 lambda 也不相等的委托,-=按实例比对自然解不掉;结果是每次开关对象都在静态事件上多挂一条链,链越挂越长,每条链都钉着一个已经"销毁"的 MonoBehaviour。这类泄漏在 Profiler 里的表现是退出战斗场景后,本该死掉的对象数量随重开次数线性上涨,Memory 快照里能看到委托对象连成串。

逐段解读好代码。_handler字段保存委托实例,注册与注销引用同一实例,委托比对按实例相等成立,退订干净。把处理逻辑换成实例方法RefreshUI后,委托依然持有this,但因为成对注销,泄漏链在 OnDisable 就被切断。若事件源允许,更彻底的写法是静态方法加显式传参:GlobalEvents.OnScoreChanged += OnGlobalScore;配static void OnGlobalScore(int s),委托不持有任何实例,连"忘写 OnDisable"的最坏情况也只是丢一次回调而不是泄漏一个对象。

工程上给三条硬规则。第一,凡是订阅静态对象或全局单机的事件,OnEnable 与 OnDisable 必须成对出现且可核对,代码评审直接查这一对。第二,绝不对静态事件注册捕获局部状态的 lambda,要传状态就把状态挂到回调参数或由订阅者自查。第三,注册接口若提供RemoveAllListeners(如 Unity UI 的 ButtonEventTrigger 一族)且确认没有其他订阅者,销毁前调用一次兜底;有其他订阅者时不能调,否则会把别人的回调一并清掉,这条要写进团队约定。

七、优化方案

先给结论清单,再逐条讲适用边界:

  1. 不捕获变量的 lambda 用 static(C# 9+)
  2. 频繁创建的事件处理器缓存委托
  3. 协程中固定 WaitForSeconds 等 yield 指令
  4. 热路径中避免使用匿名函数

方案阶梯从零成本到需要重构排列:

方案分配前提适用位置
static lambda稳态零lambda 不捕获任何变量一切位置,热路径首选
缓存委托到字段/静态字段首次一次,之后零委托内容与实例无关或缓存属该实例每帧/每点击重复使用的处理器
实例方法替代匿名函数每次转换委托一次,可再缓存逻辑与实例状态绑定事件回调、比较器
静态方法 + 显式参数零逻辑可以不依赖实例字段热路径里的比较、转换逻辑
局部函数(不逃逸)零只在本方法内调用,不转委托一段逻辑需要拆分但不外传
函数指针(C# 9)零签名兼容、静态方法、仅限非托管约定极端热路径,如逐元素批量操作

逐条说明。static lambda 的价值不只是省几字节,它同时是一份契约:读代码的人立刻知道这个 lambda 不依赖上下文,编译器帮你把违约变成编译错误。缓存委托适合"处理器本身固定、只是反复挂载"的场景,注意缓存归属:实例处理器缓存在实例字段,静态处理器才允许进静态字段。实例方法替代匿名函数的收益是少一次显示类分配,若该方法组还会被反复转换,配上缓存字段就把委托分配也省掉。局部函数在不逃逸时由编译器编译为普通私有方法,调用是直接调用,连委托对象都不产生;一旦被转成委托往外传,它立刻退化成和 lambda 一样的分配行为,这个边界值得在评审时明确。函数指针是最后一档,它要求目标是静态方法且签名满足非托管约定,托管对象作为参数时它管不住生命周期,所以只适合"输入输出都是裸数据"的极端路径,普通业务代码用前面几档就够。

关于"热路径中避免使用匿名函数"这条,落地方式不是禁止语法而是改变数据流:把每帧的逻辑提成命名方法,需要参数就走显式参数,需要缓存就在 Awake 或首次使用时建好委托。命名方法还有一个附带收益,Profiler 采样时栈帧名字可读,匿名函数与显示类方法名在采样树里是<Update>b__12_0这类乱码,排查成本高一截。

八、量级估算:一次闭包分配值多少字节

下表全部为典型估算,用于判断"值不值得治",不是任何机型上的实测常数。对象头按 64 位 Mono/Boehm 布局 16B 计,实际随后端浮动。

分配来源单次字节(典型估算)频率假设每秒垃圾量
显示类实例(1 个捕获字段)~24B每帧 1 次~1.4KB/s
Action 委托对象~40B每帧 1 次~2.4KB/s
捕获变量的 lambda(显示类 + 委托)~64B每帧 1 次~3.8KB/s
只捕获 this 的 lambda~40B每帧 1 次~2.4KB/s
不捕获的 lambda(稳态)0任意0
协程每次 yield new WaitForSeconds~32B + 对象本身每秒 10 次~0.3KB/s
循环内为 10 个按钮各建闭包~640B仅初始化时一次性

读表要点有三个。第一,单看每次几十字节很容易放松警惕,真正的风险是频率列:每秒几 KB 的稳态分配在小堆上也许几分钟才触发一次收集,堆涨到几十 MB 后触发阈值变高、单次收集变长,两头恶化,这正是 Boehm 非分代结构的特点(详见 03 篇)。第二,捕获 this 比捕获局部变量便宜一个显示类,但"便宜"不等于免费,Update 里每帧一次的形态 B 依然是每秒 2.4KB 量级,该缓存还是要缓存。第三,最后一行说明优化不是一刀切:初始化期一次性创建十个闭包完全可接受,要杀的是"频率与帧率挂钩"的那类分配。

自查方法给一条捷径:Unity Profiler 的 Timeline 上开 GC Alloc 计数器,对着嫌疑方法逐个注释二分,分配归零的那次注释对应的 lambda 就是源头;装了 Burst 或用 IL2CPP 时也可以在生成的 C++ 文件里搜DisplayClass,每个命中都对应一处显示类分配。

九、常见误区与反模式

误区一:"lambda 很短,应该没有分配。" 错在哪:分配次数与 lambda 体的长度完全无关,只与捕获形态和调用频率有关。空体 lambda 只要捕获了变量,显示类与委托一笔不少。正确做法是查捕获列表,不是数语句。

误区二:"把 lambda 缓存成静态字段就万事大吉。" 错在哪:静态字段缓存委托时,委托的 target 若是某个实例,该实例就无法回收;缓存带this的委托等于把一个 MonoBehaviour 钉在全局。正确做法:静态 lambda 才进静态字段,实例委托缓存在实例字段并在销毁时置空。

误区三:"委托复用只要逻辑一样就行,-=用什么写法无所谓。" 错在哪:委托按实例比对,语义相同的两个 lambda 是两个实例,-=解不掉;这是第六节泄漏事故的直接原因。正确做法:保存委托实例,成对增删,或者改用静态方法订阅。

误区四:"IL2CPP 下闭包会被优化掉。" 错在哪:IL2CPP 只是把闭包翻译成 C++ 类,堆分配照旧,Boehm 照旧管理(见 2.4)。正确做法:两个后端执行同一套优化,验收都以真机 Profiler 的 GC Alloc 为准。

十、与相关章节的对比

章节负责的分配来源与本文的界线
08-01 字符串拼接、格式化、ToString闭包常把字符串分配带进热路径,字符串本身的账归那边
08-02 LINQ迭代器、结果容器Where/Select 里的 lambda 分配归本文,迭代器与 ToList 归那边
08-04 装箱与反射隐式装箱、反射元数据lambda 参数若是值类型走 object 重载,会在闭包之外再叠一层装箱
08-06 async 状态机async lambda 的状态机类async lambda 在闭包账之外还有状态机分配,见那边
08-08 Unity API 陷阱引擎 API 返回值分配yield 指令缓存一条在本文第五节,清单在那边

本文在全专栏的位置一句话:它处理的是"编译器替你偷偷 new 对象"这一类里最常见的一种。字符串是显式可见的分配,闭包是语法层面的分配,两者叠加几乎覆盖了 Unity 项目热路径 GC 的半壁江山。

本章总结

  1. 闭包分配来自编译器生成的隐藏类实例。捕获变量的 lambda 产生闭包类实例和委托分配。
  2. 捕获形态决定代价:捕获局部变量是显示类加委托两笔;只捕获 this 只有委托一笔;不捕获走编译器缓存,稳态零分配。
  3. 方法被调用 N 次就创建 N 套闭包实例,频率与调用点挂钩,写进 Update 就是每帧一份。
  4. Unity 陷阱集中在三处:循环内给事件挂捕获变量的 lambda、协程每次 yield new、静态事件上订阅捕获 this 的匿名函数。
  5. 事件订阅泄漏的根子在引用链:事件源活着,委托活着,委托引用的场景对象全活着;解药是成对注销与静态方法订阅。
  6. 优化方案:不捕获变量用 static lambda、缓存委托、协程中固定 yield 指令,热路径优先走静态方法加显式参数。

一句话记忆点:看到 lambda 先问"捕获了什么、每秒跑几次",答案乘以几十字节,就是你喂给 Boehm 的账单。

对不捕获变量的 lambda,使用 static 修饰符消除委托分配。

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

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

立即咨询