☰
04-03-哈希-Dictionary-TKey-TValue-下-关键操作逐行分析
2026/9/25 5:17:45 网站建设 项目流程

Dictionary<TKey,TValue>(下):关键操作逐行分析

系列:C# 与常用数据结构源码剖析 · 数据结构—哈希与映射篇
源码入口:dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs
前置知识:04-02(桶数组、条目数组、冲突链与空闲链表)
版本说明:本文讨论现代 .NET(dotnet/runtime代码线)的共同设计。实现细节会随 tag 改变;阅读某个项目时,应把文中的方法名与项目实际使用的 runtime tag 对照,而不是把“当前主分支”自动等同于 .NET 8、Unity Mono 或 IL2CPP。


一、先建立一张可执行的心智模型

Dictionary<TKey,TValue>的公开 API 很多,但核心路径并不多:插入最终汇入TryInsert,查询最终汇入FindValue,删除由Remove摘除冲突链节点,容量耗尽时由Resize重建桶链。真正的难点不是记住每行源码,而是同时守住几组不变式。

第一组是索引编码。_buckets[b]存的不是条目索引,而是“条目索引加一”:零表示空桶,正数减一才得到_entries下标。这样,运行时可以依赖 CLR 对新数组的零初始化,不必再把每个桶填成-1。条目的next则使用正常的零基下标,-1表示冲突链结束。

第二组是数量关系。_count不是当前有效元素数,而是曾经启用过的条目区间上界;删除不会把它减一。_freeCount是其中已经删除、可复用的位置数,因此公开的Count本质上是_count - _freeCount。只要还有空闲节点,下一次插入应优先复用它,而不是盲目追加或扩容。

第三组是两类链共用Entry.next。有效条目用next >= -1表示桶内冲突链;删除条目用小于-1的负值编码 free list。现代实现可见常量StartOfFreeList,并通过可逆变换保存下一个空闲索引。这个设计避免了给Entry再加一个字段,也让 Resize 能用next >= -1判断条目是否仍然有效。

桶链: buckets[b] = entryIndex + 1 entries[i].next = 下一个有效条目下标,或 -1 空闲链:freeList = 第一个空闲条目下标,或 -1 entries[i].next = StartOfFreeList - 下一个空闲条目下标 数量: 有效元素数 = _count - _freeCount

第四组是不变键。一个键插入以后,决定其哈希码和相等性的状态不能再变化。若可变对象在字典内改变了GetHashCode或比较结果,条目仍挂在旧桶中,之后即使用“同一个对象”查找也可能失败。这不是 Resize 能修复的普通问题,而是键类型破坏了哈希容器契约。

本文的代码分三类标注:结构化节选保留运行时字段和分支形状,但会删去条件编译、内联提示及重复的值类型/引用类型快路径;伪代码只解释算法;调用示例是面向业务的普通 C#。它们都不应冒充某个 tag 可直接替换编译的完整原文件。


二、插入总入口:TryInsert

2.1 三种公开语义为何能共用一条路径

Add、TryAdd与索引器 setter 的差异只发生在“已经找到等价键”之后:Add抛异常,TryAdd返回false,setter 覆盖值。因此内部用InsertionBehavior表达策略,公共入口不需要各复制一套查找和扩容代码。

// 结构化节选:名称与职责对应现代 dotnet/runtime,省略属性与辅助抛错方法。 public void Add(TKey key, TValue value) => TryInsert(key, value, InsertionBehavior.ThrowOnExisting); public bool TryAdd(TKey key, TValue value) => TryInsert(key, value, InsertionBehavior.None); public TValue this[TKey key] { set => TryInsert(key, value, InsertionBehavior.OverwriteExisting); }

这里首先要纠正一个常见误读:覆盖既有键的 value 与插入新键不是同一种结构变化。现代 runtime 的TryInsert在覆盖分支直接写entry.value并返回,新增条目才推进_version。因此不能凭经验在伪源码里给每个成功 setter 都加_version++;枚举失效规则必须按目标 runtime 的真实源码与文档判断。

2.2 从入口到桶定位

方法先拒绝空键。随后,如果字典尚未初始化,就分配第一组桶和条目数组。哈希码由比较器产生:值类型且使用默认比较器时,现代实现通常保留便于 JIT 去虚调用的专门路径;引用类型或自定义比较器则通过_comparer。这些分支会随版本优化,但语义不变:产生哈希的比较器与判断相等的比较器必须是同一套等价关系。

