Dictionary<TKey,TValue>(上):核心数据结构
系列 :C#与常用数据结构源码剖析 · 数据结构-哈希与映射篇
阅读时间 :约 55 分钟
源码位置 :dotnet/runtime@5535e31.../Dictionary.cs
版本边界 :以
v8.0.0/ commit5535e31a712343a63f5d7d796cd874e563e5ac14的 .NET 8 实现形状为主线,字段、快速取模和 comparer 特化会随 tag 与架构变化。本文代码均是教学节选或伪代码,不伪装成可直接替换原文件的完整源码。前置知识:04-01(Hash 原理)
一、引言
大多数人以为 Dictionary 内部存储的是 KeyValuePair<TKey,TValue> 数组。实际并非如此------.NET 的 Dictionary 使用了一个精巧的双数组 + 空闲链表设计,这个设计是理解它全部行为的关键。
它是"用结构体替代对象"哲学的极致体现:一个 Entry struct 包含 hashCode、next、key、value 四个字段,紧凑排列在连续内存中,没有单独为每个键值对分配堆对象。这种设计与 04-01 中讨论的链地址法一脉相承------但 .NET 用连续数组和 int 索引替代了传统链表节点。
理解了这个架构,你就能回答这些问题:
- 为什么 Dictionary 的
Remove不会缩小数组? - 为什么删除后再添加能复用之前的位置?
- 为什么 1-based 索引能让数组的默认零值直接表示"桶为空"?
- 为什么
next字段的负数范围被设计为双重编码?
本文从 .NET 8 的实现形状出发,按结构与不变式解析设计。对私有字段做"逐行"分析时必须固定 tag,因此文中更重视哪些性质在操作前后不能被破坏。理解这一篇,后续 Add、Remove 和 Resize 的控制流才有可验证的主线。
二、双数组架构全景
2.1 核心字段一览
// System.Collections.Generic.Dictionary<TKey,TValue>
// 源码路径: dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs
public class Dictionary<TKey, TValue> : IDictionary<TKey, TValue>
{
private int[]? _buckets; // 桶数组,存储冲突链头索引(1-based)
private Entry[]? _entries; // 条目数组,所有键值对紧凑存储
private int _count; // 已使用过的 Entry 上界,包含尚未复用的空闲槽
private int _freeList; // 空闲链表头(-1 表示无空闲)
private int _freeCount; // 空闲条目数量
private int _version; // 版本号(枚举器并发检测)
private IEqualityComparer<TKey>? _comparer; // 键比较器
private KeyCollection? _keys; // Keys 属性缓存
private ValueCollection? _values; // Values 属性缓存
private const int StartOfFreeList = -3; // 空闲链表编码基值(见第四章)
}
架构全景图(分工示意,数值不表示同一个实际快照):
Dictionary<TKey,TValue>
┌─────────────────────────────────────────────────────────┐
│ _buckets (int[]) _entries (Entry[]) │
│ ┌─────┬─────┬─────┬┐ ┌──────┬──────┬─────┬───────┐ │
│ │ 3 │ 0 │ 5 ││ │hash │ next │ key │ value │ │
│ ├─────┼─────┼─────┤│ ├──────┼──────┼─────┼───────┤ │
│ │ 0 │ 0 │ 0 ││ │0xA3 │ -1 │ "a" │apple │ │
│ ├─────┼─────┼─────┤│ ├──────┼──────┼─────┼───────┤ │
│ │ 0 │ 0 │ 0 ││ │0xA3 │ 0 │ "b" │banana │ │
│ └─────┴─────┴─────┘│ ├──────┼──────┼─────┼───────┤ │
│ │ │0xB7 │ -1 │ "d" │durian │ │
│ _freeList = ... │ ├──────┼──────┼─────┼───────┤ │
│ _freeCount = ... │ │(free)│ ... │ ... │ ... │ │
│ _count = ... │ └──────┴──────┴─────┴───────┘ │
│ _version = 5 │ │
└─────────────────────────────────────────────────────────┘
关键设计决策 :_buckets 和 _entries 是两个独立数组。前者只存桶头索引,后者存完整条目。在常见实现中,两个数组通常以同一选定容量初始化,并非"桶数量远小于条目数"。分离的价值是角色清晰:先读紧凑的 int 桶头,再访问 Entry;不为每个冲突节点分配独立对象。
_count 是最容易读错的字段。它不是公开 Count 的直接存储,而是从 Entry 数组头部开始已经启用过的槽位上界。删除一个元素后 _count 通常不减,_freeCount 增加;因此对外元素数是 _count - _freeCount。只有理解这个不变式,才不会在阅读 Resize、枚举或空闲槽复用时得出错误结论。
2.2 Entry 结构体------紧凑的数据容器
private struct Entry
{
public uint hashCode; // 键的完整哈希码(32 位无符号)
public int next; // 双重编码:>=0 用于冲突链,<0 用于空闲链表
public TKey key; // 键
public TValue value; // 值
}
Entry 是一个结构体,每个元素不是独立托管对象,因此没有逐节点对象头。但 Entry 数组本身仍是托管对象,键或值如果是引用类型,Entry 中保存的仍然是需要 GC 追踪的引用。"结构体"不等于字典不使用托管堆。
不同泛型参数组合下的 Entry 大小(64 位系统):
Dictionary<string, object>:
uint hashCode: 4 bytes
int next: 4 bytes
string key: 8 bytes (引用)
object value: 8 bytes (引用)
─────────────────────────
总计: 24 bytes (正好 3 个 8 字节对齐)
Dictionary<int, int>:
uint hashCode: 4 bytes
int next: 4 bytes
int key: 4 bytes
int value: 4 bytes
─────────────────────────
总计: 16 bytes (紧凑打包)
Dictionary<long, string>:
uint hashCode: 4 bytes
int next: 4 bytes
long key: 8 bytes (对齐到 8 字节边界)
string value: 8 bytes
─────────────────────────
总计: 24 bytes
为什么是 uint hashCode 而不是 int?
因为 Dictionary 内部是这样使用哈希码的:
// 比较器返回 int,按位转换为 uint 保留全部 32 位
uint hashCode = (uint)comparer.GetHashCode(key);
int bucketIndex = (int)(hashCode % (uint)_buckets.Length);
不再用 & 0x7fffffff 清掉符号位,意味着哈希的 32 位都可用。在冲突链比较时,先比较 hashCode,再调用键相等性逻辑,可以快速排除大部分不匹配条目。"单条 CPU 指令"不是 C# 语义保证,具体机器码要以对应 JIT/AOT 产物为准。
// FindValue 中的比较路径
if (entry.hashCode == hashCode // 快速路径:单条指令比较
&& _comparer.Equals(entry.key, key)) // 慢路径:可能虚方法调用
{
return entry; // 命中
}
// 不匹配 → 继续沿冲突链查找
三、1-based 桶索引的设计巧思
3.1 为什么桶索引要加 1
这是整个设计中最小但最精妙的细节。
// _buckets[bucket] 的语义:
// 0 → 桶为空(没有条目映射到此桶)
// j+1 → 桶的第一个条目在 _entries[j]
为什么是 j+1 而不是直接用 j?
问题在于:数组索引从 0 开始。_entries[0] 是一个有效的条目。如果 _buckets[i] = 0 表示空桶,但 _buckets[i] = 0 正好也是 _entries[0] 的索引------冲突了!无法区分"桶为空"和"桶指向第 0 个条目"。
解决的方案是存储索引 + 1:
// 取桶中第一个条目
int rawBucketValue = _buckets[bucketIndex];
if (rawBucketValue == 0)
{
// 桶为空,条目不存在
return false;
}
int entryIndex = rawBucketValue - 1; // 还原为真实索引
这样设计的好处:
- 零检查成本 :
_buckets[i] == 0就是"空桶",没有额外标志位 - 无额外内存:不需要为每个桶存储一个"是否为空"的 bool
- 简单高效:取值-减1-使用,三个步骤一条龙
这看起来是一个微小的优化,但在每次 Dictionary 操作(插入、查找、删除)中都会用到。在每秒数百万次操作的场景中,这个设计节省了可观的 CPU 周期。
3.2 桶的初始化和冲突链
初始状态(空字典,尚未添加元素):_buckets 所有元素为 0。_entries 为 null 或空数组。
添加第一个键值对后:
// 添加 ("c", "cherry"),假设 hashCode = 0xA3,桶索引为 0
_buckets[0] = 1 // 1-based:指向 _entries[0]
_entries[0] = { hashCode: 0xA3, next: -1, key: "c", value: "cherry" }
// next = -1 表示冲突链尾
图示:
_buckets: [1, 0, 0, ...]
│
▼
_entries[0]: { 0xA3, -1, "c", "cherry" }
添加第二个键,哈希到同一桶时:
// 添加 ("b", "banana"),hashCode = 0xA3,桶索引也为 0
_buckets[0] = 2 // 新头:指向 _entries[1]
_entries[1] = { hashCode: 0xA3, next: 0, key: "b", value: "banana" }
// next = 0:指向 _entries[0](老条目)
// 冲突链:_buckets[0]=2 → _entries[1].next=0 → _entries[0].next=-1
图示:
_buckets: [2, 0, 0, ...]
│
▼
_entries[1]: { 0xA3, 0, "b", "banana" } ← 链表头
│
▼
_entries[0]: { 0xA3, -1, "c", "cherry" } ← 链表尾
添加第三个键,哈希到不同桶:
// 添加 ("d", "durian"),hashCode = 0xB7,桶索引为 4
_buckets[4] = 3 // 1-based:指向 _entries[2]
_entries[2] = { hashCode: 0xB7, next: -1, key: "d", value: "durian" }
完整状态:
_buckets: [2, 0, 0, 0, 3, 0, ...]
│
▼
_entries[2]: { 0xB7, -1, "d", "durian" }
_entries[0] ← _entries[1] ← _buckets[0] (冲突链)
四、next 字段的双重编码
4.1 两条链共享同一个字段
Entry.next 是一个 int 字段,但它编码了两个完全不同的链表:
Used Chain(已使用链) :当 next >= 0 时,表示同一冲突桶中下一个条目的索引。next = -1 表示链尾。
Free List(空闲链表) :当 next < -2 时,表示该条目已被删除,处于空闲列表。使用公式 StartOfFreeList - next 解码为实际索引(StartOfFreeList = -3):
// 空闲链表解码
// next = -3 → 空闲索引 0 (StartOfFreeList - (-3) = -3 + 3 = 0)
// next = -4 → 空闲索引 1 (StartOfFreeList - (-4) = -3 + 4 = 1)
// next = -5 → 空闲索引 2
// next = -6 → 空闲索引 3
// ...
于是 -1 和 -2 被保留为特殊哨兵值:
| next 值 | 含义 |
|---|---|
>= 0 |
在 Used Chain 中,指向下一个条目的索引 |
-1 |
Used Chain 链尾 |
-2 |
Free List 链尾 |
<= -3 |
在 Free List 中,用公式解码为下一个空闲条目的索引 |
4.2 双重编码的完整示例
假设 _entries 中有 6 个条目,索引 0、1、2 活跃,索引 3、5 被删除(空闲):
_entries:
索引 hashCode next key value
0 0xA3 -1 "a" "apple" ← used chain 尾
1 0xA3 0 "b" "banana" ← next = 0 → _entries[0]
2 0xA3 1 "c" "cherry" ← 冲突链头,_buckets[0] 指向
3 (free) -2 null null ← free list 尾 (next = -2)
4 0xB7 -1 "d" "durian" ← 另一条 used chain
5 (free) -6 null null ← free list 头,指向 3
解码:-3 - (-6) = 3 → _entries[3]
_freeList = 5
_freeCount = 2
空闲链表:
_freeList = 5 → _entries[5].next = -6 → 解码得 3 → _entries[3].next = -2 → 链尾
4.3 设计理由:零成本抽象
为什么不单独用一个 bool _isFree 字段标记"是否空闲"?
// 方案 A(实际方案):复用 next 字段
// Entry 大小不增加,没有额外字段
// 方案 B(朴素方案):增加 bool _isFree
private struct Entry {
public uint hashCode;
public int next;
public TKey key;
public TValue value;
public bool _isFree; // ❌ 额外字段
}
// 在 64 位系统上,bool 占 1 字节,但 struct 会 padding 到 4 字节对齐
// 实际 padding 取决于后续字段、泛型参数和平台对齐,不能固定断言每项浪费 3 字节
实际方案复用已存在的 next 字段,避免添加标志与另一个索引。这是紧凑编码,但"零开销"仍然过度简化:编解码需要整数运算,Entry 实际大小受对齐影响,整个字典还有两个数组对象与管理字段。
五、空闲链表机制详解
5.1 删除时加入空闲链表
当一个键被 Remove 时,该条目不从 _entries 数组中移除------而是被插入空闲链表:
// Remove 操作的关键步骤(简化版)
public bool Remove(TKey key)
{
int entryIndex = FindEntry(key); // 在 used chain 中查找
if (entryIndex < 0) return false;
// ... 从 used chain 中移除(调整前一个条目的 next 指针)...
// 将该条目插入 free list 头部
ref Entry entry = ref _entries[entryIndex];
entry.next = StartOfFreeList - _freeList; // 编码空闲链索引
entry.key = default!; // 断开引用(帮助 GC)
entry.value = default!; // 断开引用
_freeList = entryIndex; // 更新 free list 头
_freeCount++; // 空闲计数 +1
// 版本号是否在 Remove 中变化是版本实现细节,应查阅固定 tag
return true;
}
过程演示:
初始状态:字典有 4 个条目,无空闲位置(_freeList = -1, _freeCount = 0)。
第一次删除 _entries[5]:
操作前:_freeList = -1(无空闲)
操作后:_freeList = 5
_entries[5].next = StartOfFreeList - (-1) = -3 + 1 = -2(free list 尾)
_freeCount = 1
图示:
_freeList = 5 ──→ _entries[5].next = -2 (链尾)
第二次删除 _entries[8]:
操作前:_freeList = 5
操作后:_freeList = 8
_entries[8].next = StartOfFreeList - 5 = -3 - 5 = -8
_freeCount = 2
图示:
_freeList = 8 ──→ _entries[8].next = -8 ──解码--> 5 ──→ _entries[5].next = -2 (链尾)
5.2 添加时优先复用空闲位置
添加新条目时,不是总是追加到 _entries 末尾------而是先查看 free list 是否有空位:
// Add 中决定条目存储位置的核心逻辑
private int GetEntryIndex()
{
int index;
if (_freeCount > 0)
{
// ✅ 有空闲位置:从 free list 头部取一个复用
index = _freeList; // 取 free list 头
_freeList = StartOfFreeList - _entries[_freeList].next; // 更新 free list 头
_freeCount--;
}
else
{
// ❌ 无空闲位置:追加到 _entries 末尾
index = _count;
// 如果 _count == _entries.Length,触发 Resize
}
return index;
}
这个机制的关键特性:删除不会导致 _entries 数组收缩,后续添加直接复用空位 。这避免了"增删频繁"场景下的数组反复分配------Remove 不会触发数组操作,Add 只是在数组中找一个空位填进去。
与 List<T> 的对比:
| 特性 | List<T> 删除 | Dictionary<K,V> 删除 |
|---|---|---|
| 内部操作 | 移动后续元素填补空位(O(n)) | 先按键查链,命中后调整索引并入空闲链(期望 O(1)) |
| 内存收缩 | 无(Clear/Trim 除外) | 无(TrimExcess 除外) |
| 空位复用 | 不适用(List 用 _size 直接覆盖) | 通过 free list 复用 |
| 为什么不同 | List 必须保持紧凑顺序 | Dictionary 的 Entry 物理顺序不是键值映射语义 |
5.3 生命周期实例:增删交替
var dict = new Dictionary<int, string>();
// 阶段 1:添加 5 个元素
for (int i = 0; i < 5; i++)
dict.Add(i, i.ToString());
// _entries: [0,1,2,3,4], _count=5, _freeCount=0, _freeList=-1, Count=5
// 阶段 2:删除 2 个(索引 1 和 3)
dict.Remove(1);
dict.Remove(3);
// _entries: [0, F, 2, F, 4] (F = free)
// _count=5, _freeCount=2, Count=3
// _freeList 指向最后删除槽,再链向上一个空闲槽
// 注意:_entries 大小不变!只是多了两个"空洞"
// 阶段 3:再添加 2 个
dict.Add(5, "five"); // 复用 _freeList=3 的位置
dict.Add(6, "six"); // 复用 _entries[1] 的位置
// _entries: [0, SIX, 2, FIVE, 4]
// _count=5, _freeCount=0, Count=5, _freeList=-1
// _entries 数组在此期间从未缩容/重新分配
// 没有空闲槽且 _count == _entries.Length 时,下一次添加触发扩容
六、初始化与容量策略
6.1 构造函数的容量选择
public Dictionary() : this(0, null) { }
public Dictionary(int capacity, IEqualityComparer<TKey>? comparer)
{
if (capacity > 0)
{
Initialize(capacity);
}
if (comparer != null)
{
_comparer = comparer;
}
}
延迟初始化(Lazy Initialization):
如果使用无参构造(capacity = 0),Initialize 不会被调用:
var dict = new Dictionary<string, int>();
// dict._buckets == null
// dict._entries == null
// dict._freeList == 0 (未初始化)
直到第一次 Add,才在 Insert 方法中调用 Initialize(0):
private void Insert(TKey key, TValue value, ...)
{
if (_buckets == null)
{
Initialize(0); // 第一次 Add 才初始化
}
// ...
}
延迟初始化的价值:避免为不必要的空字典分配数组。如果你创建了一个 Dictionary 但只在条件分支中使用它,不会浪费任何堆内存。
6.2 GetPrime 素数选择
private int Initialize(int capacity)
{
int size = HashHelpers.GetPrime(capacity);
_buckets = new int[size];
_entries = new Entry[size];
_freeList = -1;
return size;
}
HashHelpers.GetPrime 从预计算素数表(04-01 第 3.3 节)中选取 >= capacity 的最小素数:
internal static int GetPrime(int min)
{
// 预计算素数表
foreach (int prime in Primes)
{
if (prime >= min) return prime;
}
// 超过 720 万 → 动态计算
for (int i = min | 1; i < int.MaxValue; i += 2)
{
if (IsPrime(i) && (i - 1) % HashPrime != 0)
return i;
}
return min;
}
初始容量 = 0 时 → GetPrime(0) 返回 3(表中第一个素数)→ 第一次 Add 后数组大小为 3。
6.3 版本演进必须固定 tag
Dictionary 的整体轮廓长期稳定:桶、Entry 数组、冲突链和空闲链。但字段类型、桶索引编码、字符串哈希策略、快速取模、序列化兼容逻辑和枚举器行为都发生过演进。一篇源码文不能只写".NET 8 源码"就宣称所有细节永久有效;应记录例如 v8.0.0 的 tag,以及源文件路径。
原文曾将一段连续扫描 hashCode 的 Vector256 代码标成".NET 8 Dictionary FindValue 简化源码"。这个模型与 Dictionary 的冲突链布局并不匹配:同一桶的条目由 next 索引连接,它们不保证在 Entry 数组中连续,因此不能在没有对应布局证据时假设可一次加载八个链节点的 hashCode。本文删除该伪源码和"3---5 倍"结论。若某个未来版本确实改变了内存布局或增加 SIMD 路径,应以具体 PR、tag 与基准报告另行分析。
IAlternateEqualityComparer 和 alternate lookup 也属于版本敏感主题,不在本篇的核心布局上强行挂一个".NET 6+"标签。它们的真实 API 形状、约束和可用版本放在 04-04,并要通过当前目标 SDK 编译或查阅对应 ref assembly 确认。
6.4 容量不等于可再添加数
Capacity 表示当前内部数组可容纳的 Entry 槽位数,公开 Count 表示活跃键值对数,_freeCount 表示已启用区间中可复用的删除槽。因此下一次添加可能复用空闲槽,即使 _count 已经到达数组长度;只有无空闲槽且已启用上界到达容量时才需要 Resize。
EnsureCapacity(n) 的意思是确保内部容量至少为 n,并不是"在当前 Count 之外再留 n 个"。若已知最终会有 10,000 个活跃键,应传最终总量上界,不是传增量后在每轮叠加。预留可以避免中途扩容,但同时分配 bucket 和 Entry 空间;在大量小字典或低内存设备上,过度预留也有真实代价。
七、与 List<T> 的核心差异对比
| 特性 | Dictionary<K,V> | List<T> |
|---|---|---|
| 内部存储 | 双数组(桶 + 条目) | 单数组 |
| 空闲管理 | Free List(复用删除位置) | 无(_size 指针覆盖) |
| 按键/按值删除 | 期望 O(1),先查链再摘链 | O(n) 查找,再搬移后续元素 |
| 缩容策略 | 不自动缩容(TrimExcess 手动) | 不自动缩容(TrimExcess 手动) |
| 扩容策略 | 由对应版本 HashHelpers 选择 |
常见实现按倍增长,受上限约束 |
| 无参构造 | 通常延迟到首次写入分配内部数组 | 通常先共享空数组,首次写入再增长 |
| 枚举器类型 | struct(值类型) | struct(值类型) |
| 并发安全 | 不保证(有版本检测) | 不保证(有版本检测) |
两者的核心差异源于数据组织方式的不同 :List 的语义要求 [0, Count) 是紧凑的顺序区间,因此中间删除需搬移;Dictionary 的语义是键到值的映射,Entry 槽的物理顺序不是公开契约,因此删除可以摘除冲突链节点并留下可复用空洞。这里调整的是整数索引,不是托管指针。
7.1 枚举顺序不是持久化契约
因为枚举器通常扫描 Entry 的已启用区间并跳过空闲槽,某些现代实现在只添加时会呈现接近插入顺序的观察结果。但删除后的空闲槽复用会让新键出现在旧槽位,Trim/Resize 与版本变化也可改变观察结果。公开 Dictionary 契约不应被当成有序映射。
因此不要把枚举顺序直接写入回放 Hash、网络协议、存档 diff 或签名计算。需要确定性时,应按明确 comparer 排序键,或选择对顺序有公开保证的容器。这会带来排序时间与临时存储,必须纳入预算,不能为避免成本而依赖实现偶然性。
八、不变式:用于阅读源码和设计测试的导航仪
与其记住每一行实现,不如记住操作前后必须成立的性质:
- 公开
Count == _count - _freeCount,且三者都不为负。 - 每个活跃 Entry 恰好能从一个 bucket 的冲突链到达,不能同时属于两条链。
- 每个空闲 Entry 恰好能从
_freeList到达,且空闲节点数等于_freeCount。 - 活跃链与空闲链不相交,所有索引均在
[0, _count)内,并且链最终终止。 - 对每个活跃 Entry,其 hashCode 定位的 bucket 与它所在链一致,链中不存在两个按 comparer 相等的键。
- 引用类型键和值被删除后不应只因空闲 Entry 而被长期保活;实现会在需要时清除相应字段。
这些不变式可以用来手绘一个最小 Dictionary 实验:为多个键故意返回相同哈希,依次 Add、Remove 链头/链中/链尾、再 Add,每步都验证所有剩余键可查找、已删键不可查找、Count 正确且无重复键。公开 API 不暴露内部数组,若要验证实现级不变式,可在自制教学版字典中加断言,不建议生产代码用反射依赖 BCL 私有字段。
九、内存与 GC 账本
字典至少包含字典对象、bucket 数组和 Entry 数组。小字典的固定对象与数组开销可能比实际数据还显眼,因此不要为每个实体创建大量只存一两项的字典,却不测量总驻留量。对键集很小且固定的组件,数组、字段或紧凑结构体可能更合适。
Entry 中的引用会被 GC 扫描。Dictionary<int, int> 的 Entry 数组不包含引用,Dictionary<string, object> 则包含键与值引用;两者的相同 Capacity 不代表相同的数组字节数或 GC 扫描特征。大值结构体还会增大每个 Entry,使 Resize 复制与缓存局部性成本上升。这并不意味着应把所有大值换成类,因为类又引入独立对象和间接访问;选择要由数量、更新、复制和生命周期一起决定。
Clear 将字典变为逻辑空,但通常保留容量以便复用。TrimExcess 可以在长期低水位时缩小内部数组,但需要新数组与重建成本,不应在帧循环或每次删除后调用。一次偶然峰值后是保留容量还是缩容,取决于峰值是否会重现、内存预算和可接受的重建时机。
十、线程安全、可变键与信任边界
Dictionary 不是并发可写容器。一个线程在 Resize 或修改冲突链时,另一个线程读取不具备字典级保证。枚举器的版本检测是尽早暴露部分修改错误的机制,不是锁,也不是内存屏障契约。跨线程共享时应使用锁保护完整不变式,发布不可变快照,或选择符合操作模式的并发容器。
键在存入后,参与 GetHashCode 和 Equals 的状态必须保持稳定。如果可变键在字典内部改变了哈希,它仍留在旧 bucket 链上,用新状态查找就可能失败;这不是 Dictionary 会自动修复的情况。详细的 comparer 契约、复合键和序列化边界在 04-04 继续展开。
对不可信输入,哈希冲突可以将期望 O(1) 查找拉长为链遍历。不应自制只取前几个字符的弱哈希比较器,也不应在网络边界允许无上限键数、键长度和复杂 comparer 工作量。随机化字符串哈希是防御的一部分,不能替代输入配额、超时和资源限制。
十一、总结
Dictionary 的核心数据结构建立在一个精巧的双数组设计上:
- _buckets\[\] + _entries\[\] 双数组------桶数组提供期望 O(1) 的桶定位,条目数组紧凑存储所有槽位
- Entry struct 紧凑设计------无对象头、无虚方法表,每个键值对只占用其所必需的字节
- 1-based 桶索引------使数组默认零值自然表示空桶,同时保留 Entry 0 作为有效槽
- next 字段双重编码------一个 int 同时服务于冲突链和空闲链表,不增加任何内存
- Free List 机制------删除不引发数组收缩,后续添加优先复用空位,避免增删时的反复分配
- 延迟初始化------空字典不分配任何数组,只在第一次添加时初始化
理解这个数据结构,你就掌握了阅读 Dictionary 全部源码的钥匙。下一篇我们把 Add、FindValue、Remove、Resize 四个核心操作逐行走一遍------你会看到每个操作如何在这个精妙的结构上运转。
延伸阅读:
dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs:完整源码- 04-01:Hash 原理------哈希函数、素数桶、冲突解决的背景知识
- 本文 04-03:Dictionary(下):关键操作逐行分析
- 阅读建议:在 dotnet/runtime 固定 tag 后同时查看
Dictionary.cs、HashHelpers.cs与相应单元测试,不从单个性能博客反推源码布局。
下一篇 :Dictionary<TKey,TValue>(下):关键操作逐行分析