
做后端久了你迟早会遇到一个问题多个线程同时读写同一个字典稍不留神就碰见“Collection was modified”或者拿到一份脏数据。我最早踩这个坑的时候第一反应是给 Dictionary 加一把 lock后来发现锁一旦上多吞吐量直接崩尤其在读写比例接近 1:1 的时候整个接口的延迟都会跟着遭殃。后来换到 ConcurrentDictionary 才把这块彻底兜住。C#.NET 里的 ConcurrentDictionaryTKey, TValue 是 System.Collections.Concurrent 命名空间下最常用的并发集合之一从 .NET Framework 4.0 开始提供。它的设计目标很明确用更细粒度的锁和无锁读路径替代粗暴的全表锁让多线程环境下“读多写多”的字典操作保持高吞吐。这篇内容会从底层设计、核心 API、缓存实战、参数调优、常见问题五个方向展开把原理和踩坑心得一次讲透。不管你是刚接触并发编程的新手还是已经用 ConcurrentDictionary 写过缓存的老手这篇文章应该都能帮你把这块拼图补完整。1. 为什么并发字典不能靠“Dictionary lock”硬扛1.1 全表锁的三个致命问题很多人一听到线程安全脑子里第一个反应就是加锁。早年我在项目里确实也是这样写的private readonly Dictionarystring, string _dict new(); private readonly object _lock new(); public string Get(string key) { lock (_lock) { return _dict.TryGetValue(key, out var value) ? value : null; } } public void Set(string key, string value) { lock (_lock) { _dict[key] value; } }这套写法在低频场景下没有问题但一旦进入高并发三个问题会立刻暴露第一是锁的粒度太大。整个字典只有一把锁任何线程做任何操作都要排队。读操作本来可以并发执行结果也被锁串行化了这在高并发读场景下非常浪费。第二是读写互相阻塞。写操作持锁期间读操作只能等着读操作持锁期间写操作也只能等着。哪怕你用的是 ReaderWriterLockSlim读写之间的切换成本也比想象中高。第三是锁的争用会放大延迟。当两个线程同时想要这把锁CPU 会进入锁等待和上下文切换线程数量越多争用越剧烈响应时间越不稳定。Hashtable 的问题更直接。它在内部对整个表加的是 Monitor 级别的锁读和写都需要锁性能在并发场景下几乎是最差的选择。除非是维护老代码否则我不建议再用它扛任何新的并发需求。1.2 ConcurrentDictionary 的总体设计思路ConcurrentDictionary 改变的并不是“要不要锁”而是“锁锁住多少东西”。它没有用全表锁而是把字典拆分成多个桶bucket每个桶对应一个独立的锁。键值对根据哈希值被分配到不同的桶里。写操作只需要锁住目标桶其他桶的操作完全不受影响读操作大多数情况下连锁都不用拿直接沿着链表查找。这个思路本质上就是“分而治之”。类比一下一家餐厅如果只有一个收银台高峰期所有人都排队付款ConcurrentDictionary 的做法是开多个收银窗口不同窗口各管各的队列顾客按自己的订单号分流到不同窗口自然快很多。它对外还提供了一批原子操作比如 GetOrAdd、AddOrUpdate、TryUpdate。这些 API 的内部实现保证了“检查再插入”“检查再更新”这类逻辑是原子的。也就是说你不需要先 TryGetValue 判断一下再 Add因为那样中间插入了竞争窗口直接用 GetOrAdd 才对。这套设计的代价是内存占用比普通字典大逻辑也更复杂。所以它并不是“线程安全的 Dictionary”这么简单而是一个在并发场景下做了大量权衡的数据结构。2. 底层原理拆解那些面试不会细讲的地方2.1 Node 桶数组 独立锁数组看 ConcurrentDictionary 的核心实现主要围绕三个内部概念展开Node、桶数组、锁数组。Node 是存储键值对的最小单元。它大概类似这样internal sealed class Node { internal readonly TKey Key; internal TValue Value; internal volatile Node Next; internal readonly int Hashcode; }每个 Node 都保存了键、值、下一个节点的引用和哈希码。同一个桶里的键值对会用链表串起来。桶数组是 Node[]用来承载链表头。多个桶会共享一把锁原因很简单如果每个桶配一把锁内存开销太大而且很多时候桶内根本还没有数据所以 ConcurrentDictionary 维护了一个更小的锁数组通过哈希值把桶映射到锁上。锁数组的长度和并发度相关。默认并发度是 CPU 的逻辑核心数也就是 Environment.ProcessorCount。这个值会影响锁数组大小但并不是越大越好。如果你把并发级别设置成 64但实际只有一个 4 核的机器大部分锁对象都是闲置的还会白白浪费内存。扩容时ConcurrentDictionary 会把桶数组翻倍把所有节点重新哈希到新的桶里。这个动作内部会同时获取所有锁所以扩容瞬间会有一段时间的写阻塞但读操作仍然可以继续不会出现“整个字典不能用”的情况。2.2 读路径为什么可以不加锁读路径不加锁核心依赖是引用类型赋值和有序读写的“可见性”保证。节点对象一旦被挂在桶里它的 Key 和 Hashcode 就不会变了Value 字段允许被替换但是通过 volatile 或类似机制保证写入的有序性和可见性。读线程执行 TryGetValue 时会先拿到当前的表引用再拿到目标桶的链表头然后遍历查找 key。因为节点的 Key 是不变的即使写线程同时在做插入或删除读线程也只会看到一个“一致的中间状态”不会读到半初始化对象也不会死锁。这就像你在看一个公告板有人正在往上面贴新公告但你看到的每条公告要么是贴完的完整版要么是还没贴的状态不会看到一张贴了一半的纸。Volatile 和内存屏障保证的就是这种“原子粘贴”效果。不过不加锁的读路径也意味着它不保证“读到的就是最新值”。如果写线程刚把节点挂上去读线程可能还在用之前看到的表引用所以严格来说ConcurrentDictionary 提供的是线程安全不是“强一致性快照”。在绝大多数缓存、配置、状态管理场景里这种弱一致性完全够用。2.3 写路径如何保证原子写路径稍微复杂一些。以 Add 为例流程大致是计算 key 的哈希码根据哈希码定位桶和对应的锁然后加锁再在桶内遍历链表。如果 key 已经存在根据操作类型决定是覆盖还是报错如果不存在就创建一个新 Node 并把链表头指向它。这里有个关键点ConcurrentDictionary 不是“无锁数据结构”它是有锁的只是锁的粒度非常细。和 Java 的 ConcurrentHashMap 不同它的桶和锁不是一一对应的而是多个桶共享一把锁。这样锁的数量可控内存占用合理。扩容时它会尝试获取所有锁然后重新分配桶数组。这也是为什么在高并发写场景下频繁扩容对性能影响很大。后面我会专门聊怎么做可以减少扩容带来的损失。3. 核心 API 实战高频操作的选型与细节3.1 读、写、删的基本姿势ConcurrentDictionary 的 key 不允许为 null传入 null 会直接抛 ArgumentNullException。value 可以为 null但只要你想把 null 作为“不存在”的判断依据就会踩坑。所以我的习惯是字典里的 value 尽量用引用类型或者可空类型并且不要把 null 当成有效值。TryGetValue 是读取的标准姿势if (cache.TryGetValue(key, out var value)) { // 使用 value } else { // 未命中 }Add 方法在新 key 已存在时会抛 ArgumentException所以在并发场景下直接调用 Add 是不太安全的。我希望你优先考虑 TryAddbool added dict.TryAdd(key, value); if (!added) { // key 已存在按需处理 }TryRemove 的常见用法有两种。一种是按 key 删bool removed dict.TryRemove(key, out var removedValue);另一种是按“键值对”删只在 key 对应当前值时才删除。这个重载在缓存淘汰场景里非常有用bool removed dict.TryRemove(new KeyValuePairTKey, TValue(key, entry));第二种用法可以避免误删别人刚更新的新值。我在缓存代码里经常依赖它做“比较后删除”的原子操作。3.2 GetOrAdd 和 AddOrUpdate 的原子语义与委托陷阱GetOrAdd 是缓存场景的王牌 API。它做的是“如果 key 不存在就根据委托生成值并加入字典如果 key 已经存在就返回已有值”。整个判断和执行在内部是原子的所以不需要自己加锁。但很多人没注意到一个坑valueFactory 委托在高并发下可能被多个线程同时调用并且每个线程都会执行一次完整的 factory 逻辑。ConcurrentDictionary 保证的是“最终字典里只有一个键值对”但不保证“factory 只被调用一次”。看这段代码int factoryCalls 0; var value dict.GetOrAdd(key, k { Interlocked.Increment(ref factoryCalls); return FetchFromDatabase(k); });如果 8 个线程同时调用factoryCalls 可能大于 1。每次 factory 都真的会去查数据库或调远程接口。这就是缓存击穿风险的来源。想要避免这个风险通用的做法是不要把真正的昂贵计算直接写在 GetOrAdd 的委托里而是在 value 里包一层 Lazy 。我先留个悬念第 4 部分会给出一个完整的缓存实现。AddOrUpdate 和 GetOrAdd 类似更新委托同样可能被多次调用。它适合做计数器累加这类场景dict.AddOrUpdate(visitCount, 1, (key, oldValue) oldValue 1);第一个参数是 key第二个是 key 不存在时写入的初始值第三个是存在时的更新逻辑。这个 API 保证从“取旧值算新值写回”整个过程在锁内完成比你先 TryGetValue 再赋值的写法安全得多。3.3 TryUpdate被低估的条件更新 APITryUpdate 做的事情很有意思只有当前值等于期望值的时候才执行更新。它等价于一个原子版的“比较再交换”类似于并发编程里的 CAS。bool updated dict.TryUpdate(key, newValue: new, comparisonValue: expected);如果 key 当前的 value 等于 expected就替换成 new返回 true否则什么都不做返回 false。这个 API 在实现乐观并发、防止“丢失更新”时非常好用。举个例子。你维护一个配置项希望“只在用户没改过的情况下更新默认值”就可以用 TryUpdate 来做判断。如果你用“先 TryGetValue 再 CompareExchange 再赋值”中间肯定会有竞争窗口而 TryUpdate 把这三步收敛成了一个原子操作。它在缓存淘汰里也很好用用一个 Entry 对象持有数据和时间戳过期后只要 Entry 引用没变就能安全替换如果引用变了说明有其他线程先做了淘汰那就放弃当前操作。3.4 Count、IsEmpty、Keys 的隐藏开销Count 属性返回的确实是精确值但实现里要汇总每个桶锁维护的计数开销不小。在并发写频繁的场景里频繁调用 Count 会让性能明显下降。如果只是想知道字典是否为空应该用 IsEmpty它走的是快速判断不需要拿所有锁。Keys 和 Values 这两个属性要小心。它们返回的是集合快照还是视图不同 .NET 版本上的行为有差异。我个人的习惯是如果后续要稳定地遍历某一时刻的 key 列表直接 ToArray() 或 ToList() 转出来不要在遍历的同时依赖字典的实时内容。ConcurrentDictionary 的枚举不会像普通 Dictionary 那样抛“集合已修改”的异常但它也不会给你一个冻结在某一时刻的完美快照。所以对“当前到底有多少个 key”“刚才那一刻的 key 列表是什么”这类问题一定要先明确你是不是真的需要精确值。大多数缓存统计场景一个近似值就够了。4. 实操项目从 0 到 1 构建一个高性能并发缓存4.1 需求与方案选型假设我们要做一个带过期时间的本地缓存。功能要求不复杂外部传入一个 key 和加载函数第一次访问时加载数据并缓存缓存过期后下一次访问重新加载高并发下同一个 key 不能因为缓存失效就把后端服务打爆。这个需求非常适合 ConcurrentDictionary。因为它的 GetOrAdd 能在 key 不存在时做原子初始化它的 TryRemove(KeyValuePair) 能在淘汰时避免误删新值。再加上 Lazy 解决“多个线程同时执行 factory”的问题基本就是教科书级别的组合。我直接跳过 HashMap 和普通 Dictionary 方案原因前面已经说过了并发读写场景下普通字典加锁的吞吐量很快会成为瓶颈。4.2 第一个版本与它的坑先看最直接的写法public class SimpleCacheTKey, TValue { private readonly ConcurrentDictionaryTKey, TValue _cache new(); private readonly FuncTKey, TValue _factory; public SimpleCache(FuncTKey, TValue factory) { _factory factory; } public TValue Get(TKey key) { return _cache.GetOrAdd(key, _factory); } }这个版本的问题是并发调用 Get 时如果 key 不在缓存中factory 会被执行很多次。假设 100 个线程同时打到这个 key数据库可能被 100 个请求打到。因为 GetOrAdd 的 valueFactory 并不是“只执行一次”的保证。如果 factory 的开销很大查数据库、调远程接口、做复杂计算这个版本的缓存等于形同虚设。别人一发起热点请求后端就容易被击穿。4.3 Lazy 解决防击穿正确的做法是把值包进 Lazy public class ConcurrentCacheTKey, TValue { private readonly ConcurrentDictionaryTKey, LazyTValue _cache new(); private readonly FuncTKey, TValue _factory; public ConcurrentCache(FuncTKey, TValue factory) { _factory factory; } public TValue Get(TKey key) { var lazy _cache.GetOrAdd(key, k new LazyTValue(() _factory(k))); return lazy.Value; } }这段代码的关键在于 Lazy 的默认执行模式。多个线程可能创建出多个 Lazy 实例但字典只保留其中一个。所有线程拿到的是同一个 Lazy 实例之后访问 Lazy.Value 时CLR 会保证内部 factory 只执行一次。也就是说即使有 100 个线程同时 Get实际查数据库的只有一个线程其他线程等着拿同一个结果。这就是防击穿的核心。唯一要留意的是Lazy 默认模式在 factory 抛异常时会缓存异常。也就是说如果第一次加载数据库就失败了后续所有线程拿到的都是同一个异常。如果你的业务希望“失败后下一次重试”就需要在 catch 里主动删除缓存项或者用自定义的带状态包装类而不是直接用默认 Lazy 。4.4 加入 TTL 与惰性淘汰接下来加入过期时间。ConcurrentDictionary 本身没有 TTL 机制我们需要在 Entry 里记录创建时间在 Get 时判断是否过期。过期后不直接返回旧值而是尝试替换。我给出的实现里用了一个私有 Entry 类内部持有 Lazy 和创建时间public class ExpiringCacheTKey, TValue where TKey : notnull { private sealed class Entry { public required LazyTValue Lazy { get; init; } public required DateTimeOffset CreatedAt { get; init; } } private readonly ConcurrentDictionaryTKey, Entry _cache new(); private readonly FuncTKey, TValue _factory; private readonly TimeSpan _ttl; public ExpiringCache(FuncTKey, TValue factory, TimeSpan ttl) { _factory factory; _ttl ttl; } public TValue Get(TKey key) { var now DateTimeOffset.UtcNow; var entry _cache.GetOrAdd(key, k new Entry { Lazy new LazyTValue(() _factory(k)), CreatedAt now }); if (now - entry.CreatedAt _ttl) { var current entry; if (_cache.TryRemove(new KeyValuePairTKey, Entry(key, current))) { entry _cache.GetOrAdd(key, k new Entry { Lazy new LazyTValue(() _factory(k)), CreatedAt DateTimeOffset.UtcNow }); } else if (!_cache.TryGetValue(key, out entry) || entry null) { entry _cache.GetOrAdd(key, k new Entry { Lazy new LazyTValue(() _factory(k)), CreatedAt DateTimeOffset.UtcNow }); } } return entry.Lazy.Value; } }这段逻辑重点在两个地方第一使用 TryRemove(KeyValuePair) 重载确保我只删除“自己看到的那个过期 Entry”。如果别的线程已经把它替换成新 EntryTryRemove 会返回 false我不会误删新数据。第二TryRemove 返回 false 后我会再 TryGetValue 拿最新 Entry如果拿不到说明 key 刚刚被删了那就再 GetOrAdd 一次。整个过程不会对字典加外部锁也不会出现删掉别人新值的问题。再强调一下这个实现属于“惰性淘汰”。过期条目不是被后台线程主动扫描删掉的而是被下一次 Get 触发淘汰的。如果缓存数据量很大而且大量 key 不再被访问它们会一直留在字典里占用内存。要处理这种情况可以额外起一个定时任务定期清理过期 Entry或者边遍历边比对 CreatedAt。具体清多频繁取决于你的业务可接受的内存增长。5. 参数、性能与内存权衡5.1 concurrencyLevel 和 capacity 怎么选大多数情况下不传任何构造参数直接用 new ConcurrentDictionaryTKey, TValue() 就够了。默认的并发度取的是当前机器 CPU 核心数默认的初始容量是一个较小的值适合绝大多数业务。但有一个场景我建议主动指定参数预先能估计到数据规模。比如你要一次性灌入 10 万个配置项那么用new ConcurrentDictionarystring, string(concurrencyLevel: Environment.ProcessorCount, capacity: 100000)会更合适。为什么 capacity 要大因为扩容的代价很高。字典容量不够时会发生扩容扩容需要分配新的桶数组、把已有节点重新哈希、挂到新桶上。如果你预先知道最终规模直接给一个够大的初始容量可以省掉多次扩容带来的停顿和内存浪费。concurrencyLevel 这个参数很多人会误以为设得越大越好。其实它只影响内部锁数组的长度。锁数组太长会浪费内存太短会导致多个桶共享一把锁、增加锁竞争。我的经验是除非你的机器核心数特别高否则默认值就是最优的。如果你在旧版 .NET Framework 上开发倒是有可能需要手动设置这个参数因为旧版本的默认并发度和当前版本的处理逻辑不太一样。5.2 内存开销与扩容代价ConcurrentDictionary 的内存开销比普通 Dictionary 大不少。每一个键值对对应一个 Node 对象这个对象除了 key 和 value还要保存 Next 引用和哈希值。再加上桶数组本身、锁数组、计数器数组整体内存占用可能是 Dictionary 的好几倍。如果你缓存的是海量的小对象比如几百万个 int 到 int 的映射用 ConcurrentDictionary 存储并不划算。这种情况下可以考虑分段的使用多个普通 Dictionary每个字典自己带锁或者干脆换用更专门的高性能结构。扩容期间写线程会短暂阻塞这也是线上性能抖动的一个隐患。如果服务流量有明显的高峰低谷可以在低谷期预热缓存提前把 key 塞进去让扩容发生在低压力时段。另外特别提醒一下ConcurrentDictionary 的默认构造在首次使用时也会有一段初始化成本。如果你在静态字段里保存一个空的 ConcurrentDictionary第一次访问时才会真正分配桶数组和锁数组这个时间点可能恰好撞上一个请求峰值。对于极端的性能要求可以用预构造的方式提前触底初始化。5.3 什么时候不要用 ConcurrentDictionary不是所有并发字典场景都适合 ConcurrentDictionary。下面这几类场景我会选别的方案如果你需要“多个操作组合在一起原子完成”ConcurrentDictionary 管不了。比如“先检查 key A再读取 key B然后决定是否更新 key C”这三个操作不能在一个并发字典 API 里完成。这时候必须引入外部锁或者换用支持事务的数据结构。如果每个 value 本身是一个需要频繁修改内部字段的可变对象ConcurrentDictionary 只保证“value 引用的读写是线程安全的”不保证“对象内部状态的线程安全性”。你在多线程里同时修改同一个对象实例的字段该加锁还是要加锁。如果是纯统计计数场景比如每秒钟几十万次的加法ConcurrentDictionary 并不是最优解因为每次 AddOrUpdate 都要走哈希和锁。更高效的做法是使用多个普通计数器分片配合 Interlocked 或原子内存操作最后汇总。如果你的数据只是写一次、后面几乎不读或者几乎不写、只有并发读也可以考虑用 ImmutableDictionary 加 ReaderWriterLockSlim 之类的组合在某些场景下吞吐表现会更好。6. 常见问题速查与排查记录6.1 GetOrAdd 的委托被调用多次这是问得最多的问题。你发现 GetOrAdd 的 valueFactory 在高并发下执行了多次以为是 bug。其实这是设计如此。GetOrAdd 只保证“最终字典里只有一个值”不保证 factory 只执行一次。修复方法就是前面说的 Lazy 包装。不要在 valueFactory 里写有副作用的代码比如发短信、记日志、写文件。这类操作一旦并发执行后果是意外且不好排的。如果你的 factory 逻辑要求绝对只能执行一次建议用 Lazy 或类似的一次性初始化机制而不是依赖 GetOrAdd 本身。6.2 复合操作不原子导致的数据错乱有一个反模式经常出现在老代码里if (!dict.ContainsKey(key)) { dict.TryAdd(key, value); }这不是原子的。两个线程可能同时判断 ContainsKey 返回 false然后都执行 TryAdd最后只有一个线程成功另一个线程白白做了一次无用功。正确写法是直接 TryAdd然后根据返回值判断是否插入成功。另一个类似的错误是if (dict.TryGetValue(key, out var old)) { dict.TryUpdate(key, newValue, old); }TryGetValue 和 TryUpdate 之间key 对应的值可能已经变了你用旧值作为 comparisonValue 会导致更新失败。这种代码从语义上说是安全的只是执行结果可能不符合预期。如果你真的想做“读改写”就应该用 AddOrUpdate 或循环配合 TryUpdate。6.3 线上 CPU 飙高和内存增长的排查思路如果线上服务 CPU 突然飙高怀疑是 ConcurrentDictionary 的锅我一般按这个顺序排查先看是不是有热点 key。某个 key 的访问频率远高于其他 key导致所有线程都在抢同一个桶的锁。缓解方式是对热 key 做本地拆分或给 value 加二级缓存。再看是不是扩容太频繁。如果字典容量增长很快扩容会反复发生。可以在代码里临时打印 Count 和内部桶数组大小的日志或者直接在构造时给一个大一点的 capacity观察是否缓解。再看是不是 Count 被高频调用。Count 会汇总所有段的计数频繁调用会造成锁等待。如果是代码里可以日志加一个开关或者改成后置上报。内存增长的排查则是另一个方向。如果你的缓存条目没有过期清理机制那么只要是缓存内存就一定涨。判断是“尿崩式增长”还是“正常保留”要结合业务 key 规模来看。ConcurrentDictionary 自己不会主动淘汰任何条目所以“缓存击穿、内存爆炸”这类问题根因往往在业务代码不在字典本身。6.4 易踩的边界点序列化、枚举与 nullConcurrentDictionary 的枚举器不会抛普通 Dictionary 那种“集合已修改”的异常因为它内部通过桶和链表遍历修改过程中只是可能看到一部分旧值、一部分新值。但这不代表你可以依赖这种“半实时”的枚举结果。需要某一时刻的稳定数据时先用 ToArray() 生成快照。序列化时需要留意不同 .NET 版本之间的序列化结果可能不兼容。特别是使用 BinaryFormatter 这种已经不建议使用的组件在新旧运行时之间搬运 ConcurrentDictionary 容易出问题。如果要把字典内容发给下游我更建议转成普通 Dictionary 或者 JSON 再传。还有一个隐蔽点键的相等比较器。ConcurrentDictionary 默认使用键类型的默认比较器。如果你改了默认比较器的行为或者传入了自定义 IEqualityComparer那么哈希值的计算、扩容时重新分配桶的路径都会受影响。遇到“同一个 key 居然被放进不同桶”这类诡异问题先检查比较器的 GetHashCode 是否稳定。最后再分享一个实操经验我自己实际用 ConcurrentDictionary 做缓存时最深的体会是线程安全的集合只能保证“字典内部不会坏”不能保证“业务逻辑一定对”。真正容易出问题的从来不是字典本身而是我们对“原子”二字的理解。GetOrAdd 不等于工厂方法只跑一次TryRemove 不等于任意删除Count 也不等于便宜的操作。如果你从这篇文章里只记住一句话那就是先把并发语义想清楚再选 API遇到缓存击穿第一时间想到 Lazy 遇到条件更新第一时间想到 TryUpdate。 C#.NET 的 ConcurrentDictionary 是一个趁手的工具但用对它靠的还是对这些边界条件的敬畏。