// 结构化节选:展示控制流,不是某个版本的逐字源码。 private bool TryInsert(TKey key, TValue value, InsertionBehavior behavior) { if (key is null) throw new ArgumentNullException(nameof(key)); if (_buckets is null) Initialize(0); Entry[] entries = _entries!; uint hashCode = ComputeHashCodeWithConfiguredComparer(key); ref int bucket = ref GetBucket(hashCode); int i = bucket - 1; uint collisionCount = 0; while ((uint)i < (uint)entries.Length) { ref Entry entry = ref entries[i]; if (entry.hashCode == hashCode && KeysEqual(entry.key, key)) { if (behavior == InsertionBehavior.OverwriteExisting) { entry.value = value; return true; } if (behavior == InsertionBehavior.ThrowOnExisting) throw new ArgumentException("An item with the same key exists."); return false; } i = entry.next; if (collisionCount++ > (uint)entries.Length) throw new InvalidOperationException("Concurrent operations are not supported."); } // 未找到键:选择 free list 或尾部,再写入新链头。 return InsertNewEntry(hashCode, key, value, ref bucket, entries, collisionCount); }

把i >= 0写成(uint)i < (uint)entries.Length是运行时源码常见的边界写法:负数转成无符号数后会成为极大值,所以一个比较同时排除了负数和越界正数。教学伪代码写i >= 0可以表达链终止,却隐藏了数组上界保护;阅读真实源码时要识别这种差异。

比较顺序也有意义。先比较已缓存的hashCode,只有哈希相同才调用相等比较器。哈希不同必然不等,哈希相同却仍可能不等。绝不能为了“优化”省略后一个比较,否则碰撞会把不同键误判成同一键。

2.3 collisionCount 是损坏探测,不是线程安全

一次合法遍历不可能访问超过entries.Length个节点。如果无锁并发写入把next链破坏成环,循环可能永不结束;计数超过数组长度后抛出异常,能把死循环转成可诊断失败。但它不能使普通Dictionary支持多写者,也不能给读者提供内存可见性保证。

错误的推论是:“既然源码会检测并发,多个线程可以同时写,只是偶尔抛异常。”实际后果还可能包括读到中间状态、丢失更新、错误结果或结构损坏。共享可变字典需要外部锁,或根据原子操作语义改用ConcurrentDictionary<TKey,TValue>。即便只读线程很多,也必须确保发布之后不再有任何并发写入。

2.4 复用、追加与扩容

没有找到既有键后,插入位置有三种来源。存在删除槽时,从_freeList取出一个位置并解码下一个 free 节点;没有删除槽且_count小于数组长度时,使用_count指向的新位置;二者都不可用才 Resize。

// 结构化节选:只保留位置选择与链接动作。 int index; if (_freeCount > 0) { index = _freeList; _freeList = StartOfFreeList - entries[index].next; _freeCount--; } else { int count = _count; if (count == entries.Length) { Resize(); bucket = ref GetBucket(hashCode); // 旧数组中的 ref 已失效 entries = _entries!; } index = count; _count = count + 1; } ref Entry destination = ref entries[index]; destination.hashCode = hashCode; destination.next = bucket - 1; destination.key = key; destination.value = value; bucket = index + 1; _version++;

这里有两个非常容易漏掉的细节。第一,bucket是旧_buckets数组元素的托管引用;Resize 更换数组后必须重新取得它。第二,新节点使用头插法:其next指向旧链头,桶再指向新节点。插入无须把同桶条目整体移动。

_count == entries.Length并不等于公开 Count 达到容量;只有_freeCount == 0才会走到这个判断。若存在删除槽,哪怕_count已在数组末端,也应先复用 free list。因此分析容量时只看Count或只看_count都可能得出错误结论。

2.5 字符串碰撞与“强制新哈希”边界

现代 runtime 的实现中还能看到针对特定字符串比较器与高碰撞阈值的防御分支:达到条件时,可能切换到随机化字符串比较器,并以相同容量执行一次强制重哈希。它不是所有TKey的通用 SIMD 优化,也不意味着每次碰撞都会扩容。

本文不把具体阈值或比较器内部类型固定成“所有 .NET 8 都如此”,因为这些属于版本实现细节。要核验时,应在目标 tag 的Dictionary.cs中沿TryInsert查找HashCollisionThreshold、NonRandomizedStringEqualityComparer和带forceNewHashCodes参数的Resize,再同时阅读同 tag 的HashHelpers.cs与字符串比较器实现。


