LinkedList:双向链表的源码不变式与选择边界
系列 :C# 与常用数据结构源码剖析 · 线性结构篇
源码基线 :
.NET 8.0.0,dotnet/runtime的v8.0.0tag,System.Private.CoreLib/src/System/Collections/Generic/LinkedList.cs阅读约定 :本文把公共 API 契约与私有实现分开。字段、循环链和版本计数是
v8.0.0的实现事实,不是应用可依赖的 ABI。文中源码为教学化节选,省略了接口、特性和抛错辅助方法,不冒充可直接替换运行时文件的逐字源码。
一、链表的价值不是"O(1) 比 O(n) 快"
LinkedList<T> 表达一个可从两端访问、可通过节点句柄在已知位置修改的双向序列。它的关键能力不是"中间插入天然快",而是:当调用者已经持有属于该链表的 LinkedListNode<T> 时,容器可以用常数次引用重连完成插入或删除。
如果只有值,没有节点,情况就不同:
LinkedList<int> list = new([10, 20, 30]);
LinkedListNode<int>? node = list.Find(20); // O(n) 查找
if (node is not null)
list.AddAfter(node, 25); // 已知节点后 O(1) 重连
list.Remove(30); // 先 Find,再删节点:整体 O(n)
list.Remove(list.First!); // 已知节点:O(1)
复杂度只描述工作量随数据规模如何增长,不描述绝对时间。List<T>.Insert(0, value) 要搬移后缀,LinkedList<T>.AddFirst(value) 要分配一个节点并写多个引用。谁在特定规模与平台上更快,取决于元素类型、操作分布、分配、GC、CPU 局部性和运行时。没有可运行基准与原始报告,不应声称固定字节数或固定倍数。
二、公共模型:容器、节点与所属关系
LinkedList<T> 与只暴露整数索引的线性容器有一个重要差异:节点本身是公开对象。First 和 Last 返回 LinkedListNode<T>?,节点公开 Value、Next、Previous 与 List。因此调用者可以长期持有某个节点,不必每次再根据值搜索。
一个节点在任意时刻只能处于两种公共状态之一:
-
已连接 :
node.List指向某个LinkedList<T>,它可作为该链表AddBefore、AddAfter或Remove的参数。 -
未连接 :新建节点或已被移除的节点,
node.List为null,可以稍后加入任意兼容的链表。var left = new LinkedList
();
var right = new LinkedList();
var node = new LinkedListNode("ready"); left.AddLast(node);
// right.AddLast(node); // 抛 InvalidOperationException:节点已属于 leftleft.Remove(node);
right.AddFirst(node); // 移除后 List 为 null,可重新接入
所属检查是容器不变式的一部分,不是多线程保护。两个线程同时移动同一节点,仍然会产生数据竞争。
Value 可修改,修改值不会改变节点位置。在 .NET 8 v8.0.0 中,LinkedListNode<T>.Value setter 也不会增加链表的 version;但业务代码不应由此推导出"枚举时修改值一定是良好协议"。若需要稳定快照,应显式复制并定义深浅复制边界。
三、.NET 8 的内部形状:一个 head 组织整个环
v8.0.0 的容器核心状态可教学化概括为:
internal LinkedListNode<T>? head;
internal int count;
internal int version;
head 为 null 表示空表。非空时,内部不用 null 表示首尾边界,而是把所有节点连成一个循环双向链表:
head
│
▼
[ A ] <-> [ B ] <-> [ C ]
▲ │
└-----------------┘
head.prev == C // 内部尾节点
C.next == head
循环形状让实现只保存 head 就能常数时间得到尾节点 head.prev,也让大部分插入可以统一成"在某节点前插入"。公共 API 不把这个环暴露给调用者:首节点的 Previous 返回 null,尾节点的 Next 返回 null。因此使用公共节点遍历时看到的仍是有限线性序列,不会无限绕环。
对于实现层,每次操作后至少要保持以下不变式:
count == 0当且仅当head == null。- 非空时,从
head连续跟随next恰好count次回到head,中途不会遇到null。 - 对每个已连接节点
x,x.next.prev == x且x.prev.next == x。 - 环上每个节点的所属容器都是当前链表,不存在同一节点同时属于两个链表的合法状态。
head.prev是逻辑尾节点,它的next为head。- 结构修改后
version变化,使旧枚举器能发现修改。
不变式比记忆某几行指针赋值更有用。无论未来字段名如何变化,只要实现仍是这种循环模型,插入、删除和枚举都必须维护同类性质。
四、AddFirst、AddLast 与已知节点插入
空链表的第一个节点是特殊情况:它的 next 和 prev 都指向自己,然后成为 head。
// 结构化节选:空表插入
private void InternalInsertNodeToEmptyList(LinkedListNode<T> newNode)
{
newNode.next = newNode;
newNode.prev = newNode;
head = newNode;
version++;
count++;
}
非空链表可将插入统一为"在 node 之前插入 newNode":
// 结构化节选:在 node 前插入
private void InternalInsertNodeBefore(
LinkedListNode<T> node,
LinkedListNode<T> newNode)
{
newNode.next = node;
newNode.prev = node.prev;
node.prev!.next = newNode;
node.prev = newNode;
version++;
count++;
}
四条引用重连后,新节点与前驱、后继都恢复了双向对称。在此基础上:
AddLast在head前插入,新节点自然成为head.prev。AddFirst同样在head前插入,然后把head更新为新节点。AddBefore(node, ...)直接在node前插入。AddAfter(node, ...)在node.next前插入,等价于在node后插入。
公共重载既可接受值,也可接受由调用者创建的节点。当传入现有节点时,实现首先验证它尚未属于任何链表;传入位置节点时,则验证它正属于当前链表。这些检查阻止了"把外部节点当作本表位置"和"同一节点重复接入"两类环损坏。
所有这些插入在已知合法节点后都是 O(1)。但如果业务输入只给出"在值 X 后插入",先找 X 的过程仍是 O(n)。不能在复杂度表中偷掉定位成本。
五、Find 与 FindLast:相等性、方向和线性成本
Find(value) 从 head 开始沿 next 查找第一个等价值;FindLast(value) 从逻辑尾节点沿 prev 反向查找。两者都使用 EqualityComparer<T>.Default,而不是依赖引用相等。对允许为 null 的 T,实现也覆盖查找空值的路径。
// 概念模型:真实源码还分开了 null 路径
public LinkedListNode<T>? Find(T value)
{
LinkedListNode<T>? node = head;
if (node is null)
return null;
EqualityComparer<T> comparer = EqualityComparer<T>.Default;
do
{
if (comparer.Equals(node.item, value))
return node;
node = node.next;
}
while (node != head);
return null;
}
循环内部表不能用 node != null 作为终止条件;回到 head 才表示扫描了一圈。这是阅读源码时容易忽略的实现边界。
Contains(value) 本质上也要扫描直到命中或绕回表头。Remove(value) 先查找第一个等价节点,命中后再调用节点删除。因此:
| 操作 | 时间 | 前提 |
|---|---|---|
First / Last |
O(1) | 只取端点 |
AddFirst / AddLast |
O(1) | 分配新节点或接入未属于其他表的节点 |
AddBefore / AddAfter |
O(1) | 已持有属于当前表的位置节点 |
Remove(node) |
O(1) | 已持有属于当前表的节点 |
Find / FindLast / Contains |
O(n) | 比较成本还要乘以单次相等比较成本 |
Remove(value) |
O(n) | 定位 O(n) + 重连 O(1) |
枚举 / CopyTo |
O(n) | 输出 n 个元素 |
Clear |
O(n) | 需要使每个公开节点失效 |
这张表也说明了双索引模式的价值:例如 LRU 缓存用字典从 key 平均 O(1) 定位节点,再用链表 O(1) 移动和淘汰;代价是两个结构必须作为一个事务维护。
六、Remove 与 Clear:删除不只是修改 Count
删除已知节点时,先把前驱和后继连在一起。如果被删节点是唯一节点,就把 head 设为 null;如果删的是 head,则让后继成为新 head。最后使节点失效、减少数量并更新版本。
// 结构化节选:省略了入参检查
private void InternalRemoveNode(LinkedListNode<T> node)
{
if (node.next == node)
{
head = null;
}
else
{
node.next!.prev = node.prev;
node.prev!.next = node.next;
if (head == node)
head = node.next;
}
node.Invalidate(); // list/next/prev 置 null
count--;
version++;
}
Invalidate 很重要。调用者可能仍持有被删节点;若它继续指向链表和相邻节点,一个过期句柄就可以无意中保活整条引用链。清除所属关系和链接后,该节点变回未连接状态,之后可被再次加入。Value 本身依然保留,因为节点还可被调用者观察和复用。
Clear 不能只将 head = null。所有节点都是公开对象,外部可能持有其中任意一个。因此 .NET 8 实现遍历原链,对每个节点执行失效化,再清空表头、数量并修改版本。这也是 Clear 为 O(n) 的原因之一。
"断开容器引用"不等于"立即释放内存"。对象只有在所有强引用都消失后才变为不可达,之后何时回收由 GC 决定。节点字典、事件、闭包、枚举器和调试器变量都可能继续保持引用。
七、枚举器:把内部环投影成线性序列
LinkedList<T>.Enumerator 保存链表、下一个节点、创建时的 version、当前值和位置状态。MoveNext 在每次推进时检查版本;读取当前节点后沿 next 移动,一旦回到 head 就把下一节点设为 null,对公共枚举呈现有限序列。
内部:A -> B -> C -> A -> ...
枚举:A, B, C, 结束
AddFirst、AddLast、AddBefore、AddAfter、Remove 和 Clear 都是结构修改,会使旧枚举器失效。后续 MoveNext 抛出 InvalidOperationException 是 fail-fast 错误检测,不是线程安全保证。在竞争时序中,读者可能在看到一致版本号之后又与写者交错;普通 LinkedList<T> 不承诺并发读写的快照、原子性或内存可见性。
枚举器是值类型,具体 foreach 路径通常无需为枚举器单独分配对象;经过 IEnumerable<T>、IEnumerator<T> 或非泛型接口时则可能装箱。这个结论说的是调用形状,不应简化为"struct 永远在栈上"。
八、内存与 CPU:建立条件模型,不传播固定倍数
对现代 .NET 的 LinkedList<T>,定性成本比固定字节数更稳定:
- 链表对象保存表头、数量、版本及其他运行时状态。
- 每个逻辑元素对应一个独立
LinkedListNode<T>对象,其中保存值以及 list/next/prev 引用。 T若是值类型,值存入节点对象内;若是引用类型,节点保存对该对象的引用。- 对象头、引用宽度、对齐、
T的形状与运行时会影响实际大小,所以不存在一个适用所有环境的"每节点固定多少字节"。
与 List<T> 的连续后备数组相比,独立节点通常意味着更多托管对象、更多引用边和更弱的遍历局部性。但"每次跟随 next 都必然 cache miss"也是错误的:节点的分配时间、堆布局、GC 移动、CPU 预取、其他对象干扰和工作集大小都会改变命中率。
GC 成本也要分层理解:
- 创建新节点会产生托管分配,但重新接入一个未属于其他表的现有节点不需要再创建节点。
- 存活链表会有意保持节点和节点值可达,这不是泄漏。
- 删除和清空会断开容器与相邻节点引用,但外部句柄、字典或回调仍可保活对象。
- 一个大量创建后立即丢弃的短命链表可提高分配率;长期存活的链表则会把节点保留到更长的生命周期。具体回收成本需用目标运行时的 GC 事件与堆快照确认。
所以应用不应仅因"理论 O(1)"就换成链表,也不应仅因"数组局部性好"就否定所有链表。先问是否真有持久节点句柄、是否高频移动已知节点,再用真实操作分布测量。
九、真正适合的场景一:LRU 缓存的双索引
LRU 缓存既要根据 key 快速定位,又要在每次命中后把已知项移到表头,并从表尾淘汰。字典与链表刚好分担两种不同索引:
public sealed class LruCache<TKey, TValue> where TKey : notnull
{
private readonly int _capacity;
private readonly Dictionary<TKey, LinkedListNode<Entry>> _byKey;
private readonly LinkedList<Entry> _recency = new();
private readonly record struct Entry(TKey Key, TValue Value);
public LruCache(int capacity, IEqualityComparer<TKey>? comparer = null)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
_byKey = new Dictionary<TKey, LinkedListNode<Entry>>(
capacity, comparer);
}
public bool TryGetValue(TKey key, out TValue value)
{
if (!_byKey.TryGetValue(key, out LinkedListNode<Entry>? node))
{
value = default!;
return false;
}
_recency.Remove(node);
_recency.AddFirst(node);
value = node.Value.Value;
return true;
}
public void Set(TKey key, TValue value)
{
if (_byKey.TryGetValue(key, out LinkedListNode<Entry>? existing))
{
existing.Value = new Entry(key, value);
_recency.Remove(existing);
_recency.AddFirst(existing);
return;
}
var added = _recency.AddFirst(new Entry(key, value));
_byKey.Add(key, added);
if (_byKey.Count <= _capacity)
return;
LinkedListNode<Entry> victim = _recency.Last!;
_recency.Remove(victim);
_byKey.Remove(victim.Value.Key);
}
}
这段代码的核心不是"链表自动得到 O(1) LRU",而是字典直接保存节点句柄,从而没有 Find。真实系统还要定义线程安全、值创建失败、逐出回调、按字节而非条目数限额等行为。更新两个结构必须在同一所有权或锁协议内完成;任何一边成功、另一边失败都会留下悬空节点或无索引项。
十、真正适合的场景二:可取消调度与顺序编辑
当调度队列按到达顺序处理,同时调用者需要通过注册时返回的句柄 O(1) 取消任务,LinkedList<T> 也可用:注册返回 LinkedListNode<T>,取消时删除该节点,处理时从 First 移除。但句柄只在 node.List == schedulerList 时有效;任务已处理、已取消或整表清空后,再取消必须按协议返回 false,而不是将过期节点当成当前任务。
如果调度要求按截止时间取最小项,普通链表就不合适:按时间找插入点需 O(n)。应评估 PriorityQueue<TElement,TPriority>、时间轮或可定位堆。若是撤销/重做系统,只在编辑器支持"保持任意当前节点并从中间分叉"时双向链表才有独特价值;普通两个栈通常更能表达标准 undo/redo 契约。
同理,队首入队尾出的普通 FIFO 不需要公开节点,Queue<T> 的环形数组往往更匹配。双端操作需要 deque 语义时,应先寻找目标平台上经验证的双端队列,不要因为 LinkedList<T> 能做就忽略节点分配成本。
十一、数组链表与索引池:优化布局会引入新契约
在高频创建节点、遍历很热或需要 Burst 的场景,可将节点存入连续槽位,用整数索引表示 next 和 prev。这不是对 LinkedList<T> 的等价无成本替换,而是一个新容器。一个可上线的索引池至少必须明确:
- 槽位存储
value、next、prev、占用标志与一个不会在每次复用时保持不变的generation。 - 外部句柄不能只是 index,而应是
(index, generation)。删除后槽位可复用,但代次变化;旧句柄因代次不匹配而被拒绝,避免 ABA 式"旧 index 指向新对象"。 - 每次接入和删除都同时维护 head/tail/count、双向对称和空闲链;删除头尾、唯一节点与中间节点必须分别测试。
- 容器扩容可以保持 index,但紧凑压缩若搬迁槽位就会使所有句柄失效;API 必须选择"不搬槽位"或显式返回重定向表。
- 代次计数有限,要定义溢出策略,而不是假定它永不回绕。
- 调试构建中应有完整不变式扫描:活动数与 count 一致、活动链无重复、空闲链无重复、两类槽位不相交,且每个活动节点的前后链对称。
只写一个包含 Node[]、Next、Prev 的示例,却没有处理 head/tail、容量耗尽、双重删除、过期句柄和代次,不是可用的数据结构。它即使通过了几个顺序用例,也可能在槽位复用后静默修改错误对象。因此本文不给出一个省略关键部分的"高性能数组链表";实际实现应将句柄与属性测试一起交付。
十二、Unity、Mono、IL2CPP 与 Burst 边界
Unity 项目首先要核对编辑器完整版本、API Compatibility Level、脚本后端和目标平台。桌面 .NET 8 v8.0.0 的私有 LinkedList<T> 字段与 GC 行为不是 Unity Player 的自动证据。公共操作可用也不意味着 Mono 与 IL2CPP 使用同一份 CoreCLR 实现。
LinkedList<T> 和 LinkedListNode<T> 是托管对象。IL2CPP 把 IL 转换为 C++ 再 AOT 编译,并不会把每个托管节点自动变成无 GC 的紧凑原生槽位。节点的分配、对象生命周期和遍历成本要在目标 Player 上使用 Unity Profiler、Memory Profiler 和可控回放测量,不能把 Editor 或 CoreCLR 微基准平移为真机结论。
Burst Job 不能将普通托管 LinkedList<T> 作为可直接调度的原生容器。Unity Collections 中的 NativeList<T> 是连续动态数组,不是链表;它不因名字含 List 就提供稳定节点句柄或 O(1) 中间删除。NativeArray<T>、NativeList<T> 和自定义原生索引池还有 Allocator、Dispose、JobHandle 依赖、AtomicSafetyHandle 和并发写限制,与 LinkedListNode<T>.List 契约不可互换。
若真需要 Burst 可用的索引链,节点中的 T 和所有状态必须符合 Burst/原生容器要求,且代次句柄、空闲链、容量与作业依赖都由项目自己维护。开发成本只有在 Profile 已证明托管节点是实际瓶颈,且标准连续容器不能表达必需语义时才合理。
十三、常见失败反例
反例一:只有值,却宣称中间删除 O(1)。 Remove(value) 必须先线性查找。要获得已知节点的常数重连,业务模型要保存节点句柄或用另一索引直接找到节点。
反例二:保存节点却不定义失效。 节点移除或 Clear 后 List 为 null。调用者必须能识别句柄过期;多线程下仅检查 List 也不是原子取消协议。
反例三:认为 AddAfter 可直接"移动"已连接节点。 一个节点已属于链表时,不能再接入。要移动必须先 Remove(node) 再 AddBefore/AddAfter(node),并在同一同步边界内完成。
反例四:在 foreach 中删除当前节点。 foreach 枚举的是值,结构修改会使枚举器失效。若要单线程原地删除,手动保存下一节点:
for (LinkedListNode<int>? node = list.First; node is not null;)
{
LinkedListNode<int>? next = node.Next;
if ((node.Value & 1) == 0)
list.Remove(node);
node = next;
}
反例五:用链表做排名或按时间排序队列。 链表不提供快速有序定位和第 k 项索引。根据需求选 SortedSet、排序数组、PriorityQueue 或顺序统计结构。
反例六:将链表私有环或节点对象图序列化。 持久化应保存逻辑值序列、schema 和必要标识,读取时重建容器。私有 next/prev/head/version 不是跨版本格式。
反例七:因为链表不搬移元素就认为它没有分配或 GC 成本。 用值重载新增元素通常创建新节点对象。是否可接受必须用目标负载测量。
十四、可复现实验:测前提,不测传说
实验应回答"我的操作分布下是否值得保存节点",而不是只测一次空表插入。建议建立 net8.0 Release 项目,锁定 SDK 完整版本,并保存 BenchmarkDotNet 完整报告:
dotnet new console -n LinkedListLab -f net8.0
cd LinkedListLab
dotnet add package BenchmarkDotNet
dotnet run -c Release
至少拆分五组负载:
- 顺序遍历 :
List<int>与LinkedList<int>处理相同校验和;数量参数化,防止结果未消费而被优化。 - 已知节点删除 :预先保存
LinkedListNode<int>[]句柄,基准迭代中删除指定节点;每次迭代重建容器,不得让后续迭代删除已空数据。 - 只有值的删除 :用
Remove(value)与已知节点路径对照,命中位置分成头、中、尾和未命中。 - 构建与分配 :分开测量从空构建、复用已分配节点和
List<T>合理预分配;记录总分配、GC 计数与存活堆,不只看 Mean。 - 端到端 LRU:使用可重放的 key 轨迹,参数化命中率、容量和写入比,对比正确性等价的实现,同时检查最终键集与淘汰顺序。
报告必须记录 OS、CPU、.NET SDK/runtime 完整版本、GC 模式、元素类型、数据规模、输入种子、命中位置、构建是否计时与原始统计分布。若要说明 CPU 局部性,应使用所在平台可用的硬件计数器并说明统计误差,不要用"节点数量"直接冒充 cache miss 数。
功能实验则应优先于性能实验:
- 空表的 First/Last 为 null,单节点的 First 与 Last 是同一节点。
- 从两端与中间插入后,正向序列与反向节点遍历互为逆序。
- 删除头、尾、唯一节点和中间节点后 Count 和序列正确,被删节点的 List/Next/Previous 为 null。
- 把已属于一张表的节点加入另一张表会失败;移除后可合法重用。
- Find/FindLast 在重复值和 null 值上分别返回第一个/最后一个等价节点。
- 结构修改后旧枚举器失效;纯 Value 更新的行为按目标 tag 锁定,不推广为跨版本并发契约。
- LRU 在更新、命中、淘汰和重复 key 轨迹后,字典节点都属于同一 recency 链,两者数量一致。
Unity 实验需要在实际 Editor 与目标设备 Development/Release Player 上分别执行,明确 Mono 或 IL2CPP,保存 Profiler capture 与 Memory Profiler 快照。如果比较托管链表和原生索引池,必须包含数据转换、Job 调度、依赖完成和 Dispose 的端到端成本。
十五、代码审查清单
- 业务真的需要稳定节点句柄吗,还是只需 FIFO、LIFO、索引访问或优先级?
- 所谓 O(1) 操作是否已持有节点,还是偷掉了 Find/Contains 的 O(n) 定位?
- 节点所属关系是否被验证,过期句柄、重复删除和跨表接入如何处理?
- 若同时维护字典与链表,两者更新是否在一个所有者/锁/回滚协议内?
- 是否在枚举期间修改结构,或把 version 检查误当作线程安全?
- 节点 Value 是可变对象时,读者需要活视图还是深快照?
- 每次业务操作创建多少节点,是否真的出现分配/GC 瓶颈,还是只根据大 O 猜测?
- 性能结论是否包含完整环境、输入分布、正确性校验和原始报告,而非固定字节/倍数?
- 自定义索引池是否有
(index,generation)句柄、空闲链、完整边界测试和代次溢出策略? - Unity 结论是否固定 Editor、API profile、Mono/IL2CPP、Player 平台,并没有把
NativeList误当原生链表? - Burst/原生容器的 Allocator、Dispose、JobHandle 和并发权限是否在同一设计中受控?
- 序列化是否只保存逻辑值与业务顺序,而没有依赖私有节点环、字段名和版本号?
十六、结论:只在节点身份有价值时购买链表成本
.NET 8 v8.0.0 的 LinkedList<T> 以 head、count 和 version 组织一个内部循环双向链。head.prev 提供尾节点,节点的 list/next/prev 维护所属与双向对称,公共 Next/Previous 则把内部环投影为有限序列。Add 通过常数次重连接入节点,Remove 断链后使节点失效,Clear 必须遍历并使全部公开节点脱离,枚举器用 version 发现结构修改。
"链表插入/删除 O(1)"的完整句子必须包含"已持有属于该表的节点"。只有值时,Find、Contains 与 Remove(value) 仍然是线性搜索。节点对象还带来独立分配、引用跟踪和局部性成本,但实际字节数、cache miss 和性能倍数必须在固定环境中测量。
因此,LinkedList<T> 最有说服力的场景不是"任何中间修改",而是 LRU、可取消就绪队列和显式当前位置等真正需要稳定节点身份的模型。对普通遍历、索引访问、FIFO、优先级和 Burst 数据处理,数组、List<T>、Queue<T>、PriorityQueue、NativeList<T> 或经完整验证的代次索引池往往能更准确地表达契约。