Array、List、LinkedList 与 Span:从所有权到成本模型的选型指南
系列:C# 与常用数据结构源码剖析 · 数据结构-线性篇
阅读时间:约 85 分钟
版本口径:公共语义以 C# 12、.NET 8为参考;Unity 部分以 Unity 2022.3 LTS 为讨论基线。私有布局、增长策略与代码生成需按目标运行时复核。
选型原则:先确定所有权、长度、顺序与生命周期,再比较复杂度、分配和缓存;不使用脱离元素类型与硬件的固定倍率。
一、第一问不是“谁最快”,而是“谁拥有存储”
Array、List、LinkedList 是容器;Span 是视图。把四者放在一张“存 10 万个 int 占多少内存”表里,会重复计算或隐藏底层 owner。
| 类型 | 拥有/管理什么 | 长度能否变化 | 底层位置 |
|---|---|---|---|
T[] | 一个固定长度托管数组对象 | 否 | 连续元素区 |
List<T> | List 对象管理一个可替换的 T[] | Count 可变,Capacity 可变 | 有效元素在后备数组 [0,Count) |
LinkedList<T> | 容器管理一组独立 Node 对象 | 可变 | 节点通常分散在托管堆 |
Span<T> | 不拥有;借用一段连续 T | 视图长度固定,可切片成新视图 | 数组、stackalloc、native memory 等 |
int[] owner = new int[128];
Span<int> view = owner.AsSpan(16, 32);
这里不是“数组 128 项 + Span 32 项”两份数据;Span 只是指向 owner 中一段。owner 被修改时 view 看到变化。Span 离开作用域不释放数组,数组可达性和 GC 决定其生命周期。
选择 Span 的问题是“如何借用现有连续存储并携带长度”,不是“用什么容器长期保存集合”。
二、语义矩阵:先看能否表达需求
| 维度 | Array | List<T> | LinkedList<T> | Span<T> |
|---|---|---|---|---|
| 固定逻辑长度 | 是 | 否 | 否 | 当前视图是 |
| 连续元素存储 | 是 | 是(后备数组) | 否(节点分散) | 要求底层连续 |
| O(1) 随机索引 | 是 | 是 | 否 | 是 |
| 尾部增长 | 不能原地增长 | 摊销 O(1) | 已有尾节点时 O(1) | 不拥有,不能增长 |
| 中间插入保持顺序 | 新数组并复制 O(n) | 移动尾部 O(n) | 已知节点时 O(1) 链接 | 不能改变 owner 长度 |
| 按值查找 | 无序时 O(n) | O(n) | O(n) | O(n) |
| 切片是否复制 | 常规 range 产生新数组 | GetRange 复制 | 无连续切片 | Slice/O(1) 视图 |
| 可跨 await/普通对象字段 | 是 | 是 | 是 | 否;考虑 Memory<T> |
| 结构修改版本检测 | 无集合版本 | 枚举器检查 version | 枚举器检查 version | 不追踪 owner 版本 |
| 单个元素分配节点 | 否 | 否 | 是 | 否,但 owner 可能分配 |
复杂度只在前提成立时有意义。LinkedList 插入 O(1) 的前提是调用方已经持有属于该链表的 LinkedListNode<T>;如果先 Find(value),总成本 O(n)。List 尾部 Add 的摊销 O(1) 隐藏了扩容时 O(n) 复制和新数组分配。Span 索引 O(1) 不代表创建它的 native/池 owner 免费。
三、Array:固定长度、连续存储和协变陷阱
3.1 固定长度不是不可变
数组长度在创建后固定,但元素可写:
var values = new int[4];
values[0] = 10;
// 无法把 values 原地增长到 8;只能创建并复制到新数组。
固定长度适合协议帧、构建完成后的只读数据、矩阵/查找表和 API 明确返回快照。若调用方需要不可修改契约,暴露 ReadOnlySpan<T>、ReadOnlyMemory<T> 或只读接口;但只读视图不使拥有数组深度不可变,owner 仍能写。
3.2 连续只限元素槽
int[] 内联连续 int。Enemy[] 若 Enemy 是 class,连续的是引用槽,Enemy 对象仍分散。缓存比较必须固定 T:拿 int[] 与 LinkedList<LargeClass> 比较没有解释力。
数组本身是托管对象,有对象头/长度/对齐,实际大小依运行时与架构。大数组的 GC 策略也依后端,不能套固定字节。
3.3 数组协变
C# 引用类型数组具有历史协变:
string[] names = new string[1];
object[] objects = names; // 编译允许
objects[0] = new object(); // 运行时抛 ArrayTypeMismatchException
运行时每次潜在不安全存储要保护数组实际元素类型。泛型 List<string> 不能赋给 List<object>,因此类型错误更早。公共 API 接受 object[] 并写入时,要考虑调用方实际传入派生数组。
值类型数组不参与这种引用协变。Span<T> 也不提供数组式协变,因为可写别名会破坏类型安全;ReadOnlySpan<T> 的转换规则应按语言/API查看,不能由数组经验推断。
3.4 快照与切片
数组赋值只复制引用,不是快照;Clone/ToArray 是浅元素复制。元素为 class 时对象仍共享。数组 range array[a..b] 创建新数组,而 array.AsSpan(a,count) 创建视图。相同 [..] 语法在目标类型上有不同成本。
四、List:动态数组的 Count、Capacity 与版本契约
4.1 两个长度
Count 是有效元素数,Capacity 是后备数组长度。Add 在 Count < Capacity 时写尾槽;满时分配更大数组、复制 Count 个元素并替换 owner。增长倍率和初始容量是版本细节。
var items = new List<Entity>(expectedCount);
预估容量能把扩容移出热路径,但过估提高常驻数组和峰值。Clear 通常把 Count 归零并清必要引用,不自动把 Capacity 归零。TrimExcess 以分配复制换内存,若随后增长会震荡。
4.2 中间增删
Insert/RemoveAt 为保持顺序移动尾段。成本可近似为移动元素数乘元素槽宽度,再加引用写屏障/清槽等。大 struct 的每次移动字节多;class 元素移动引用槽但对象本体不移动。
大量过滤可用 RemoveAll 或稳定压缩,将多次 RemoveAt 变成一次线性写回。若顺序不重要,swap-back O(1) 删除,但会改变索引、枚举顺序和确定性。
4.3 枚举 version
List 结构修改推进 version,已有 Enumerator 在 MoveNext 时发现不一致并抛异常。它是 fail-fast,不是线程同步。
for 循环没有同样的 version 快照;边遍历边 Add/Remove 可能悄悄改变访问集合。把 foreach 改 for 不是纯性能等价变换,要先定义修改语义。
4.4 内部视图失效
桌面 .NET 的 CollectionsMarshal.AsSpan(list) 可借用有效后备区,但潜在扩容/结构修改会使视图失效,且绕过 List API 写元素不会推进 version。Unity 目标未必提供该 API。普通业务不应反射后备数组。
List 本身非线程安全;多个只读线程只有在没有任何写者且元素状态也安全时才成立。
五、LinkedList:O(1) 修改购买了什么
5.1 已知节点与查找必须拆开
LinkedListNode<Entry>? node = list.Find(target); // O(n)
if (node is not null)
list.Remove(node); // O(1)
总操作仍 O(n)。LRU 常用 Dictionary<TKey,LinkedListNode<Entry>> 保存节点索引,使查找期望 O(1),再 O(1) 移到链首/删除。这时维护两套结构的一致性是代价:每次添加、删除、淘汰必须同步更新。
传给 Remove/AddBefore 的 node 必须属于预期列表或处于允许的 detached 状态;同一节点不能同时属于两个链。
5.2 节点成本
每个元素通常有 Node 对象,含 value、prev、next、list 等引用/状态。实际字节数依 T、对象头、对齐和 runtime,不能固定写 48B。节点增加分配数和 GC 图边;遍历依赖下一节点引用,局部性通常弱于连续数组。
但不能说每次遍历都 cache miss 或固定慢几十倍。allocator 可能让近期节点相邻,方法体和 T 也会主导。用硬件计数器和目标数据测。
5.3 适用不变量
LinkedList 有价值的条件通常同时成立:
- 已持有节点引用或有外部索引;
- 频繁在任意已知节点附近插入/删除/移动;
- 不需要按整数索引随机访问;
- 节点身份本身是协议的一部分;
- 分配/局部性成本可接受。
撤销历史若只在尾部 push/pop,List/Stack 可能更简单;消息队列应考虑 Queue;大量顺序数据编辑可能用 gap buffer、rope 或批量数组。不要因“中间删除 O(1)”单独选链表。
5.4 快照和枚举
复制 LinkedList 需要枚举并创建新节点 O(n);普通变量赋值仍共享同一链表对象。枚举器有版本检测。Node 引用让调用方可长期保留元素/整条 list 关系,缓存节点时要在删除后同步释放引用。
六、Span:ref safety、切片与借用所有权
6.1 ref struct 生命周期
Span/ReadOnlySpan 是 ref struct,编译器限制装箱、普通 class 字段、闭包捕获以及跨不允许的 await/yield。限制用于防止视图活过 stackalloc 或托管内部 byref。
static int Sum(ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int value in values)
sum = checked(sum + value);
return sum;
}
Span 不保证底层只读、不移动或永远存活;它在允许作用域内保持合法引用语义。来自 native memory 的 Span 还依赖外部 owner 不提前 Dispose。
6.2 切片是视图
Slice/范围通常只是调整起点和长度 O(1),不复制元素。子 Span 与父 Span 别名同一存储,写入可互相观察。需要独立快照应 ToArray 并承担 O(n) 分配复制。
6.3 stackalloc、数组和 native
- 数组 Span:数组是 owner,GC/引用生命周期由运行时维护;
- stackalloc Span:存储随方法栈帧结束失效,长度必须有上限;
- native Span:调用方证明地址、长度、对齐与 owner 生命周期;
- 池数组 Span:归还池后视图立即不再可用。
Span 索引仍检查边界;JIT/AOT 可能消除可证明冗余检查,但不是规范保证。“零分配”只指创建视图本身不创建元素副本,周边 parse、delegate、owner 仍可能分配。
6.4 跨 async 使用 Memory
Span 不能作为普通 async 状态机字段跨 await。Memory<T>/ReadOnlyMemory<T> 可保存,但仍是视图/包装,不总是 owner:
static async Task ConsumeAsync(ReadOnlyMemory<byte> data)
{
await WaitAsync();
Parse(data.Span);
}
如果 data 包装 ArrayPool 数组,调用完成前不能归还。Memory 解决语言生命周期表达,不自动解决资源所有权。需要独立稳定数据时复制到自有数组。
七、复杂度表必须带隐藏前提
| 操作 | Array | List | LinkedList | Span |
|---|---|---|---|---|
x[i] | O(1),范围检查 | O(1),按 Count 检查 | O(n),从邻近端走 | O(1),范围检查 |
| 尾部添加 | 新数组 O(n) | 摊销 O(1),扩容 O(n) | O(1)+Node 分配 | 不支持结构增长 |
| 已知整数位置插入 | 新数组 O(n) | O(n) 移动 | 先导航 O(n) | 不支持 |
| 已知 Node 旁插入 | 不适用 | 不适用 | O(1)+Node 分配 | 不适用 |
| 按值删除首项 | 新数组/自定义 O(n) | 查找+移动 O(n) | Find O(n)+unlink | 只可逻辑过滤/复制 |
| 已知 Node 删除 | 不适用 | 不适用 | O(1) | 不适用 |
| 遍历 | O(n) 连续槽 | O(n) 连续有效槽 | O(n) 指针追踪 | O(n) 取决于底层 |
| 快照 | O(n) 浅复制 | O(n) ToArray/新 List | O(n) 新节点 | ToArray O(n);切片不是快照 |
O(n) 还应乘以元素复制、比较/谓词与缓存成本。引用类型复制槽,值类型复制值;Contains 的 Equals 可能比导航贵。并发锁、GC、扩容峰值和所有权错误不在大 O 中。
八、内存与 GC 成本模型
不要写死对象字节数,使用组成式:
Array ≈ array header + capacity * sizeof(slot) + alignment
List ≈ List object + backing array(capacity)
LinkedList ≈ list object + count * (node header + value + links + alignment)
Span ≈ 一个短生命周期 view;底层 owner 成本另计
T 为 class 时 slot 是引用,元素对象成本另算;T 为 struct 时值内联,若含引用字段,GC 仍需扫描对应引用。大 struct 改善对象数但可能增加复制和缓存带宽。
List 扩容产生新数组,旧数组等 GC;LinkedList 每节点分配且删除节点随后回收;Array 一次分配但换大小需新数组;Span 不拥有数据,不能从“Span 0B”推导业务总内存 0B。
缓存局部性也不是二元标签。连续遍历适合硬件预取;随机索引会破坏。LinkedList 指针依赖限制预取,但节点分配位置有分布。元素为分散 class 时,数组只让引用槽连续,访问对象字段仍跳转。
九、快照、只读与线程安全
IReadOnlyList<T> 只限制通过接口修改,不保证底层不变;ReadOnlySpan 也是只读视图而非不可变 owner。变量复制 Array/List/LinkedList 只复制引用。真正快照要复制元素槽,并对可变元素决定是否深拷贝。
四者都不自动提供并发写安全。Span 对同一存储的多个别名也可能数据竞争。发布后完全不变的数组适合多线程只读;List 若无写也可读,但不能同时扩容;LinkedList 写会改变多个链接;Memory 跨线程仍需 owner 同步。
需要 lock-free/并发结构时,根据队列、map、snapshot 语义选专用类型,不是在这些线性结构外随手加 volatile。
十、Unity、Jobs 与 NativeContainer 边界
Unity 2022.3 的托管 Array/List/LinkedList 在 Mono/IL2CPP 类库与 GC 上运行;本文 .NET 8 私有布局和 API 不可直接套用。Span<T> 可用性还受 Unity 编译器、API Compatibility Level 与目标平台影响。
Burst Job 通常不能使用托管 List/LinkedList/class 对象。使用 NativeArray<T>、NativeList<T> 等 NativeContainer,并遵守 Allocator.Temp/TempJob/Persistent、Dispose、SafetyHandle 和 JobHandle 依赖。NativeList 扩容也会使地址/别名失效。
托管 List -> 复制/烘焙 -> NativeArray -> schedule Job -> Complete/依赖 -> Dispose
端到端成本包含复制、schedule、同步,不只比较循环内核。若数据长期就在 Native/ECS 域,避免每帧来回转换;若只是少量主线程数据,普通 List 可能更简单。
UnityEngine.Object[]/List 保存托管包装引用;原生对象销毁后有 Unity 特殊 null 语义。数组/List 清除索引不等于立即卸载资产,应配合 Addressables/资源 owner。
Unity 主线程热路径关注尾帧与 GC Alloc,服务端关注吞吐、每请求分配和并发。相同容器可以因预算不同得到不同选择。
十一、场景决策树
是否需要拥有数据?
├─ 否,只在同步调用链借用连续区域
│ └─ Span/ReadOnlySpan
│ └─ 要跨 await?改用 Memory/ReadOnlyMemory,并明确 owner
└─ 是
├─ 长度创建后固定?
│ ├─ 是:Array(发布只读时考虑 ReadOnlyMemory/不可变契约)
│ └─ 否
│ ├─ 需要随机索引/顺序遍历?List
│ └─ 频繁操作任意已知节点?
│ ├─ 是,且节点身份/分配可接受:LinkedList
│ └─ 否:先用 List/Queue/Deque/专用结构
再问五个修正问题:
- 顺序可否改变?若可,List swap-back 可能免中间搬移。
- 是否已有节点索引?没有就不能领取 LinkedList O(1) 查找收益。
- 是否需要稳定快照?切片和只读接口都不是快照。
- owner 是否跨 async/线程/Job?ref safety 与资源生命周期必须匹配。
- 元素是大 struct、class 引用还是含引用 struct?复制/GC 模型不同。
十二、典型场景
12.1 LRU
Dictionary<TKey,LinkedListNode<Entry>> + LinkedList 能 O(1) 期望查找与节点移动。需要原子维护两结构、容量上限、更新值/顺序、淘汰和并发锁。若容量很小或访问少,简单 List 可能更易维护;测量再选。
12.2 网络解析
拥有接收 buffer 的数组/池;同步解析用 ReadOnlySpan 切片;异步排队要转移 Memory owner 或复制。不能把指向即将归还池的 Span/Memory 放入队列。
12.3 帧内实体列表
主线程动态实体通常 List 预估容量;批量稳定删除用压缩,顺序无关用 swap-back。数据导向 Job 使用 NativeArray/NativeList。LinkedList 只有在系统确实持有节点并频繁移动时才考虑。
12.4 发布配置快照
构建阶段 List,完成后 ToArray 发布;读者用 ReadOnlyMemory/Span 临时读取。数组元素若为可变 class,仍需不可变 DTO 或深拷贝策略。
十三、常见反例
- 把 Span 当拥有数据的第五种容器,并把 owner 内存漏算。
- 认为数组固定长度等于元素不可变。
- 用
object[]接收string[]后写入任意 object,触发协变异常。 - 把 List 尾部 Add 写成“每次 O(1)”,忽略扩容尖峰。
- Clear 后断言 List 已释放后备数组。
- 枚举 List 时改用 for,意外取消 version 失败语义。
- 先 LinkedList.Find 再 Remove,却只宣称删除 O(1)。
- 用 LinkedList 做随机索引/顺序扫描热点,只看理论插入。
- 缓存已删除 LinkedListNode,延长对象生命周期或误复用。
- 把 Span 切片当独立快照。
- 让 Span 跨 await,或归还池后继续使用 Memory。
- 持有 List/NativeList 内部引用后让容器扩容。
- 认为 ReadOnly 接口/视图让底层线程安全。
- 把 Unity NativeContainer 的 Dispose 交给 GC。
- 引用固定节点字节、缓存 miss、性能倍率作为跨平台定律。
十四、可复现实验
14.1 操作分布而非单点数字
参数化 n、T(int、大 struct、class)、初始 Capacity、插入/删除位置分布和读写比。分别测构建、稳态、扩容尖峰、释放后存活;返回 checksum,验证最终序列。
14.2 LinkedList 前提实验
分开测 Remove(knownNode) 与 Find+Remove;LRU 同时包含 Dictionary 查找与两结构更新。随机/顺序访问分别记录 CPU、分配与硬件 cache/branch counter(平台支持时),不从一个 n 推导固定倍率。
14.3 快照/切片实验
对数组 range、AsSpan Slice、ToArray、ReadOnlyMemory 分别修改原 owner,验证别名/复制行为;元素换成可变 class 再验证浅复制。跨 async 对池 owner 做正确/提前归还两种状态机测试,错误路径应由 owner 防护而非执行未定义使用。
14.4 GC 与容量
阶跃负载让 List 达到峰值后 Clear,比较保留容量与 Trim 后再增长;记录分配、存活、GC 和峰值,不只看 Count。LinkedList 构建/清空后用堆快照确认节点根路径消失,避免局部 Enumerator/Node 仍持有。
14.5 Unity Player
同一语义分别在 Editor Mono、目标 IL2CPP Release、NativeArray/Burst(适用)测试。包含 managed-native 复制、schedule/Complete、Dispose,记录 GC Alloc、主线程/Job 时间、帧分位数与内存。保存完整 Unity/Burst/Collections 版本和设备。
BenchmarkDotNet 实验记录 .NET SDK/runtime、Tier/PGO、CPU/架构、输入、预热和原始报告。不要把 Debug、第一次 JIT 或数据创建放进被测操作,除非调查的正是启动。
十五、总结:选的是所有权和不变量,不是类型排名
Array 拥有固定长度连续存储,适合构建完成的数据与明确快照,但元素可变且引用数组协变会推迟类型错误。List 用 Capacity 换动态增长,随机访问和遍历友好;中间增删、扩容、version 与内部引用失效必须计入。
LinkedList 用每元素节点、较弱局部性和 GC 图换取已有节点附近 O(1) 链接修改;若先查找,总体仍 O(n)。Span 不拥有数据,它以 ref-safety 提供同步短期连续借用与 O(1) 切片;跨 async 用 Memory 仍要管理 owner。
可靠选择先写清长度、顺序、节点身份、快照、跨 async/Job 与元素布局,再用带前提的复杂度和目标环境实验比较。没有一种结构覆盖所有场景,也没有固定倍率能代替这些问题。

1188

被折叠的 条评论
为什么被折叠?