三、查询核心:FindValue 与 ref return

3.1 返回“空引用”如何表示未找到

内部查询若返回普通TValue,当 value 恰好是default时就无法区分“存在且值为默认值”和“不存在”。现代实现让FindValue返回ref TValue:命中时引用条目里的字段,未命中时返回Unsafe.NullRef<TValue>()。调用者再用Unsafe.IsNullRef判断。

// 结构化节选:合并了默认比较器和自定义比较器的两套循环。 internal ref TValue FindValue(TKey key) { if (key is null) throw new ArgumentNullException(nameof(key)); if (_buckets is not null) { Entry[] entries = _entries!; uint hashCode = ComputeHashCodeWithConfiguredComparer(key); int i = GetBucket(hashCode) - 1; uint collisionCount = 0; while ((uint)i < (uint)entries.Length) { ref Entry entry = ref entries[i]; if (entry.hashCode == hashCode && KeysEqual(entry.key, key)) return ref entry.value; i = entry.next; if (collisionCount++ > (uint)entries.Length) throw new InvalidOperationException("Concurrent operations are not supported."); } } return ref Unsafe.NullRef<TValue>(); }

ContainsKey只关心引用是否为空;TryGetValue命中后把值复制到out;索引器 getter 未命中则抛KeyNotFoundException。它们共用同一次桶链遍历,所以在“若存在就取值”的场景,TryGetValue通常比ContainsKey后再用索引器更合适:后者明确执行两次查询,而不是因为某个未经复现的固定性能倍数。

// 调用示例:一次查询,同时表达缺失是正常分支。 if (players.TryGetValue(playerId, out PlayerState state)) { Update(state); } // 两次查询:只有在第一次检查和第二次读取具有独立语义时才值得这样写。 if (players.ContainsKey(playerId)) { PlayerState sameState = players[playerId]; Update(sameState); }

3.2 ref return 不等于公共 API 零拷贝

FindValue的 ref return 首先是内部复用机制。普通TryGetValue仍会把 value 赋给out,普通索引器 getter 也会按值返回;当TValue是大型结构体时,这一步仍可能复制。不要把“内部返回引用”宣传成“所有读取都不会复制”。

需要原地操作时,CollectionsMarshal.GetValueRefOrNullRef或GetValueRefOrAddDefault提供低层入口,但它们故意绕开了通常的封装保护。取得的引用只应在一个很短的、可审计的区域内使用;持有期间不得执行任何可能增删条目或触发 Resize 的字典操作,也不应跨await、回调或未知方法调用传播。

// 调用示例:短生命周期地原地修改结构体值。 ref Stats stats = ref CollectionsMarshal.GetValueRefOrNullRef(table, id); if (!Unsafe.IsNullRef(ref stats)) { stats.HitCount++; } // 到这里就不再使用 stats;后续可以安全地进行可能扩容的操作。

所谓“Resize 后引用成为悬空指针”也应谨慎措辞:这是托管引用,不应与不受 GC 跟踪的野指针混为一谈;真正的 API 契约是,集合发生结构修改后,先前取得的 ref 不再保证代表字典当前槽位,继续使用会造成逻辑错误并违反该 API 的使用约束。

3.3 为什么不存在可信的 Vector256 冲突链扫描结论

此前常见的一种说法是:“.NET 8 的FindValue用Vector256一次比较八个 hash code。”这与Dictionary的基本布局冲突:同一个桶的条目通过next散布在_entries中,并不保证八个候选哈希连续存放。无条件加载连续八个条目不仅比较了别的桶,还无法沿链保持正确性。

因此本文删除该断言及相关“提升百分比”。如果未来某个 runtime tag 真正加入向量化路径,也必须以该 tag 的Dictionary.cs、对应 PR 或可复现实验为证,说明向量加载的数据布局和适用条件。不能把其他哈希表(例如采用连续控制字节的开放寻址实现)、JIT 对普通代码的自动向量化,或数组扫描优化移植成 Dictionary 的事实。


四、Remove:同时维护桶链与 free list

4.1 查找时必须记住前驱

删除的查找过程与 FindValue 相似,但多了last。若删除链头,桶要改指向后继;若删除中间或尾部节点,前驱的next要越过当前节点。只清空条目而不修链,会让后续查找访问已删除槽;只修桶链而不加入 free list,则会永久浪费容量。

