C# 高性能写法(四):集合
集合性能首先取决于数据结构。容量、查找方式和内存布局选错后,上层代码即使写得再精细,也很难弥补算法复杂度和频繁分配带来的成本。
如果需要先梳理集合接口、底层数据结构和不同实现之间的关系,可以阅读《深入理解 C# 集合:接口、实现与选型》。
1. 🧭 根据主要操作选择集合
不同集合优化的操作不同。选择时应先判断代码最常执行的是遍历、查找、插入、排序还是并发访问。
| 主要需求 | 推荐集合 | 典型复杂度 |
|---|---|---|
| 固定长度、按索引访问 | 数组 | 索引 O(1) |
| 动态长度、顺序遍历 | List<T> | 索引 O(1),尾部追加均摊 O(1) |
| 根据键查找 | Dictionary<TKey, TValue> | 查找通常接近 O(1) |
| 判断是否存在、去重 | HashSet<T> | 查找通常接近 O(1) |
| 先进先出 | Queue<T> | 入队、出队均摊 O(1) |
| 后进先出 | Stack<T> | 入栈、出栈均摊 O(1) |
| 按优先级取元素 | PriorityQueue<TElement, TPriority> | 入队、出队 O(log n) |
| 始终保持键有序 | SortedDictionary<TKey, TValue> | 查找、插入 O(log n) |
| 创建一次、频繁读取 | FrozenDictionary / FrozenSet | 为读取优化 |
数据量和使用方式同样重要。只有几个元素时,简单的数组或列表可能比哈希集合更快,因为它们结构简单、内存连续,也不需要计算哈希值。
2. 📦 提前设置集合容量
List<T> 容量不足时会分配更大的内部数组并复制已有元素。Dictionary<TKey, TValue> 和 HashSet<T> 扩容时还需要重新组织内部存储。
已知大致数量时,可以在创建集合时指定容量:
var users = new List<User>(expectedCount);
var usersById = new Dictionary<int, User>(expectedCount);
var uniqueIds = new HashSet<int>(expectedCount);
集合已经创建后,也可以使用 EnsureCapacity:
users.EnsureCapacity(users.Count + incomingCount);
容量只是预留空间,不是元素数量。估算过小仍会扩容,估算明显过大则会增加内存占用,因此应根据常见输入规模设置,而不是随意指定一个很大的数字。
2.1 Clear 不会释放内部容量
Clear() 会移除元素,但集合通常会保留已经分配的容量。这适合反复复用集合,也意味着一个曾经装过大量数据的长生命周期集合可能继续占用较多内存。
TrimExcess() 可以缩小容量,但它需要重新分配和复制,不应放在高频路径中反复调用。只有集合会长期保留,并且容量明显高于后续需求时,才值得收缩。
3. 🔍 避免 Dictionary 重复查找
下面的代码会先检查键,再通过索引器执行一次查找:
if (usersById.ContainsKey(id))
{
return usersById[id];
}
只需要读取值时,使用 TryGetValue 完成一次查找:
if (usersById.TryGetValue(id, out User? user))
{
return user;
}
添加元素时也应根据语义选择 API:
- 确定键不存在时使用
Add,重复键会抛出异常。 - 重复键属于正常分支时使用
TryAdd。 - 需要覆盖已有值时使用索引器。
- 读取已有值时使用
TryGetValue。
明确选择 API 可以减少重复工作,也能避免把异常当作普通控制流程。
4. 🎯 为重复查找选择 HashSet
List<T>.Contains 需要从头到尾扫描,单次复杂度为 O(n)。当同一个集合会被反复用于成员判断时,可以使用 HashSet<T>:
HashSet<int> allowedIds = sourceIds.ToHashSet();
foreach (User user in users)
{
if (allowedIds.Contains(user.Id))
{
Process(user);
}
}
创建 HashSet<T> 本身需要时间和内存,因此它适合数据量较大或需要多次查询的场景。数据很少并且只查找一次时,构建哈希集合可能得不偿失。
集合间的去重、交集、并集和差集,也可以直接使用 HashSet<T> 的 IntersectWith、UnionWith、ExceptWith 等原地操作,避免创建多层 LINQ 查询。
5. 🔤 正确处理字符串键
字符串作为键时,应在创建集合时指定符合业务语义的比较器:
var headers = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
这比每次查找前调用 ToLower() 或 ToUpper() 更直接,也不会为标准化结果创建临时字符串。
- 区分大小写的标识符通常使用
StringComparer.Ordinal。 - 不区分大小写的协议字段和标识符通常使用
StringComparer.OrdinalIgnoreCase。 - 面向自然语言的内容才考虑区域性比较规则。
比较器必须在集合创建时确定。不要在同一个集合中混用不同的标准化方式,否则容易出现语义错误和重复键。
6. 🧩 保证键稳定并提供高质量哈希
对象加入 Dictionary 或 HashSet 后,参与相等比较和哈希计算的字段不能改变。否则对象仍位于旧的哈希位置,后续可能无法找到或移除。
自定义值类型作为键时,可以实现 IEquatable<T>:
public readonly struct Coordinate : IEquatable<Coordinate>
{
public Coordinate(int x, int y) => (X, Y) = (x, y);
public int X { get; }
public int Y { get; }
public bool Equals(Coordinate other) =>
X == other.X && Y == other.Y;
public override bool Equals(object? obj) =>
obj is Coordinate other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
}
良好的哈希值应让常见输入尽量均匀分布,并保证相等对象产生相同哈希值。键本身过大也会增加复制、比较和计算哈希的成本,必要时可以改用更小、更稳定的标识符。
7. ❄️ 读取远多于写入时使用 Frozen 集合
FrozenDictionary<TKey, TValue> 和 FrozenSet<T> 会在创建阶段分析数据并构建适合读取的内部结构,适合路由表、配置映射、命令表等创建一次、长期查询的数据。
using System.Collections.Frozen;
FrozenDictionary<string, Handler> handlers = source
.ToFrozenDictionary(
static item => item.Name,
static item => item.Handler,
StringComparer.Ordinal);
Frozen 集合的构建成本通常高于普通 Dictionary 或 HashSet,因此不适合频繁重建或更新。它的优势来自后续的大量读取,而不是创建速度。
还需要区分几个概念:
- Frozen 集合创建后不能修改,主要为读取性能优化。
ImmutableDictionary支持通过结构共享持续生成新的不可变版本,适合状态快照。ReadOnlyDictionary只是只读包装,底层 Dictionary 仍可能被其他代码修改。
8. ⚡ 谨慎使用 CollectionsMarshal
CollectionsMarshal 可以绕过部分集合抽象,直接访问内部存储,适合经过基准测试确认的底层热路径。
8.1 直接遍历 List 的内部数组
using System.Runtime.InteropServices;
Span<Item> items = CollectionsMarshal.AsSpan(list);
foreach (ref Item item in items)
{
item.Update();
}
AsSpan 不会复制元素,但 Span 存活期间不能对列表执行添加或删除操作,否则列表可能更换内部数组,使已有 Span 失效。
8.2 直接更新 Dictionary 中的值
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(
counts,
key,
out bool exists);
if (!exists)
{
count = 0;
}
count++;
这种写法可以避免查找两次,并直接修改集合中的值,对大型结构体尤其有用。但在引用使用期间,不能执行可能导致 Dictionary 扩容的操作。普通业务代码仍应优先使用 TryGetValue,只有确认这里是热点时再降低抽象层级。
9. ♻️ 使用 ArrayPool 复用大型缓冲区
频繁创建大型临时数组会增加 GC 压力。调用方能够严格管理生命周期时,可以从共享池中租用数组:
using System.Buffers;
byte[] buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
Process(buffer.AsSpan(0, length));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
使用对象池时必须注意:
- 返回的数组长度可能大于请求长度。
- 数组内容不保证已经清零,读取前必须写入。
- 数组归还后不能继续使用或保存引用。
- 敏感数据应在归还时清理。
- 小型、低频数组通常不值得引入池化管理。
对象池减少的是反复分配,不是让内存变成免费资源。租用后长期不归还,会降低池的复用价值并增加常驻内存。
10. 🔢 使用 PriorityQueue 维护优先级
需要不断加入元素并取出当前最高或最低优先级元素时,不必每次都对整个列表排序:
var queue = new PriorityQueue<Job, int>();
queue.Enqueue(job, job.Priority);
if (queue.TryDequeue(out Job? next, out int priority))
{
Process(next);
}
PriorityQueue<TElement, TPriority> 使用堆结构,入队和出队为 O(log n),查看队首为 O(1)。默认情况下较小的优先级先出队,并且相同优先级的元素不保证保持插入顺序。
如果需要完整排序后的结果,最终仍应使用排序;PriorityQueue 更适合任务调度、Top-K、路径搜索等持续维护局部最优元素的场景。
11. 🔒 并发集合不是默认选择
ConcurrentDictionary、ConcurrentQueue 等集合为多线程共享访问提供安全保证,同时也会引入同步、内存屏障和更复杂的内部结构。
只有集合确实被多个线程同时读写时,才应使用并发集合。单线程或由外层锁保护的场景,普通集合通常更简单,也可能更快。
使用 ConcurrentDictionary.GetOrAdd 和 AddOrUpdate 时还要注意:传入的委托可能在竞争下执行多次,最终只有一个结果进入集合。委托内部不应包含只能执行一次的扣款、发送消息等副作用。
对于生产者和消费者模型,还应考虑容量限制和背压。无限增长的并发队列即使操作很快,也可能最终耗尽内存。
12. 🔄 注意遍历方式和装箱
直接遍历具体的泛型集合,编译器通常可以使用其结构体枚举器:
foreach (Item item in list)
{
Process(item);
}
如果把集合转换成非泛型接口,值类型元素会发生装箱;通过某些接口类型枚举时,结构体枚举器也可能被装箱。高频底层路径中,可以优先保留具体集合类型或使用 Span。
不过,接口抽象通常具有更高的设计价值。只有性能分析确认枚举器分配是实际问题时,才值得为了消除装箱改变接口边界。
13. ✅ 集合优化顺序
遇到集合性能问题时,可以按下面的顺序检查:
- 当前集合是否符合最常见的访问模式。
- 是否能提前设置容量,减少扩容和复制。
- 是否存在
ContainsKey加索引器、重复Contains等多次查找。 - 字符串键是否使用了合适的
StringComparer。 - 自定义键是否稳定,并提供正确的相等和哈希实现。
- 只读数据是否适合 Frozen 集合。
- 大型临时缓冲区是否值得使用
ArrayPool<T>。 - 是否真的需要并发集合,以及是否存在无界增长。
- 最后再考虑
CollectionsMarshal、Span 和消除枚举器装箱等底层优化。
集合优化首先是数据结构和算法问题,其次才是 API 调用技巧。选对集合带来的收益,通常比把一段普通代码改成复杂的低层实现更稳定。