什么时候应该用 Array,而不是 List?

一、引言:为什么大家几乎都在用 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 等增删操作
  • 内建的 SortBinarySearchFind
  • 完美的 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# 编译器会生成 ldelemstelemldelema 等专用 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 堆中多了一块等待回收的区域。归根结底,谈缓存友好性,首先要明确访问模式,其次才是内存布局的连续性。

关键启示

  • 对于需要极致性能的循环,数组配合 forforeach 都是极优解。
  • 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(AddRemoveExistsBinarySearchSort 等)使得集合操作比数组方便太多。在大多数业务系统中,开发效率和可维护性的优先级远高于微小的性能差异,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 的设计意图?