// 结构化节选:现代实现形状,比较器快路径被合并。 public bool Remove(TKey key) { if (key is null) throw new ArgumentNullException(nameof(key)); if (_buckets is null) return false; uint hashCode = ComputeHashCodeWithConfiguredComparer(key); ref int bucket = ref GetBucket(hashCode); Entry[] entries = _entries!; int last = -1; int i = bucket - 1; uint collisionCount = 0; while ((uint)i < (uint)entries.Length) { ref Entry entry = ref entries[i]; if (entry.hashCode == hashCode && KeysEqual(entry.key, key)) { if (last < 0) bucket = entry.next + 1; else entries[last].next = entry.next; entry.next = StartOfFreeList - _freeList; ClearReferencesWhenNeeded(ref entry); _freeList = i; _freeCount++; return true; } last = i; i = entry.next; if (collisionCount++ > (uint)entries.Length) throw new InvalidOperationException("Concurrent operations are not supported."); } return false; }

删除会让同一个Entry.next从“桶内冲突链”语义切换为“空闲链”语义。下图以删除中间节点 3 为例:先让活动链的前驱 7 越过它,再把槽位 3 的next改写为空闲链编码。两条链是删除前后的状态,不是槽位 3 同时属于两条链。

After Remove

Before Remove

因此审查Remove时必须同时检查两个连接:活动桶链已不再可达目标,而_freeList又能通过可逆编码找到旧的空闲链头。任何一边漏更新,都会破坏查找正确性或槽位复用。

考虑桶中原有7 -> 3 -> 1 -> -1。删除 7 时没有前驱,桶从编码后的 8 改为后继 3 的桶编码 4;删除 3 时,条目 7 的next从 3 改成 1;删除 1 时,条目 3 的next改成-1。三种情况本质都是单链表摘除,时间取决于目标在桶链中的位置。

4.2 删除槽为何使用特殊负数

假设_freeList原为-1。第一次删除位置i时,entry.next = StartOfFreeList - (-1),结果仍小于-1;随后_freeList = i。第二次删除位置j时,其编码保存旧头i。插入复用j时执行逆变换,就恢复出i。

删除:encodedNext = StartOfFreeList - oldFreeList 复用:oldFreeList = StartOfFreeList - encodedNext

使用这段编码后,有效条目的next域为-1或非负数,空闲条目的next域小于-1。Resize 遍历_entries[0.._count)时可以据此跳过删除槽。早期 .NET Framework 的字段布局和空闲标记方式并不完全相同,所以不要把现代实现的常量反推到所有历史版本或 Unity 自带运行时。

4.3 清理引用与容量回收是两件事

删除引用类型键值后,运行时会在类型需要时把key、value清成默认值,避免空闲槽继续把对象保活。现代源码通常通过RuntimeHelpers.IsReferenceOrContainsReferences<T>()避免为纯值类型做无意义写入。这里解决的是对象可达性,而不是释放字典的数组。

Remove通常不会缩短_entries或_buckets,所以一个曾达到百万条目的字典即使删到很少,数组仍可能保持峰值容量。这不是引用泄漏:旧 value 可以被 GC 回收,但容器自身的预留空间仍在。确认进入低水位且能接受一次 O(n) 整理时,才考虑TrimExcess或重建字典;在频繁增删阶段贸然压缩,可能很快再次扩容。

4.4 Remove、Clear 与枚举器的版本差异

旧资料经常概括为“任何修改都会_version++,foreach 中 Remove 一定抛异常”。这个说法对不同运行时并不普遍成立。现代 .NET 的实现与文档允许某些删除/清空场景不使枚举器失效,而 .NET Framework、Unity 所用的 Mono 版本或其他兼容实现可能不同;新增键依旧是必须谨慎对待的结构变化。

工程代码不应利用模糊记忆猜测。若确实要边枚举边删除,应查目标框架的 API 文档并跑最小测试;跨 Unity、服务器和工具链共享的库,最稳妥且可移植的写法仍是先收集待删除键,再在枚举结束后删除。

// 调用示例:不依赖特定 runtime 的枚举器宽松规则。 keysToRemove.Clear(); foreach (var pair in table) { if (ShouldRemove(pair.Value)) keysToRemove.Add(pair.Key); } foreach (var key in keysToRemove) table.Remove(key);

