C# 中Dictionary源码详解

Dictionary

上篇文章介绍了HashTable的实现原理,Dictionary与其大不相同。

  1. Dictionary使用拉链法解决哈希冲突,HashTable使用Double Hash。
  2. Dictionary是泛型类型,对于值类型和引用类型的key,Hash比较器是不同的。
  3. 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链表上的下一个节点地址下标。

jdk1.8之前的内部结构

 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中查找类似,这里不再赘述。

猜你喜欢

转载自blog.csdn.net/qq_40404477/article/details/120738818