C# 集合选型与 LINQ 性能

系统梳理 C# 集合类型的选型依据与 LINQ 的真实开销,覆盖 List、Dictionary、HashSet、ImmutableArray 的复杂度与内存布局,讲解分配、装箱、迭代器与委托带来的隐性成本,并给出循环、Span 与 ArrayPool 的替代写法与度量方法。

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 省一个值字段
有序键遍历SortedDictionaryO(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 的源生成、转换器与版本兼容策略。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排