五、Resize:搬迁条目,重建桶链

5.1 普通扩容并不重新调用键的 GetHashCode

插入没有空闲槽且条目数组已满时,参数lessResize()通常先由HashHelpers.ExpandPrime(_count)选新容量,再调用内部重载。新数组容量改变后,桶位置当然会改变,但条目已经缓存了hashCode,普通扩容只需用缓存值重新取模并链接,无须再次调用每个键的GetHashCode。

“rehash”这个词容易造成歧义:它可能指重新计算哈希码,也可能只指按照旧哈希码重建桶映射。本文把前者称为强制新哈希,把后者称为重建桶链。

// 结构化节选:展示普通 Resize 与强制新哈希的共同骨架。 private void Resize() => Resize(HashHelpers.ExpandPrime(_count), forceNewHashCodes: false); private void Resize(int newSize, bool forceNewHashCodes) { Entry[] entries = new Entry[newSize]; int count = _count; Array.Copy(_entries!, entries, count); if (forceNewHashCodes) { SwitchEligibleStringComparerIfRequired(); for (int i = 0; i < count; i++) { if (entries[i].next >= -1) entries[i].hashCode = RecomputeHash(entries[i].key); } } _buckets = new int[newSize]; for (int i = 0; i < count; i++) { if (entries[i].next >= -1) // 跳过 free list 节点 { ref int bucket = ref GetBucket(entries[i].hashCode); entries[i].next = bucket - 1; bucket = i + 1; } } _entries = entries; }

旧伪代码常写if (hashCode >= 0)判断有效条目,但现代Entry.hashCode是uint,该条件恒为真,完全无法排除删除槽。正确的现代判据来自next的编码区间。也不能用key != null判断,因为值类型键没有 null,而清理策略和合法键域也不支持这种推断。

5.2 为什么复制之后还必须重建 next

假设缓存哈希为 42,旧桶数为 7,则桶号是42 % 7;新桶数为 17,桶号变成42 % 17。直接复制_entries只保留了旧next链,那些链是针对旧桶布局建立的。Resize 必须清零新桶数组,逐个访问有效条目,以新容量定位桶,再用头插法重写next。

条目通常保持原数组下标,删除槽也随[0, _count)一起复制;因此普通 Resize 的职责是增大容量和重建桶,不是压实碎片。压实并缩小容量属于TrimExcess的语义,具体实现可能建立新数组并只复制有效项,不能简单写成“TrimExcess 调用同一个 Resize”。

5.3 容量、素数与 FastMod

现代实现通过HashHelpers.GetPrime/ExpandPrime选择容量,并可能在 64 位环境使用预计算乘数执行快速取模。教学上可以理解为hashCode % buckets.Length,但真实GetBucket可能不是直接的%指令。素数容量能降低某些低质量哈希与容量因子的规律性重合;它绝不是允许键提供劣质或可变哈希的许可证。

不要承诺每次容量“精确两倍”,也不要凭一条示例序列断言固定倍率。容量上限、素数表、动态寻素和溢出处理都属于HashHelpers的具体版本实现。业务真正可控的是提供合理的预计元素数,避免已知规模下的多轮搬迁,而不是依赖某个未写入 API 契约的内部容量值。

5.4 复杂度与帧时间

在哈希分布良好且负载受控时,查找、插入和删除的期望复杂度是 O(1);单次 Resize 需要遍历已启用的条目区间并分配新数组,是 O(n)。通过几何增长,多次插入的扩容成本可分摊,所以插入的摊还复杂度仍是 O(1)。最坏情况下,大量键落入同一桶,查找和修改都会退化到 O(n)。

“摊还 O(1)”不等于每一帧耗时稳定。游戏主线程更关心尖峰:一次大数组分配、条目复制和桶链重建可能集中发生在某帧。已知关卡对象上限、寻路节点量或缓存规模时,应在加载阶段传入容量或调用EnsureCapacity,并以目标设备测量,而不是引用别人机器上的毫秒数。

预分配也并非越大越好。过大的_buckets和_entries增加常驻内存与缓存足迹,大对象分配还会改变 GC 行为。预计数应来自业务上界或监控分位值;对波动特别大的缓存,还要同时设计淘汰和压缩时机。


六、异常、契约与并发边界

6.1 API 级异常不是一个集合

