03-05-线性-Array-List-LinkedList与Span-所有权与成本模型选型

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 的问题是“如何借用现有连续存储并携带长度”,不是“用什么容器长期保存集合”。


二、语义矩阵:先看能否表达需求

维度ArrayList<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 有价值的条件通常同时成立:

  1. 已持有节点引用或有外部索引;
  2. 频繁在任意已知节点附近插入/删除/移动;
  3. 不需要按整数索引随机访问;
  4. 节点身份本身是协议的一部分;
  5. 分配/局部性成本可接受。

撤销历史若只在尾部 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 解决语言生命周期表达,不自动解决资源所有权。需要独立稳定数据时复制到自有数组。


七、复杂度表必须带隐藏前提

操作ArrayListLinkedListSpan
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/新 ListO(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/专用结构

再问五个修正问题:

  1. 顺序可否改变?若可,List swap-back 可能免中间搬移。
  2. 是否已有节点索引?没有就不能领取 LinkedList O(1) 查找收益。
  3. 是否需要稳定快照?切片和只读接口都不是快照。
  4. owner 是否跨 async/线程/Job?ref safety 与资源生命周期必须匹配。
  5. 元素是大 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 或深拷贝策略。


十三、常见反例

  1. 把 Span 当拥有数据的第五种容器,并把 owner 内存漏算。
  2. 认为数组固定长度等于元素不可变。
  3. 用 object[] 接收 string[] 后写入任意 object,触发协变异常。
  4. 把 List 尾部 Add 写成“每次 O(1)”,忽略扩容尖峰。
  5. Clear 后断言 List 已释放后备数组。
  6. 枚举 List 时改用 for,意外取消 version 失败语义。
  7. 先 LinkedList.Find 再 Remove,却只宣称删除 O(1)。
  8. 用 LinkedList 做随机索引/顺序扫描热点,只看理论插入。
  9. 缓存已删除 LinkedListNode,延长对象生命周期或误复用。
  10. 把 Span 切片当独立快照。
  11. 让 Span 跨 await,或归还池后继续使用 Memory。
  12. 持有 List/NativeList 内部引用后让容器扩容。
  13. 认为 ReadOnly 接口/视图让底层线程安全。
  14. 把 Unity NativeContainer 的 Dispose 交给 GC。
  15. 引用固定节点字节、缓存 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 与元素布局,再用带前提的复杂度和目标环境实验比较。没有一种结构覆盖所有场景,也没有固定倍率能代替这些问题。


继续旅程进入 04 篇章:哈希与映射 → Hash 原理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

淡海水

感谢支持 共同进步 好运++

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值