C# 中Hashtable 源码详解

  • 什么是hashtable? 
  • hashtable扩容机制是什么?
  • hashtable是如何解决哈希冲突的?
  • rehash是什么?
  • hashtable是如何进行查找控制的?

HashTable并不是泛型类型,使用object类型会给值类型带来装箱拆箱的压力。

构造函数

HashTable内部维护了一个桶数组,一个桶可以保存一组键值对。

桶数组在初始化时,容量并不一定等于传入的capacity值, 而是会选择一个大于该值的最小质数作为数组大小。

同样的,在进行扩容时,也是先按目前大小×2,然后选择一个大于该结果的最小质数作为新数组容量。

为什么哈希表的大小要用质数呢?

主要是因为一般采用模运算来获取元素存放地址: index = hashcode % length。当容量特别大时倒无所谓,但是哈希表的容量通常又不会特别大,因此采用质数作为模数,也就是容量,会减小哈希冲突的次数。

_loadsize表示的是桶数组容量与负载系数的乘积,负载系数参数需在0.1-1.0之间,但其上限为0.72。

负载系数越小,代表着数据存储越稀疏,查询效率越高,相应的内存占用也越多。

初始化时,负载系数默认是0.72。

 public Hashtable(int capacity, float loadFactor)
        {
            if (capacity < 0)
                throw new ArgumentOutOfRangeException(nameof(capacity), SR.ArgumentOutOfRange_NeedNonNegNum);
            if (!(loadFactor >= 0.1f && loadFactor <= 1.0f))
                throw new ArgumentOutOfRangeException(nameof(loadFactor), SR.Format(SR.ArgumentOutOfRange_HashtableLoadFactor, .1, 1.0));

            // Based on perf work, .72 is the optimal load factor for this table.
            _loadFactor = 0.72f * loadFactor;

            double rawsize = capacity / _loadFactor;
            if (rawsize > int.MaxValue)
                throw new ArgumentException(SR.Arg_HTCapacityOverflow, nameof(capacity));

            // Avoid awfully small sizes
            int hashsize = (rawsize > InitialSize) ? HashHelpers.GetPrime((int)rawsize) : InitialSize;
            _buckets = new bucket[hashsize];

            _loadsize = (int)(_loadFactor * hashsize);
            _isWriterInProgress = false;
            // Based on the current algorithm, loadsize must be less than hashsize.
            Debug.Assert(_loadsize < hashsize, "Invalid hashtable loadsize!");
        }

        private struct bucket
        {
            public object? key;
            public object? val;
            public int hash_coll;   // Store hash code; sign bit means there was a collision.
        }

HashTable中的双重哈希策略

如下代码,非常简洁。

hashcode1 = GetHashCode(key)

hashcode2 = 1 + (hashcode1 * 101 ) % (size - 1)

为什么求出hashcode1要使用uint,并且要& 0x7FFFFFFF 呢?

因为需要将二进制符号位作为碰撞标记。初始化应置为0,发生哈希碰撞时再将其置为1。

        private uint InitHash(object key, int hashsize, out uint seed, out uint incr)
        {
            // Hashcode must be positive.  Also, we must not use the sign bit, since
            // that is used for the collision bit.
            uint hashcode = (uint)GetHash(key) & 0x7FFFFFFF;
            seed = (uint)hashcode;
            // Restriction: incr MUST be between 1 and hashsize - 1, inclusive for
            // the modular arithmetic to work correctly.  This guarantees you'll
            // visit every bucket in the table exactly once within hashsize
            // iterations.  Violate this and it'll cause obscure bugs forever.
            // If you change this calculation for h2(key), update putEntry too!
            incr = (uint)(1 + ((seed * HashHelpers.HashPrime) % ((uint)hashsize - 1)));
            return hashcode;
        }

Remove方法的实现原理

想要理解Add方法的源代码,必须先对Remove操作有一点了解。

首先需要根据Key的哈希值来找到 对象在桶数组中的存储位置。与添加对象时,位置查找算法是基本一致的。怎么添进去的,怎么查出来。

要注意的是,在移除元素时,需要

_buckets[bn].hash_coll &= unchecked((int)0x80000000);

 if (_buckets[bn].hash_coll != 0)   _buckets[bn].key = _buckets;
 else    _buckets[bn].key = null;

