一、 Dictionary<K, V> 的底层双数组架构与扩容

现代C#开发里我们基本都在用 Dictionary<K, V>,它之所以查询高效且支持泛型,离不开它底层的双数组设计:

1
2
private int[]? _buckets;    // 桶数组:存的是 entries 数组的索引下标
private Entry[]? _entries; // 条目数组:实际存 Key-Value 数据和链表指针

1. 它是怎么处理哈希冲突的?

当两个不同的键通过 GetHashCode() 计算出来的哈希值取模后,定位到了同一个 bucket(即发生哈希冲突),Dictionary 采用的是拉链法(链地址法)

它不会满地去找空桶,而是让新条目的 next 指针指向老条目,在_entries数组内部组成一个单向链表

2. 扩容时的隐藏开销

当数据写满(_count == _entries.Length)时,字典会触发动态扩容。

扩容的操作非常重:不仅是把数组容量翻倍,最消耗性能的是它需要对所有老数据重新计算哈希值,并在新数组里重新分配桶(Rehash)

避坑策略:如果明确知道要从数据库或者配置文件里一次性加载几千条映射数据,初始化时一定要指定初始容量,比如 new Dictionary<string, int>(1000);,一次性到位,避免中途高频扩容带来的内存拷贝开销。

二、 Hashtable 与 Dictionary 的本质区别

有些老代码里还能看到 Hashtable 的影子,它们俩除了泛型之外,底层的冲突解决机制完全不同:

特性 Hashtable Dictionary
类型安全 非泛型(键值全是 object 泛型(类型安全)
冲突处理 开放寻址法(冲突了就按公式找下一个空桶) 拉链法(冲突了在内部拉单向链表)
装箱拆箱 存取值类型(如int)时会频繁装箱拆箱,吃内存 泛型设计,完全没有装箱拆箱开销
多线程安全 默认不支持并发写(并发读写得用Synchronized 同样不支持并发读写,多线程会把链表指针搞错

结论:新项目一律首选 Dictionary(或者多线程下用 ConcurrentDictionary),旧的 Hashtable除非是为了兼容老古董系统,否则不需要再用了。

三、 实际编写代码时的几个典型大坑

1. 滥用 Add() 和强行索引取值

之前我很喜欢直接用 Add() 写入或者用 dict[key] 强行取值,但这在生产环境就是定时炸弹。

  • 如果键在字典里已经存在了,调用 Add() 会直接抛 ArgumentException 异常崩溃。
  • 如果键不存在,直接用 dict[key] 取值会直接抛 KeyNotFoundException 报错闪退。

安全的规范写法:

1
2
3
4
5
6
7
8
9
10
// 1. 读取:一律用 TryGetValue,安全且拿结果方便
if (dict.TryGetValue("Sensor_01", out int value)) {
// 拿到了 value 再做业务
}

// 2. 写入或更新:直接用索引器赋值(存在则覆盖旧值,不存在则创建)
dict["Sensor_01"] = 100;

// 3. 纯添加(.NET Core+):用 TryAdd,键存在就返回 false,绝不报错
dict.TryAdd("Sensor_02", 200);

2. 在 foreach 循环里做 Remove 操作

有时候需要把字典里一些过期或者为 null 的数据清理掉,如果直接在遍历里删数据,百分之百报 InvalidOperationException崩溃。

1
2
3
4
// 错误示范:迭代器会直接报错
foreach (var kvp in dict) {
if (kvp.Value == null) dict.Remove(kvp.Key);
}

正确解法:遍历的时候只负责把需要删除的 Key收集到一个临时的 List 里。等遍历彻底结束后,再单独跑一个循环去逐个 Remove 掉这些收集好的键。

3. 多线程并发读写没有加锁

普通的 Dictionary 内部结构很紧凑,多线程同时往里面无序写入时,如果恰好遇到同时扩容或者修改同一个单向链表,会把底层指针直接指乱,导致 Count 数量变小、数据丢失,甚至导致程序死循环卡死。

  • 避坑:涉及多线程数据并发读写,要么套一层 lock 锁,要么直接换成线程安全的 ConcurrentDictionary<K, V>

四、 项目实战:用字典实现一个简单的带过期缓存

在做上位机数据轮询或者后端配置缓存时,我们经常用字典做本地缓存。为了防止内存无限堆积,需要结合时间戳做一个过期的剔除机制。

写了一个比较通用的简化版本地缓存类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LocalCache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, CacheItem> _cache = new();
private readonly TimeSpan _lifeTime; // 缓存生存周期

public LocalCache(TimeSpan lifeTime) => _lifeTime = lifeTime;

public void Set(TKey key, TValue value)
=> _cache[key] = new CacheItem(value, DateTime.UtcNow); // 统一用 UTC 时间,防时区坑

public bool TryGet(TKey key, out TValue? value)
{
if (_cache.TryGetValue(key, out var item))
{
// 检查有没有过期
if (DateTime.UtcNow - item.CreatedAt <= _lifeTime)
{
value = item.Value;
return true;
}
// 过期了就顺手拔掉,释放内存
_cache.Remove(key);
}
value = default;
return false;
}

private class CacheItem
{
public TValue Value { get; }
public DateTime CreatedAt { get; }
public CacheItem(TValue value, DateTime createdAt)
=> (Value, CreatedAt) = (value, createdAt);
}
}

五、 个人总结

  1. 写字典存取要带上“防御性”思维:不能想当然觉得数据一定存在或者一定不重复。多写一行 TryGetValue 或使用索引器直接赋值,能省掉后续大把排查线上死机的时间。
  2. 多线程环境要敏感:写代码时只要看到多线程并行(如 ParallelTask)操作集合,脑子里第一反应就应该是去看这个集合是不是线程安全的,不是的话立马加锁或换 Concurrent 家族组件。
  3. 容量预估是个好习惯:不只是 StringBuilder,字典的底层扩容也是极耗资源的操作。在初始化时根据数据规模多给一个初始参数,能减少很多隐藏的性能开销。