Dictionary
上篇文章介绍了HashTable的实现原理,Dictionary与其大不相同。
- Dictionary使用拉链法解决哈希冲突,HashTable使用Double Hash。
- Dictionary是泛型类型,对于值类型和引用类型的key,Hash比较器是不同的。
- Dictionary再resize的时候可能会使用新的随机Hash比较器。
构造函数
Dictionary内部维护了两个非常重要的数组,它们是拉链法的实现基础。
private int[]? _buckets;
private Entry[]? _entries;private struct Entry
{
public uint hashCode;
public int next;
public TKey key;
public TValue value;
}
初始化 Dictionary可以指定一个capacity,但两个数组的长度是取大于该值的最小质数。这是为了减小哈希碰撞的概率,与HashTable一样,默认是3。
拉链法 运作机制
网上常见的拉链法图片是这样的,实际上这个图是会给没了解过原理的朋友带来误解的。
首先,下图所有链表上的元素,实际上是全部保存在Entry[]? _entries,这一个数组当中的。
观察Entry结构体的实现,可以发现它使用next字段来表示hash链表上的下一个节点地址下标。
int[]? _buckets 桶数组,表示Hash链表的开始。根据Key值的hashcode来判断分配在哪个桶上。
需要注意的是,在Dictionary实现中,_buckets 数组中保存的值是从1开始的,假如链表头节点位于_entries下标为0,则该桶保存的值为 index+1 = 1。
另外,当有新节点加入时,是添加到了Hash链的首位,并且修改该桶所指向的下标地址。
另外,Dictionary实现中_freeCount、_freeList、StartOfFreeList三个字段也非常有趣。
我一开始以为表示的是字典的剩余容量,后来发现_freeCount只会在Remove操作之后增加,这才明白,原来它代表的是已释放空间容量,而_freeList 则表示上一个释放空间地址。
实际上, Dictionary用了一种非常巧妙的方式,维护了一个已释放空间地址链,而_freeList 则是指向最近一次释放地址的下标。
当添加一个新元素时,会优先从已释放地址当中选择,根据_freeList 所表示的地址添加完之后,_freeList 会自动退回到上一个空闲地址。注意是 “上一个”,优先分配到最近释放的地址上。
具体实现看下文源码。
Remove实现原理
实际上,通过对Hashtable和Dictionary两个对象源码的学习,我发现只有结合Remove和Add方法一起去看,才能理解作者的设计思路。
与HashTable一样,我们先来看相对比较简单的Remove方法,是如何实现的:
public bool Remove(TKey key)
{
if (_buckets != null)
{
Debug.Assert(_entries != null, "entries should be non-null");
uint collisionCount = 0;
uint hashCode = (uint)(_comparer?.GetHashCode(key) ?? key.GetHashCode());
// GetBucket(uint hashCode) => buckets[hashCode % (uint)buckets.Length];
ref int bucket = ref GetBucket(hashCode);
Entry[]? entries = _entries;
int last = -1;
// i用来表示元素在entries 中的位置,猜测buckets数组中保存的有效值最小为1。
int i = bucket - 1;
// i<0表示当前桶存储的下标bucket为0,说明要移除的key不存在,return false。
while (i >= 0)
{
ref Entry entry = ref entries[i];
// 如果找到该元素
if (entry.hashCode == hashCode && (_comparer?.Equals(entry.key, key) ?? EqualityComparer<TKey>.Default.Equals(entry.key, key)))
{
if (last < 0)
{
// last<0表示要移除的元素是哈希链上的第一个元素,
// 因此需要将桶中存储的下标值更新为 第二个元素的位置 + 1;
// 这也应证了我们上面提到的,buckets数组中保存的有效值最小为1
bucket = entry.next + 1; // Value in buckets is 1-based
}
else
{
// last>=0表示要移除的元素是哈希链上中间或结尾的某个元素,
// 因此不需要更新桶地址值,只需要将上一个元素的next节点设置为要移除元素的next节点即可
entries[last].next = entry.next;
}
//为什么要给已经移除的元素设置next??这一步非常关键,看下文
entry.next = StartOfFreeList - _freeList;
if (RuntimeHelpers.IsReferenceOrContainsReferences<TKey>())
{
entry.key = default!;
}
if (RuntimeHelpers.IsReferenceOrContainsReferences<TValue>())
{
entry.value = default!;
}
//空闲指针指向i,表示该地址空闲可用
_freeList = i;
_freeCount++;
return true;
}
// 没进上面那个if,说明存在hash链但没找到对应的key,
// 指针往下移一位,继续寻找,当找到末尾时,i将等于-1
last = i;
i = entry.next;
collisionCount++;
if (collisionCount > (uint)entries.Length)
{ ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
}
}
}
return false;
}
Add实现原理
根据前面提到的,结合源码发现:
将新增元素分配到_freeList 指向的地址,然后_freeList 又指向了StartOfFreeList - entries[_freeList].next为什么?
这里需要结合remove操作来看,其中有一行代码让我很不能理解:
entry.next = StartOfFreeList - _freeList;
该空间的key-value虽然已经释放,但其next指针依然保留,并且指向了StartOfFreeList - _freeList为什么?
注意看我加粗的两个表达式!!
假如现在_freeList = 1,而此时remove掉了地址为2的元素。那么地址2处的next指针指向了StartOfFreeList(恒为-3的常量) - _freeList,也就是-4。接下来再将_freeList调整为最近释放地址,即2.
然后Add操作添加一个新的元素,理所当然将其分配到了2地址。那么此时
entries[_freeList].next是几?-4!
StartOfFreeList - entries[_freeList].next,是几? 1!
之后_freeList 又指向了StartOfFreeList - entries[_freeList].next, 应该是几?1!
等于说,Dictionary将已释放的空闲地址串成了一个链!当分配新元素时,会从该链的最末端取一个空闲地址,然后_freeList自动移动到上一个空闲地址,成为新的链尾!!!
想到这里,我也终于明白了,空闲指针为什么叫_freeList,因为它确实是一个链!
private bool TryInsert(TKey key, TValue value, InsertionBehavior behavior)
{
...
uint hashCode = (uint)((comparer == null) ? key.GetHashCode() : comparer.GetHashCode(key));
//碰撞次数
uint collisionCount = 0;
//根据hashCode计算所在的桶,注意是ref
// GetBucket(uint hashCode) => buckets[hashCode % (uint)buckets.Length];
ref int bucket = ref GetBucket(hashCode);
//此处 i为什么是bucket - 1呢?
int i = bucket - 1;
// 这里if...else中的 while 主要目的是为了检测哈希链中是否存在环,
// 假如是update,会直接更新元素并返回
// 假如是添加了相同key的元素,会抛出异常
if (comparer == null)
{
//没有指定comparer 的话,Dictionary会使用默认比较器
//这里分为值类型key 和 引用类型Key两种情况,代码与下面高度类似,直接看下面↓
if (typeof(TKey).IsValueType)
{ ... }
else
{ ... }
}
else
{
while (true)
{
// 注意:这里是添加元素时,跳出循环的唯一出口,非常巧妙的将i转成了uint。
// 当字典初始化后第一次添加,bucket 一定为0,i此时为-1。
// 因为符号位是二进制首位,负数为1,所以转成uint之后,会是一个非常大的整数。
if ((uint)i >= (uint)entries.Length)
{
break;
}
//Key与当前元素相等,是添加则抛出异常,是修改则变更值并return
if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key))
{
if (behavior == InsertionBehavior.OverwriteExisting)
{
entries[i].value = value;
return true;
}
if (behavior == InsertionBehavior.ThrowOnExisting)
{
ThrowHelper.ThrowAddingDuplicateWithKeyArgumentException(key);
}
return false;
}
i = entries[i].next;
collisionCount++;
if (collisionCount > (uint)entries.Length)
{ ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
}
}
}
// 注意:虽然初始化后的字典容量是3,但此时_freeCount值为0,而_freeList则为-1
// 经过我的分析:_freeCount表示经remove释放的空间数,
// _freeList表示的是remove释放的空间地址。
// 结合remove方法你会发现,这里设计的非常巧妙。
int index;
if (_freeCount > 0)
{
index = _freeList;
Debug.Assert((StartOfFreeList - entries[_freeList].next) >= -1, "shouldn't overflow because `next` cannot underflow");
_freeList = StartOfFreeList - entries[_freeList].next;
_freeCount--;
}
else
{
int count = _count;
if (count == entries.Length)
{
Resize();
bucket = ref GetBucket(hashCode);
}
index = count;
_count = count + 1;
entries = _entries;
}
// bucket 是用来记录哈希链的起点地址,
// 从entry.next = bucket - 1;操作可用看出哈希链会将新元素添加在头部,
// 因此哈希链的最后一个next指针指向的值一定是-1
// 而bucket = index + 1; 说明bucket 中的有效值最小为1
ref Entry entry = ref entries![index];
entry.hashCode = hashCode;
entry.next = bucket - 1;
entry.key = key;
entry.value = value;
bucket = index + 1;
_version++;
if (!typeof(TKey).IsValueType && collisionCount > HashHelpers.HashCollisionThreshold && comparer is NonRandomizedStringEqualityComparer)
{
Resize(entries.Length, true);
}
return true;
}
扩容
Dictionary的扩容和HashTable的扩容类似,均是根据 当前容量×2 取大于该值的最小质数。
但是,Dictionary在扩容时会有是否强制刷新HashCode的选项(只对引用类型的Key起作用),假如forceNewHashCodes为True,则将会随机生成一个新的IEqualityComparer,用来生成新的HashCode。
索引器查找
索引器查找与Remove中查找类似,这里不再赘述。