04-02-哈希-Dictionary-TKey-TValue-上-核心数据结构

Dictionary<TKey,TValue>(上):核心数据结构

系列 :C#与常用数据结构源码剖析 · 数据结构-哈希与映射篇

阅读时间 :约 55 分钟

源码位置dotnet/runtime@5535e31.../Dictionary.cs

版本边界 :以 v8.0.0 / commit 5535e31a712343a63f5d7d796cd874e563e5ac14 的 .NET 8 实现形状为主线,字段、快速取模和 comparer 特化会随 tag 与架构变化。本文代码均是教学节选或伪代码,不伪装成可直接替换原文件的完整源码。

前置知识:04-01(Hash 原理)


一、引言

大多数人以为 Dictionary 内部存储的是 KeyValuePair<TKey,TValue> 数组。实际并非如此------.NET 的 Dictionary 使用了一个精巧的双数组 + 空闲链表设计,这个设计是理解它全部行为的关键。

它是"用结构体替代对象"哲学的极致体现:一个 Entry struct 包含 hashCodenextkeyvalue 四个字段,紧凑排列在连续内存中,没有单独为每个键值对分配堆对象。这种设计与 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 排序键,或选择对顺序有公开保证的容器。这会带来排序时间与临时存储,必须纳入预算,不能为避免成本而依赖实现偶然性。

八、不变式:用于阅读源码和设计测试的导航仪

与其记住每一行实现,不如记住操作前后必须成立的性质:

  1. 公开 Count == _count - _freeCount,且三者都不为负。
  2. 每个活跃 Entry 恰好能从一个 bucket 的冲突链到达,不能同时属于两条链。
  3. 每个空闲 Entry 恰好能从 _freeList 到达,且空闲节点数等于 _freeCount
  4. 活跃链与空闲链不相交,所有索引均在 [0, _count) 内,并且链最终终止。
  5. 对每个活跃 Entry,其 hashCode 定位的 bucket 与它所在链一致,链中不存在两个按 comparer 相等的键。
  6. 引用类型键和值被删除后不应只因空闲 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 或修改冲突链时,另一个线程读取不具备字典级保证。枚举器的版本检测是尽早暴露部分修改错误的机制,不是锁,也不是内存屏障契约。跨线程共享时应使用锁保护完整不变式,发布不可变快照,或选择符合操作模式的并发容器。

键在存入后,参与 GetHashCodeEquals 的状态必须保持稳定。如果可变键在字典内部改变了哈希,它仍留在旧 bucket 链上,用新状态查找就可能失败;这不是 Dictionary 会自动修复的情况。详细的 comparer 契约、复合键和序列化边界在 04-04 继续展开。

对不可信输入,哈希冲突可以将期望 O(1) 查找拉长为链遍历。不应自制只取前几个字符的弱哈希比较器,也不应在网络边界允许无上限键数、键长度和复杂 comparer 工作量。随机化字符串哈希是防御的一部分,不能替代输入配额、超时和资源限制。


十一、总结

Dictionary 的核心数据结构建立在一个精巧的双数组设计上:

  1. _buckets\[\] + _entries\[\] 双数组------桶数组提供期望 O(1) 的桶定位,条目数组紧凑存储所有槽位
  2. Entry struct 紧凑设计------无对象头、无虚方法表,每个键值对只占用其所必需的字节
  3. 1-based 桶索引------使数组默认零值自然表示空桶,同时保留 Entry 0 作为有效槽
  4. next 字段双重编码------一个 int 同时服务于冲突链和空闲链表,不增加任何内存
  5. Free List 机制------删除不引发数组收缩,后续添加优先复用空位,避免增删时的反复分配
  6. 延迟初始化------空字典不分配任何数组,只在第一次添加时初始化

理解这个数据结构,你就掌握了阅读 Dictionary 全部源码的钥匙。下一篇我们把 Add、FindValue、Remove、Resize 四个核心操作逐行走一遍------你会看到每个操作如何在这个精妙的结构上运转。


延伸阅读

相关推荐
戴西软件1 小时前
国内有哪些数据轻量化格式转换软件?
数据库·算法·安全·信息可视化·自动化·rpa
老洋葱Mr_Onion1 小时前
【C++】CSP-J初赛模拟卷七错题整理(作者自用)
c++·算法·深度优先
土星云SaturnCloud2 小时前
Real-ESRGAN超分辨率算法原理与边缘侧部署实践
服务器·算法·ai·边缘计算·real-esrgan
cz07102 小时前
hot100_搜索二维矩阵 II
算法·leetcode
longxiaozhang62 小时前
C#异常处理:程序出错了怎么办?
开发语言·数据库·c#
疯狂打码的少年2 小时前
【数据结构】板块总结 + 下期预告(数据库技术)
数据结构·笔记·算法
郝学胜_神的一滴2 小时前
Effective Python 条款 2:遵循 PEP 8 编码风格,写出高质量 Python 代码
python·算法
郝学胜-神的一滴2 小时前
Effective Python 条款 1:确认你正在使用的 Python 版本
开发语言·数据结构·python·程序人生·算法
sel_93 小时前
【多轮对话论文导读(二)】多轮对话与LLM Agent论文阅读:长期记忆、多轮评估与Agent训练
人工智能·深度学习·算法·语言模型·自然语言处理