Dictionary底层原理
本篇文章将介绍C#在.NET下的Dictionary的底层源码,源码都根据自己的理解加上了注释,源码直接到官网即可查看下载https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs
关键数据结构 Entry
private struct Entry {
public int hashCode; // 哈希值,-1代表此Entry未使用
public int next; // 下一个Entry的索引,-1说明此Entry为末尾
public TKey key; // Key of entry
public TValue value; // Value of entry
}
重要变量
private int[] buckets; // Hash桶,存储Entry下标
private Entry[] entries; // Entry数组,存放元素
private int count; // Entries当前的下标
private int version; // 当前版本,防止迭代过程中集合被更改
private int freeList; // 被Remove的Entry的头节点下标
private int freeCount; // 有多少个被删除的Entry,有多少个空闲的位置
private IEqualityComparer<TKey> comparer; // 比较器
private KeyCollection keys; // 存放Key的集合
private ValueCollection values; // 存放Value的集合
初始化 Initialize
private void Initialize(int capacity) {
//buckets和entries的size为大于容量的最小质数
int size = HashHelpers.GetPrime(capacity);
buckets = new int[size];
//初始所有桶都没有记录 值为-1
for (int i = 0; i < buckets.Length; i++) buckets[i] = -1;
entries = new Entry[size];
freeList = -1;
}
- 注意点:Hash桶的容量为什么要大于容量的最小质数
- Hash桶的容量大于等于给定的容量无可厚非
- Hash桶的容量必须为质数,原因跟哈希函数密切相关,如果哈希函数选择的好对容量也就没有这么多限制,比较常见的哈希就是将哈希值与桶容量取余,涉及到除法就有可能为整除,加入桶容量为15 ,某关键字和15取余映射到0,且其步长为5,如果0 5 10三个位置都已经被占用那么其只会不停的0 5 10 0 5 10 … 0 5 10,会造成死循环程序的崩溃。
增加Add 和 修改
Dictionary提供了公开Add方法,同时也提供了对操作符 [ ] 的重载,下面是两者的不同
- Add只能进行增加,如果主键重复会进行报错,方法内部调用的是Insert(key, value, true);
- 操作符[ ]可增加,可修改,主键重复即是修改,方法内部调用的是Insert(key, value, false);
private void Insert(TKey key, TValue value, bool add) {
if( key == null ) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets == null) Initialize(0);
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
int targetBucket = hashCode % buckets.Length;
//这一步是为了判断是否需要修改
//根据哈希值找到对应的桶,进而找到桶记录的相应的Entry下标,由于可能哈希冲突所以要循环
for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next) {
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
//已经存在,若是add调用则抛出错误,否则进行修改并返回即可
if (add) {
ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
}
entries[i].value = value;
version++;
return;
}
}
//接下来的逻辑是不存在时,应该将其赋给一个空闲的Entry,并做相应的哈希运算
//index为将要分配给新Entry在Entries中的位置
int index;
//freeList记录着被删除的Entry链表的头节点索引,freeCount是个数,优先将新Entry分配到之前被删除的Entry位置
if (freeCount > 0) {
index = freeList;
freeList = entries[index].next;
freeCount--;
}
else {
//当freeList没有空闲位置时,则应继续在Entries数组的末尾添加新Entry,注意容量不够时需要先扩容
if (count == entries.Length)
{
Resize();
//扩容后需要重新计算哈希值
targetBucket = hashCode % buckets.Length;
}
index = count;
count++;
}
//将Entry的值复制到entries[index]下,采用头插法维护buckets对应的链表
entries[index].hashCode = hashCode;
entries[index].next = buckets[targetBucket]; //头插法,让新node指向原链表的头
entries[index].key = key;
entries[index].value = value;
buckets[targetBucket] = index; //头插法,新node成为新链表的头
version++;
//collisionCount,如果哈希碰撞次数过多,也会进行扩容,此时扩容并不真正的扩大容量而是重新进行哈希值的计算,重新构造Bucket,以减少冲突次数
if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer))
{
comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
Resize(entries.Length, true);
}
}
- 注意点
- 无论是哈希冲突所构造链表,还是Remove构造freeList空闲链表,都采用的头插法
- 注意添加新元素时,应该优先填补因Remove而加入freeList的元素而不是继续向末尾添加
- 这种设计使得count指针不会回溯,也不会每次遍历找空位置,以空间换时间的一种巧妙思想
- 设计妙处
- Entry连续的存在Entries数组中,所有用到的链表都是利用数组的下标模拟实现的
- 减少了内存碎片化,申请一块大内存而避免频繁多次申请小内存串成链表
- Entry连续的存在Entries数组中,所有用到的链表都是利用数组的下标模拟实现的
删除Remove
public bool Remove(TKey key) {
if(key == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets != null) {
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
int bucket = hashCode % buckets.Length;
int last = -1; //置last初始为-1 , 遍历删除过程和链表相似,一前一后双指针
for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) {
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
//当last == -1说明要删除的是头节点,直接将头指针指向next即可,否则将last对应的next指向当前i的next即可
if (last < 0) {
buckets[bucket] = entries[i].next;
}
else {
entries[last].next = entries[i].next;
}
//将Remove的Entry置为初始状态,并利用头插法加入到freeList中
entries[i].hashCode = -1;
entries[i].next = freeList;
entries[i].key = default(TKey);
entries[i].value = default(TValue);
freeList = i;
freeCount++;
version++;
return true;
}
}
}
return false;
}
查询 FindEntry
查询相对较为简单,一般通过 操作符[ ]即可实现访问,但这种查询方式不够安全当主键不存在时会报错,使用TryGetValue即可防止主键不存在时的报错。
-
[ ]方式,若不存在则抛出错误
-
public TValue this[TKey key] { get { int i = FindEntry(key); if (i >= 0) return entries[i].value; ThrowHelper.ThrowKeyNotFoundException(); return default(TValue); } set { Insert(key, value, false); } }
-
-
TryGetValue方式,若不存在则返回false
-
public bool TryGetValue(TKey key, out TValue value) { int i = FindEntry(key); if (i >= 0) { value = entries[i].value; return true; } value = default(TValue); return false; }
-
查询就是根据哈希定位对应的桶,根据桶中记录的下标找到Entries对应的Entry因为哈希冲突的存在还要遍历链表
private int FindEntry(TKey key) {
if( key == null) {
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
}
if (buckets != null) {
int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) {
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i;
}
}
return -1;
}
扩容操作 Resize
-
扩容操作的触发条件
-
Entries已经满了,此时必须要进行扩容操作。
-
private void Resize() { Resize(HashHelpers.ExpandPrime(count), false); } // 返回大于两倍的oldsize的最小质数,如果超出了所定义的最大值则取最大值 public static int ExpandPrime(int oldSize) { int newSize = 2 * oldSize; // Allow the hashtables to grow to maximum possible size (~2G elements) before encoutering capacity overflow. // Note that this check works even when _items.Length overflowed thanks to the (uint) cast if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize) { //保证最大值本身就是质数 Contract.Assert( MaxPrimeArrayLength == GetPrime(MaxPrimeArrayLength), "Invalid MaxPrimeArrayLength"); return MaxPrimeArrayLength; } return GetPrime(newSize); }
-
-
CollisionCount哈希碰撞次数过多,也会进行扩容操作(此时的扩容并非真正的扩大容量,而是重新计算哈希值,重新构造Bucket和Entries已减少哈希冲突。
-
Resize(entries.Length, true); //容量不改变,重新计算哈希
-
-
Resize方法
private void Resize(int newSize, bool forceNewHashCodes) {
//保证新size要大于等于oldsize
Contract.Assert(newSize >= entries.Length);
//用新容量创建两个新副本
int[] newBuckets = new int[newSize];
for (int i = 0; i < newBuckets.Length; i++) newBuckets[i] = -1;
Entry[] newEntries = new Entry[newSize];
//将原Entries中的复制到副本中
Array.Copy(entries, 0, newEntries, 0, count);
//如果是哈希冲突次数过多调用的则重新计算哈希值
if(forceNewHashCodes) {
for (int i = 0; i < count; i++) {
if(newEntries[i].hashCode != -1) {
newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF);
}
}
}
//由于size的改变要重新构造bucket,注意可能因为Remove造成的Entries中间部分为空,要略过
for (int i = 0; i < count; i++) {
if (newEntries[i].hashCode >= 0) {
int bucket = newEntries[i].hashCode % newSize;
newEntries[i].next = newBuckets[bucket];
newBuckets[bucket] = i;
}
}
buckets = newBuckets;
entries = newEntries;
}