
LINQ 与函数式编程的 GC 代价篇章08-性能优化篇 |阅读时间约 45 分钟 |前置知识了解 C# LINQ 基本语法一、引言LINQLanguage Integrated Query为 C# 带来了优雅的函数式查询语法极大提升了代码的表达力。然而每一条 LINQ 查询背后都隐藏着 GC 分配迭代器对象、闭包实例、临时容器。在 Unity 游戏的 Update 路径中不加节制的 LINQ 使用是 GC 卡顿的常见元凶。核心问题LINQ 优先考虑开发体验而非运行时性能。每条查询都可能产生 3-6 个堆分配对象。在非性能路径配置加载、UI 初始化中完全可接受但在每帧执行的代码中必须避免。注意这句必须避免针对的是分配频率不是 LINQ 本身。LINQ 的设计目标是表达力它生成的代码要处理任意 IEnumerable 实现必然带一层抽象税每个操作符都是一个委托加一个迭代器状态机链起来就是一串对象。本文的任务是把这层抽象税逐项拆成账单让你知道钱花在哪、花多少、什么场合该省最后给出一套可以直接搬走的替代写法。全文按这个顺序展开第二节先把热路径上的分配为什么贵讲清楚第三节逐个拆闭包、迭代器、临时容器三类分配来源并补上委托缓存与装箱的叠加账第四节讲缓存 IEnumerable 与多次枚举的陷阱第五节给量级估算与口径第六节是替代写法的完整工具箱第七节讨论不可变集合的取舍最后三节是使用边界、误区清单与章节定位。二、热路径上的分配为什么贵2.1 分配速率决定 GC 频率先建立算账的坐标系。单次 LINQ 查询的几个对象单独看确实不起眼一个闭包实例加一个迭代器几十个字节量级典型估算。问题在于调用频率把这个数字乘了上去而 Unity 的 GC 对分配速率远比单次大小敏感。Unity 默认的 Boehm 收集器不分代、不压缩堆涨到阈值附近就触发一轮收集每一轮都是对整堆的标记与清扫。分配越密收集越勤每次收集要面对的垃圾也越多。把一帧的账摊开看60FPS 下的一帧16.7ms 预算里某处热路径执行一条 LINQ 查询 Where 闭包实例 [分配 1] ~32B典型估算 Where 迭代器 [分配 2] ~48B典型估算 Select 迭代器 [分配 3] ~48B典型估算 Listint 对象 [分配 4] ~32B典型估算 List 内部 T[] 初建 [分配 5] 按容量1000 个 int 约 8KB List 扩容再分配若容量没预估 再来一次数组 ------------------------------------------------------ 单帧合计约 1.2KB 量级示例量级随数据规模与链长浮动 每秒 60 帧 × 1.2KB ≈ 72KB/s示例量级持续灌入堆 → 堆水位上升加快 → GC 触发间隔缩短 → 每秒都可能多挨几次全堆扫描这条因果链要读对LINQ 不会让 GC 立刻卡一下它通过抬高分配速率让 GC 来得更频繁卡顿是频率上升之后的间接结果。所以治理思路也跟着定下来热路径上把这几个对象消掉GC 频率自然回落只调增量 GC 的时间片而不降分配治的是暂停长度治不了频率。2.2 两条常见误解先纠正第一条误解是我没用 ToList只是写了个 Where应该没有分配。惰性求值省掉的是结果容器省不掉查询对象本身。Where(...)这个调用在你写下它的那一刻就返回一个新对象迭代器状态机的实例它只是推迟了遍历的时机并没有推迟对象的创建。真正零分配的查询只有把它优化成不产生任何对象的形态比如缓存好的委托直接配 for 循环。第二条误解是编译器很聪明会把 LINQ 优化成和手写循环一样的代码。JIT 与编译器做过不少内联与去虚化优化但优化的前提是能证明等价LINQ 链上的每个节点都可能被自定义实现替换、可能被多个枚举器共享、委托可能是任意代码编译器无法在通用规则下把整条链折叠成一个循环。结果就是对象该创建还是创建只是创建之后跑得快一点。想拿回零分配只能在源码层面换写法。三、LINQ 的 GC 分配来源3.1 闭包分配Closure AllocationLINQ 的 Where、Select、OrderBy、Any 等方法都接受一个委托参数FuncT, bool 等。当 lambda 表达式捕获了外部变量时编译器生成一个闭包类的实例// 问题代码每次调用都产生闭包分配 public Listint FilterLarge(Listint numbers, int threshold) { // lambda 捕获了 threshold编译器生成闭包类 return numbers.Where(n n threshold).ToList(); // 分配 1: 闭包类实例持有 threshold // 分配 2: WhereEnumerableIterator 迭代器 // 分配 3: Listint 内部数组 }逐段解读。方法体第一行做了三件事为了把局部变量threshold带进 lambda编译器把它提升成一个显示类display class的字段并实例化这是分配 1Where调用把这个 lambda 编译出的方法包成委托再连同源序列一起塞进一个 Where 迭代器对象这是分配 2ToList遍历迭代器把结果灌进新 ListList 内部数组是分配 3元素多时还要按 2 倍速扩容每扩一次又一次数组分配。返回值只有那个 List前两个对象在查询结束的瞬间就成了垃圾。编译器生成的闭包类代码如下伪代码// 编译器生成的闭包类 internal class c__DisplayClass0_0 { public int threshold; // 被捕获的变量提升为字段 public bool FilterLargeb__0(int n) { return n threshold; } }这段伪代码解释了闭包为什么必然上堆threshold已经不是栈上的局部变量而是某个堆对象的字段lambda 方法要读它就先得持有这个对象引用。编译器没有别的选择因为委托可能在方法返回之后才被调用比如存进事件里栈帧那时早就没了。名字里的c__DisplayClass是编译器生成的标识你在 Profiler 的调用栈里看到它就说明命中的正是这条分配路径。关键认知即使 lambda 体很短捕获变量的闭包始终是堆分配。不捕获变量的 lambda 可以被编译器缓存为静态委托但通常仍然会产生迭代器分配。3.2 迭代器分配Iterator AllocationLINQ 的每个操作符都返回一个 IEnumerable其实际类型是编译器生成的迭代器状态机类。链式调用特别严重// 链式 LINQ每步一个迭代器 var result numbers .Where(n n 10) // WhereEnumerableIterator 实例 .Select(n n * 2) // SelectEnumerableIterator 实例 .OrderBy(n n) // OrderByEnumerator 实例 .ToList(); // Listint 实例 内部数组 // 共产生 4-5 个堆分配对象逐段解读。注意这四行里Where和Select并不执行过滤与投影它们只是各创建一个包装了源序列和委托的小对象像在流水线上挂了两节车厢。真正干活的是ToList它向链尾要枚举器枚举器再向上游逐层要闭包在这个过程中才被反复调用。OrderBy是链上最重的一环它必须拿到全部元素排序因此会额外申请整段缓冲区并持有排序用的比较委托元素量等于缓冲区分配量。整条链跑完沿途的迭代器对象全部变成垃圾最后活下来的只有 ToList 产出的容器。每个操作符的新对象还有个隐性成本间接层。foreach走到链尾要经过一层接口调用、一层可能的虚方法分派CPU 缓存局部性也比连续数组差。这一部分是时间成本可以在 Profiler 的 Total Time 里看到它与 GC 分配是两笔独立的账优化时两边都要看。3.3 临时容器分配ToArray()、ToList()、ToDictionary() 等终端操作内部创建新容器并将数据复制进去// ToList 内部实现简化的分配路径 public static ListT ToListT(this IEnumerableT source) { var list new ListT(); // 分配 1: ListT 对象 foreach (var item in source) list.Add(item); // 可能触发内部数组扩容再分配 return list; }逐段解读。第一行的new ListT()在多数运行时实现里并不预先申请数组或者只申请一个很小的初始容量所以紧接着的Add循环会一路把数组从 4、8、16 这样翻上去容量序列随实现版本浮动翻倍是常见策略。每次扩容都是新数组、整段复制、旧数组作废对 1000 个元素的整型列表扩容链产生的累计拷贝量接近最终数组容量的两倍典型估算。改法很直接调用方知道或估得出上限时绕开 ToList自己 new 一个带容量的 List 再手动灌数据扩容次数直接归零。这也预告了第六节替代写法的核心思路。3.4 完整 LINQ 分配清单LINQ 方法产生的堆分配Where(lambda)闭包类若捕获变量 Where 迭代器Select(lambda)闭包类若捕获变量 Select 迭代器OrderBy(lambda)闭包类 排序迭代器 临时缓冲区GroupBy(lambda)多个闭包 GroupBy 迭代器 分组容器ToList()List 对象 内部 T[] 数组ToArray()T[] 数组First() / Any()迭代器虽然后者不遍历全部Count()迭代器Max() / Min() / Sum()迭代器这张表按最坏情况读有两处需要补注。Count() 对 ICollection 有快路径源序列本身就是 List 时通常只做接口转换、不产生新对象走慢路径自定义序列时才会取枚举器而枚举器若是结构体会在存进接口变量时装箱那也是一次分配。First() 与 Any() 同理包装对象照建但只取头几个元素就释放数据面的开销远小于 ToList分配面并没有省掉。原则是看 Profiler 的调用栈不要背表。3.5 Lambda 捕获与委托缓存3.1 说过捕获变量就必有闭包对象反过来完全不捕获外部变量的 lambda 是另一档待遇编译器会把它编成静态方法并放进一个缓存类里同一个 lambda 在整个进程生命周期里只生成一个委托对象之后每次使用都是复用那个已缓存的实例。把这条规则用起来热路径上写不捕获的 lambda委托本身就是零增量分配的迭代器照旧要算。// 捕获每次调用 FilterLarge 都新建闭包与委托 public Listint Bad(Listint src, int limit) src.Where(n n limit).ToList(); // limit 被捕获闭包每次新建 // 不捕获委托被编译器缓存只剩迭代器与容器的账 public Listint Better(Listint src, int limit) { var limitLocal limit; // 挪出来也仍是捕获注意陷阱 return src.Where(IsBigger).ToList(); // 方法组见下文说明 } static bool IsBigger(int n) n 100; // 静态方法不碰外部状态逐段解读。第一个方法里的n n limit引用了参数limit每次调用都要实例化显示类承载它委托跟着一起新建。第二个方法改用一个静态方法转换出来的委托不依赖任何实例状态可以被缓存复用但注意中间那行注释指出的陷阱把参数先赋给局部变量再让 lambda 捕获局部变量捕获的事实没有变闭包照样每次新建很多人在这里以为我明明没用参数。另外方法组转换在不同语言版本上的缓存行为并不一致较新编译器对静态方法组有委托缓存优化Unity 所用的编译器随编辑器版本变化热路径上不要赌语言特性直接缓存到static readonly字段最稳。捕获的更深话题闭包生命周期、事件订阅泄漏、匿名函数的分配分类由 08-03 专篇展开本文只用上面这条结论热路径的 lambda 尽量不捕获捕获了就按一次堆分配记账。3.6 装箱叠加委托本身不装箱Funcint, bool是引用类型参数是值类型也不会把 lambda 装起来。LINQ 路径上的装箱来自另外三个入口识别它们比背规则有用序列本身是非泛型的比如从 ArrayList 或老接口拿来的IEnumerable枚举出来的每个元素装一次箱foreach (object o in ...)就是典型现场lambda 里把值类型当 object 用x.Equals(threshold)调到Equals(object)时装箱string.Format、装箱版Append、拼接成字符串同理查询结果被塞进以 object 为键的字典或泛型约束为 class 的容器键与值各装一次。装箱的对象与闭包、迭代器叠在同一段代码里Profiler 上表现为一簇相邻的 GC.Alloc。箱体本身是 16 到 24 字节量级典型估算随值类型大小浮动高频出现时和闭包迭代器加起来并不可观。箱体问题的完整机制与检测方法在 08-04本文记住入口清单即可。四、缓存 IEnumerable 的陷阱与多次枚举4.1 缓存的是一条查询不是一份结果一个很自然的优化动作是查询结果不常变那我把查询缓存起来。写出来往往是这样// 看似缓存了查询实际每次枚举都重新执行整条链 private IEnumerableItem _cachedQuery; public IEnumerableItem CachedQuery { get { if (_cachedQuery null) _cachedQuery _items.Where(i i.IsActive).Select(i i.Resolve()); return _cachedQuery; // 惰性查询对象不是查询结果 } } void Tick() { foreach (var it in CachedQuery) { /* 每次枚举新建迭代器、重跑委托、重建链 */ } var count CachedQuery.Count(); // 第二遍同样的分配再来一次 }逐段解读。_cachedQuery存下的只是链尾那个包装对象它里面持着源列表和委托。每次foreach都会向它要枚举器它再向上游重新创建 Where、Select 的迭代器委托从头再跑一遍分配也从头再发生一遍缓存把构造查询省了把执行查询的分配原封不动留在每次枚举上还外加一个更隐蔽的问题源列表变了这条缓存链的行为跟着变调用方拿到的是不确定的内容。Count()那一行是第二遍枚举同样一整套再付一次。判断标准很简单缓存的对象是 IEnumerable 就要警惕除非你保证它整个生命周期只被枚举一次。4.2 调用链上的分配发生在哪一步把 4.1 的调用链摊平分配点的位置比LINQ 有分配这句话有用得多_cachedQuery _items.Where(a).Select(b); 【定义时】1 次 │ Where 闭包 Select 闭包若有捕获 │ 2 个迭代器包装对象 ▼ foreach (var x in _cachedQuery) 【每次枚举】 │ └─ GetEnumerator() 新建链上每个迭代器2~3 个对象 │ └─ MoveNext() × N 委托逐元素执行无新分配 │ └─ Dispose() 迭代器交给 GC ▼ _CachedQuery.Count() 【每次调用】 └─ 再走一遍同样的枚举路径 迭代器再来 2~3 个对象 _ToList() 结果若改用它 【每次查询】1 个 List 1 个数组结论按位置给出定义时的分配付一次枚举时的分配按枚举次数付终端容器按查询次数付。想真正清零要么让枚举次数降为一次查出来立刻物化成缓存的 List要么把整条链改写成手写循环加复用容器让枚举这一步本身不产生对象。4.3 foreach 接口枚举的隐性装箱还有一个与缓存配对出现的小坑源列表静态类型写成IEnumerableT时foreach通过接口取枚举器。List 的枚举器是结构体走接口这条路会被装进一个盒子里一次循环一次装箱盒子的释放又是一笔垃圾。写成foreach (var x in myList)静态类型就是 List则直接用结构体枚举器零分配。差别只在变量声明处的类型行为却完全不同这类问题在我把容器当接口传参的重构之后最容易冒出来症状是原本干净的调用栈突然多出一行ListT.Enumerator相关的 GC.Alloc。五、实际成本与量级分析5.1 一个可复现的对照场景using UnityEngine; using System.Linq; using System.Collections.Generic; public class LINQBenchmark : MonoBehaviour { private Listint _numbers; void Awake() { _numbers new Listint(1000); for (int i 0; i 1000; i) _numbers.Add(i); } void Update() { // 测试 1LINQ 版本每帧产生分配 var large1 _numbers.Where(n n 500).Select(n n).ToList(); // GC Alloc ≈ 1.2KB // 测试 2手动循环版本零分配 var large2 new Listint(500); for (int i 0; i _numbers.Count; i) if (_numbers[i] 500) large2.Add(_numbers[i]); // GC Alloc: 0 (List 已在外部分配) } }逐段解读。测试 1 的一行里藏着四个分配视角Where与Select各建一个迭代器捕获不发生lambda 只引用参数n不捕获外部状态委托走缓存ToList建 List 与其内部数组元素 500 个上下时数组经历一次到两次扩容。注释里的 ≈1.2KB 是示例量级实际数值随运行时实现与容量策略浮动。测试 2 之所以是 0靠的是两个前置条件写在外面结果容器提前按容量建好循环体里没有 ToString、没有闭包、没有方法调用产生新对象。注意new Listint(500)那个容量参数如果漏掉它Add 到第 4 个元素就开始扩容链512、1024 一路翻上去虽不回到 KB 级但零分配的说法就不再成立。5.2 量级对照表Profiler 数据以下数字为示例量级与典型估算不是跨平台实测常数验收以你自己的真机采集为准操作列表大小每次 GC AllocGC 频率LINQ WhereToList1000~1.2 KB高LINQ 链式(WhereSelectOrderBy)1000~3.5 KB高手动 for 循环10000 B无手动 foreach 循环10000 B值类型枚举器无分配无口径说明有三点。第一1.2KB 与 3.5KB 是同规模数据在一次完整查询下的分配总量估算含迭代器、委托包装、容器与数组扩容换运行时版本会有出入。第二GC 频率一列说的是这段代码对频率的推动方向不是每秒触发次数的实测值真实频率还取决于项目整体分配速率与堆规模。第三两行零分配的成立前提是容器预分配且循环体内无其他分配条件破坏了数字就跟着变。5.3 Unity 项目里的实际影响在 Unity 的每帧 Update 中使用 LINQ1000 个元素产生的 1.2KB 分配在 PC 上不明显但在低端 Android 设备上加上其他代码的分配会使 GC 触发频率大幅增加。把这个结论再往深处推一层。1.2KB 乘以 60 帧是每秒约 72KB示例量级一分钟就是 4MB 上下而实际项目里每帧产生分配的远不止一处飘字、UI、事件、反射缓存、特效数据全都在同一口锅里。低端机的堆本来就紧张字符串与容器分配叠上去之后GC 每隔几秒就要扫一次整堆表现在真机上就是规律性的掉帧尖峰。所以判断我的 LINQ 到底要不要管看的不是单次字节数而是它在你项目每帧总分配里的占比占到三成以上就值得动手占比很小则优先处理更大的那一块具体排序方法与本章 08-10 的帧级预算对照着用。六、热路径替代写法6.1 手动 for 循环// ❌ LINQ每帧 ~1.2KB var filtered items.Where(i i.IsActive).Select(i i.Value).ToList(); // ✅ for 循环零分配 Listint results _cachedList; // 外部预分配 results.Clear(); for (int i 0; i items.Count; i) { if (items[i].IsActive) results.Add(items[i].Value); }逐段解读。坏版本一次查询三笔账两个迭代器加一个结果容器i i.IsActive若引用了类字段items是字段时 lambda 捕获 this还多一个闭包。好版本把结果容器换成类字段_cachedListClear()只清长度不释放底层数组之后的Add在容量足够时不产生任何分配循环体是一次字段读取加一次比较加一次写入没有委托调用没有接口分派。两者在可读性上的差距真实存在取舍标准写在第八节的决策表里。还要提醒一句results作为类字段被调用方拿到时要明确它的所有权调用方如果拿去排序或持有引用下一次 Clear 会改到他的数据接口设计上要么返回副本要么约定只在本帧内使用。6.2 缓存查询结果对于查询结果变化不频繁的场景缓存 LINQ 结果private Listint _cachedFiltered; private int _lastThreshold -1; public Listint GetFiltered(int threshold) { // 参数未变化时直接返回缓存 if (_lastThreshold threshold _cachedFiltered ! null) return _cachedFiltered; _lastThreshold threshold; _cachedFiltered _data.Where(d d threshold).ToList(); return _cachedFiltered; }逐段解读。这个写法与 4.1 的反例只差一个词缓存的是 ToList 产出的 List物化结果而不是 IEnumerable查询本身。参数没变时直接返回既有列表整条查询链一次都不执行闭包、迭代器、容器全都不产生参数变了才付一次完整查询的钱之后继续复用。_lastThreshold初值取 -1 是保证首帧必然执行查询避免真实参数恰好等于初值时拿到空缓存。这个模式的适用边界也清楚参数空间小、变化低频的数据才值得缓存参数每次都变的场景等于把缓存变成了摆设还额外多养一个列表。返回内部列表同样有所有权问题只读使用没问题要修改就先复制。6.3 结构体枚举器避免迭代器分配// 自定义结构体枚举器零分配 public struct FastListEnumerableT { private ListT _source; public FastListEnumerable(ListT source) _source source; public Enumerator GetEnumerator() new Enumerator(_source); public struct Enumerator { private ListT _source; private int _index; public Enumerator(ListT source) { _source source; _index -1; } public T Current _source[_index]; public bool MoveNext() { _index; return _index _source.Count; } } } // 使用零分配枚举 var enumerable new FastListEnumerableint(_numbers); foreach (var n in enumerable) { /* 零分配 */ }逐段解读。这段代码利用了foreach的鸭子类型规则编译器不看接口只找GetEnumerator方法及其返回类型的MoveNext与Current。因为FastListEnumerable和它的枚举器都是结构体整个枚举过程在栈上完成不产生任何堆对象这就是手写迭代器能做到零分配的原因。_index初始化为 -1 是让第一次MoveNext先走到 0 再返回有效性。代价是代码量与维护成本每个数据源都要手写一套链式组合能力也得自己重新发明所以它只适合已经证明是热点、且查询形态固定的那一两处不适合当通用 LINQ 替代品。需要组合过滤与投影时先按 6.1 展开成循环实在要复用再考虑这套结构体模式。6.4 Unity Collections 包Unity 的 Collections 包提供了不产生 GC 分配的数据结构using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; NativeListint nativeList new NativeListint(Allocator.Temp); nativeList.Add(42); // NativeList 的操作不经过 GC nativeList.Dispose();逐段解读。NativeList的存储开在托管堆之外Add不走 GC 分配路径这就是它与ListT的根本差别。Allocator.Temp指向帧内或方法内的临时分配适合这一帧用完就扔的数据加工更长生命周期要选Persistent或TempJob并配好生命周期否则内存泄漏不会以 GC 垃圾的形式出现而是以内存只涨不掉的形式出现比分配问题更难查。必须写Dispose是第二条纪律泄漏的 Native 容器还会在检查点报错。这套工具的正确用法是数据搬运与批量计算用 Native业务逻辑与接口边界回到托管细节属于 08-05 与 08-07 的延伸范围本文只要求记住它解决了容器本身的分配这一层。6.5 用对容器容量与预分配替代方案里最容易被漏掉的一条就算保留 LINQ 或 foreach把容器容量预估对分配量也会掉一个档。new ListT(expectedCount)、数组直接按长度建、StringBuilder预设容量思路相同让第一次建多大发生在你知道答案的地方而不是让容器在循环里一路翻倍。配合 08-07 的 Span 与栈上数组热路径上真正需要上堆的东西可以压到接近零具体手法见该篇。七、不可变集合与函数式风格的取舍函数式风格常与不可变数据绑定查询返回新序列更新生成新副本共享而不见状。这个取舍在服务端与工具链里成立在游戏热路径上要重新算账。先看成本结构。不可变集合的每一次修改都是一份新容器整段复制加新节点分配哪怕只改一个元素。频繁读、极少写的配置数据用它很合适引用可以安全地到处传不需要加锁也不用担心被谁改坏。每帧写入的数据用它就是灾难写一次付一次整容器的钱这与降低分配速率的目标直接对冲。取舍按写入频率划线场景特征推荐风格理由只读共享、加载期构建不可变集合或只读包装安全与表达力优先分配一次性每帧读、每帧少量写可变容器 复用写入零分配是硬需求查询结果跨帧持有物化缓存List/数组把惰性链的分配收敛到一次每帧查询、结果即用即弃手写循环连物化容器都省掉再看语言层面的取舍。LINQ 与 lambda 的表达力在低频且难读的代码里回报最高加载期的数据整形、编辑器工具、事件响应里的过滤排序这些地方一次查询的可读性收益远超几十个字节的分配。反过来每帧执行的查询往往是简单条件加简单投影手写循环的可读性并不差还顺带把性能账清零。把两种风格各自用在回报高的地方比全面禁止或全面拥抱都更符合工程实际。顺带说明一个常见顾虑换成手写循环会不会让代码难维护经验做法是给热路径的循环写清楚意图注释与单元测试把 LINQ 版本留在测试里当参照实现两边结果一致才说明改写没改坏逻辑。性能改写不怕代码变长怕的是没有等价性验证。八、何时可以使用 LINQLINQ 并非完全禁止以下场景使用 LINQ 是合理的初始化/加载阶段场景加载、配置读取、资源导入一次性开销UI 事件响应用户点击、界面更新低频操作编辑器工具代码仅在 Unity Editor 中运行数据量极小列表 10 个元素绝对禁止的场景Update/LateUpdate/FixedUpdate 中、协程的循环体中、每帧执行的属性访问器中。把这条边界变成可执行的判断配一张决策表判断项走 LINQ走手写循环执行频率每帧执行或固定每帧多次加载期、事件触发、低频轮询数据规模小于几十条分配量可忽略上百条以上或容量可预估查询形态多条件组合、需要 OrderBy/GroupBy单条件过滤加简单投影团队位置工具、编辑器、低频业务逻辑战斗、渲染、UI 刷新、网络收发验收方式不看 Profiler 也没风险必须连真机看 GC Allocated In Frame两条容易漏的补充判断。协程里看起来不是每帧但 yield 之间的代码可能每帧都在跑循环体里塞 LINQ 等于每帧分配属性访问器同理public IEnumerableItem ActiveItems _items.Where(...)这种写法把分配藏进了读取方读的人毫无察觉规范上应该让这类属性返回物化结果或干脆改成方法命名。九、常见误区与反模式误区一LINQ 慢所以所有项目一律禁用。错误做法是在 Code Review 里无差别打回任何 LINQ。为什么错LINQ 的成本是分配与抽象开销它换来的可读性在低频代码里是实打实的收益一刀切会让加载逻辑、数据整形变得又长又难改团队把精力花在无收益的重写上性能却没变化因为热点根本不在这。正确做法是按第八节的决策表分档热路径禁用低频路径保留用 Profiler 数据决定要不要继续收紧。误区二把 IEnumerable 缓存起来就等于缓存了查询结果。错误做法是给惰性查询对象加一个字段缓存然后在多处枚举。为什么错IEnumerable 缓存的是怎么查不是查到了什么每次枚举都重新建迭代器、重跑委托分配按枚举次数计费源数据变化还会让结果漂移性能和正确性一起出问题。正确做法是缓存物化结果List 或数组参数或源数据变化时失效重建参照 6.2 的写法。误区三改成手写循环就一定零分配。错误做法是把 LINQ 换成 for 循环之后宣布完工。为什么错循环体里调用 ToString、拼接字符串、new 中间对象、用没带容量的 List 反复 Add、把结构体枚举器走接口装箱任何一条都会把分配加回来循环只是消灭了迭代器与闭包这两项。正确做法是改完看 Profiler 的 GC Alloc 明细确认调用栈里不再出现目标分配点再对照第六节检查容器容量。误区四只看单帧分配数字做决策。错误做法是看到单次 300 字节就判定不值得优化或者看到 1KB 就立刻判定必须重构。为什么错单次数字不乘频率就没有意义300 字节在每帧 20 次的路径上是每秒约 360KB示例量级1KB 在关卡加载时一辈子只跑一次则无需处理。正确做法是永远按每帧分配量 × 执行频率 × 在总分配中的占比三个维度排序优化项做法与本篇第五节的口径说明一致。十、风险与方案对照表把热路径上的高频反模式收进一张表风险等级按每帧出现时对 GC 频率的推动预期给出字节数为典型估算场景坏写法分配来源好写法典型估算风险每帧过滤列表Where().ToList()闭包 迭代器 容器预分配 List for每帧 1KB 级降到 0高每帧排序OrderBy().First()排序缓冲 迭代器维护有序结构或插入排序每帧数百 B 到 KB 级高每帧求统计值Max()/Count()走接口接口枚举与可能的装箱静态类型直调属性或手写累加每帧几十到数百 B中缓存查询对象字段存 IEnumerable每次枚举重建迭代器缓存物化 List按枚举次数计费高循环里 ToList每轮物化一次每轮一个容器加数组循环外一次物化或复用容器每轮数百 B 起中捕获 lambda字段捕获进 Where显示类每次新建静态方法或不捕获写法每次 24~48B中接口变量 foreachIEnumerableT装 List结构体枚举器装箱声明为具体容器类型每循环一次装箱低排期建议先扫高再用 Profiler 验证改前改后各录一段看GC Allocated In Frame的下降幅度与 GC 次数变化只在数字确实变好时保留改写。改完不动数字的项目说明热点另有其处回到第五节说的占比排序重新找。十一、与相关章节的关系本文专注 LINQ 与函数式写法这一条线相邻主题的分工如下主题本文覆盖更深的内容在闭包与匿名函数的分配机制3.1、3.5 讲结论与改法08-03 闭包与匿名函数装箱的机制与检测3.6 列入口清单08-04 装箱与反射值类型容器与性能六节提到方向08-05 值类型与 structSpan、栈上分配、零分配套路6.5 给入口08-07 Span 与零分配编程Unity 各 API 的分配清单不覆盖08-08 Unity API 的 GC 陷阱清单分配速率与帧预算、GC 触发二、五节给结论08-10 帧级预算策略GC 为什么对分配频率敏感2.1 直接使用结论03 架构篇、06 收集器篇分工的理由与 08-01 一致本文回答这条查询付了哪些对象收集器各篇回答这些对象进了堆之后发生了什么两段账拼起来才是完整诊断。总结按重要度收拢全文要点LINQ 的分配有四个来源捕获变量的闭包、每个操作符的迭代器状态机、终端操作的容器与扩容、叠加的装箱每条都能在 Profiler 调用栈里指认出来。惰性求值推迟的是遍历不是对象创建缓存 IEnumerable 缓存的是怎么查每枚举一次就重新付一次钱。零分配的手写循环有两个前提容器预分配、循环体里没有 ToString 与中间对象缺一个都不成立。决策看频率与占比不看单次字节热路径禁 LINQ加载期与事件响应保留 LINQ拿真机 GC Allocated In Frame 验收。不可变集合适合低频写入的共享数据每帧写入的场景它与降分配目标直接冲突。维度LINQ手动循环代码可读性高中等开发效率高低GC 分配每次调用产生 1~4KB示例量级零分配容器预分配且循环干净适用场景低频/初始化高频/每帧一句话记忆点LINQ 是低频代码的表达力工具不是热路径的数据搬运工。查询写在对的频率上它帮你省时间写进每帧它就按帧向 GC 收税。