这样做的目的是为了保留hash碰撞标记。假如这个位置的元素曾经发生过Hash碰撞,则其key设为_buckets,否则置为null。

 public virtual void Remove(object key)
        {           
            uint hashcode = InitHash(key, _buckets.Length, out uint seed, out uint incr);
            int ntry = 0;

            bucket b;
            int bn = (int)(seed % (uint)_buckets.Length);  
            do
            {
                b = _buckets[bn];
                if (((b.hash_coll & 0x7FFFFFFF) == hashcode) &&
                    KeyEquals(b.key, key))
                {
                    _isWriterInProgress = true;
                   
                    _buckets[bn].hash_coll &= unchecked((int)0x80000000);
                    if (_buckets[bn].hash_coll != 0)
                    {
                        _buckets[bn].key = _buckets;
                    }
                    else
                    {
                        _buckets[bn].key = null;
                    }
                    _buckets[bn].val = null;  
                    _count--;
                    UpdateVersion();
                    _isWriterInProgress = false;
                    return;
                }
                bn = (int)(((long)bn + incr) % (uint)_buckets.Length);
            } while (b.hash_coll < 0 && ++ntry < _buckets.Length);
        }

Add方法的实现原理

了解了Remove方法的实现原理后,我们再来看下Add方法是如何添加元素的。

首先是先判断是否需要扩容,_loadsize并不等于容量,我前面已经介绍了。

其次根据碰撞次数,判断是否需要rehash。

然后再求出key的对应hash值。

那么接下来就是用do...while为添加的键值对找一个合适的位置了,详解见注释,如下代码: 

 private void Insert(object key, object? nvalue, bool add)
        {           
            if (_count >= _loadsize)
            {
                expand();
            }
            else if (_occupancy > _loadsize && _count > 100)
            {
                rehash();
            }
           
            uint hashcode = InitHash(key, _buckets.Length, out uint seed, out uint incr);
            int ntry = 0;
            int emptySlotNumber = -1;
            //先简单使用hashcode1对_buckets.Length作模运算,求出一个地址。
            int bucketNumber = (int)(seed % (uint)_buckets.Length);
            do
            {  
                //(参考上面Remove方法)假如当前地址曾发生过hash碰撞(_buckets[bucketNumber].hash_coll < 0),
                //但该桶目前为空,则暂时将指针指向该地址。
                //因为曾发生过Hash碰撞,所以当前数组中可能存在Hash值相同的其他元素,
                //需要与他们的Key值一一比较             
                if (emptySlotNumber == -1 && (_buckets[bucketNumber].key == _buckets) && (_buckets[bucketNumber].hash_coll < 0))
                    emptySlotNumber = bucketNumber;

                //假如该桶从来为空,或曾有元素但未发生过hash冲突
                if ((_buckets[bucketNumber].key == null) ||
                    (_buckets[bucketNumber].key == _buckets && ((_buckets[bucketNumber].hash_coll & unchecked(0x80000000)) == 0)))
                {
                    
                    if (emptySlotNumber != -1) 
                        bucketNumber = emptySlotNumber;
                  
                    //保存键值与hashcode,然后return
                    _isWriterInProgress = true;
                    _buckets[bucketNumber].val = nvalue;
                    _buckets[bucketNumber].key = key;
                    _buckets[bucketNumber].hash_coll |= (int)hashcode;
                    _count++;
                    UpdateVersion();
                    _isWriterInProgress = false;

                    return;
                }

                //当前桶的key等于要添加的key,判断是否是add操作,是则抛出异常,否则update
                if (((_buckets[bucketNumber].hash_coll & 0x7FFFFFFF) == hashcode) &&
                    KeyEquals(_buckets[bucketNumber].key, key))
                {
                    if (add)
                    {
                        throw new ArgumentException(SR.Format(SR.Argument_AddingDuplicate__, _buckets[bucketNumber].key, key));
                    }
                    _isWriterInProgress = true;
                    _buckets[bucketNumber].val = nvalue;
                    UpdateVersion();
                    _isWriterInProgress = false;

                    return;
                }

                //到这里,说明当前桶不为空,也就是出现了hash碰撞,
                //将碰撞标志位置为1,碰撞计数+1
                if (emptySlotNumber == -1)
                {
                    if (_buckets[bucketNumber].hash_coll >= 0)
                    {
                        _buckets[bucketNumber].hash_coll |= unchecked((int)0x80000000);
                        _occupancy++;
                    }
                }
                // 利用上面求得的hashcode2,也就是incr,重新计算一个新的地址
                bucketNumber = (int)(((long)bucketNumber + incr) % (uint)_buckets.Length);
            } while (++ntry < _buckets.Length);

            //找到合适的地址,将键值对保存到该桶
            if (emptySlotNumber != -1)
            {
                
                _isWriterInProgress = true;
                _buckets[emptySlotNumber].val = nvalue;
                _buckets[emptySlotNumber].key = key;
                _buckets[emptySlotNumber].hash_coll |= (int)hashcode;
                _count++;
                UpdateVersion();
                _isWriterInProgress = false;

                return;
            }
        
            Debug.Fail("hash table insert failed!  Load factor too high, or our double hashing function is incorrect.");
            throw new InvalidOperationException(SR.InvalidOperation_HashInsertFailed);
        }

