ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

04-03-哈希-Dictionary-TKey-TValue-下-关键操作逐行分析

2026/8/26 18:49:56 拓冰建站 浏览量
04-03-哈希-Dictionary-TKey-TValue-下-关键操作逐行分析 DictionaryTKey,TValue下关键操作逐行分析系列C# 与常用数据结构源码剖析 · 数据结构—哈希与映射篇源码入口dotnet/runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs前置知识04-02桶数组、条目数组、冲突链与空闲链表版本说明本文讨论现代 .NETdotnet/runtime代码线的共同设计。实现细节会随 tag 改变阅读某个项目时应把文中的方法名与项目实际使用的 runtime tag 对照而不是把“当前主分支”自动等同于 .NET 8、Unity Mono 或 IL2CPP。一、先建立一张可执行的心智模型DictionaryTKey,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 可直接替换编译的完整原文件。二、插入总入口TryInsert2.1 三种公开语义为何能共用一条路径Add、TryAdd与索引器 setter 的差异只发生在“已经找到等价键”之后Add抛异常TryAdd返回falsesetter 覆盖值。因此内部用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支持多写者也不能给读者提供内存可见性保证。错误的推论是“既然源码会检测并发多个线程可以同时写只是偶尔抛异常。”实际后果还可能包括读到中间状态、丢失更新、错误结果或结构损坏。共享可变字典需要外部锁或根据原子操作语义改用ConcurrentDictionaryTKey,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 return3.1 返回“空引用”如何表示未找到内部查询若返回普通TValue当 value 恰好是default时就无法区分“存在且值为默认值”和“不存在”。现代实现让FindValue返回ref TValue命中时引用条目里的字段未命中时返回Unsafe.NullRefTValue()。调用者再用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.NullRefTValue(); }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 list4.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 RemoveBefore 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.IsReferenceOrContainsReferencesT()避免为纯值类型做无意义写入。这里解决的是对象可达性而不是释放字典的数组。Remove通常不会缩短_entries或_buckets所以一个曾达到百万条目的字典即使删到很少数组仍可能保持峰值容量。这不是引用泄漏旧 value 可以被 GC 回收但容器自身的预留空间仍在。确认进入低水位且能接受一次 O(n) 整理时才考虑TrimExcess或重建字典在频繁增删阶段贸然压缩可能很快再次扩容。4.4 Remove、Clear 与枚举器的版本差异旧资料经常概括为“任何修改都会_versionforeach 中 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 级异常不是一个集合空键通常触发ArgumentNullExceptionAdd遇到等价键触发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 : IEqualityComparerint { 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 Dictionaryint, 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 DictionaryMutableKey, string { [key] player }; key.Id 20; Console.WriteLine(map.Count); // 条目仍占据字典 Console.WriteLine(map.ContainsKey(key)); // 结果不再可依赖为成功查找八、源码阅读与代码审查清单阅读目标版本源码时按以下顺序比从第一行滚到最后更有效在Dictionary.cs找Entry、StartOfFreeList、_count、_freeCount先写出有效条目和空闲条目的判据。从公共Add、TryAdd、索引器追到TryInsert记录三种InsertionBehavior在既有键分支的差异。检查默认比较器、自定义比较器、值类型与引用类型是否有两套循环不要因教程合并分支而认为真实源码也只有一套。在 Resize 后寻找重新获取bucket与entries的代码确认没有继续使用旧数组引用。在Remove中分别模拟删除链头、中间和尾部并验证 free list 编码能被下一次插入逆向解码。在Resize中确认有效条目的判断字段区分普通重建桶链与forceNewHashCodes。到同一 tag 的HashHelpers.cs查看容量与取模辅助方法到字符串比较器文件核对碰撞防御条件。查看目标框架文档与测试确认_version和枚举器对 Add、overwrite、Remove、Clear 的实际规则。审查业务代码时则应问另一组问题是否用稳定、不可变且分布合理的键自定义比较器是否同时满足相等与哈希契约“存在则读取”是否无意中写成ContainsKey加索引器的双查找缺失究竟是正常分支还是不变式破坏已知元素规模时是否合理预分配预估是否过大到浪费常驻内存是否把Remove误当成释放数组容量是否在频繁波动期反复TrimExcess是否跨结构修改保存CollectionsMarshal返回的 ref是否把 ref 带过await或未知回调是否有未同步的并发写或把链环检测、_version误认为线程安全机制性能结论是否附带可运行基准、环境、输入分布与原始结果是否出现无法追溯的固定倍数Unity 结论是否明确 Player 后端和版本而不是用桌面 .NET 源码替代实测九、结论四条路径一组共同约束TryInsert先在桶链中排除重复键再复用 free slot、尾部追加或扩容并把新节点接到桶头FindValue沿同一条链查找用内部 ref 表达“命中字段”或“空引用”Remove从桶链摘除节点、清理需要清理的引用再把槽位编码进 free listResize复制条目并按新桶数重建所有有效链只有特定防御路径才强制重新计算哈希。四条路径共享的底层约束比任何一行微优化都重要桶使用一基编码冲突链与空闲链共用next但占据不同数值区间_count - _freeCount才是有效数量键的哈希与相等性必须稳定一致数组替换后旧 ref 不能继续当作当前存储位置并发损坏检测不提供并发安全。掌握这些约束后源码的版本差异会变得容易定位JIT 快路径、比较器类型、取模方法、枚举版本策略都可能变化但每次变化仍必须维护同一组容器不变式。工程实践也会更克制——用语义选择 API用容量规划控制尖峰用目标环境基准替代神奇倍数用确切 runtime tag 替代笼统的“.NET 8 源码就是这样”。延伸阅读04-02Dictionary 核心数据结构04-04Dictionary 高级话题自定义 Comparer 与序列化dotnet/runtimesrc/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.csdotnet/runtimesrc/libraries/System.Private.CoreLib/src/System/Collections/HashHelpers.csMicrosoft LearnDictionaryTKey,TValue、CollectionsMarshal.GetValueRefOrNullRef使用时选择与项目 Target Framework 对应的文档版本