做C#开发这几年,如果要我从常用集合里选一个“回本率”最高的,那一定是 Dictionary(字典)。它能用键值对存数据,按键访问元素,查找速度接近常数级别,数据量从几百涨到几十万,程序基本不会明显变卡。很多朋友学C#时最先接触的是数组和 List,等到业务里出现大量“按某个唯一标识找对应数据”的需求,数组和 List 就有点力不从心了。这篇博文就围绕 C# 字典(Dictionary)从零起步,把创建、初始化、增删改查、遍历、性能优化和避坑心得一次讲透。适合刚接触字典功能的新手,也适合用了一段时间但对内部细节不太确定的开发者。
1. 为什么说 Dictionary 是 C# 里最实用的集合类型
1.1 从一次重构说起:List 不够用的时候
前两年我给某后台管理系统写配置模块,最开始图省事,把所有配置项扔进一个 List ,每次要取某个配置的值,就写 foreach 从头到尾比对。当时配置项只有一两百条,跑起来还行,没觉得慢。后来业务扩展,配置项涨到几千条,而且每次请求都要查几十次配置,页面接口的耗时肉眼可见地上来了。
我用性能分析工具一看,卡点全在 List 的线性查找上。几千条数据其实不算多,但架不住频繁查,时间全耗在逐个比较上。后来把存储结构换成 Dictionary<string, ConfigItem>,以配置名为 Key,查询接口的耗时直接降了两个数量级,代码还更简洁。这次重构是我把 Dictionary 列为高频集合的原因之一。
其实这不是个别现象。只要你的需求是“根据某个唯一标识快速找到关联数据”,字典结构几乎就是最优解。数组和 List 也能做到“按索引找元素”,但它们的索引是数字下标,和业务里的“编号”“名称”往往对不上;就算强行用下标映射,还得自己维护一套关系,容易出错。
1.2 字典的核心机制:哈希、键值对和“查字典”这个比喻
Dictionary<TKey, TValue> 是 C# 里最典型的键值对集合。一个字典里可以存若干“键值对”,每个键对应一个值,键不允许重复。取数据的时候,只要提供键,就能得到对应的值。这个行为很像我们查纸质字典:根据单词(键)找到释义(值),而不是从头到尾翻页找。
那为什么字典查找这么快?因为它背后依赖哈希表(Hash Table)机制。插入一个键值对时,字典会先计算键的哈希码(Hash Code),再根据哈希码决定这个键值对应该放在哪个“桶”(Bucket)里。查找时同样先算哈希码,直接定位到对应的桶,再在桶里做少量比较。理想情况下,无论字典里有 100 条还是 10 万条数据,查找次数都差不多,时间复杂度接近 O(1)。这就是它和 List 线性查找 O(n) 的本质区别。
这里要提醒一点:哈希表不是魔法,它换取速度的代价是空间。字典底层会预留多个桶,哪怕只存一个键值对,也会占用一个默认大小的数组。所以在“数据量小、遍历为主”的场景里,字典不一定比 List 快;但在“随机按键查找”的场景里,字典的优势是压倒性的。
2. 从零上手:Dictionary 的创建、初始化与基础操作
2.1 创建一个字典的几种姿势
创建字典最常用的方式是直接实例化泛型类型,并指定键和值的类型:
// 键为 string,值为 int Dictionary<string, int> scores = new Dictionary<string, int>();也可以配合目标类型推断写法,让代码更短:
var scores = new Dictionary<string, int>();如果创建时就有一批已知数据,可以用集合初始化器一次性填充:
var scores = new Dictionary<string, int> { ["语文"] = 90, ["数学"] = 95, ["英语"] = 88 };等价于逐项赋值。注意集合初始化器里使用的语法是索引器赋值,不是 Add 方法,后面我会说这两者的区别。
还有一个容易被忽略但很实用的参数:初始容量。如果预先知道字典会存很多数据,建议在构造函数里告诉它:
var configMap = new Dictionary<string, string>(10000);为什么推荐这个?因为 Dictionary 内部数组不够用的时候会触发扩容,扩容要重新分配一块更大的数组,并把已有元素重新计算哈希、重新放入新数组。这个操作成本很高。给一个合理的初始容量,能减少扩容次数,等于用一次“预分配”换后续的平稳运行。
2.2 增删改查:最常用的四项操作详解
字典的增删改查看起来简单,但细节非常多。我按使用频率一个个说。
新增或修改值,最常用的是索引器:
scores["物理"] = 85; // 新增 scores["语文"] = 92; // 修改已有键的值索引器的特点是:键不存在就新增,键已存在就覆盖。这个语义在业务里非常自然,所以我一般建议“设置值”场景直接用索引器。
如果希望严格区分“新增”和“重复”,可以用 Add 方法:
scores.Add("化学", 78); scores.Add("化学", 80); // 这里会抛 ArgumentExceptionAdd 方法遇到重复键会直接抛异常,而不是静默覆盖。这个行为有利于发现数据问题,但同时要求调用方确保键一定不存在。在不明朗的情况下,可以先判断 ContainsKey 再决定分支。
删除使用 Remove 方法,它会返回 bool 值,表示是否真的删掉了某个键:
bool removed = scores.Remove("英语"); if (removed) { Console.WriteLine("删除成功"); }如果删除本来就不存在的键,Remove 不会抛异常,只会返回 false。这个设计很友好,避免了不少无谓的判断。
查找是字典用得最多的操作。最直观的写法是先用 ContainsKey 判断,再用索引器取值:
if (scores.ContainsKey("数学")) { int mathScore = scores["数学"]; }这段代码逻辑没错,但效率不是最优,因为 ContainsKey 和索引器各做了一次哈希查找。更好的做法是直接用 TryGetValue,一次查找同时完成“是否存在”和“取值”:
if (scores.TryGetValue("数学", out int mathScore)) { Console.WriteLine(mathScore); }这个写法在我日常开发里出现频率极高,可以说是字典最值得养成的习惯之一。
2.3 其他常用成员:Count、Clear、Keys、Values
字典还有一些常用成员,虽然不如增删改查频繁,但很实用:
- Count:获取字典中键值对的数量。
- Clear:清空所有键值对。
- ContainsKey:判断键是否存在。
- Keys:拿到所有键的集合。
- Values:拿到所有值的集合。
Keys 和 Values 经常配合遍历使用,比如只关心所有配置名,或者只关心所有分数。要注意它们返回的是集合视图,会跟着字典内容变化,不能整体替换。
var keys = scores.Keys; foreach (var key in keys) { Console.WriteLine(key); }到这里,字典的基本操作已经能覆盖大部分日常需求。接下来要谈的是真正影响代码质量和运行性能的部分:遍历、排序和内部实现细节。
3. 遍历、排序与性能优化:从“能跑”到“跑得稳”
3.1 遍历字典的几种正确姿势
字典遍历最普通的形式是 foreach 遍历 KeyValuePair<TKey, TValue>:
foreach (KeyValuePair<string, int> pair in scores) { Console.WriteLine($"{pair.Key}: {pair.Value}"); }这里的 pair 是只读的结构体,Key 和 Value 分别代表键和值。如果只需要键或只需要值,可以直接遍历 Keys 和 Values,减少一点无谓的解构:
foreach (string key in scores.Keys) { Console.WriteLine(key); } foreach (int value in scores.Values) { Console.WriteLine(value); }还有一类场景是用 LINQ 对字典做投影或过滤。比如把字典中满足某些条件的项转成列表:
var highScores = scores .Where(pair => pair.Value >= 90) .Select(pair => pair.Key) .ToList();这里要提醒:LINQ 的 Where、Select 返回的是延迟执行的序列,只有在真正遍历或调用 ToList 时才会执行。如果你在查询后面又修改了字典,再遍历结果时可能拿到的是新状态,容易引发一些隐蔽逻辑错误。所以一旦字典可能变化,尽量在查询后立即物化(比如 ToList)。
遍历时最需要注意的坑是:不能在 foreach 过程中直接修改字典。比如:
foreach (var pair in scores) { scores.Remove(pair.Key); // 运行时会抛 InvalidOperationException }这是因为字典内部维护了一个版本号,每次增删改都会让版本号变化,foreach 在迭代时发现版本号变了,就会认为集合被并发修改,主动抛出异常。我见过不少新手在这里踩坑,后来都改成先收集要删除的键,循环结束后再统一移除:
var keysToRemove = scores .Where(pair => pair.Value < 60) .Select(pair => pair.Key) .ToList(); foreach (var key in keysToRemove) { scores.Remove(key); }3.2 字典有没有顺序?怎么按 Key 或按 Value 排序
这个问题几乎每个接触字典的人都会问:字典能不能保持插入顺序?答案是:不能保证。Dictionary 底层的哈希表布局取决于哈希值和容量,遍历顺序可能是插入顺序,也可能完全不是,甚至同一份数据在不同版本的运行时里顺序都可能不同。所以绝对不要依赖字典的遍历顺序。
如果业务上需要有序输出,常见做法是先把字典转成列表,再排序:
var sortedByKey = scores .OrderBy(pair => pair.Key) .ToDictionary(pair => pair.Key, pair => pair.Value);但注意,ToDictionary 创建的新字典同样不保证顺序,排序只在列表阶段有效。如果只是想展示有序结果,应该保留 List 而不是转回字典:
var sortedList = scores .OrderBy(pair => pair.Key) .ToList();然后遍历 sortedList 输出。按 Value 排序就把 OrderBy 的参数改成 pair.Value,配合 Descending 还可以做降序。这里想表达的是:字典适合快速查找,排序是列表的强项,两者结合使用,各取所长。
3.3 性能优化的几个关键点
字典性能看似是框架层面的事,但使用方式会直接影响内部表现。
第一个关键点是前面提到的初始容量。一个经典场景:你从数据库里查出十万行数据,要放入字典。如果直接用无参构造函数,字典会在元素数超过容量时反复扩容,造成多次大数组拷贝和哈希重算。正确姿势是:
var idMapping = new Dictionary<string, OrderInfo>(rowCount);把预估的行数传给构造函数,让字典一次分配到位。这个优化在数据量大时效果非常明显。
第二个关键点是键的选择。字典通过键的哈希码定位桶,因此键类型必须保证哈希码稳定且分布均匀。string 和 int 这类不可变类型是理想选择。如果自定义类作为键,必须正确重写 Equals 和 GetHashCode,否则可能因为默认引用相等导致明明内容相同的对象却查不到值。
第三个关键点是避免不必要的重复查找。前面说过的 TryGetValue 就是典型改进:用一次查找代替 ContainsKey 加索引器两次查找。在一次循环里处理大量数据时,这种微优化会累积成明显差异。
还有一点和内存相关:字典的桶数组即便在 Clear 之后也不一定会立刻释放。Clear 会清空元素,但内部桶容量可能被保留以备后续复用。如果你在某个大字典清空后希望立即释放内存,可以调用 TrimExcess 方法,让容量收缩到接近实际元素数。不过这方法不是银弹,频繁调用反而会导致反复扩容,建议只在确定字典不会再增长时使用。
4. 踩坑实录:字典使用中的高频问题与排查技巧
4.1 KeyNotFoundException 其实可以完全避免
很多初学者一接触字典,就会在某个“凌晨三点”被这个异常问候:试图用索引器访问不存在的键时,Dictionary 会直接抛出 KeyNotFoundException。比如:
string value = configMap["notExistKey"]; // 抛异常这个异常本身很合理,但问题在于很多场景下“键不存在”是正常业务分支,而不是错误。我再强调一次,正确处理方式是 TryGetValue:
if (configMap.TryGetValue("notExistKey", out string value)) { // 只有键存在时才进来 } else { // 不存在时走这里 }如果只是想知道键是否存在,不想取值,用 ContainsKey;如果想取值又想避免异常,用 TryGetValue。我个人的习惯是优先 TryGetValue,因为它一次操作就完成了判断和取值,代码可读性和性能都好。
4.2 自定义类型做键:Equals 和 GetHashCode 必须同时重写
用 int、string、Guid 做键基本不会出问题,但用自定义类做键时,坑就来了。默认情况下,类使用引用相等,即两个对象即使字段完全相同,也被视为不同。如果直接把这个类当键,你会遇到“新建一个内容相同的对象去查字典却查不到”的情况。
解决办法是重写 Equals 和 GetHashCode,并且两个方法必须配套实现。规则是:Equals 返回 true 的两个对象,GetHashCode 必须返回相同的哈希码。常见的错误是只重写了 Equals,忘了 GetHashCode,结果字典里相同内容依然被分到不同桶,查找失败。还有一种更隐蔽的错误:把可变字段加入 GetHashCode 的计算,然后在字典使用过程中修改了这些字段,导致对象的哈希码变了,字典再也找不到它原来的位置。
所以我通常建议:如果用自定义类做键,最好把键设计成不可变类型,字段在构造后不再改变。如果做不到不可变,至少在字典生命周期内不要修改键对象的参与哈希计算的字段。
4.3 多线程并发读写:Dictionary 不是线程安全的
Dictionary 在多线程环境下如果同时有读写操作,可能出现数据错乱,甚至引发无限循环或者内存崩溃。这不是危言耸听。生产环境里,我见过一次因为多个线程同时往同一个 Dictionary 写入,导致程序挂起的案例,排查时发现异常信息非常诡异,后来才确认是并发写入造成的内部状态损坏。
处理并发有几个方案:
- 读多写少且写操作不频繁时,可以用锁保护字典。
- 标准做法是使用 ConcurrentDictionary<TKey, TValue>,它在读写路径上做了精细的并发控制,使用方式和 Dictionary 类似,但再增删方法上有差异。
- 如果数据是静态配置,启动时构建一次,之后只读不写,那 Dictionary 本身是安全的,不需要额外处理。
说说 ConcurrentDictionary 和 Dictionary 的差异。ConcurrentDictionary 不推荐用索引器直接赋值做“新增”,因为它在并发下会带来同步开销和不确定性;更推荐 AddOrUpdate、GetOrAdd 这些原子方法:
var cache = new ConcurrentDictionary<string, int>(); cache.AddOrUpdate("cpu", 10, (key, oldValue) => oldValue); var value = cache.GetOrAdd("cpu", 0);AddOrUpdate 的含义是:键不存在就添加第一个值,键存在就用委托更新。GetOrAdd 更直白:键不存在时添加并返回,键存在时直接返回已有值。这些都是并发字典独有的方法,能避免“先检查再操作”中间被别的线程插入数据的竞态问题。
4.4 版本号机制引发的 InvalidOperationException
前面遍历部分提到过,foreach 过程中不能修改字典。这个异常的本质是版本号机制。字典内部有一个版本计数器,每次增减改都会让计数器变化;foreach 开始时记录版本号,迭代时发现版本号不一致就直接抛出 InvalidOperationException。
这个机制的设计意图是防止迭代过程中集合状态变化导致不可预知的行为。遇到这个异常,不要尝试在循环里“取巧”修改,正确做法是先收集要变更的项,循环结束后统一操作。另一种办法是复制一份快照再遍历:
foreach (var pair in scores.ToList()) { // 现在可以安全修改 scores,因为遍历的是快照 }ToList 会生成一个独立的列表副本,对它遍历不会受原字典版本号变化影响。这在批量更新场景里很实用,代价是额外内存和时间,适合字典不大的情况。
5. 字典的选用边界:什么时候用它,什么时候换别的
5.1 Dictionary、List、HashSet 怎么选
很多开发者拿到需求,第一反应是“全部用 List 搞定”,但 List 在某些场景下会让代码越来越别扭。我整理了一个简单的选择参照:
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 按业务标识查找对应值 | Dictionary | 按键定位,O(1) 查找 |
| 只关心元素是否唯一存在 | HashSet | 自动去重,哈希查找 |
| 按顺序遍历、维护有序列表 | List | 索引访问、排序方便 |
| 键值对需要保持插入顺序 | Dictionary + List | 字典负责查找,列表负责顺序 |
这里的“Dictionary + List”是一种常见组合:用一个 List 保存顺序,用一个 Dictionary 保存“键到列表索引”的映射。比如要做一个支持“队首插入”和“按键定位”的结构,单靠 Dictionary 搞不定顺序,单靠 List 搞不定查找,组合起来就很好用。
5.2 数据量小的时候别迷信字典
我之前把字典吹得再厉害,也得说实话:如果数据量只有几条到几十条,并且大多是整体遍历,字典未必比 List 快。因为计算哈希、访问桶数组也有固定开销,数据量小的时候反而可能比逐个比较更慢。但这点性能差异在绝大多数业务里都可以忽略,代码可读性和维护性才是更重要的决策因素。
真正的决策点在于查找频率和语义。如果你反复做的就是“根据某个 ID 查对象”,哪怕数据量只有几千条,我也建议用字典。如果只是辅助展示、一次性排序,List 完全够用。选结构是看操作模式,不是只看数据量。
5.3 字典里可以放复杂类型吗
当然可以。字典的值可以是任何类型,包括自定义类、匿名类型或者其他集合。比如:
var orderMap = new Dictionary<string, List<OrderItem>>();这种“键到列表”的嵌套结构在实际业务里非常常见,比如一个客户对应多个订单,一个订单对应多个明细。往里面加数据时,要注意列表可能为空,需要先初始化:
if (!orderMap.TryGetValue(customerId, out var items)) { items = new List<OrderItem>(); orderMap[customerId] = items; } items.Add(newOrderItem);这里先尝试获取已有列表,没有就创建新列表并放回字典,然后把明细加进去。这个模式是我写业务逻辑时最常用的字典操作之一。
6. 几个能直接抄的实战片段
6.1 分组统计:统计单词出现次数
需求:给一篇文章,统计每个单词出现次数。用字典可以很自然地实现:
var wordCount = new Dictionary<string, int>(); foreach (var word in words) { if (wordCount.TryGetValue(word, out int count)) { wordCount[word] = count + 1; } else { wordCount[word] = 1; } }这个写法把“存在则加一,不存在则初始化为1”的逻辑写得特别清楚。如果觉得 if 太长,有一些更紧凑的写法,但新手建议先用这个版本,逻辑清晰优先。
6.2 构建缓存:按 ID 缓存对象
缓存是字典的高频场景,但要给缓存加容量限制,否则内存会一直涨。一个简单策略是:缓存超过上限时,按插入顺序清掉一部分。虽然 Dictionary 本身不保证顺序,但可以借助额外的 Queue 或 List 记录键的插入顺序:
var cache = new Dictionary<string, object>(); var accessOrder = new Queue<string>(); if (!cache.ContainsKey(id)) { cache[id] = data; accessOrder.Enqueue(id); if (cache.Count > maxCacheCount) { string oldestKey = accessOrder.Dequeue(); cache.Remove(oldestKey); } }这算是一个最简单的 LRU 雏形。实际项目里可以把缓存访问也记录进去,实现真正按“最近使用”淘汰,但上面这段已经能应付不少场景。
6.3 一行代码完成字典到列表的转换
字典转列表、列表转字典是经常要用到的套路:
// 字典转列表 List<KeyValuePair<string, int>> list = scores.ToList(); // 列表转字典(要求键不重复) Dictionary<string, int> dict = personList.ToDictionary(p => p.Name);ToDictionary 需要指定键的选择逻辑,如果列表里有两个相同键,会抛异常。在不确定数据唯一性时,先做去重或用 GroupBy 转成“键到列表”的字典:
var grouped = personList.GroupBy(p => p.Name) .ToDictionary(g => g.Key, g => g.ToList());这样即使有同名的人,也不会丢失数据。
写在最后:我的几个使用习惯
写到这里,字典的基础、进阶和实战差不多都覆盖了。我自己在实际项目里慢慢养成了一些小习惯:能用 TryGetValue 就不用 ContainsKey 加索引器组合,能预估容量就尽量给构造函数传容量,自定义键类型永远先想 Equals 和 GetHashCode 是否配套,多线程并发优先考虑 ConcurrentDictionary 而不是“先加锁试试”。这些习惯不是一开始就会的,都是踩过坑、看过诡异异常、翻过源码之后一点点积累起来的。
字典这个结构要说玄也玄,说简单也简单。它其实就是一组“键到值”的映射关系,只是背后的哈希表让它跑得快。掌握它不需要记住太多 API,真正要理解的是键的唯一性、哈希的稳定性、迭代的版本号约束这三大原则。把这三个原则刻在脑子里,再用本文里的代码多跑几遍,零基础入门到日常熟练,不需要太久。