1. 集合选型的决策框架
一句话总结: 选集合先问三个问题——是否按下标随机访问、是否按键查找或去重、元素数量与生命周期如何,答案基本能锁定唯一合理的类型。
集合选型不是风格问题,而是复杂度问题。把一个高频查找的场景写成 List<T>,每次 Contains 都是 O(n);把只需要顺序遍历的场景写成 Dictionary,白白付出哈希与桶的开销。先建立一张决策表,再谈微观优化。
| 需求 | 首选 | 复杂度 | 备注 |
|---|---|---|---|
| 顺序访问、可增长 | List<T> | 索引 O(1)、追加均摊 O(1) | 默认选择,缓存友好 |
| 固定长度、极致性能 | T[] | 索引 O(1) | 无边界检查消除时可被 JIT 优化 |
| 键查找、去重 | Dictionary<K,V> | 均摊 O(1) | 键必须实现良好哈希 |
| 只判存在 | HashSet<T> | 均摊 O(1) | 比 Dictionary 省一个值字段 |
| 有序键遍历 | SortedDictionary | O(log n) | 红黑树,插入慢于 Dictionary |
| 快照不可变 | ImmutableArray<T> | 索引 O(1) | 读多写少、跨线程共享 |
| 并发读写 | ConcurrentDictionary | 均摊 O(1) | 分段锁 + 无锁读 |
决策框架的要点是:先确定访问模式,再确定并发模型,最后才考虑内存占用。绝大多数业务代码的瓶颈不在集合类型本身,而在于把 O(n) 的查找写进了 O(n) 的循环里,形成 O(n²)。
// 反例:O(n^2) 的查找
public static bool 有重复_慢(List<int> ids)
{
for (int i = 0; i < ids.Count; i++)
for (int j = i + 1; j < ids.Count; j++)
if (ids[i] == ids[j]) return true;
return false;
}
// 正例:O(n) 的哈希判定
public static bool 有重复_快(List<int> ids)
{
var seen = new HashSet<int>(ids.Count);
foreach (var id in ids)
if (!seen.Add(id)) return true; // Add 返回 false 表示已存在
return false;
}
在 10 万元素规模下,前者需要约 50 亿次比较,后者只需 10 万次哈希与插入,差距是数量级的。这类改写不需要任何底层知识,只需要先把复杂度选对。
2. 数组、List 与 Span 友好遍历
一句话总结: List 是数组的包装,索引访问会被内联成数组访问,但接口调用与枚举器会阻止边界检查消除,改用 Span 或索引循环能显著提速。
List<T> 内部就是一个 T[] _items 加一个 int _size。通过索引器访问时,JIT 能内联到数组访问;但一旦通过 IList<T> 接口访问,就会退化为接口调用,且 JIT 无法消除边界检查。
// 慢:接口调用 + 无法消除边界检查
public static long SumViaInterface(IList<int> data)
{
long sum = 0;
for (int i = 0; i < data.Count; i++)
sum += data[i];
return sum;
}
// 快:具体类型 + 索引,JIT 可内联并优化
public static long SumViaList(List<int> data)
{
long sum = 0;
for (int i = 0; i < data.Count; i++)
sum += data[i];
return sum;
}
foreach 在 List<T> 上不会装箱,因为编译器会优先使用结构体枚举器;但如果变量声明为 IEnumerable<T>,就会通过接口调用 GetEnumerator(),产生一次装箱并丧失内联机会。
// 慢:以接口类型接收,枚举器被装箱
public static int CountEven(IEnumerable<int> data)
{
int c = 0;
foreach (var x in data) if ((x & 1) == 0) c++;
return c;
}
// 快:具体类型,使用 List<T>.Enumerator 结构体
public static int CountEvenFast(List<int> data)
{
int c = 0;
foreach (var x in data) if ((x & 1) == 0) c++;
return c;
}
2.1 用 Span 消除切片与拷贝
一句话总结: Span<T> 是栈上切片视图,切分、截断、反转都不分配,是把热点遍历从 O(n) 拷贝降到 O(1) 视图的关键工具。
处理数组的局部片段时,传统写法用 Array.Copy 或 LINQ 的 Skip/Take,两者都会分配。Span<T> 提供零分配的切片。
// 传统:Skip/Take 会分配迭代器与新数组
int[] 解析头部_旧(int[] frame) =>
frame.Skip(4).Take(8).ToArray();
// Span:零分配切片,直接引用原内存
public static ReadOnlySpan<int> 解析头部(Span<int> frame)
=> frame.Slice(4, 8);
// 实际解析:把二进制帧读成整数序列
public static bool TryReadHeader(ReadOnlySpan<byte> frame, out int version)
{
version = 0;
if (frame.Length < 8) return false;
version = BinaryPrimitives.ReadInt32LittleEndian(frame.Slice(4, 4));
return true;
}
Span 的代价是它只能存活在栈上,不能作为字段、不能在 async 方法中跨 await 使用,也不能被装箱。把 Span 用在热路径的解析、切分、比较上,把结果立刻复制到持久结构中,是标准的用法边界。
2.2 stackalloc 与 ArrayPool
一句话总结: 小缓冲区用 stackalloc 走栈,大缓冲区用 ArrayPool 复用,两者都能把高频分配从 Gen0 移出,直接压低 GC 压力。
// 栈上小缓冲:不经过 GC
public static int 解析小帧(ReadOnlySpan<byte> input)
{
Span<byte> buf = stackalloc byte[64];
input.Slice(0, Math.Min(64, input.Length)).CopyTo(buf);
return buf.IndexOf((byte)0xFF);
}
// 池化大缓冲:复用数组,避免反复分配
public static async Task<int> 处理大块(Stream stream, int length)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
int read = await stream.ReadAsync(buffer.AsMemory(0, length));
return read;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // 必须归还
}
}
stackalloc 的安全阈值通常控制在 1 KB 以内,过大有栈溢出风险;ArrayPool 归还时默认不清零,若缓冲区曾存敏感数据,应使用 Return(buffer, clearArray: true)。
3. Dictionary、HashSet 与键的选择
一句话总结: 哈希集合的性能几乎完全由键的 GetHashCode 与 Equals 决定,值类型键要注意默认哈希质量,引用类型键要警惕可变字段。
Dictionary<TKey, TValue> 使用分离链接法,桶数组加条目数组,条目里保存哈希码以加速比较。默认容量为 0,第一次插入时分配 3 个桶,随后按质数序列增长。预先给出容量能避免多次扩容与重哈希。
// 已知规模时预分配,避免扩容
var map = new Dictionary<string, Order>(capacity: 10_000);
3.1 自定义键的哈希实现
一句话总结: 自定义结构体键应实现 IEquatable<T> 与高质量 GetHashCode,否则默认反射式哈希会带来性能与正确性双重问题。
public readonly struct OrderKey : IEquatable<OrderKey>
{
public readonly int TenantId;
public readonly long OrderId;
public OrderKey(int tenantId, long orderId)
{
TenantId = tenantId;
OrderId = orderId;
}
public bool Equals(OrderKey other)
=> TenantId == other.TenantId && OrderId == other.OrderId;
public override bool Equals(object? obj)
=> obj is OrderKey k && Equals(k);
public override int GetHashCode()
=> HashCode.Combine(TenantId, OrderId); // 优于手写乘法
public static bool operator ==(OrderKey a, OrderKey b) => a.Equals(b);
public static bool operator !=(OrderKey a, OrderKey b) => !a.Equals(b);
}
注意 HashCode.Combine 返回的哈希在进程间不稳定(随机化种子),因此绝不能把哈希值持久化或用于跨进程协议。它只用于进程内的字典与集合。
3.2 字符串键的比较器
一句话总结: 字符串键默认用区分大小写的序数比较,若业务需要忽略大小写,务必显式传入 StringComparer,切勿依赖 ToLower 预处理。
// 正确:显式比较器,字典内部按序数忽略大小写
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// 错误:ToLower 会分配新字符串,且受文化影响
var bad = new Dictionary<string, string>();
bad[header.ToLower()] = value;
ToLower() 在小数据量下看似无害,但在百万次查找的循环里会分配百万个临时字符串,把 Gen0 压力推到不可接受的水平。比较器方式则完全避免分配。
4. 不可变集合与线程安全集合
一句话总结: ImmutableArray 适合读多写少与跨线程共享快照,ConcurrentDictionary 适合高并发读写,两者的语义与适用场景完全不同,不可互相替代。
ImmutableArray<T> 是结构体包装的数组,读操作与 T[] 几乎等价,但写入会产生新实例。
public sealed class ConfigSnapshot
{
private readonly ImmutableArray<string> _allowedHosts;
public ConfigSnapshot(IEnumerable<string> hosts)
=> _allowedHosts = hosts.ToImmutableArray();
public bool IsAllowed(string host)
=> _allowedHosts.Contains(host); // 只读,线程安全
public ConfigSnapshot WithHost(string host)
=> new ConfigSnapshot(_allowedHosts.Add(host)); // 返回新快照
}
ImmutableArray<T> 的默认值(default)其底层数组为 null,访问会抛 NullReferenceException,这是最常见的坑。要么在构造函数里初始化,要么用 ImmutableArray<T>.Empty。
并发集合的选择则取决于读写比例:
// 读多写少:ConcurrentDictionary 无锁读
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
public CacheEntry GetOrAdd(string key)
=> _cache.GetOrAdd(key, static k => Load(k)); // 工厂可能被多次调用
// 生产者消费者:Channel 优于 BlockingCollection
private readonly Channel<Job> _queue =
Channel.CreateBounded<Job>(new BoundedChannelOptions(1024)
{
FullMode = BoundedChannelFullMode.DropOldest,
});
ConcurrentDictionary.GetOrAdd 的工厂委托可能被并发调用多次,若工厂有副作用(如打开连接、扣减库存),必须改为先 TryGetValue 再 TryAdd 并处理竞争。
5. LINQ 的隐藏开销
一句话总结: LINQ 的每一层查询都引入迭代器状态机与委托调用,链式查询还会重复遍历;可读性换来的开销在热路径上往往不可接受。
一个 Where(...).Select(...).ToList() 至少涉及:两个迭代器对象分配、两个委托分配(若捕获变量则还有闭包对象)、一次列表分配与多次增长。在每秒百万次调用的路径上,这些分配会迅速累积。
// 每次调用分配:2 个迭代器 + 2 个委托 + 1 个 List + 内部数组
public static List<string> 活跃用户名_慢(IEnumerable<User> users)
=> users.Where(u => u.IsActive)
.Select(u => u.Name)
.ToList();
// 零 LINQ:单次遍历,一次分配
public static List<string> 活跃用户名_快(List<User> users)
{
var result = new List<string>(users.Count);
foreach (var u in users)
if (u.IsActive) result.Add(u.Name);
return result;
}
常见的 LINQ 陷阱清单:
Count() > 0应为Any(),后者短路返回。Where(...).FirstOrDefault()应改用FirstOrDefault(predicate)单次遍历。OrderBy(...).First()用MinBy可降到 O(n)。- 对
IEnumerable<T>多次枚举会重复执行查询,应先ToList()固化。 Contains在List<T>上是 O(n),在HashSet<T>上是 O(1)。
// 反例:O(n^2)
bool 有交集_慢(List<int> a, List<int> b)
=> a.Any(x => b.Contains(x));
// 正例:O(n)
bool 有交集_快(List<int> a, List<int> b)
{
var set = new HashSet<int>(b);
return a.Any(set.Contains);
}
5.1 闭包与委托分配
一句话总结: 捕获外部变量的 lambda 会生成闭包类并每次分配,改用静态 lambda 加显式状态参数可完全消除这项分配。
// 有分配:闭包捕获 threshold
int threshold = 10;
var big = list.Where(x => x > threshold).ToList();
// 无闭包:静态 lambda + 显式参数(.NET 9 的 Where 重载)
var big2 = list.Where(threshold, static (x, t) => x > t).ToList();
在 .NET 8 及以后,Enumerable 的多处重载都提供了带状态参数的形式,配合 static lambda 可以让编译器把委托缓存为静态单例,彻底消除每次调用的委托分配。
6. 用循环、Span 与 ArrayPool 替代 LINQ
一句话总结: 热路径上的 LINQ 应被显式循环替代,字符串与字节处理优先用 Span 系列方法,聚合运算优先用 Vector 或直接循环。
.NET 6 起,Span<T> 与 ReadOnlySpan<T> 上有大量零分配扩展:IndexOf、Contains、StartsWith、SequenceEqual、Slice、Split 的替代写法等。
// 字符串分割的零分配写法
public static int 统计逗号分隔项数(ReadOnlySpan<char> line)
{
int count = 0;
foreach (var range in line.Split(',')) // MemoryExtensions.Split,无分配
if (!line[range].IsWhiteSpace()) count++;
return count;
}
// 字节查找:Span.IndexOf 由 SIMD 加速
public static int 查找分隔符(ReadOnlySpan<byte> data, byte sep)
=> data.IndexOf(sep);
聚合运算也值得改写。Sum() 在 int[] 上有专门的重载且较快,但对 IEnumerable<int> 会走迭代器;在超大规模数值计算上,Vector<T> 或 TensorPrimitives 能带来数倍提升。
// 手写循环 + 局部累加,JIT 可向量化
public static long SumArray(int[] data)
{
long sum = 0;
foreach (var x in data) sum += x;
return sum;
}
ArrayPool 则用于缓冲区复用,尤其是在解析循环中反复申请临时数组的场景。注意归还时不要保留对数组的引用,池化的数组随时可能被其他调用方租走。
7. 度量与调优实践
一句话总结: 任何优化都必须先度量,用 BenchmarkDotNet 拿到可信数据,用分配分析确认瓶颈在 CPU 还是在 GC,避免凭直觉改写。
BenchmarkDotNet 是 .NET 性能测量的标准工具,它处理预热、抖动、统计显著性,并报告分配量。
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class LinqVsLoop
{
private readonly List<int> _data = Enumerable.Range(0, 1000).ToList();
[Benchmark(Baseline = true)]
public int Linq() => _data.Where(x => x % 2 == 0).Sum();
[Benchmark]
public int Loop()
{
int s = 0;
foreach (var x in _data) if ((x & 1) == 0) s += x;
return s;
}
}
运行 dotnet run -c Release 后,报告会给出 Mean、Ratio、Allocated 三列。典型结果是 Loop 比 LINQ 快 2 到 5 倍,分配从约 200 B 降到 0 B。只有在 Allocated 列显示显著分配、且该路径在真实负载中占比高时,改写才值得。
再配合 dotnet-counters 观察 alloc-rate 与 gen-0-gc-count 可以判断优化方向:若分配率高且 GC 次数也高、CPU 大量花在 GC 上,说明瓶颈是分配而非算法;反之若分配很低而 CPU 仍高,则应去看热点方法。两种结论对应完全不同的优化手段,先分清再动手。
8. 总结
| 环节 | 要点 |
|---|---|
| 选型 | 先定访问模式与并发模型,再定类型;查找去重一律上哈希集合 |
| 数组与 List | 用具体类型而非接口,避免 IEnumerable 参数导致的装箱与内联失败 |
| Span | 切片、解析、比较优先 Span,小缓冲 stackalloc,大缓冲 ArrayPool |
| 哈希键 | 实现 IEquatable 与 HashCode.Combine;字符串键用显式 StringComparer |
| 不可变与并发 | ImmutableArray 共享快照,ConcurrentDictionary 高并发读写,注意工厂重入 |
| LINQ 开销 | 每层迭代器与委托都分配,热路径改为单次遍历循环 |
| 度量 | BenchmarkDotNet 看 Mean 与 Allocated,先分清是 CPU 瓶颈还是 GC 瓶颈 |
集合与 LINQ 的优化本质上是一场关于复杂度和分配的取舍。把 O(n) 的查找塞进循环、把热路径交给链式查询,是绝大多数 .NET 性能事故的共同起点;而修正它们往往只需要改变几行代码。下一篇会转向另一个高频陷阱区——序列化,讨论 System.Text.Json 的源生成、转换器与版本兼容策略。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。