空键通常触发ArgumentNullException;Add遇到等价键触发ArgumentException;索引器 getter 查不到键触发KeyNotFoundException;容量增长超过支持范围可能触发与容量相关的异常;检测到并发导致的链异常则可能抛InvalidOperationException。调用者应根据 API 语义选择方法,而不是用异常做日常分支。

// 缺失属于正常业务状态:TryGetValue。 if (!inventory.TryGetValue(itemId, out Item item)) return Result.NotFound; // 重复表示程序不变式被破坏:Add 让问题立即暴露。 inventory.Add(item.Id, item); // 重复时保留旧值:TryAdd 明确表达意图。 bool accepted = inventory.TryAdd(item.Id, item); // 重复时替换:索引器 setter。 inventory[item.Id] = item;

比较器自身也可能抛异常,键的GetHashCode/Equals若含业务逻辑同样可能失败。更严重的是,相等性不满足自反、对称、传递,或“相等对象必须有相等哈希”时,字典行为会失去可推理性。比较器应尽量纯、稳定、无副作用。

6.2 普通 Dictionary 的并发使用规则

多个线程并发读取一个已经安全发布且永不再修改的字典,通常是合理用法;任何线程可能写入时,所有相关访问都应纳入同一同步协议。ContainsKey与索引器组成的 check-then-act 即使各自调用没有抛错,组合也不是原子的。

ConcurrentDictionary也不是把任意多步业务逻辑自动变成事务。应使用它提供的GetOrAdd、TryUpdate、AddOrUpdate等原子 API,并理解用户委托可能被调用多次或在锁外执行的版本契约。若要求“检查库存、扣减、写审计”整体原子,仍需更高层锁或事务模型。

6.3 Unity 不能直接套用桌面 .NET 结论

Unity 项目可能运行 Mono 或 IL2CPP,并受 Unity 版本、API Compatibility Level、目标平台和裁剪影响。即使公共 API 名称相同,CoreLib 实现也不必与某个dotnet/runtimetag 逐行一致。分析 Unity 性能时,应记录编辑器/Player、后端、架构、构建配置和目标设备,并以对应托管库源码或生成代码验证。

因此本文给出的StartOfFreeList、null-ref 查询与随机化字符串比较器等,是阅读现代dotnet/runtime的路线图,不是对所有 Unity Player 内部布局的保证。能迁移的是哈希表原理、复杂度和审查方法,不能无证据迁移的是字段尺寸、分支、枚举失效细节和精确耗时。


七、把源码知识转化为实验

7.1 正确性实验:制造可控碰撞

先构造一个始终返回相同哈希、但按值判断相等的比较器。它可以验证碰撞不会改变正确性,并展示链长对相等比较次数的影响。这个实验只能用于教学,不能作为生产比较器。

sealed class CountingCollisionComparer : IEqualityComparer<int> { public int EqualsCalls { get; private set; } public bool Equals(int x, int y) { EqualsCalls++; return x == y; } public int GetHashCode(int value) => 0; } var comparer = new CountingCollisionComparer(); var map = new Dictionary<int, string>(comparer); for (int i = 0; i < 1_000; i++) map.Add(i, i.ToString()); bool found = map.TryGetValue(0, out _); Console.WriteLine($"found={found}, equals={comparer.EqualsCalls}");

实验应分别查询链头、链尾和不存在的键,再换回默认比较器作对照。不要只报一次 wall-clock 时间;至少记录 runtime 版本、CPU、构建配置、预热、迭代数、数据规模、键分布与分配量。若使用 BenchmarkDotNet,应保存配置和原始报告,不把单次 Debug 运行包装成精确结论。

7.2 Resize 实验:区分逻辑数量与容量

可以先EnsureCapacity,批量插入,删除一部分,再重新插入同等数量。观察“删除后数组容量不立即下降”和“重新插入优先复用槽位”。公共 API 不暴露全部内部字段,正式测试应优先检验可见行为和分配;反射查看_count、_freeCount只适合针对特定 runtime 的学习工具,并要标注字段并非兼容契约。

还可设计两个基准:一个在计时前预分配足够容量,一个从空字典增长到同样数量。差异反映的是整个增长过程中的分配与搬迁,不应被描述成每次 Add 的固定成本。随后把预计容量设得远超实际,检查内存与遍历局部性,理解预分配的另一面。

7.3 可变键失败实验

