资讯详情

资讯详情

C# Dictionary详解:从语法原理到性能优化与常见坑

最早学 C# 的时候我一直以为数据处理就是数组、List、数组、List。直到做一个内部小工具需要在几千条订单记录里按订单号快速找金额用 List 加循环一个个比对数据一上千就明显卡顿。后来换成 C# 的Dictionary字典同一个查询几乎瞬间完成。从那以后凡是“根据某个键去找对应值”的场景我第一反应就是键值对集合。这篇就把我从零上手 Dictionary 过程中的语法、原理、性能边界和踩过的坑完整梳理一遍适合刚开始接触 C# 集合、或者已经在写但是不太明白底层行为的读者。1. 最开始让我换成 Dictionary 的场景按订单号查金额1.1 列表遍历为什么越到后面越难受假设你有几千个订单对象每个订单有订单号OrderId和金额Amount需求是输入一个订单号立刻返回金额。用 List 的常规写法是这样的ListOrder orders LoadOrders(); // 加载订单列表 // 需要查找时 decimal amount 0; foreach (var order in orders) { if (order.OrderId targetId) { amount order.Amount; break; } }这个写法逻辑没错但性能隐患非常直接它本质上是线性查找最坏情况下要把整个列表从头扫到尾。平均查找次数随着数据量增长随着订单量翻倍耗时也翻倍。数据几百条时没感觉到几万条时你再做批量查询就能明显看到画面卡顿因为每个订单号都要从头遍历一次。我当时在这个工具里要做的是把 Excel 里的几百个订单号批量匹配到订单列表外层循环几百次内层每次扫几千条直接就变成几百万次比较运行时一下子就上来了。这时才意识到需要频繁按键查找的场景列表的线性结构并不合适。1.2 字典解决这个问题的核心思路用键直接定位值Dictionary 做的事情可以理解为把你要查的“键”比如订单号直接映射到对应的“值”比如金额对象。它不依赖遍历而是通过一种计算方式从键本身推算出存储位置。我改完之后的查询代码变成这样// 构建字典键是订单号值是订单对象 Dictionarystring, Order orderMap new Dictionarystring, Order(); foreach (var order in orders) { orderMap[order.OrderId] order; } // 批量查询时 foreach (var targetId in targetIds) { if (orderMap.TryGetValue(targetId, out var order)) { Console.WriteLine(${targetId}: {order.Amount}); } }同样是几万条数据效果立竿见影。单个查找的时间不再随列表长度线性增长基本稳定在可忽略的级别。这个特性的底层原因会在第 3 章展开你先记住结论字典擅长“按唯一键快速取值”但不适合“有序遍历”和“范围查询”。这个特性决定了你后面所有选型判断。2. 快速上手字典的创建、添加、读取与修改2.1 创建字典的几种常见写法Dictionary 的完整类型名称是DictionaryTKey, TValue尖括号里的两个参数分别代表键的类型和值的类型。最基础的创建方式Dictionarystring, int scoreMap new Dictionarystring, int();这是最常见的用法键是字符串类型学生姓名值是整数类型成绩。如果你的数据量在一开始就是已知的可以用集合初始化器Dictionarystring, int scoreMap new Dictionarystring, int { { 张三, 92 }, { 李四, 88 }, { 王五, 76 } };还有一种写法是从另一个字典复制内容Dictionarystring, int copy new Dictionarystring, int(scoreMap);有个新手容易忽略的点字典的键是唯一的不允许重复但值可以重复。你可以有多个学生都得 88 分但不能有两个都叫“张三”的键。如果你希望同一个键对应多个值需要用DictionaryTKey, ListTValue这种嵌套形式后面第 7 章会讲到。2.2 添加、读取、修改和删除添加用Add方法读取用方括号索引器scoreMap.Add(赵六, 95); // 添加新键值对 int score scoreMap[张三]; // 读取键为“张三”的值这里有个非常容易踩的坑Add方法遇到已存在的键会直接抛ArgumentException提示“添加的项具有相同的键”。如果业务场景里你无法确定键是否已经存在更稳妥的是用索引器赋值它会自动覆盖旧值scoreMap[张三] 93; // 键已存在则覆盖旧值 scoreMap[钱七] 81; // 键不存在则自动添加删除用Remove接收一个键bool removed scoreMap.Remove(李四);Remove会返回布尔值表示是否成功删除。如果键不存在不会报错只会返回false。我习惯在需要清理数据时先看这个返回值避免无谓的异常判断。2.3 遍历字典的两种常见姿势遍历字典时foreach拿到的每个元素都是KeyValuePairTKey, TValue结构里面有Key和Value两个属性foreach (KeyValuePairstring, int kvp in scoreMap) { Console.WriteLine(${kvp.Key}: {kvp.Value}); }如果你只需要值可以直接遍历Valuesforeach (int s in scoreMap.Values) { Console.WriteLine(s); }只需要键的情况遍历Keysforeach (string name in scoreMap.Keys) { Console.WriteLine(name); }这里提醒一件事字典的遍历顺序是不保证的不要依赖插入顺序。虽然在小数据量下它往往看起来是插入顺序但这是实现细节不是契约。如果你需要按字母或日期排序输出建议把键取出来手动排序或者直接用第 6 章要讲的SortedDictionary。2.4 和旧版 Hashtable 的区别为什么现在都推荐泛型版很多老项目里还能看到Hashtable它和 Dictionary 在“键值对”这件事上很相似。但实际使用体验差别不小。对比项HashtableDictionaryTKey, TValue类型安全键和值都是 object编译期不检查泛型强类型写入错误类型直接编译报错性能键值类型是值类型时会装箱拆箱无装箱拆箱损耗速度更快使用体验读取可能需要在类型间来回转换类型明确代码可读性更好我自己维护旧项目时最烦的是从 Hashtable 里取出数据还要强制转换而且转的时候一不小心就是InvalidCastException。Dictionary 没有这个问题类型在声明时已经定死编译期就能拦住大部分错误。现在新代码一律写泛型字典Hashtable 只在临场看老代码时认识即可。3. 字典底层到底做了什么哈希、桶与冲突3.1 哈希函数把键变成“柜子编号”要理解 Dictionary 为什么快先想象一个快递柜。快递柜有几百个格子每个格子有编号。存包裹时你不用挨个看每个格子里是什么而是根据取件码直接走到对应格子。哈希函数在这里起的作用就是计算取件码。你传入任意一个键它通过哈希算法得到一个整数这个整数再被映射到内部数组的某个位置。于是存放和查找都变成了“直接到某个位置看一眼”不需要和全量数据比较。在 C# 中每个对象都有一个GetHashCode()方法字符串、整数等常用类型的实现都足够高效。Dictionary 内部就是调用键的GetHashCode()得到初始哈希值再经过一个内部运算决定把这个键值对放在第几个“桶”里。3.2 冲突两个不同键算出同一个位置怎么办理论上哈希算法不能保证每个不同键都得到完全不同的哈希码。两个不同的键算出了同一个存放位置就叫“哈希冲突”。Dictionary 处理冲突的方式是经典的“拉链法”思路同一个桶里不只放一个元素而是形成一个链条数组或链表把同桶元素串起来。查找时先通过哈希码定位到桶再在桶里逐个比较这些元素的键是否和目标键相等。所以最理想情况是每个桶里只有一个元素查找一次命中如果很多键挤在同一批桶里查找就退化成线性扫描。好在 Dictionary 内部会自动调整桶的数量尽量让冲突保持在一个很低的水平。3.3 扩容涨容量时的一次性性能抖动为了让冲突率低Dictionary 不会把所有数据硬塞进一个固定大小的数组。当元素数量逼近阈值时它会自动扩容把内部数组变大并且把已经存在的所有元素重新计算桶位置、搬一次家。这个过程叫 rehash。扩容本身是有开销的。如果你的字典数据量很大并且是持续增长的你可能会在某个瞬间观察到一次明显的耗时波动。要规避这种情况可以在创建字典时预估大致的数据量并预设容量Dictionarystring, Order orderMap new Dictionarystring, Order(20000);这个容量不是限制最大值而是告诉内部结构“我预期大概有多少条数据”让它提前分配好桶数组减少中途反复扩容的次数。实际条数超出预设也没关系字典自动继续扩容只是没有提前分配那么平滑。3.4 为什么键必须不可变这一点是很多人的知识盲区。字典在存储和查找时依赖键的哈希码确定位置。如果某个键被放进字典之后再被修改导致它的哈希码发生变化那么字典再用这个键去查找时哈希定位到的桶已经不是当初存放的桶结果就是“键明明在里面却查不到”。举一个反面示例using System; public class Person { public string Name { get; set; } } // 错误示范 var dict new DictionaryPerson, int(); var p new Person { Name A }; dict[p] 100; p.Name B; // 修改了键对象 // 此时 dict.TryGetValue(p, ...) 很可能失败因为 p 的哈希码变了所以做键的对象要么是不可变类型字符串、整数、Guid 等要么是你自己定义并且控制得住的类型绝不能让数据被塞进字典后还能随意改。实际工作中我通常直接用字符串或 Guid 做键很少让复杂对象充当键原因就在这。4. API 选择逻辑什么时候用哪个方法4.1 TryGetValue 和 ContainsKey 的对比新手常见的写法是先查键是否存在再取值if (scoreMap.ContainsKey(张三)) { int s scoreMap[张三]; // 使用 s }这个写法能跑通但不是最优解。它做了两次查找第一次判断键是否存在第二次按下标取出值。更优雅的做法是使用TryGetValueif (scoreMap.TryGetValue(张三, out int s)) { // 使用 s }TryGetValue只做一次查找找到了就把值放进 out 参数并返回true找不到返回false。在循环里反复查询时这一点性能差异会变得非常明显。我写代码的习惯是只要取值的场景优先TryGetValueContainsKey更多用在“只关心存在性不需要拿值”的场景比如判断某个键是否已注册。C# 7 之后的 out 变量内联声明让这段代码非常干净不需要提前声明变量写起来也很顺手。4.2 Add 和索引器赋值怎么选前面提过Add和索引器赋值的区别。再补充一个具体的决策依据如果你确定这个键不存在用Add能额外获得一层保护一旦出现重复键立刻抛异常提醒你逻辑出错了如果你设计的场景就是“有则覆盖无则新增”用索引器赋值更合适。我遇到过一种 bug批量导入数据时Excel 里同一行出现了两次用Add直接抛异常我立刻发现了源数据问题。如果当时用的是索引器覆盖数据会悄悄被后一条覆盖问题要很久才能暴露。所以这里没有绝对的好坏取决于你希望“重复键”被当成严重问题还是正常逻辑。4.3 Remove、Clear、Keys 和 Values 的使用细节Remove(key)删除键值对返回 bool键不存在时不会抛异常。Clear()清空所有键值对字典对象本身还在可以继续使用。Keys和Values返回的是集合视图不建议在遍历时修改原字典。还有一个细节遍历字典时如果你在foreach里删除或修改元素会直接抛InvalidOperationException提示“集合已修改”。如果要边遍历边删正确做法是先把要删的键收集起来遍历结束后再统一删除Liststring toRemove new Liststring(); foreach (var kvp in scoreMap) { if (kvp.Value 60) { toRemove.Add(kvp.Key); } } foreach (var key in toRemove) { scoreMap.Remove(key); }这个小技巧在实际代码里经常用比如清理成绩不及格的学生列表、过滤异常数据等。5. 最容易翻车的几个地方自定义键、并发和容量5.1 自定义类型做键必须正确处理哈希如果你确实需要用自己的类作为键仅仅重写Equals还不够必须保证两点第一Equals和GetHashCode的行为一致——两个对象Equals为true那么它们的哈希码必须相同第二键对象放进字典后不能改变哈希相关字段。C# 9 引入的record类型帮了大忙。它默认实现了基于值的Equals和GetHashCode只要你用 record 定义键类型多数情况下不需要手写public record Person(string Name, int Age); var dict new DictionaryPerson, string(); dict[new Person(张三, 30)] 上班族;如果必须用普通 class建议补上Equals和GetHashCode并确保参与哈希计算的字段不可变。我自己的经验除非必需否则不用自定义对象当键遇到复合键需求优先考虑拼接字符串或者用元组配合ValueTuple简单省事还不容易出错。5.2 字符串键的大小写问题字符串是做键的首选但字符串默认是区分大小写的。如果你希望字典不区分大小写可以用带比较器的构造函数Dictionarystring, int caseInsensitive new Dictionarystring, int(StringComparer.OrdinalIgnoreCase);加上这个比较器之后abc和ABC会被视为同一个键。这在处理用户输入、URL 路径、文件名相关数据时非常有用。比如从配置文件里读取键名你无法保证用户写的是全小写还是混用大小写用忽略大小写的比较器可以省掉一箩筐麻烦。顺带一提字符串默认比较器是Ordinal而有些场景可能用CurrentCulture比较。字典查找默认不涉及文化差异除非你明确需要否则保持Ordinal即可这样性能更好行为也更可预测。5.3 多线程并发普通字典在并发写入时会出大问题普通DictionaryTKey, TValue不是线程安全的。多个线程同时写同一个字典轻则数据丢失重则抛出ArgumentException、InvalidOperationException甚至导致内部数组状态损坏。排查这种问题非常难因为它可能是偶发的而且报错信息不一定直观。如果项目里确实有多线程写入需求优先用ConcurrentDictionaryTKey, TValue。它的核心 API 更贴合并发场景ConcurrentDictionarystring, int cache new ConcurrentDictionarystring, int(); cache.TryAdd(key1, 100); cache[key1] 101; // 直接更新也能正常工作 int val cache.GetOrAdd(key2, _ 200);GetOrAdd是并发字典里非常有用的一个方法意思是如果键不存在就用委托生成默认值并写入如果已存在直接返回现有值。我做缓存表的时候经常会用到它避免先判断再插入时的竞态问题。还要注意ConcurrentDictionary不保证所有方法具备原子性。比如先调用ContainsKey再调用TryAdd中间可能被其他线程插入相同键所以能用原子方法就尽量用原子方法不要自己拼两段判断。5.4 预设容量不是性能调优神技但能减少扩容抖动如果你知道自己将有约十万条数据创建字典时给一个接近的容量是一个低成本的优化习惯Dictionarystring, double priceMap new Dictionarystring, double(100000);这和“提前准备好足够的柜子免得客人来了再往屋里搬”一个道理。容量设得太大会浪费内存太小会频繁扩容。实际项目中我通常按业务上线估算给一个偏大于峰值预期的值然后继续监控内存占用。不需要精算只要避免从 4、16、64 这种小规模一路扩容到十万这种极端抖动即可。6. 字典不是万能的几个容易混淆的集合选型6.1 先看一张选型速查表需求场景推荐集合理由按唯一键快速取值DictionaryTKey, TValueO(1) 平均查找最常用只关心键是否存在不需要关联值HashSet去重和存在性判断内存更小遍历时希望按键排序SortedDictionaryTKey, TValue按键排序存储遍历有序占用低内存且需要有序按键SortedListTKey, TValue内存友好但插入开销随数据量变大多线程环境读写共享字典ConcurrentDictionaryTKey, TValue原生线程安全原子方法齐全按索引位置访问元素List /数组支持下标随机访问直接按位置需要范围查询或连续内存顺序数组 / 列表必要时配合排序排序后位置关系清晰这张表解决了 80% 的集合选型问题。我见过不少同事在只需要判重的场景里硬用 Dictionary把值设成true其实换HashSetstring更简单还省一份值的存储空间。在只有“存在性判断”需求时不要拿字典硬扛因为你根本没有关联的值。6.2 需要排序遍历时SortedDictionary 和 SortedList 怎么区分SortedDictionary和SortedList都会按键排序但实现方式不同前者是一棵平衡树插入和删除相对稳定后者更接近数组二分维护的方式内存连续性更强。实际体验差异是写多读少且同时需要按键排序迭代时SortedDictionary更稳妥数据量中等、读操作频繁且内存敏感时SortedList的查找表现通常不错。两者查找复杂度有区别但绝大多数业务数据量下差别不足以成为系统瓶颈。你在选值时更多要考虑的是插入删除的频率和你对遍历顺序的依赖程度。6.3 多线程环境下的取舍很多误用发生在并发场景。搜索热点词里一度很流行一个讨论“字典加上锁不就行了吗”确实可以但加的锁粒度、范围和条件不同踩坑概率很高。比如跟踪每个 key 的更新频率时如果用普通 Dictionary 配合整个方法锁性能会下降得厉害因为所有操作都被串行化了用ConcurrentDictionary把锁粒度细化到桶级别吞吐量会好看很多。我自己的建议是如果只是读多写少且初始化后内容基本固定可以用 ImmutableDictionary 或者普通字典加只读标记只要有持续的并发写入直接选ConcurrentDictionary把心思放在业务逻辑上别过早做“精细锁”的设计。6.4 一个反直觉的点数据量很小时Dictionary 不一定更快字典并不是绝对意义上的“快”它付出了计算哈希码、处理冲突、维护内部数组等成本。当你的数据量只有几十条并且就是顺序遍历一遍就能完成的场景List 反而更简单更高效。我总是提醒自己性能优化必须有“感知”作为前提。如果页面不卡、接口不慢就不要为“未来可能的数据量”预支复杂度。先把结构写清楚等确认瓶颈在后加字典改造是更自然的路径。7. 字典在真实项目里的几种常见用法7.1 构建查询缓存避免重复访问数据源最常见的业务场景是缓存查询结果。比如订单状态是频繁使用的配置数据每次都查数据库不合适。可以启动时加载一次存入字典Dictionaryint, string statusNameMap new Dictionaryint, string(); // 从数据库或接口加载 statusNameMap[1] 待支付; statusNameMap[2] 已支付; statusNameMap[3] 已发货; statusNameMap[4] 已完成; // 使用的地方 string statusName statusNameMap.TryGetValue(order.StatusCode, out var name) ? name : 未知状态;用TryGetValue一行代码拿到友好名称读起来非常清晰。这种“码值翻译”的用法在报表、导出、展示层大量出现。7.2 分组统计与聚合字典经常用于对一组数据做分组统计。比如按日期统计订单金额总和Dictionarystring, decimal sumByDate new Dictionarystring, decimal(); foreach (var order in orders) { string dateKey order.CreateTime.ToString(yyyy-MM-dd); if (sumByDate.TryGetValue(dateKey, out decimal current)) { sumByDate[dateKey] current order.Amount; } else { sumByDate[dateKey] order.Amount; } }这段代码很好懂第一次遇到某个日期时当前值为 0就创建条目之后再遇到同一日期就在已有值上累加。这个模式我写了无数次数据再多也只遍历一次列表性能非常可控。顺带一提C# 的 LINQ 也有GroupBy可以做到类似效果但如果你还要做复杂处理手动遍历字典往往更直观。7.3 对象映射和批量转换在写数据处理工具时经常需要“用一个对象的某个唯一字段找到另一个对象”。比如你有客户 ID 列表和客户详细信息列表希望快速建立 ID 到客户信息的映射Dictionaryint, Customer customerMap customers.ToDictionary(c c.Id, c c);这样后续遍历 ID 列表时每次查找都是一次 O(1) 操作整体效率非常高。注意用ToDictionary时同样要求键不重复一旦出现重复 ID 会立即抛异常。如果你不确定数据源是否有重复先用GroupBy或Distinct清理或者循环手动写入并决定是否覆盖别让异常在运行中途炸出来。7.4 嵌套字典与复合结构当你要表达“部门 - 人员 - 薪资”这种层级关系时可以用嵌套字典Dictionarystring, Dictionarystring, decimal departmentPayMap new(); if (!departmentPayMap.TryGetValue(研发部, out var payMap)) { payMap new Dictionarystring, decimal(); departmentPayMap[研发部] payMap; } payMap[张三] 15000m;嵌套字典适合层级清晰、层级数量固定的数据关系。如果层级超过两层、或者存在复杂的遍历需求可读性会迅速下降那时我建议重新审视结构考虑定义一个小类或列表对象不要把字典当万能嵌套容器。我个人平时还有一个习惯在字典里存的不是散落的字段值而是完整的业务对象。比如存订单号对应订单对象而不是存订单号对应金额字符串。原因很简单做业务拆分时同一个订单对象在多个场景都要用我不需要为了取一个字段额外准备一套字典。提示使用TryGetValue时如果值类型是class需要注意取出的可能是空引用。先判断返回的 bool再使用对象避免空引用异常。字典这个东西边界清晰性能明确踩坑点又相对固定。写多了之后我大多数时候不需要再纠结数据结构看到需求基本条件反射式地选集合。不过这也正是字典的妙处它看起来简单用对了能让代码又短又稳。希望这篇能帮你少走一些我当年走过的弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →