08-02-优化-LINQ与函数式编程的GC代价

发布时间:2026/10/28 19:07:12
08-02-优化-LINQ与函数式编程的GC代价

LINQ 与函数式编程的 GC 代价

篇章:08-性能优化篇 |阅读时间:约 45 分钟 |前置知识:了解 C# LINQ 基本语法


一、引言

LINQ(Language 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(典型估算) List<int> 对象 [分配 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 Allocation)

LINQ 的 Where、Select、OrderBy、Any 等方法都接受一个委托参数(Func<T, bool> 等)。当 lambda 表达式捕获了外部变量时,编译器生成一个闭包类的实例:

// 问题代码:每次调用都产生闭包分配 public List<int> FilterLarge(List<int> numbers, int threshold) { // lambda 捕获了 threshold,编译器生成闭包类 return numbers.Where(n => n > threshold).ToList(); // 分配 1: 闭包类实例(持有 threshold) // 分配 2: WhereEnumerableIterator 迭代器 // 分配 3: List<int> 内部数组 }

逐段解读。方法体第一行做了三件事:为了把局部变量threshold带进 lambda,编译器把它提升成一个显示类(display class)的字段并实例化,这是分配 1;Where调用把这个 lambda 编译出的方法包成委托,再连同源序列一起塞进一个 Where 迭代器对象,这是分配 2;ToList遍历迭代器把结果灌进新 List,List 内部数组是分配 3,元素多时还要按 2 倍速扩容,每扩一次又一次数组分配。返回值只有那个 List,前两个对象在查询结束的瞬间就成了垃圾。

编译器生成的闭包类代码如下(伪代码):

// 编译器生成的闭包类 internal class <>c__DisplayClass0_0 { public int threshold; // 被捕获的变量提升为字段 public bool <FilterLarge>b__0(int n) { return n > threshold; } }

这段伪代码解释了闭包为什么必然上堆:threshold已经不是栈上的局部变量,而是某个堆对象的字段,lambda 方法要读它就先得持有这个对象引用。编译器没有别的选择,因为委托可能在方法返回之后才被调用(比如存进事件里),栈帧那时早就没了。名字里的<>c__DisplayClass是编译器生成的标识,你在 Profiler 的调用栈里看到它,就说明命中的正是这条分配路径。

关键认知:即使 lambda 体很短,捕获变量的闭包始终是堆分配。不捕获变量的 lambda 可以被编译器缓存为静态委托,但通常仍然会产生迭代器分配。

3.2 迭代器分配(Iterator Allocation)

LINQ 的每个操作符都返回一个 IEnumerable,其实际类型是编译器生成的迭代器状态机类。链式调用特别严重:

// 链式 LINQ:每步一个迭代器 var result = numbers .Where(n => n > 10) // WhereEnumerableIterator 实例 .Select(n => n * 2) // SelectEnumerableIterator 实例 .OrderBy(n => n) // OrderByEnumerator 实例 .ToList(); // List<int> 实例 + 内部数组 // 共产生 4-5 个堆分配对象

逐段解读。注意这四行里Where和Select并不执行过滤与投影,它们只是各创建一个"包装了源序列和委托"的小对象,像在流水线上挂了两节车厢。真正干活的是ToList:它向链尾要枚举器,枚举器再向上游逐层要,闭包在这个过程中才被反复调用。OrderBy是链上最重的一环,它必须拿到全部元素排序,因此会额外申请整段缓冲区并持有排序用的比较委托,元素量等于缓冲区分配量。整条链跑完,沿途的迭代器对象全部变成垃圾,最后活下来的只有 ToList 产出的容器。

每个操作符的新对象还有个隐性成本:间接层。foreach走到链尾要经过一层接口调用、一层可能的虚方法分派,CPU 缓存局部性也比连续数组差。这一部分是时间成本,可以在 Profiler 的 Total Time 里看到;它与 GC 分配是两笔独立的账,优化时两边都要看。

3.3 临时容器分配

ToArray()、ToList()、ToDictionary() 等终端操作内部创建新容器,并将数据复制进去:

// ToList 内部实现(简化的分配路径) public static List<T> ToList<T>(this IEnumerable<T> source) { var list = new List<T>(); // 分配 1: List<T> 对象 foreach (var item in source) list.Add(item); // 可能触发内部数组扩容(再分配) return list; }

逐段解读。第一行的new List<T>()在多数运行时实现里并不预先申请数组,或者只申请一个很小的初始容量,所以紧接着的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 List<int> Bad(List<int> src, int limit) => src.Where(n => n > limit).ToList(); // limit 被捕获,闭包每次新建 // 不捕获:委托被编译器缓存,只剩迭代器与容器的账 public List<int> Better(List<int> 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 装箱叠加

委托本身不装箱,Func<int, 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 IEnumerable<Item> _cachedQuery; public IEnumerable<Item> 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 接口枚举的隐性装箱

还有一个与缓存配对出现的小坑:源列表静态类型写成IEnumerable<T>时,foreach通过接口取枚举器。List 的枚举器是结构体,走接口这条路会被装进一个盒子里,一次循环一次装箱,盒子的释放又是一笔垃圾。写成foreach (var x in myList)(静态类型就是 List)则直接用结构体枚举器,零分配。差别只在变量声明处的类型,行为却完全不同,这类问题在"我把容器当接口传参"的重构之后最容易冒出来,症状是原本干净的调用栈突然多出一行List<T>.Enumerator相关的 GC.Alloc。

五、实际成本与量级分析

5.1 一个可复现的对照场景

using UnityEngine; using System.Linq; using System.Collections.Generic; public class LINQBenchmark : MonoBehaviour { private List<int> _numbers; void Awake() { _numbers = new List<int>(1000); for (int i = 0; i < 1000; i++) _numbers.Add(i); } void Update() { // 测试 1:LINQ 版本(每帧产生分配) var large1 = _numbers.Where(n => n > 500).Select(n => n).ToList(); // GC Alloc ≈ 1.2KB // 测试 2:手动循环版本(零分配) var large2 = new List<int>(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 List<int>(500)那个容量参数,如果漏掉它,Add 到第 4 个元素就开始扩容链,512、1024 一路翻上去,虽不回到 KB 级,但"零分配"的说法就不再成立。

5.2 量级对照表

Profiler 数据(以下数字为示例量级与典型估算,不是跨平台实测常数,验收以你自己的真机采集为准):

操作列表大小每次 GC AllocGC 频率
LINQ Where+ToList1000~1.2 KB高
LINQ 链式(Where+Select+OrderBy)1000~3.5 KB高
手动 for 循环10000 B无
手动 foreach 循环10000 B(值类型枚举器无分配)无

口径说明有三点。第一,1.2KB 与 3.5KB 是同规模数据在一次完整查询下的分配总量估算,含迭代器、委托包装、容器与数组扩容,换运行时版本会有出入。第二,"GC 频率"一列说的是这段代码对频率的推动方向,不是每秒触发次数的实测值,真实频率还取决于项目整体分配速率与堆规模。第三,两行"零分配"的成立前提是容器预分配且循环体内无其他分配,条件破坏了数字就跟着变。

5.3 Unity 项目里的实际影响

在 Unity 的每帧 Update 中使用 LINQ,1000 个元素产生的 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 循环:零分配 List<int> 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)还多一个闭包。好版本把结果容器换成类字段_cachedList,Clear()只清长度不释放底层数组,之后的Add在容量足够时不产生任何分配;循环体是一次字段读取加一次比较加一次写入,没有委托调用,没有接口分派。两者在可读性上的差距真实存在,取舍标准写在第八节的决策表里。还要提醒一句:results作为类字段被调用方拿到时要明确它的所有权,调用方如果拿去排序或持有引用,下一次 Clear 会改到他的数据,接口设计上要么返回副本,要么约定只在本帧内使用。

6.2 缓存查询结果

对于查询结果变化不频繁的场景,缓存 LINQ 结果:

private List<int> _cachedFiltered; private int _lastThreshold = -1; public List<int> 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 FastListEnumerable<T> { private List<T> _source; public FastListEnumerable(List<T> source) => _source = source; public Enumerator GetEnumerator() => new Enumerator(_source); public struct Enumerator { private List<T> _source; private int _index; public Enumerator(List<T> source) { _source = source; _index = -1; } public T Current => _source[_index]; public bool MoveNext() { _index++; return _index < _source.Count; } } } // 使用:零分配枚举 var enumerable = new FastListEnumerable<int>(_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; NativeList<int> nativeList = new NativeList<int>(Allocator.Temp); nativeList.Add(42); // NativeList 的操作不经过 GC nativeList.Dispose();

逐段解读。NativeList的存储开在托管堆之外,Add不走 GC 分配路径,这就是它与List<T>的根本差别。Allocator.Temp指向帧内或方法内的临时分配,适合"这一帧用完就扔"的数据加工;更长生命周期要选Persistent或TempJob并配好生命周期,否则内存泄漏不会以 GC 垃圾的形式出现,而是以"内存只涨不掉"的形式出现,比分配问题更难查。必须写Dispose是第二条纪律,泄漏的 Native 容器还会在检查点报错。这套工具的正确用法是"数据搬运与批量计算用 Native,业务逻辑与接口边界回到托管",细节属于 08-05 与 08-07 的延伸范围,本文只要求记住它解决了"容器本身的分配"这一层。

6.5 用对容器容量与预分配

替代方案里最容易被漏掉的一条:就算保留 LINQ 或 foreach,把容器容量预估对,分配量也会掉一个档。new List<T>(expectedCount)、数组直接按长度建、StringBuilder预设容量,思路相同:让"第一次建多大"发生在你知道答案的地方,而不是让容器在循环里一路翻倍。配合 08-07 的 Span 与栈上数组,热路径上真正需要上堆的东西可以压到接近零,具体手法见该篇。

七、不可变集合与函数式风格的取舍

函数式风格常与不可变数据绑定:查询返回新序列,更新生成新副本,共享而不见状。这个取舍在服务端与工具链里成立,在游戏热路径上要重新算账。

先看成本结构。不可变集合的每一次"修改"都是一份新容器:整段复制加新节点分配,哪怕只改一个元素。频繁读、极少写的配置数据用它很合适,引用可以安全地到处传,不需要加锁也不用担心被谁改坏。每帧写入的数据用它就是灾难,写一次付一次整容器的钱,这与"降低分配速率"的目标直接对冲。取舍按写入频率划线:

场景特征推荐风格理由
只读共享、加载期构建不可变集合或只读包装安全与表达力优先,分配一次性
每帧读、每帧少量写可变容器 + 复用写入零分配是硬需求
查询结果跨帧持有物化缓存(List/数组)把惰性链的分配收敛到一次
每帧查询、结果即用即弃手写循环连物化容器都省掉

再看语言层面的取舍。LINQ 与 lambda 的表达力在"低频且难读"的代码里回报最高:加载期的数据整形、编辑器工具、事件响应里的过滤排序,这些地方一次查询的可读性收益远超几十个字节的分配。反过来,每帧执行的查询往往是简单条件加简单投影,手写循环的可读性并不差,还顺带把性能账清零。把两种风格各自用在回报高的地方,比"全面禁止"或"全面拥抱"都更符合工程实际。

顺带说明一个常见顾虑:换成手写循环会不会让代码难维护?经验做法是给热路径的循环写清楚意图注释与单元测试,把 LINQ 版本留在测试里当参照实现,两边结果一致才说明改写没改坏逻辑。性能改写不怕代码变长,怕的是没有等价性验证。

八、何时可以使用 LINQ

LINQ 并非完全禁止,以下场景使用 LINQ 是合理的:

  • 初始化/加载阶段:场景加载、配置读取、资源导入(一次性开销)
  • UI 事件响应:用户点击、界面更新(低频操作)
  • 编辑器工具代码:仅在 Unity Editor 中运行
  • 数据量极小:列表 < 10 个元素

绝对禁止的场景:Update/LateUpdate/FixedUpdate 中、协程的循环体中、每帧执行的属性访问器中。

把这条边界变成可执行的判断,配一张决策表:

判断项走 LINQ走手写循环
执行频率每帧执行或固定每帧多次加载期、事件触发、低频轮询
数据规模小于几十条,分配量可忽略上百条以上,或容量可预估
查询形态多条件组合、需要 OrderBy/GroupBy单条件过滤加简单投影
团队位置工具、编辑器、低频业务逻辑战斗、渲染、UI 刷新、网络收发
验收方式不看 Profiler 也没风险必须连真机看 GC Allocated In Frame

两条容易漏的补充判断。协程里看起来不是"每帧",但 yield 之间的代码可能每帧都在跑,循环体里塞 LINQ 等于每帧分配;属性访问器同理,public IEnumerable<Item> 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中
接口变量 foreachIEnumerable<T>装 List结构体枚举器装箱声明为具体容器类型每循环一次装箱低

排期建议先扫"高",再用 Profiler 验证:改前改后各录一段,看GC Allocated In Frame的下降幅度与 GC 次数变化,只在数字确实变好时保留改写。改完不动数字的项目,说明热点另有其处,回到第五节说的占比排序重新找。

十一、与相关章节的关系

本文专注 LINQ 与函数式写法这一条线,相邻主题的分工如下:

主题本文覆盖更深的内容在
闭包与匿名函数的分配机制3.1、3.5 讲结论与改法08-03 闭包与匿名函数
装箱的机制与检测3.6 列入口清单08-04 装箱与反射
值类型容器与性能六节提到方向08-05 值类型与 struct
Span、栈上分配、零分配套路6.5 给入口08-07 Span 与零分配编程
Unity 各 API 的分配清单不覆盖08-08 Unity API 的 GC 陷阱清单
分配速率与帧预算、GC 触发二、五节给结论08-10 帧级预算策略
GC 为什么对分配频率敏感2.1 直接使用结论03 架构篇、06 收集器篇

分工的理由与 08-01 一致:本文回答"这条查询付了哪些对象",收集器各篇回答"这些对象进了堆之后发生了什么",两段账拼起来才是完整诊断。

总结

按重要度收拢全文要点:

  1. LINQ 的分配有四个来源:捕获变量的闭包、每个操作符的迭代器状态机、终端操作的容器与扩容、叠加的装箱,每条都能在 Profiler 调用栈里指认出来。
  2. 惰性求值推迟的是遍历,不是对象创建,缓存 IEnumerable 缓存的是"怎么查",每枚举一次就重新付一次钱。
  3. 零分配的手写循环有两个前提:容器预分配、循环体里没有 ToString 与中间对象,缺一个都不成立。
  4. 决策看频率与占比,不看单次字节:热路径禁 LINQ,加载期与事件响应保留 LINQ,拿真机 GC Allocated In Frame 验收。
  5. 不可变集合适合低频写入的共享数据,每帧写入的场景它与降分配目标直接冲突。
维度LINQ手动循环
代码可读性高中等
开发效率高低
GC 分配每次调用产生 1~4KB(示例量级)零分配(容器预分配且循环干净)
适用场景低频/初始化高频/每帧

一句话记忆点:LINQ 是低频代码的表达力工具,不是热路径的数据搬运工。查询写在对的频率上,它帮你省时间;写进每帧,它就按帧向 GC 收税。