
1. 聊一聊 List 的前世今生它到底解决了什么问题很多刚入门的 C# 开发者会在数组和 List 之间纠结。我当年做第一个上位机项目的时候被一个看起来特别简单的问题卡了一整个下午要动态接收串口数据数组长度是固定的我根本不知道下一秒会来多少字节这怎么存当时用数组的做法是先猜一个最大长度比如 1024然后自己维护一个计数器数据满了再手动扩容代码写起来又丑又容易崩。后来同事看了一眼说了一句你直接用 List 不就行了那一刻我才真正意识到List 不是数组的替代品而是数组在动态场景下的接棒者。List 是 System.Collections.Generic 命名空间下的泛型集合类它的本质是一个封装了动态扩容逻辑的数组。你没有看错List 的底层就是一个 T[] 数组只不过它帮你把数组满了怎么办插入时元素怎么移动删除时怎么保持连续这些脏活累活全干了。这个设计非常聪明——它保留了数组最核心的优势也就是通过索引访问元素是 O(1) 的同时又弥补了数组长度不可变的短板。那为什么不用 ArrayList很多老项目里能看到 ArrayList 的身影它也能动态扩容但它里面存的是 object也就是把所有元素都装箱成引用类型。这就带来两个问题一是性能每次往里塞一个 int 就要发生一次装箱取出来又要拆箱在大量数据、高频操作的场景下这个开销不可忽视二是类型安全ArrayList 里可以同时塞 int、string、自定义对象你取出来的时候必须小心翼翼地做强制类型转换一旦类型不对就是 InvalidCastException。List 用泛型把这两个问题都解决了它的底层数组是 T[]存储空间是连续的读性能几乎和数组没差别同时类型安全在编译期就能保证。List 的适用场景非常广。上位机的数据采集、WinForm 界面里的临时数据缓存、JSON 反序列化出来的对象列表、算法题里的动态结果集、业务层的批量操作……只要是数量不确定、需要频繁追加、偶尔按索引读取的数据List 几乎都是第一选择。甚至可以说C# 开发者在日常项目里写的绝大多数集合用的都是 List 。也有人会问既然 List 那么好那数组是不是就该淘汰了完全不是。数组在固定长度、需要极致性能的场景下依然无可替代。比如一个长度为 4 的矩阵坐标你没必要用 List 去管理再比如某个算法里你需要频繁地通过索引去读写元素用数组可能更直接。这里有一个基本的选型原则长度固定优先用数组长度不确定或者需要增删优先用 List 这个原则到什么时候都不过时。2. 从底层原理拆解 List 扩容、存储与那点微妙的内存细节2.1 List 的三个核心字段看懂就算入门了要深入理解 List 绕不开它的三个核心字段_items、_size和_version。这不是什么冷门的源码知识而是理解整个 List 行为的关键。_items是真正存储数据的 T[] 数组所有元素都连续存放在这里。_size记录的是当前实际存了多少个元素注意它不等于数组的长度。比如底层数组分配了 8 个格子的容量但你现在只往里塞了 3 个元素那_size就是 3而不是 8。_version是一个版本号每次集合的结构发生变化添加、插入、删除、清空_version就会自增。这个版本号是给迭代器用的目的就是防止你在 foreach 的过程中修改集合导致迭代行为变得不可预测。这三个字段决定了 List 的两个公开属性Count和Capacity。Count就是_size表示实际元素个数这个大家天天用Capacity是_items.Length表示当前底层数组的容量这个属性常常被忽略但它恰恰是性能调优最容易出效果的地方。打个比方Capacity 就像你租的仓库面积Count 就是仓库里实际放了多少个货架。仓库面积可以比货架数大很多但货架数永远不可能超过仓库面积。当你货架放不下了就得换一个更大的仓库把旧货架全部搬过去——这就是扩容。2.2 扩容机制为什么说预分配容量能救命List 的扩容策略是当_size达到_items.Length时创建一个新的数组新数组的长度是原来的两倍如果初始容量为 0则扩容为 4然后调用Array.Copy把旧数组的全部元素拷贝到新数组里最后用新数组替换旧数组。这个策略有一个很关键的结论单次 Add 操作的时间复杂度是 O(1)但扩容那一次是 O(n)。虽然均摊下来每次 Add 仍然是 O(1)但如果你的数据量很大且初始容量为 0前几次扩容会频繁触发拷贝性能损失非常直观。我之前实测过一个场景往一个空 List 里添加 100 万个元素不预设容量的情况下耗时在几十毫秒级别而提前用new Listint(1000000)预设容量之后耗时直接降到个位数毫秒。这个差距在高频调用的上位机采集循环里会被进一步放大。所以如果你能估算出元素的大致数量或者知道一个合理上限一定要在构造 List 的时候就传入容量参数。这不是什么玄学优化而是从底层扩容机制推导出来的必然结论。即便你估算得不是那么精确多分配一些容量只是多占用一点内存总比反复扩容要划算。2.3 值类型与引用类型的存储差异一个容易被忽略的坑List 存储值类型如 int、struct和引用类型如 string、class 实例时行为有一个显著差异。值类型是直接存储在 List 内部的数组里也就是数据内联在连续内存上引用类型存储的是引用地址真正的对象在堆上。这意味着什么首先List 的内存局部性比 ListListVector3而不是Listobject会有明显的性能优势。其次当你把 struct 从 List 里取出来时拿到的是一份副本修改这个副本不会影响集合里的原始元素这一点特别容易踩坑。我见过不少人写这样的代码struct Point { public int X; public int Y; } ListPoint points new ListPoint(); points.Add(new Point { X 1, Y 2 }); points[0].X 10; // 编译报错是的这行代码会编译报错。因为points[0]返回的是一个副本你修改副本没有任何意义编译器干脆直接在编译期就堵住了这个坑。这其实是 C# 对开发者的一种保护。如果你真想修改集合中的结构体字段正确做法是Point p points[0]; p.X 10; points[0] p;或者干脆把 Point 改成 class。这个细节在面试里经常被当作考察点在实际项目里也确实是不少人查半天才发现的 bug 源头。3. 增删改查的实用手册每个操作背后的时间代价与使用姿势3.1 添加元素操作最简单门道却最多List 添加元素有四个主要方法Add、AddRange、Insert和InsertRange。Add往末尾追加均摊 O(1)这是最常用的。AddRange一次性添加一组元素内部会先计算新数组是否有足够的容量再做一次数组拷贝比循环调用 Add 高效很多。如果你的数据源是另一个集合或者数组优先用 AddRange 而不是 for 循环。Insert往指定位置插入时间复杂度是 O(n)因为插入位置之后的所有元素都要往后移动一位。这在元素数量少的时候无所谓但如果你在一个上万元素的列表头部反复插入性能会非常难堪。看一个反例Listint list new Listint(); for (int i 0; i 10000; i) { list.Insert(0, i); // 每次插入都在头部后面所有元素都要移动 }这段代码的时间复杂度是 O(n²)在数据量大一点的时候会卡到你怀疑人生。如果你确实需要频繁在头部插入建议仔细评估一下是不是应该用 LinkedList 或者先用 List 正常 Add最后统一 Reverse这样往往比反复 Insert(0, ...) 快得多。3.2 删除元素Remove 与 RemoveAt 背后的陷阱删除操作有几个常见的姿势RemoveAt(index)按下标删时间复杂度 O(n)Remove(item)按值删它内部会先用IndexOf找到第一个匹配项再调RemoveAt时间复杂度同样是 O(n)但多了一次元素比较RemoveAll(PredicateT)可以按条件一次性删除多个元素内部实现是原地算法高效且遍历一次就能完成。在实际项目中有一个性能陷阱特别常见在一个循环里反复调Remove或RemoveAt。比如在 for 循环里根据条件删除元素删除后索引会变化容易漏删而且性能不好。推荐的做法是从后往前倒着删或者用RemoveAll一行代码搞定。RemoveAll底层用了一个很巧妙的双指针思路不需要频繁移动大量元素而是用一个慢指针指向当前位置应该放谁最后统一截断效率远高于逐个删除。// 不推荐的循环删除写法 for (int i 0; i list.Count; i) { if (list[i] % 2 0) { list.RemoveAt(i); i--; // 手动修正索引容易出错 } } // 推荐的写法 list.RemoveAll(x x % 2 0);3.3 查找与索引Contains、IndexOf、Find 各显神通Contains用来判断元素是否存在时间复杂度 O(n)底层是EqualityComparerT.Default.Equals做相等比较。IndexOf返回第一个匹配项的索引也是 O(n)。Find、FindLast、FindAll则通过委托来定义匹配条件用起来更灵活但你传入的委托会在每一个元素上执行同样是 O(n)。有一个比较冷门但有用的方法是BinarySearch它基于二分查找时间复杂度 O(log n)但前提是集合必须已经按升序排序。如果没排序就用 BinarySearch结果完全不可预期甚至可能返回一个负数这个负数做按位取反运算后恰好是应该插入的位置。这个行为在官方文档里有明确说明但很多人没用对就骂它坑。Listint list new Listint { 1, 3, 5, 7, 9 }; int index list.BinarySearch(5); // 2 int notFound list.BinarySearch(6); // 负数说明 6 不在集合里 int insertAt ~notFound; // 6 应该插入的位置也就是索引 3这个负数的按位取反等于插入位置的特性在实现有序集合插入逻辑的时候特别好用既能判断元素是否存在又能直接算出插入点。3.4 迭代过程中修改集合那个让你抓狂的 InvalidOperationExceptionforeach 循环里给 List 添加或者删除元素运行时会抛出InvalidOperationException: Collection was modified; enumeration operation may not execute.这个异常几乎所有 C# 开发者都遇到过。为什么会有这个限制原因就是前面提到的_version字段。foreach 实际上是通过迭代器实现的迭代器在开始迭代时会记住当前的_version每次移动时检查_version有没有变化如果有变化就说明有人在迭代过程中动了集合它为了避免你拿到不一致的数据干脆就抛异常了。这是一个非常合理的设计。设想一下你在 foreach 的过程中删除了元素那下一个元素到底是原来的下一个还是跳过了一个这个语义根本说不清楚。所以 .NET 用异常来强制你换一种更安全的写法。如果你确实要在遍历时修改集合有几种替代方案先遍历找到要删的元素记录下来遍历结束后统一删除用反向 for 循环从后往前删用ListT.RemoveAll一行代码完成条件删除干脆换成LinkedListT在增删多的场景下更合适。4. 排序、去重与转换List 那些开箱即用的高级能力4.1 Sort 的底层逻辑不是快排那么简单List .Sort() 底层用的是什么排序算法很多人会说快排其实不准确。.NET 用的是内省排序Introspective Sort简单说就是快排为主、堆排兜底、插入排序处理小规模数据的混合体。设计思路是这样大部分情况下走快速排序平均 O(n log n)当递归深度超过某个阈值说明数据分布可能很差比如几乎有序快排会退化成 O(n²)就切换成堆排序保证 O(n log n)当待排序的子数组长度很小阈值一般是 16就改用插入排序因为小规模下插入排序的常数因子更小实际更快。这对我们有什么实际意义首先你不用关心数据的最坏分布.NET 在语言层面帮你兜底了所以放心 Sort 就好。其次Sort 是不稳定排序也就是说两个相等的元素排序前后的相对位置不保证不变。如果你需要稳定排序就要用OrderBy它是稳定的但会引入额外的开销。自定义排序有两种简洁写法list.Sort((a, b) a.Age.CompareTo(b.Age)); // 按 Age 升序 list.Sort((a, b) b.Age.CompareTo(a.Age)); // 按 Age 降序 list list.OrderBy(x x.Age).ToList(); // Linq 写法稳定排序如果你要按多个字段排序可以用ThenBylist list.OrderBy(x x.LastName).ThenBy(x x.FirstName).ToList();4.2 去重、交集、差集的几种打开方式去重最人狠话不多的方式是配合 Linq 使用// 基本类型直接去重 Listint distinctList list.Distinct().ToList(); // 对象列表按某个属性去重 ListPerson result list .GroupBy(p p.Id) .Select(g g.First()) .ToList();GroupBy的去重思路其实就是先按 Id 分组然后每组取第一个这也是 C# 里按属性去重的标准写法。还有一种更高级的写法是用DistinctBy但这是 .NET 6 之后才引入的老项目上用不了GroupBy 的写法更通用。交集、差集、并集用 Linq 的Intersect、Except、Union就能搞定它们内部会用 HashSet 来加速比嵌套循环高效得多。需要注意的是这些方法默认使用默认相等比较器如果是自定义对象需要重写Equals和GetHashCode或者传一个IEqualityComparerT进去。4.3 List 与数组、IEnumerable 之间的互转这三者之间的转换是日常开发里频率极高的操作// ListT 转数组 T[] array list.ToArray(); // 数组转 ListT ListT list array.ToList(); // ListT 转 IEnumerableT其实 ListT 本身就实现了 IEnumerableT IEnumerableT enumerable list; // IEnumerableT 转 ListT ListT list collection.ToList();ToArray()和ToList()的内部实现都做了如果能复用就直接复用的优化。比如ListT.ToArray()在内部_size等于_items.Length时可能直接返回内部数组的拷贝而非深度加工所以在循环里频繁ToArray()虽然有点浪费但不会造成灾难性性能问题。不过如果循环体很大还是建议把ToArray()提到循环外。另外要注意AsReadOnly()返回的是一个只读的包装器它不能修改集合但底层还是同一个 List 。如果你在外面持有原 List 的引用照样能改。所以它只是一种约束不是不可变集合。真正不可变要用System.Collections.Immutable里的ImmutableListT。4.4 与 LINQ 组合拳延迟执行与陷阱List 和 LINQ 的组合是 C# 开发中非常高频的用法。Where、Select、OrderBy、GroupBy这些扩展方法返回的都是IEnumerableT它们有一个共同特性延迟执行。什么是延迟执行就是你写了var result list.Where(x x.Age 18);这一行代码并不会立刻去遍历 list而是等到你真的去foreachresult 或者调用ToList()、Count()、First()等终结操作的时候才真正执行。这个过程叫惰性求值。这个特性带来一个经典 bug你构造了一个 Where 查询然后修改了 list 的内容再来遍历 result结果发现结果集和你预期的不一样。因为查询是在遍历时才执行的它读到的集合已经是修改后的状态。解决办法很简单如果查询结果要被多个地方使用或者你担心原集合会被修改就立即.ToList()物化它。就好比你去餐厅点菜菜单Where 查询只是给你看的真正炒菜遍历是后厨按你下单时的内容做的。如果你中途改了菜单上菜的内容就可能对不上。5. 性能实战从定时器的抖动到百万级数据的存活之战5.1 扩容抖动藏在高频操作里的隐形杀手在高频场景里List 的自动扩容会带来一种非常隐蔽的性能问题——抖动。设想你有一个上位机程序每 100 毫秒从设备读一批数据存到 List 里数据长度不稳定一会儿 1024一会儿 4096。如果初始容量设置得不好List 内部会不断触发扩容每次扩容都要把旧数组的数据全部搬到新数组造成一次不小的 GC 压力。在系统运行久了之后GC 的压力会反映到定时器周期上导致采集频率产生肉眼可见的抖动。我自己遇到过的一个真实案例用 List 缓存串口消息时程序跑三四个小时之后采集周期从标准的 100ms 慢慢变成了 150ms、200ms而且越到后面越卡。查了才发现罪魁祸首就是因为数据长度波动频繁底层数组反复扩容旧的大数组对象迟迟不能被 GC 回收导致第 0 代堆里的内存碎片越来越多。解决思路有两步。第一步是预估上限直接把容量开到足够大的值比如new Listbyte(4096)保证运行时几乎不会触发扩容第二步如果确实会有超过上限的数据就在构造后预留一个合理的余量宁可多占几百个字节也不要在数据采集中途去扩容。5.2 大量数据的高效处理数组、List 与并行化当你要处理的数据量达到几十万、甚至上百万时List 的每一项操作看起来都很小但累积起来就很可观。这时候有几个优化思路第一用ListT.CopyTo把数据复制到数组后用原生数组去遍历。数组的遍历是 JIT 最擅长优化的场景有些情况下性能比 List 快 10% 到 20%。第二用CollectionsMarshal.AsSpan(list)方法把 List 的底层存储暴露为一个 Span 这样就能用 Span 的 API 操作数据。这是 .NET 5 之后引入的高级玩法性能非常高但要注意它直接访问了底层数组你需要确保这段时间不会有其他线程修改 List。Spanint span CollectionsMarshal.AsSpan(list); for (int i 0; i span.Length; i) { span[i] * 2; // 直接修改底层数据性能拉满 }第三如果数据量大且需要并行处理可以利用Parallel.For和ConcurrentBagT。但有个细节要小心并行地读 List 没问题并行地写同一位置也没有问题只要索引不冲突但如果并行地 Add就会产生线程安全问题需要使用ConcurrentBagT或者加锁。5.3 内存与引用GC 视角下的 List 生命周期List 本身就是一个对象它持有对底层数组的引用。当你把 List 当成局部变量用完就丢时底层数组也会随之失去引用可以被 GC 回收。但有一个问题常常被忽视List 的 Capacity 越大GC 扫描和回收的成本就越高尤其在堆内存紧张的时候大对象被分配到第 2 代堆回收一次的成本非常高。所以有一个实践建议如果你在一个长时间运行的服务里反复创建和丢弃 List 但每个 List 的容量都特别大GC 的压力会很明显。这时候可以考虑用ArrayPoolT来重用底层的数组虽然会牺牲一些代码可读性但能显著减少 GC 压力。byte[] rentedBuffer ArrayPoolbyte.Shared.Rent(4096); try { // 用 rentedBuffer 填充数据 } finally { ArrayPoolbyte.Shared.Return(rentedBuffer); }这种玩法在游戏开发、高频采集这类性能苛刻的场景中几乎是标配。普通业务项目里不需要这么激进但理解它背后的道理能帮你更理性地决定什么时候该用高级优化什么时候老老实实写就行。6. 多线程环境下的 List 它天生不是线程安全的6.1 为什么说 List 不是线程安全的List 的文档和无数踩坑案例都告诉我们多个线程同时读写同一个 List 实例是不安全的。不安全的地方体现在哪举一个最直接的例子线程 A 正在遍历 List 读取数据线程 B 这时候调用 Add 触发扩容底层数组被整体换掉了。线程 A 通过迭代器访问的还是旧的数组但旧的数组已经被替换成了一个大数组而且迭代器检查_version时发现版本号变了直接抛异常。就算不抛异常数据也可能读到一半是旧的、后半段是新的状态完全不可信。再举一个更微妙的例子两个线程同时 Add。因为 List 的 Add 并不是原子操作它内部是先检查容量够不够再写入_size位置最后_size。两个线程同时执行可能出现 A 线程写入的位置被 B 线程覆盖或者_size自增次数和实际写入次数不一致的情况。结果是 Count 数值和你实际 Add 的次数对不上甚至可能发生数组越界异常。6.2 如何安全地在多线程中用 List解决方案不外乎几种第一种加锁。用lock把读和写包起来这是最简单也最稳妥的方案。但要注意用普通 lock 遍历 List 时如果在 foreach 里加 lock锁的粒度太大读取频繁时性能下降明显用 for 循环加锁锁粒度更小但代码写起来难看。总之加锁的正确姿势要根据实际读写比例来判断。第二种使用线程安全的集合类。.NET 提供了ConcurrentBagT、ConcurrentQueueT、ConcurrentDictionaryTKey,TValue等专门处理并发场景的集合。BlockingCollectionT更是在生产者-消费者模型里非常好用。但注意这些集合的类型并不都实现了IListT你不一定能把它当 List 一样用需要重新思考数据访问模式。第三分割数据。如果生产者多、消费者单可以考虑用多个 List 分别接收不同线程的数据最后再合并。这种思路在做数据采集时特别常见每个采集线程维护自己的 List主线程最后把多个 List 用 AddRange 合并完全避免了锁竞争。private object _locker new object(); private Listint _list new Listint(); // 写入 lock (_locker) { _list.Add(data); } // 读取 lock (_locker) { int count _list.Count; for (int i 0; i count; i) { Console.WriteLine(_list[i]); } }如果你只是偶尔读一下、频繁写而且读的数据允许一定延迟可以考虑用ReaderWriterLockSlim做读写分离锁这样写入不频繁时读取几乎是并发的性能会好很多。6.3 一个真实的上位机并发场景复现我在做一个温度监测系统的时候设备会通过 Modbus 协议实时上报温度数据上位机需要把最新的一批数据缓存到 List 里同时另一个线程每隔一秒读取这个 List 去刷新 UI 曲线。最初版本直接在主线程用定时器遍历 List采集线程在后台堵塞时也往里 Add。运行不到两分钟UI 线程就抛异常了原因正是Collection was modified; enumeration operation may not execute.。排查过程其实不复杂先看异常堆栈定位到遍历代码再检查这个 List 是否被其他地方修改最终确认是两个线程共用同一个 List 导致的。修复方案是新建一个 List 作为缓存采集线程往缓存里写UI 线程通过加锁的方式把缓存里最新的数据拷贝到 UI 展示用的临时数组里。这个方案避免了在 UI 线程持有锁过久也保证了数据的最终一致性。多线程下用集合第一原则永远是不要让一个集合同时被两个线程裸奔读写。要么锁要么换并发集合要么用写时复制的思路。没有第四条路。6.4 ObservableCollection 与 UI 绑定的特殊场景在 WPF 或 WinForm 开发中经常需要把列表数据绑定到界面控件上比如 ListBox、DataGrid。如果直接用 List 做绑定然后往 List 里 Add 新数据界面是不会自动刷新出新行的。因为 List 没有实现INotifyCollectionChanged接口控件不知道集合内容变了。正确的选择是ObservableCollectionT它实现了INotifyCollectionChanged每次添加、删除、清空都会通知 UI 刷新。代价是它的内部实现比 List 重一点点而且它没有AddRange批量添加要循环 Add会导致 UI 多次刷新性能稍差。解决方案是把一批数据先加进一个临时 List再一次性用foreach塞给 ObservableCollection或者干脆用BindingOperations.EnableCollectionSynchronization来支持跨线程更新集合。这里还有一个常见的坑如果你修改了集合中被绑定的对象本身的属性比如tempData.Temperature 26.5ObservableCollection 是收不到通知的界面依然显示旧值。要让 UI 实时刷新被绑定的数据类还得实现INotifyPropertyChanged。所以完整的 MVVM 绑定链路是ObservableCollection 管集合变化通知INotifyPropertyChanged 管对象属性变化通知两者缺一不可。7. 面试考点与项目选型List 的台前幕后7.1 面试官最爱问的几个 List 问题很多 C# 面试题都围绕 List 展开因为它是 C# 基础知识和底层原理的良好切入点。我整理几个高频问题并给出我自己的答题思路问List 和数组有什么区别核心答点底层都是数组List 封装了动态扩容长度可变化List 提供了更多方法Add、Insert、Remove、Sort 等数组在固定长度和追求极致性能时优先List 有容量概念Count 和 Capacity 不同。问List 扩容的机制是什么核心答点默认容量从 0 开始首次 Add 扩容为 4之后每次扩容为原来的两倍扩容伴随数组拷贝预分配容量可以避免扩容性能损耗均摊时间复杂度 O(1)。问为什么 foreach 遍历 List 时不能修改集合核心答点迭代器通过版本号_version检测集合是否被修改修改集合会改变元素顺序和容量导致迭代状态不可预测这是设计上的保护机制替代方案有反向 for、RemoveAll 等。问List 如何实现线程安全核心答点List 不是线程安全的可以用 lock或用 ConcurrentBag、BlockingCollection 等并发集合或分割数据再合并并发读写时迭代器会抛异常。问List 和 LinkedList 如何选择核心答点List 是连续内存、索引访问 O(1)、插入删除是 O(n)LinkedList 是链表、索引访问 O(n)、插入删除是 O(1)前提是你已经有节点的引用日常绝大多数场景用 List 就够了LinkedList 的适用场景其实非常窄。这些问题的答案并不神秘但如果你能在回答里带上底层字段、版本号机制、扩容的具体数值这些细节会给面试官留下这个人真的深入看过源码的印象。7.2 从 List 到 LinkedList 、HashSet 、DictionaryTKey,TValue 的选型思路实际开发中集合选型是一个经常被低估的问题。很多人所有场景都用 List 其实是不理性的。这里给一个简单的决策路径需要通过索引随机访问元素 → List需要在头部或中间频繁插入删除且数据量大 → LinkedList需要快速判断一个元素是否存在且不关心顺序 → HashSet需要根据键快速查找值 → DictionaryTKey, TValue需要保持元素唯一且有序 → SortedSet需要 FIFO 先进先出 → Queue需要 LIFO 后进先出 → StackList 是万金油但不是所有场景的最优解。举个例子如果有一个去重需求用list.Contains(x)去判断时间复杂度是 O(n)整体就是 O(n²)数据量一上来就卡死而用 HashSet 做判重每个操作是 O(1)整体 O(n)天壤之别。不过也要提醒一句HashSet 也是用数组作为底层存储的但它的数组是桶的结构数据不是连续存放的遍历性能排序上不如 List 。所以需要去重按序遍历的场景可以先用 HashSet 完成去重再转回 List 排序两全其美。7.3 我的建议什么场景该用 List 什么时候该换结合我做上位机、WinForm 桌面应用和业务系统的经验我给出一份比较务实的建议清单优先使用 List 的场景数据量不确定需要动态追加的场景需要频繁按索引读取数据的场景数据结构相对简单不需要复杂的并发控制数据需要排序、查找、过滤结合 Linq 很好用只在单线程内使用或已经通过锁保护。不建议使用 List 的场景需要在大量数据中间频繁插入或删除需要频繁判断元素是否存在数据是键值对的形式需要按 Key 查找多线程高并发写入需要绑定 UI 并自动刷新改用 ObservableCollection数据量极大且高度在意 GC 压力。这个清单不是硬规则但它能帮你在架构阶段就规避很多后患。说到底List 是地基理解它的一砖一瓦你才能知道什么时候该在这个地基上加楼层、什么时候该换地基。7.4 一个完整的实战演示通讯采集 实时刷新 定时存储最后放一个我实际用过的综合小例子把 List 的常见操作串联起来。场景是根据串口接收到的传感器数据解析出温度值实时更新到界面上同时每隔一分钟把历史数据批量写入文件。public class SensorData { public DateTime Time { get; set; } public double Temperature { get; set; } } public partial class MainForm : Form { private readonly object _locker new object(); private readonly ListSensorData _history new ListSensorData(1024); private readonly ObservableCollectionSensorData _viewData new ObservableCollectionSensorData(); public MainForm() { InitializeComponent(); dataGridView.ItemsSource _viewData; } // 模拟串口数据到达 private void OnDataReceived(double temperature) { var data new SensorData { Time DateTime.Now, Temperature temperature }; lock (_locker) { _history.Add(data); // UI 上只保留最新的 50 条 _viewData.Add(data); if (_viewData.Count 50) _viewData.RemoveAt(0); } } // 定时存储历史数据 private void TimerSaveElapsed(object sender, EventArgs e) { ListSensorData snapshot; lock (_locker) { snapshot new ListSensorData(_history); _history.Clear(); } using (StreamWriter writer new StreamWriter(history.csv, true)) { foreach (var item in snapshot) { writer.WriteLine(${item.Time:O},{item.Temperature:F2}); } } } }这个例子覆盖了几个关键点用指定容量构造 List 来减少扩容加锁保护共享历史数据用快照方式做数据的批处理用 ObservableCollection 做 UI 绑定。每一项单独拎出来都不难但组合在一起就是一个健壮的小系统。你完全可以把它当作一个模板根据自己的业务场景去扩展。从 ArrayList 到 List 再到各种并发集合和不可变集合C# 的集合体系越做越完善但 List 的地位依然稳固。它简单、高效、功能全面是绝大多数 C# 程序里最常用到的数据结构。把它的原理搞透彻你写出来的代码质量和排查问题的速度都会有明显提升。我个人实际项目里的体会是很多让人抓狂的诡异 bug最后追根溯源都在最基础的数据结构用法上。比如那个为什么界面不刷新的问题其实不是控件的问题是集合类型选错了比如那个为什么程序越跑越卡的问题也不是内存泄漏只是扩容太频繁而在反复触及垃圾回收。如果早一点把 List 的容量和底层数组的关系想明白这些坑都能提前避掉。最后再分享一个小技巧如果你要频繁操作 List 的尾部数据不妨试试直接操作底层数组用list.Capacity分配好空间后自己维护一个count变量来控制有效长度这在某些极致性能场景下比 List 本身还要稳。毕竟List 是你手里的工具而不是束缚你的框架理解它然后在该绕开它的时候果断绕开。