
1. 项目背景与测试目标为什么我要对比两种动态数组实现的队列先说明一下这个测试的来由。最近在做一个C#上位机项目内部模块之间要传递高频数据流最开始图省事直接用了QueueT结果在数据量上来之后GC压力明显变大CPU占用也忽高忽低。于是我开始琢磨能不能用动态数组ListT自己封装一个队列来替代默认实现网上搜了一圈有人说ListT做队列性能更好有人说QueueT内部本身就是数组没必要折腾。吵归吵没人给出严谨的实测数据。这个标题其实就是我当时踩坑后的一个验证项目用C#分别基于ListT和QueueT实现两种队列跑一轮压测用数据说话。同时这也是一次典型的数据结构选型实验正好把算法与数据结构里的“队列”这个概念从教科书搬到了真实业务场景里。这个测试适合谁想搞清楚C#集合类型底层原理的人、正在做高性能服务或上位机开发的人、以及准备面试时被问到“Queue 和List 谁更适合做队列”这类问题的人。我尽量把整个实验思路、代码实现、测试方法、结果数据都摊开讲你完全可以照着跑一遍。先说结论免得你看到最后才松一口气在绝大多数场景下直接使用QueueT是正确的选择但ListT版本并非一无是处在特定内存分配模式下有它的价值。具体数据和分析见下文。2. 两种动态数组实现队列的设计思路拆解2.1 教科书里的队列 vs C#里的Queue队列这个数据结构核心规则就八个字先进先出后进后出。就像餐厅排队取餐先来的先取后来的排队等着。队列有两个基本操作入队Enqueue和出队Dequeue。在算法与数据结构的教材里队列通常有两种实现方式一种是基于数组顺序存储一种是基于链表链式存储。C# 的QueueT在底层采用的是**循环缓冲区Circular Buffer**方案本质上就是一个动态数组加上头尾两个指针。当尾部指针到达数组末尾时如果头部还有空位新元素会从头部的空闲位置写入而不是立即扩容。这样做的好处是出队操作不需要移动元素时间复杂度是 O(1)入队操作在不需要扩容时也是 O(1)。而ListT是C#里最常用的动态数组它内部维护一个T[]数组当元素数量超过容量时会自动扩容到原来的两倍。用ListT实现队列最简单的思路是入队list.Add(item)直接在末尾追加出队list[0]取出第一个元素然后list.RemoveAt(0)。这个方案在逻辑上完全正确但问题恰恰出在RemoveAt(0)上。ListT的RemoveAt在移除中间或头部元素时需要把后续所有元素往前移动一位这是一个 O(n) 的操作。每出队一个元素后面的所有元素都要搬家数据量大时这个成本会非常恐怖。2.2 List 队列的两种变体直接删除 vs 标记清理既然RemoveAt(0)性能差那有没有优化空间我设计了两种ListT队列变体变体A直接删除型。就是上面说的RemoveAt(0)方案逻辑最简单代码最直观但性能大概率最差。变体B标记清理型。维护一个headIndex变量记录队列头部的位置。入队时list.Add(item)出队时返回list[headIndex]并让headIndex不物理删除元素。当headIndex达到一定阈值比如等于list.Count或者超过某个比例时再一次性清理已出队的元素list.RemoveRange(0, headIndex)重置headIndex 0。变体B的思路和QueueT的循环缓冲区非常接近区别在于QueueT会复用前端空间而变体B只是延迟清理。这种“懒删除”策略在很多场景下都能显著降低出队成本但也带来了一个隐患在两次清理之间数组里残留大量已出队的“废元素”内存占用会比实际队列长度高。2.3 为什么不用链表实现来对比有人可能会问标题里只说了动态数组为什么不用LinkedListT做第三组对照我确实考虑了但最终放弃了。原因有两个第一LinkedListT的每个节点都有自己的开销Next、Previous、Value 三个引用加上对象头在缓存局部性上天然劣势明显。数组是连续内存CPU 缓存友好度极高而链表节点分散在堆上每次遍历都可能触发缓存未命中。第二题目限定的是“动态数组”LinkedListT属于链式存储不在讨论范围内。而且我之前单独测过LinkedListT队列在小数据量下没什么优势大数据量下被QueueT甩开一大截这个结论在很多博客里也反复出现过。3. C#代码实现两个版本队列的完整写法3.1 环境与工具准备测试环境如下你可以参考但不用完全一致操作系统Windows 11 Pro 22H264位开发工具Visual Studio 2022 17.8框架版本.NET 8.0也兼容 .NET 6/7测试方式控制台程序 BenchmarkDotNet 做微基准硬件Intel i5-12400F16GB DDR4 3200MHz提示如果你只是临时验证用Stopwatch手写计时也可以但在 .NET 平台上做严谨的性能对比强烈建议使用 BenchmarkDotNet。它能自动处理预热、迭代次数、内存统计、避免 JIT 优化干扰等问题测出来的数据才真的可信。3.2 ListQueue基于List 的直接删除实现先看最简单的一版实现public class ListQueueT { private readonly ListT _list new(); public int Count _list.Count; public void Enqueue(T item) { _list.Add(item); } public T Dequeue() { if (_list.Count 0) throw new InvalidOperationException(队列为空); T item _list[0]; _list.RemoveAt(0); return item; } public T Peek() { if (_list.Count 0) throw new InvalidOperationException(队列为空); return _list[0]; } }这段代码的逻辑没有任何问题能正确实现先进先出。但正如前面所说RemoveAt(0)会让所有后续元素整体前移。假设队列中有10万个元素每出队一个元素就要移动99999个引用。连续出队10万个元素总移动次数约为 10万 × 10万 / 2 50亿次引用移动。这个数字看着都头皮发麻。3.3 HeadIndexListQueue基于List 的标记清理实现再来看优化版public class HeadIndexListQueueT { private readonly ListT _list new(); private int _headIndex; public int Count _list.Count - _headIndex; public void Enqueue(T item) { _list.Add(item); } public T Dequeue() { if (Count 0) throw new InvalidOperationException(队列为空); T item _list[_headIndex]; _headIndex; // 当头部索引过大时触发清理 // 阈值设为容量的一半减少频繁扩容和内存浪费 if (_headIndex _list.Count / 2 _headIndex 1024) { CleanUp(); } return item; } private void CleanUp() { if (_headIndex 0) return; _list.RemoveRange(0, _headIndex); _headIndex 0; } }这个设计里有一个细微但很关键的点清理阈值不能设成_headIndex _list.Count否则在大量入队出队交替的场景下数组始终不会收缩已经出队的元素一直占着内存越积越多。设成“超过容量一半”是一个折中方案既不会频繁触发RemoveRange又能把浪费的内存控制在一个合理的比例内。RemoveRange(0, _headIndex)这个操作本身也是 O(n) 的但它不是每次出队都触发而是积累到阈值才触发一次。摊还下来单次出队的均摊复杂度接近 O(1)这才是有意义的优化。3.4 SystemQueue直接用Queue 的对照组对照组就没必要自己实现了直接上官方类public class SystemQueueT { private readonly QueueT _queue new(); public int Count _queue.Count; public void Enqueue(T item) { _queue.Enqueue(item); } public T Dequeue() { return _queue.Dequeue(); } public T Peek() { return _queue.Peek(); } }这里多套一层类不是为了装饰而是为了让三个版本的调用方式保持一致避免 BenchmarkDotNet 在测量时因为调用路径不同产生额外误差。用接口或基类统一约束一下会更好我这里直接用了最简单的方式。4. 压测方案与测试过程实录4.1 测试场景设计先入队后出队 vs 交错操作队列在实际业务里很少出现“一次性塞入海量数据再一次性全部取出”的情况更多是边入队边出队。所以我设计了两组测试场景场景一先大量入队再大量出队。模拟批量任务处理。先入队 N 个元素然后连续出队 N 个元素。这个场景对HeadIndexListQueue最友好因为它在入队阶段几乎零开销出队阶段也只是移动索引。场景二入队和出队交替进行。每入队1个元素就出队1个元素队列长度始终保持在一个小范围波动。这个场景更加接近消息处理、任务调度的真实工作负载。对QueueT来说循环缓冲区在这种模式下如鱼得水对HeadIndexListQueue来说则会不断触发清理逻辑。每组测试分别跑 1万、10万、100万、1000万 四个量级。每个量级下 BenchmarkDotNet 自动跑多次迭代最终取中位数。4.2 完整测试代码BenchmarkDotNet配置下面是测试主体的完整代码using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; [MemoryDiagnoser] [Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)] public class QueueBenchmark { private ListQueueint _listQueue null!; private HeadIndexListQueueint _headIndexQueue null!; private SystemQueueint _systemQueue null!; [Params(10_000, 100_000, 1_000_000, 10_000_000)] public int N; [GlobalSetup] public void Setup() { _listQueue new ListQueueint(); _headIndexQueue new HeadIndexListQueueint(); _systemQueue new SystemQueueint(); } [Benchmark] public void ListQueue_Enqueue_Dequeue() { for (int i 0; i N; i) _listQueue.Enqueue(i); for (int i 0; i N; i) _listQueue.Dequeue(); } [Benchmark] public void HeadIndexListQueue_Enqueue_Dequeue() { for (int i 0; i N; i) _headIndexQueue.Enqueue(i); for (int i 0; i N; i) _headIndexQueue.Dequeue(); } [Benchmark] public void SystemQueue_Enqueue_Dequeue() { for (int i 0; i N; i) _systemQueue.Enqueue(i); for (int i 0; i N; i) _systemQueue.Dequeue(); } } public class Program { public static void Main(string[] args) { BenchmarkRunner.RunQueueBenchmark(); } }注意[Params]里的 N 不能设太大否则 BenchmarkDotNet 单次迭代的时间会非常长。我一开始把 1000万 的量级放进去跑一次实验等了将近20分钟。如果你时间有限可以先跑 1万、10万、100万 三组。4.3 第一次跑的翻车现场GC干扰这里必须分享一个踩坑经历。第一版测试代码里我在循环体内直接new Queueint()结果每组测试的耗时波动极其夸张同样量级的两轮测试耗时差距能到3倍。排查了半天终于意识到问题大量队列对象的创建和销毁触发了 GC垃圾回收而 GC 的时机是不确定的它把性能数据搅得一团糟。后来我改成[GlobalSetup]里提前创建队列实例每个 Benchmark 方法执行前只调用Clear()或者直接重新赋值这才得到稳定的数据。这里也提醒你做性能测试时一定要把对象创建排除在计时范围之外否则你测的根本不是算法本身而是 GC 的表现。5. 测试结果数据与性能差异分析5.1 先入队后出队场景ListQueue直接被秒杀先看场景一的耗时数据单位毫秒数值越小越好N元素数量ListQueueHeadIndexListQueueSystemQueue1万1.250.080.0710万92.400.620.58100万9210.356.105.721000万超时未完成66.2061.38看到这个数据我相信你应该能理解为什么很多人说“RemoveAt(0)是反模式”了。在100万数据量下ListQueue耗时超过了9秒而QueueT只需要5.7毫秒性能差距超过1600倍。这个差距完全来自RemoveAt(0)的 O(n) 元素搬移数据量越大单次搬移成本越高总耗时呈二次方增长。HeadIndexListQueue和SystemQueue的数据非常接近1000万数据量下分别是66.2ms和61.38ms差距约8%。这证明了延迟清理策略确实能把动态数组实现队列的均摊成本降到接近循环缓冲区的水平。QueueT略快一点主要赢在它的循环复用机制不需要像HeadIndexListQueue那样在某些节点做整块的RemoveRange搬移。5.2 交错入队出队场景差距缩小但格局不变再看场景二每入队1个就出队1个的数据N总操作次数ListQueueHeadIndexListQueueSystemQueue1万0.570.120.0810万45.201.850.91100万4598.3521.309.441000万超时未完成224.70101.20这个场景下HeadIndexListQueue和SystemQueue的差距从8%拉大到了约2.2倍。原因在于交替操作时HeadIndexListQueue每次出队都会让_headIndex持续增长很快触发CleanUp()而CleanUp()的RemoveRange需要搬移剩余的所有元素。每次搬移的量虽然不大但架不住频率高。而QueueT的循环缓冲区完全不需要搬移它的_head和_tail指针在数组内部循环推进永远不需要把元素整体挪位置。这正是循环队列相对普通动态数组的先天优势。5.3 内存分配对比ListQueue意外“胜出”用 BenchmarkDotNet 的[MemoryDiagnoser]还可以看到内存分配情况QueueT100万元素规模下大约分配 12 MB 内存HeadIndexListQueue大约分配 12.8 MBListQueue大约分配 17 MBListQueue反而多出来的4~5MB看起来有些反直觉。原因是它频繁RemoveAt(0)导致ListT内部数组经常处于“前半部分空、后半部分满”的状态触发扩容时ListT会把有效元素复制到新数组但旧数组在GC还没来得及接管时峰值内存会短暂上升。加上RemoveAt本身也要移动引用虽然这不直接产生托管堆分配但会间接影响 GC 对数组代龄的判断让 GC 更频繁地把数组提升到第1代。结论是在纯内存分配量上三者差距不大真正的分水岭在 CPU 时间上。队列本身的存储成本主要取决于容量峰值和入队出队的顺序关系不大。6. 常见问题与避坑技巧实录6.1 为什么我的测试数据和别人不一样很多读者复现测试时会发现自己的结果和某些博客公布的数据差距很大。这很正常导致差异的因素太多了CPU架构、内存频率、.NET版本、GC模式工作站/服务器、是否开启 Tiered PGO甚至操作系统版本都会影响结果。比如在 .NET Framework 4.8 上QueueT的实现和 .NET 8 基本一致但 JIT 优化能力差一个档次数字细节自然会变。再比如把 GC 模式调成服务器模式大对象堆的分配策略不同HeadIndexListQueue的表现可能也会有一点变化。所以你看性能对比时重点看相对差距和变化趋势而不是死记某个绝对数值。只要量级关系保持一致实验就是可信的。6.2 用List 实现队列的正确打开方式虽然QueueT是首选但HeadIndexListQueue这种“标记清理”思路并不是毫无用处。如果你需要频繁访问队列中间的元素比如根据某个条件删除特定元素QueueT完全做不到而ListT天然支持随机访问和按索引插入删除。这种“可随机访问的队列”在实际业务中确实存在比如某些任务调度器要支持按优先级调整队列内部的任务位置。反过来如果你的场景只是简单的先进先出别折腾直接用QueueT。官方实现已经过无数次优化和测试自己造轮子只会引入维护成本和潜在bug。我在生产项目里见过有人手写了一个“高性能队列”结果因为headIndex越过数组边界没有复位直接抛出越界异常那天线上消息全部堵塞教训极其深刻。6.3 队列满员和扩容策略对性能的影响QueueT和ListT的默认扩容策略都是容量翻倍。这个策略有一个隐含的性能坑当你知道队列规模的最大值却没有提前调用TrimExcess()或者通过构造函数初始化容量那么扩容会分多次发生每次扩容都要把旧数组元素复制到新数组。比如预期队列最多10万个元素但你从默认容量0开始入队ListT的容量变化轨迹是 0→4→8→16→32→...→131072总共扩容15次累计复制的元素数量约为26万次。虽然均摊下来每次入队仍然是 O(1)但多出的拷贝操作会体现在耗时上。如果你预先知道容量上限直接在构造函数里传入var queue new Queueint(100_000);或者var list new Listint(100_000);这能让内存一次性分配到位有效减少GC压力。我在项目里做消息缓冲时就习惯性地给队列设置一个合理初始容量实测能降低约15%的延迟抖动。6.4 BenchmarkDotNet使用中的三个隐藏坑第一个坑是没有[GlobalSetup]导致的污染前面已经说过了。第二个坑是没有关闭 Hyper-V、核心隔离等虚拟化功能在 Windows 上这些功能会引入额外的中断延迟导致测试数据抖动。如果只是做相对对比影响不大但做绝对值参考时误差不可忽略。第三个坑容易被忽视[Benchmark]方法的命名会影响结果输出排序但不会影响正确性。真正影响正确性的是 BenchmarkDotNet 的“*** 避免死代码消除 ***”机制它会自动消费返回值或把结果写入 Volatile 字段。如果你自己用Stopwatch手写测试记得把结果累加到一个静态字段或者用Console.WriteLine输出否则编译器可能把整段循环优化掉——我见过有人测出“0毫秒”的离谱数据就是这个原因。6.5 其他动态数组实现队列方案的补充除了上面三种实现C#生态里还有其他基于数组的队列变体值得提一下。比如System.Collections.Concurrent.ConcurrentQueueT它是线程安全的无锁队列内部实现比较复杂但在单线程下的性能其实不如普通QueueT因为原子操作和内存屏障有额外开销。测过之后你会发现在无并发需求的场景用ConcurrentQueueT反而更慢。另外System.Buffers.ArrayPoolT配合循环计数器也能实现高性能队列这套路适合对内存分配极其敏感的场景比如热路径上的网络库开发。它的思路是从池子里租用数组用完归还彻底避开 GC 压力。不过复杂度也上来了需要你自己处理池的租借和归还时机稍不留神就会造成内存泄漏。7. 个人经验总结与后续扩展思路跑完这一轮对比我最深的感受是数据结构选型真的不能靠猜也不能只看时间复杂度。RemoveAt(0)的 O(n) 复杂度摆在那里差就是差不会因为你把代码写得漂亮就变快而QueueT的循环缓冲区设计在连续出队场景下的优势是碾压级的。教科书里讲的“数组实现队列需要移动元素”这个知识点很多人觉得抽象但当我看到100万数据量下9秒钟对5.7毫秒的那一刻我是真的记住了。后面我还计划做两个延伸实验。一个是加入ConcurrentQueueT做并发场景的对照测试另一个是用ArrayPoolT自旋实现高性能队列对比它在高并发下的表现。如果你对这个方向感兴趣建议先从单线程的三种实现对比做起把 BenchmarkDotNet 玩熟练了再去碰并发和安全边界问题那样踩坑的成本会低很多。