概述
- 什么是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会在两种情况下出现
- 扩容时(改变桶数组长度)
- 碰撞次数达到上限(不改变桶数组长度)
当哈希碰撞次数过多时,意味着当前插入查询效率都会降低。
当因为哈希碰撞而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);
}
最后
以上就是天真胡萝卜为你收集整理的C# 中Hashtable 源码详解的全部内容,希望文章能够帮你解决C# 中Hashtable 源码详解所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复