一、引言:为什么大家几乎都在用 List<T>?
1.1 一个几乎不用思考的选择
在日常开发中,我们随手创建集合时,代码通常长这样:
csharp
List<int> numbers = new();
numbers.Add(1);
numbers.Add(2);
而直接使用数组的场景似乎越来越少:
csharp
int[] numbers = { 1, 2 };
不知不觉中,一个集体印象被慢慢固化下来:
List<T>就是 Array 的升级版,更强大、更方便。
但事实真的如此吗?
1.2 一个值得思考的问题
如果 List<T> 拥有如此全面的能力:
- 自动扩容
Add/Remove等增删操作- 内建的
Sort、BinarySearch、Find - 完美的 LINQ 支持
那为什么在 .NET Runtime、CLR 内部、高性能框架、Unity 引擎以及游戏开发中,依然有大量数组的身影?
例如下面这些,几乎是高性能场景的标配:
byte[]char[]string[]Span<T>Memory<T>ArrayPool<T>
为什么很多性能优化建议依然会说:
能用 Array,就不要用 List。
这并不是一句过时的口号,而是触及了 CLR 对数组的深层优化以及 List 本身的结构成本。
1.3 本文将回答的问题
本文不会停留在"Array 固定长度,List 可变长度"这种表面结论上,而是会深入回答:
- Array 与 List 本质是什么关系?
- CLR 为什么对 Array 有特殊优化?
- List 为什么必须依赖 Array?
- Array 为什么更容易被 JIT 优化?
- List 的额外开销来自哪里?
- 什么时候 Array 更合适?
- 什么时候 List 才是最佳选择?
二、Array 与 List 的本质关系
2.1 Array 是 CLR 的内建类型
在 C# 中,int[]、string[] 等数组并不是普通的类,而是 CLR 从运行时层面直接支持的数据结构。
CLR 为数组提供了一整套原生待遇:
- 专门的对象布局:数组对象在内存中具有固定的结构,包含对象头、长度字段以及紧随其后的元素序列。
- 专门的 IL 指令 :访问数组元素时,C# 编译器会生成
ldelem、stelem、ldelema等专用 IL 指令。 - 专门的 JIT 优化:JIT 编译器识别数组后,可以进行边界检查消除、循环展开、向量化等激进优化。
- 连续元素存储:数组保证元素在内存中紧密排列,这是实现高性能遍历的基础。
因此,Array 是运行时的一级公民(First-class Type),从 IL 到机器码都享有一套专属通道。
2.2 List<T> 本质只是 Array 的管理器
打开 List<T> 的核心源码,会发现它的本质非常简单(伪代码):
csharp
public class List<T>
{
private T[] _items; // 真正存储数据的数组
private int _size; // 当前实际元素个数
private int _version; // 用于迭代时检测修改的版本号
}
无论 List<T> 对外提供了多少方法,它保存数据的容器始终是一个 T[] 数组。
因此可以得出一个本质结论:
List<T>并不是一种新的存储结构,而是负责管理数组生命周期和容量的封装器(Wrapper)。
2.3 一张图理解二者关系
text
List<T>
+----------------------+
| _items ----------+---------+
| _size | |
| _version | |
+------------------|---------+
|
▼
Array(真正存储数据)
+----+----+----+----+----+
| 1 | 2 | 3 | 4 | 5 |
+----+----+----+----+----+
一句话总结:
List 永远依赖 Array,而 Array 并不依赖 List。
2.4 为什么 List 不能继承 Array?
很多开发者会问:既然 List<T> 的本质就是数组,那它为什么不直接继承 Array?
原因在于,Array 并不是一个普通的类。它是 CLR 直接在运行时层面创建的特殊类型,其对象布局和实例化过程都由运行时硬编码控制。普通的 C# 类无法继承这种特殊类型,编译器也会直接阻止你写出 class MyList : Array 这样的代码。
因此,List<T> 只能通过组合的方式持有数组,而不能通过继承来"是"一个数组。这也就解释了为什么 List 永远只是一个管理器,而无法替代数组本身。
三、CLR 为什么对 Array 有特殊待遇?
3.1 Array 是运行时直接支持的数据结构
在托管堆上,数组对象的布局与普通对象不同。它通常包含:
- 对象头(包含 SyncBlock 索引和 MethodTable 指针)
- 长度字段(Int32,表示元素个数)
- 连续排列的元素区域
由于 CLR 要求对象按平台字长进行内存对齐(32 位对齐 4 字节,64 位对齐 8 字节),数组对象整体大小会因运行时平台不同而有所变化,但长度字段本身始终是 32 位整数。
正因为这种布局是固定的,CLR 可以在不经过任何方法调用的情况下,直接通过偏移量计算出任意元素的地址。这种对底层布局的完全掌控,为后续的各种优化打开了大门。
3.2 数组拥有专门 IL 指令
当 C# 编译器遇到数组索引访问:
csharp
int x = arr[i];
它会生成 IL 指令 ldelem.i4,即"从数组元素加载一个 int32"。而写入 arr[i] = x; 则对应 stelem.i4。
对于 List<T>,看似相同的写法:
csharp
int x = list[i];
编译器实际生成的是对 get_Item 方法的调用:
callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32)
虽然现代 JIT 几乎总是能将这个属性的 getter 内联(Inline),最终的机器指令可能与数组访问相差无几,但数组仍然在编译路径上拥有更直接的"指令级直达"。
3.3 JIT 为什么更容易优化 Array
JIT 编译器对数组进行的优化是相当激进的:
- 边界检查消除(Bounds Check Elimination) :如果 JIT 可以在编译时推断出索引不会越界(例如在
for (int i = 0; i < arr.Length; i++)循环中),它会直接删除边界检查指令,省去大量分支跳转。 - 循环提升(Loop Hoisting):JIT 可以将数组长度加载到循环外部,避免每次迭代都重新读取。
- SIMD 向量化 :.NET 中的
Vector<T>和硬件内置指令可以一次性处理数组中的多个元素,这种优化对连续内存的数组极为友好。 - 缓存友好(Cache Friendly):由于元素紧密连续,处理器在预取数据时可以一次性加载一个 Cache Line 中的多个元素。
为什么数组在 BCE 上更有优势?
对于数组,索引 i 的合法性可以通过循环变量直接证明。例如:
csharp
for (int i = 0; i < arr.Length; i++)
{
sum += arr[i];
}
JIT 看到循环条件已经确保 i < Length,且 Length 在循环内不变(数组长度不可变),就会完全删除 arr[i] 的边界检查指令。对于 List<T>,虽然底层也是数组,但 list[i] 的属性访问涉及 _size 的单独检查(if ((uint)i >= (uint)_size) ThrowHelper...),即使 JIT 内联后,也需要证明 i < _size 与循环条件等价。大多数情况下 JIT 也能消除它,但数组仍然拥有一条更短、更确定的优化路径。
这些优化并不意味着 List<T> 无法受益(毕竟它的底层也是一个数组),但数组因为其"纯粹性",消除了任何额外包装带来的不确定性,让 JIT 更容易直接施展这些优化。
四、Array 为什么更容易被 JIT 优化?
本章重点强调"更容易被 JIT 优化",而不是简单地说"一定更快"。
4.1 List<T> 比 Array 多了一层对象间接访问
当访问 list[i] 时,逻辑路径为:
List对象 (托管堆,某地址)
│
▼
_items 引用 ──► Array对象 (托管堆,另一地址,二者没有物理相邻性)
│
▼
第 i 个元素
List<T> 和内部的 T[] 是完全独立的两个对象 ,分别占用独立的托管堆块,中间通过一个指针引用连接。访问任何元素都必须先解引用 List 对象,再通过内部数组引用访问元素。
现代 JIT 在热点循环中可以将 _items 引用提升到寄存器,从而消解掉一次间接寻址。但这一层额外的对象引用仍然意味着:List 比数组多了一次对象引用,在极端频繁的访问中,这点开销可能会被放大。
4.2 现代 .NET 中,两者性能差距已经越来越小
在 .NET 8/9 中,JIT 的优化已经非常成熟。对于绝大多数业务代码,可以认为:
text
Array 索引访问 ≈ List<T> 索引访问
真正的性能差异,往往不是来自一次属性访问,而是来自:
- 是否发生扩容:扩容会分配新数组并复制数据,这是 List 特有的开销。
- 是否频繁分配:不断创建新集合对象会给 GC 带来压力。
- 是否命中 CPU Cache:访问模式是否连续,决定了是否能充分利用硬件缓存。
因此,在普通场景下,完全不需要出于性能焦虑而刻意回避 List<T>。
4.3 CPU Cache 到底喜欢什么?
CPU 缓存通过空间局部性 和时间局部性来加速访问。数组的元素在内存中严格连续,当 CPU 加载一个元素时,会将包含该元素的一整块内存(通常是 64 字节的 Cache Line)一起拉入缓存。
但这并不意味着任何数组操作都能享受缓存红利。决定缓存效率的关键是访问模式:
- 顺序遍历(如
for循环从头到尾扫描):下一个元素大概率已在同一条 Cache Line 中,缓存命中率极高,性能达到顶峰。这适用于图像处理、序列化、向量的数学运算等场景。 - 跨步访问(如每隔 N 个元素取一个):步长越小、越有可能命中同一 Cache Line;步长一大,每次访问都可能触发新的缓存加载,效率骤降。
- 随机访问:即使数组连续,随机索引依然会导致频繁的缓存未命中(cache miss),此时数组的连续内存优势完全无法弥补访问模式的劣势。对随机访问来说,内存本身的延迟才是瓶颈,而数据结构是否连续影响甚微。
List<T> 的底层数组与裸数组在内存连续性上完全一致,因此它对顺序遍历同样友好。CPU 缓存优化针对的是底层数组,而不是 List 对象本身;由于 List<T> 通过 _items 引用一个连续数组,因此在顺序遍历时,它也能享受到缓存局部性带来的加速。但当内部数组被扩容替换后,旧数组可能产生碎片,此时新数组仍连续,只是 GC 堆中多了一块等待回收的区域。归根结底,谈缓存友好性,首先要明确访问模式,其次才是内存布局的连续性。
关键启示:
- 对于需要极致性能的循环,数组配合
for或foreach都是极优解。 - 在
List<T>上,若性能敏感,优先使用for索引访问而非foreach。 - 这些差异在多数业务场景中可以忽略,但在热点路径上会成倍放大。
五、List<T> 的额外开销来自哪里?
5.1 List<T> 比 Array 多维护哪些状态?
除了真正存储数据的 _items 数组,List<T> 还必须维护:
_size:记录当前集合中实际有多少个有效元素。_version:每当集合被修改(Add、Remove、Clear 等)时都会递增,用于在迭代过程中检测结构性变更,从而抛出InvalidOperationException。
这些额外字段的存在,让 List<T> 在管理能力上远超数组,但同时也意味着每个 List<T> 实例都多承担了这些管理职责。
5.2 扩容需要重新申请数组
List<T> 的核心开销在于扩容。当调用 Add 且内部数组已满时,扩容流程大致如下:
旧数组已满
↓
申请一块更大的新数组(通常是原大小的 2 倍)
↓
将所有旧元素复制到新数组
↓
旧数组不再被引用,等待 GC 回收
这个过程涉及内存分配、数据复制以及后续的垃圾回收,是 List<T> 最主要的性能成本来源。如果能提前预估容量并使用 new List<T>(capacity) 构造函数,就可以完全消除扩容开销。
5.3 GC 真正关注的是什么?
这里需要修正一个常见的说法:"GC 更喜欢 Array"。
实际上,GC 并不关心对象是数组还是 List。真正影响 GC 的是:
- 对象的生命周期:短命对象会被快速回收(Gen0 回收),长命对象则会经历多次晋升。
- 分配次数和大小:频繁创建新对象会增加 GC 压力,不论这个对象是数组还是 List 实例。
- 是否有频繁的额外分配:List 扩容时产生的旧数组,会成为新的垃圾。
因此,固定大小、长期复用的数组可以减少因扩容而产生的额外分配。然而需要注意的是,超大数组(85,000 字节以上)会直接进入 LOH(Large Object Heap),而 LOH 的回收成本较高且容易造成碎片。所以,并非所有数组都对 GC 更友好。
六、什么时候应该优先考虑 Array?
只有当数据规模固定,或者性能分析已经证明集合操作成为瓶颈时,Array 才通常优于
List<T>。
6.1 数据数量天然固定
有些数据本身就具有固定的维度或数量,比如:
- 星期(7 天)
- 月份(12 个)
- RGB 颜色(3 个分量)
- 棋盘格(8×8)
- 矩阵(固定维度的数学运算)
这些场景下,数组不仅能直接表达"数量恒定"的语义,还避免了动态扩容的不必要开销。
6.2 高频计算
在图像处理、机器视觉(HALCON)、OpenCV 操作、AI 推理、FFT 变换、游戏主循环等高频计算场景中,每帧或每次迭代节省几十纳秒,累积起来就可能非常可观。此时,性能的重要性往往高于开发便利性,使用数组并配合 for 循环、Span<T> 可以最大化性能收益。
6.3 与高性能 API 配合
现代 .NET 中的高性能 API 几乎都是围绕数组构建的:
Span<T>、ReadOnlySpan<T>Memory<T>、ReadOnlyMemory<T>ArrayPool<T>
这些 API 提供了零分配的切片、借用、回收能力,而它们都要求底层存储是一个连续数组。在这些 API 上构建的代码天然优先选择数组。
6.4 与 Native API 交互
当进行 P/Invoke 调用时,比如将图像数据传递给 C++ 库:
csharp
[DllImport("native.dll")]
static extern void ProcessImage(byte[] data, int length);
byte[] 可以直接被运行时固定(pinning),然后将其指针传递给原生代码,无需任何额外拷贝。而 List<byte> 则需要先取出内部数组(.ToArray() 或使用 CollectionsMarshal),数组在此类场景下是最自然的选择。
6.5 Array 也是一种设计语义
除了性能,Array 的另一个关键价值在于它所传达的设计意图:
这个集合的长度是固定的,不会增长,也不会缩减。
在 API 设计中,返回一个数组而不是 List<T>,能够清晰地告诉调用方:这是最终结果,不应再修改其结构。因此,Array 不仅仅是一种存储结构,它也是一种 API 设计语言。
6.6 已知数量但需要逐步构建怎么办?
一个非常典型的场景是:从数据库读取一万条记录,数量已知,但需要逐条添加。很多开发者会犹豫:应该用数组还是 List?
答案其实很简单:
csharp
var list = new List<T>(10000); // 指定容量,避免扩容
foreach (var record in dbResults)
{
list.Add(Transform(record));
}
return list.ToArray(); // 最终输出固定长度的数组
这种"List 构建 + Array 输出 "的模式,兼顾了开发效率与运行性能。事实上,Roslyn、ASP.NET Core、.NET Runtime 内部大量采用这种方式:构建阶段用 List<T> 的灵活性,交付时转换为 T[] 的稳定语义。这就是一种很好的工程实践。
七、什么时候应该选择 List<T>?
7.1 数据数量未知
当从文件读取数据、接收网络包、查询数据库时,事先往往无法确定集合最终会有多少元素。此时使用 List<T>,利用其自动扩容特性,可以极大简化代码,避免手动管理数组大小。
7.2 需要频繁添加元素
典型的例子是收集用户输入或动态生成结果集:
csharp
list.Add(item);
无需关心容量、无需自己创建新数组并复制,List<T> 已经把这些烦琐工作封装好了。
7.3 更关注开发效率
List<T> 提供的丰富 API(Add、Remove、Exists、BinarySearch、Sort 等)使得集合操作比数组方便太多。在大多数业务系统中,开发效率和可维护性的优先级远高于微小的性能差异,List<T> 是无可争议的默认选择。
7.4 最终再转换为 Array
一种非常常见的模式是:内部使用 List<T> 动态构建数据,最后通过 ToArray() 输出固定结果。
text
List 构建阶段
│
▼
ToArray() 转换
│
▼
固定结果(Array)
这样既保留了构建过程中的灵活性,又能在结果交付时获得数组的稳定性和语义清晰性。
八、几个容易被误解的问题
8.1 Array 一定比 List 快?
并不是。现代 .NET 中,二者在索引访问上的性能差距通常极小。绝大多数情况下,为了几个百分点的提升而牺牲可读性和可维护性,并不划算。选择集合类型时,设计意图和场景匹配度应当优先于微基准测试的细微差异。
8.2 List 很慢?
错误。List<T> 已经是 .NET 中最优秀的通用集合之一,经过了多年高度优化。在正常业务系统中,它的性能完全足够,极少会成为瓶颈。
8.3 Array 更省内存?
通常来说,是。因为数组没有 _size、_version 等额外字段,且不存在额外的 List 对象头。但对于超大数组,情况有所不同:它需要一块连续的虚拟内存空间,并且会直接分配到 LOH 上,可能带来 LOH 碎片化问题。因此,并非所有数组都对内存更友好。
8.4 List 扩容一定很耗时?
扩容确实涉及新数组的分配和数据复制,但 List<T> 采用了倍增策略,尾部追加操作的摊还时间复杂度仍然是 O(1)。在多数情况下,如果能通过构造函数预先指定容量 new List<T>(expectedSize),往往比盲目将 List<T> 改为数组更有效、更简单。
8.5 Array 是线程安全的吗?
不是。数组的固定长度特性很容易让人误以为它是线程安全的。事实是,长度固定并不代表元素读写是线程安全的。多个线程同时修改数组的同一个元素,仍然会导致竞态条件。
同样地,List<T> 也不支持并发修改。真正的线程安全需要借助锁、原子操作(Interlocked)或专门的并发集合(如 ConcurrentBag<T>、ConcurrentQueue<T>)来实现。
8.6 Array 是不可变的吗?
这是一个非常常见的误区。很多开发者认为,数组长度固定就意味着整个数组不可修改。实际上,数组的长度确实不可改变,但元素的值是可以自由修改的:
csharp
int[] arr = { 1, 2, 3 };
arr[0] = 100; // 完全合法,arr 现在是 { 100, 2, 3 }
如果需要真正不可变的集合,应该使用 ImmutableArray<T>、ReadOnlySpan<T> 或只返回 IReadOnlyList<T> 接口。
九、实际开发中的选择建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 长度固定 | Array | 直接表达固定结构,符合设计语义 |
| 高频计算 | Array | 更容易获得最佳性能,方便 SIMD 优化 |
| 图像处理 | Array | 连续缓冲区,利于缓存和 SIMD |
| 网络缓冲区 | Array | 可配合 ArrayPool 重用 |
| Native API | Array | 无需额外拷贝,可直接传递 |
| 数据数量未知 | List | 自动扩容,简单高效 |
| CRUD 集合 | List | API 丰富,开发效率高 |
| 普通业务系统 | List | 可维护性更高,性能足够 |
| 最终输出结果 | Array | 固定结构,便于共享,防止后续修改 |
十、总结:Array 与 List 从来不是谁取代谁
Array 并不是 List 的"旧版本",List 也不是 Array 的"升级版"。
两者关注的是不同层面的需求:
- Array 关注的是数据存储本身,提供连续内存、固定大小以及 CLR 级别的原生优化,更适合性能敏感或结构固定的场景。
- List 关注的是集合管理,通过在数组之上封装扩容策略和丰富 API,更适合绝大多数业务开发场景。
真正的选择标准不是"谁更快",而是:
- 数据规模是否固定?
- 是否需要动态增删?
- 是否经过性能分析,确认集合操作成为瓶颈?
- 是否需要通过固定长度来表达 API 的设计意图?