Rehash的实现原理

 Rehash会在两种情况下出现

  1. 扩容时(改变桶数组长度)
  2. 碰撞次数达到上限(不改变桶数组长度)

当哈希碰撞次数过多时,意味着当前插入查询效率都会降低。

当因为哈希碰撞而Rehash时,桶数组长度并没有改变,hash算法也没发生太大变化,那么此时rehash的意义是什么呢?

答案是为了清理无效的碰撞标记,以及碰撞次数。我们前面讲到了碰撞标记与碰撞次数的维护机制,当有元素被Remove时,二者并不会自动维护成最合理的状态。

详解见代码中的注释: 

 private void rehash(int newsize)
        {
            _occupancy = 0;

            bucket[] newBuckets = new bucket[newsize];

            int nb;
            for (nb = 0; nb < _buckets.Length; nb++)
            {
                bucket oldb = _buckets[nb];
                if ((oldb.key != null) && (oldb.key != _buckets))
                {
                    //碰撞标记重置为0
                    int hashcode = oldb.hash_coll & 0x7FFFFFFF;
                    // putEntry方法的作用就是利用hashcode1查找一个空位插入
                    // 否则碰撞标记置1,while循环,利用hashcode2查找其他空位
                    putEntry(newBuckets, oldb.key, oldb.val, hashcode);
                }
            }

            // 直到添加完成才会替换相关内部状态,包括使用新的桶数组、新的负载上限、更新版本
            // 这样做的目的是为了在rehash时允许并发读取器查看有效的hashtable内容,
            // 以及当分配这个新桶[]时,防止OutOfMemoryException。
            _isWriterInProgress = true;
            _buckets = newBuckets;
            _loadsize = (int)(_loadFactor * newsize);
            UpdateVersion();
            _isWriterInProgress = false;

            Debug.Assert(_loadsize < newsize, "Our current implementation means this is not possible.");
        }

索引器查找实现原理 

查找其实就是根据hashcode,找到对应的桶下标,计算方法与上文所介绍的一致。

不同的是,查找增加了并发控制。使用volatile 关键字声明的_isWriterInProgress 和_version变量将用来保证在进行增删改操作时,读取操作需要等待。

public virtual object? this[object key]
        {
            get
            {
                if (key == null)
                {
                    throw new ArgumentNullException(nameof(key), SR.ArgumentNull_Key);
                }

                bucket[] lbuckets = _buckets;
                uint hashcode = InitHash(key, lbuckets.Length, out uint seed, out uint incr);
                int ntry = 0;

                bucket b;
                int bucketNumber = (int)(seed % (uint)lbuckets.Length);
                do
                {
                    int currentversion;

                   
                    SpinWait spin = default;
                    while (true)
                    {
                        currentversion = _version;
                        b = lbuckets[bucketNumber];

                        if (!_isWriterInProgress && (currentversion == _version))
                            break;

                        spin.SpinOnce();
                    }

                    if (b.key == null)
                    {
                        return null;
                    }
                    if (((b.hash_coll & 0x7FFFFFFF) == hashcode) &&
                        KeyEquals(b.key, key))
                        return b.val;
                    bucketNumber = (int)(((long)bucketNumber + incr) % (uint)lbuckets.Length);
                } while (b.hash_coll < 0 && ++ntry < lbuckets.Length);
                return null;
            }

            set => Insert(key, value, false);
        }

猜你喜欢

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