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 Alloc | GC 频率 |
|---|---|---|---|
| LINQ Where+ToList | 1000 | ~1.2 KB | 高 |
| LINQ 链式(Where+Select+OrderBy) | 1000 | ~3.5 KB | 高 |
| 手动 for 循环 | 1000 | 0 B | 无 |
| 手动 foreach 循环 | 1000 | 0 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 | 中 |
| 接口变量 foreach | IEnumerable<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 一致:本文回答"这条查询付了哪些对象",收集器各篇回答"这些对象进了堆之后发生了什么",两段账拼起来才是完整诊断。
总结
按重要度收拢全文要点:
- LINQ 的分配有四个来源:捕获变量的闭包、每个操作符的迭代器状态机、终端操作的容器与扩容、叠加的装箱,每条都能在 Profiler 调用栈里指认出来。
- 惰性求值推迟的是遍历,不是对象创建,缓存 IEnumerable 缓存的是"怎么查",每枚举一次就重新付一次钱。
- 零分配的手写循环有两个前提:容器预分配、循环体里没有 ToString 与中间对象,缺一个都不成立。
- 决策看频率与占比,不看单次字节:热路径禁 LINQ,加载期与事件响应保留 LINQ,拿真机 GC Allocated In Frame 验收。
- 不可变集合适合低频写入的共享数据,每帧写入的场景它与降分配目标直接冲突。
| 维度 | LINQ | 手动循环 |
|---|---|---|
| 代码可读性 | 高 | 中等 |
| 开发效率 | 高 | 低 |
| GC 分配 | 每次调用产生 1~4KB(示例量级) | 零分配(容器预分配且循环干净) |
| 适用场景 | 低频/初始化 | 高频/每帧 |
一句话记忆点:LINQ 是低频代码的表达力工具,不是热路径的数据搬运工。查询写在对的频率上,它帮你省时间;写进每帧,它就按帧向 GC 收税。