定义一个哈希依赖可变字段的类,插入后改变字段,再执行查找与删除。这个实验能直观看到条目仍在 Count 中,却可能无法按新状态定位。恢复字段有时能“找回”条目,但这不是修复方案;正确方案是使用不可变键、以稳定 ID 为键,或删除后改变再重新插入。

sealed class MutableKey { public int Id; public override int GetHashCode() => Id; public override bool Equals(object? obj) => obj is MutableKey other && other.Id == Id; } var key = new MutableKey { Id = 10 }; var map = new Dictionary<MutableKey, string> { [key] = "player" }; key.Id = 20; Console.WriteLine(map.Count); // 条目仍占据字典 Console.WriteLine(map.ContainsKey(key)); // 结果不再可依赖为成功查找

八、源码阅读与代码审查清单

阅读目标版本源码时,按以下顺序比从第一行滚到最后更有效:

  1. 在Dictionary.cs找Entry、StartOfFreeList、_count、_freeCount,先写出有效条目和空闲条目的判据。
  2. 从公共Add、TryAdd、索引器追到TryInsert,记录三种InsertionBehavior在既有键分支的差异。
  3. 检查默认比较器、自定义比较器、值类型与引用类型是否有两套循环;不要因教程合并分支而认为真实源码也只有一套。
  4. 在 Resize 后寻找重新获取bucket与entries的代码,确认没有继续使用旧数组引用。
  5. 在Remove中分别模拟删除链头、中间和尾部,并验证 free list 编码能被下一次插入逆向解码。
  6. 在Resize中确认有效条目的判断字段,区分普通重建桶链与forceNewHashCodes。
  7. 到同一 tag 的HashHelpers.cs查看容量与取模辅助方法,到字符串比较器文件核对碰撞防御条件。
  8. 查看目标框架文档与测试,确认_version和枚举器对 Add、overwrite、Remove、Clear 的实际规则。

审查业务代码时,则应问另一组问题:

  • 是否用稳定、不可变且分布合理的键?自定义比较器是否同时满足相等与哈希契约?
  • “存在则读取”是否无意中写成ContainsKey加索引器的双查找?缺失究竟是正常分支还是不变式破坏?
  • 已知元素规模时是否合理预分配?预估是否过大到浪费常驻内存?
  • 是否把Remove误当成释放数组容量?是否在频繁波动期反复TrimExcess?
  • 是否跨结构修改保存CollectionsMarshal返回的 ref?是否把 ref 带过await或未知回调?
  • 是否有未同步的并发写,或把链环检测、_version误认为线程安全机制?
  • 性能结论是否附带可运行基准、环境、输入分布与原始结果?是否出现无法追溯的固定倍数?
  • Unity 结论是否明确 Player 后端和版本,而不是用桌面 .NET 源码替代实测?

九、结论:四条路径,一组共同约束

TryInsert先在桶链中排除重复键,再复用 free slot、尾部追加或扩容,并把新节点接到桶头;FindValue沿同一条链查找,用内部 ref 表达“命中字段”或“空引用”;Remove从桶链摘除节点、清理需要清理的引用,再把槽位编码进 free list;Resize复制条目并按新桶数重建所有有效链,只有特定防御路径才强制重新计算哈希。

四条路径共享的底层约束比任何一行微优化都重要:桶使用一基编码,冲突链与空闲链共用next但占据不同数值区间,_count - _freeCount才是有效数量,键的哈希与相等性必须稳定一致,数组替换后旧 ref 不能继续当作当前存储位置,并发损坏检测不提供并发安全。

掌握这些约束后,源码的版本差异会变得容易定位:JIT 快路径、比较器类型、取模方法、枚举版本策略都可能变化,但每次变化仍必须维护同一组容器不变式。工程实践也会更克制——用语义选择 API,用容量规划控制尖峰,用目标环境基准替代神奇倍数,用确切 runtime tag 替代笼统的“.NET 8 源码就是这样”。


延伸阅读

  • 04-02:Dictionary 核心数据结构
  • 04-04:Dictionary 高级话题:自定义 Comparer 与序列化
  • dotnet/runtime:src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs
  • dotnet/runtime:src/libraries/System.Private.CoreLib/src/System/Collections/HashHelpers.cs
  • Microsoft Learn:Dictionary<TKey,TValue>、CollectionsMarshal.GetValueRefOrNullRef;使用时选择与项目 Target Framework 对应的文档版本

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询