无论是实际的项目中,还是在我们学习的过程中,都会重点的应用到Dictionary<TKey, TValue>这个存储类型。每次对Dictionary<TKey, TValue>的添加都包含一个值和与其关联的键, 使用键检索值的速度非常快,接近 O (1) ,因为 Dictionary<TKey, TValue> 类是作为哈希表实现的。首先我们来从一个简单的例子开始,以下是对一个字典的创建和赋值。
1 Dictionary<int, string> openWith = new Dictionary<int, string>(); 2 openWith.Add(1000, "key值为1000"); 3 openWith.Add(1001, "key值为1001");相信绝大部分的开发人员对以上示例不是会陌生,那么Dictionary<TKey, TValue>的实现原理是什么样的呢?在字典的初始化、赋值、取值、扩容的实现原理是什么样的呢?很多时候我们需要知其然,更需要知其所以然。接下来我们将从其内存的存储的数据结构、取值的逻辑、扩容原则等几个视角进行仔细的了解 。那我们就沿着CoreFX中Dictionary<TKey, TValue>的实现源码来做一个简单的学习和思考,这里需要特别注意一下: 学习和分析源码时,不要先入为主,要按照框架和源码的逻辑进行解读,记录下不懂的地方重点分析,最后将整个逻辑串联起来。如果我们一开始就设定了逻辑为A-B-C,但是读到一个阶段的时候发现变成了C-B-A,这个时候就无法再继续进行下去,因为具体的实现过程中会有很多因素造成局部调整,我们可以在解读完毕之后,将实际的逻辑与个人前期理解的逻辑的差异进行比较,找出原因并做分析。 一、Dictionary<TKey, TValue>初始化 Dictionary<TKey, TValue>的构造方法较多,我们来看一下其中的基础实现方法,首先看一下对应的源码(源码中不必要的部分已经做了部分删减,保留了核心的实现逻辑)。
1 public Dictionary(int capacity, IEqualityComparer<TKey>? comparer) 2 { 3 if (capacity > 0) Initialize(capacity); 4 if (!typeof(TKey).IsValueType) 5 { 6 _comparer = comparer ?? EqualityComparer<TKey>.Default; 7 if (typeof(TKey) == typeof(string) && NonRandomizedStringEqualityComparer.GetStringComparer(_comparer!) is IEqualityComparer<string> stringComparer) 9 { 10 _comparer = (IEqualityComparer<TKey>)stringComparer; 11 } 12 } 13 else if (comparer is not null && comparer != EqualityComparer<TKey>.Default) 14 { 15 _comparer = comparer; 16 } 17 }以上的实现逻辑重点包含了两个部分,第一部分:对Dictionary<TKey, TValue>的容量初始化;第二部分是Dictionary<TKey, TValue>的IEqualityComparer? comparer的初始化,本文重点是对Dictionary<TKey, TValue>的存储结构进行分析,涉及到比较器的实现逻辑,将放在后续的章节中进行重点介绍。 我们接下来看一下Initialize()的实现逻辑进行一个简单的介绍,首先一起来看一下对应的源码实现(非必要部分已做删减,方便大家可以直观的查看)。
1 private int Initialize(int capacity) 2 { 3 int size = HashHelpers.GetPrime(capacity); 4 int[] buckets = new int[size]; 5 Entry[] entries = new Entry[size]; 6 _freeList = -1; 7 #if TARGET_64BIT 8 _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)size); 9 #endif 10 _buckets = buckets; 11 _entries = entries; 12 return size; 13 }从上面的源码可以看出,根据传入的capacity参数来设定字典对应的相关容量大小,其中包含两部分,第一部分: 根据设定的容量(capacity)大小,计算对应的buckets和entries大小,关于为什么使用buckets和entries两个数组结构,我们将在下一节重点介绍;第二部分:判断当前机器的位数,计算对应的_fastModMultiplier。我们看一下HashHelpers.GetPrime(capacity)的计算逻辑。(该类在System.Collections命名空间下,其对应的类型定义为:internal static partial class HashHelpers)
1 public static int GetPrime(int min) 2 { 3 foreach (int prime in Primes) 4 { 5 if (prime >= min) return prime; 6 for (int i = (min | 1); i < int.MaxValue; i += 2) 7 { 8 if (IsPrime(i) && ((i - 1) % HashPrime != 0)) return i; 9 } 10 return min; 11 } 12 }HashHelpers用于计算和维护哈希表容量的素数值,为什么哈希表需要使用素数?主要是为了减少哈希冲突(hash collisions)的发生,素数的选择能够减少共同的因子,减小哈希冲突的可能性。此外,选择素数还能够确保在哈希表的容量变化时,不容易出现过多的重复。如果容量选择为一个合数(非素数),那么在容量变化时,可能会导致新容量与旧容量有相同的因子,增加哈希冲突的风险。 接下来我们沿着GetPrime()的调用关系来看整个哈希表容量的计算逻辑,HashHelpers设定了一个Primes[]的只读素数数组,具体的元素如下,至于什么使用这样的素数的数组,主要是这些素数在实践中已经被证明是有效的,适用于许多常见的使用场景,更多的是有助于在哈希表等数据结构中提供更好的性能。
1 internal static ReadOnlySpan<int> Primes => new int[] 2 { 3 3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919, 4 1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591, 5 17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437, 6 187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263, 7 1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369 8 };GetPrime()会首先循环Primes[],依次判断设定的min大小与素数表元素的关系,若素数表中的元素大于min,则直接去对应的素数,无需后续的计算,如果设置的min不在预定的素数表中,则进行素数的计算。关于素数的计算逻辑,借助本文开头的Dictionary<TKey, TValue>的定义和赋值进行说明,首先对min和1进行按位或运算,初始化过程中未对capacity赋值时,则(min | 1)为1,对进行位运算后的i值校验是否符合素数定义,再进行((i - 1) % HashPrime != 0)运算,其中HashPrime = 101,用于在哈希算法中作为质数因子(101是一个相对小的质数,可以减少哈希碰撞的可能性,并且在计算哈希时更加高效),对于初始化未设置容量的Dictionary<TKey, TValue>,计算获取得到的容量为int size=3。(即3*4*8=72(bit)) (注意:对于已设定了capacity的Dictionary,按照以上的逻辑进行计算对应的size值。这里就不再做过多介绍) 计算获取到size值后,设置空闲列表为-1(_freeList = -1)。根据编译时的运行机器的位数进行分类处理,若机器为非64位,则对buckets和entries两个数组进行初始化。若机器为64位是,则需要进行重新计算,获取_fastModMultiplier,其计算逻辑如下:
public static ulong GetFastModMultiplier(uint divisor) => ulong.MaxValue / divisor + 1;以上的计算结果返回除数的近似倒数,计算用于快速取模运算的乘法因子。 通过以上的计算过程,我们可以对Dictionary<TKey, TValue>的容量计算有一个简单的认识,接下来我们来具体看一下用于存储数据和哈希索引的两个数组。 二、Dictionary<TKey, TValue>的存储基础结构 对于Dictionary<TKey, TValue>的两个重要数组buckets和entries,我们来具体的分析一下。首先来看一下Entry[]?_entries的实际的数据结构:
1 private struct Entry 2 { 3 public uint hashCode; 4 public int next; 5 public TKey key; 6 public TValue value; 7 }在Dictionary<TKey, TValue>中实际存储数据的结构是Entry[],其中数组的每个元素是一个Entry,该类型为一个结构体,用于在哈希表内部存储每个键值对的信息,其中定义的key和value则是我们在设置字典时添加的键值对,那么对于另外两个属性需要重点分析一下。 hashCode为在添加key时,将key进行计算获取得到的哈希值,哈希值的计算过程中,需要对key进行按类别进行计算,C#中对数值类型、字符串、结构体、对象的哈希值计算逻辑都不相同,其中对于"数值类型"的哈希值计算逻辑为"数字类型的哈希码生成逻辑通常是将数字类型的值转换为整数,然后将该整数作为哈希码。"对于字符串的哈希值计算逻辑为"默认的字符串哈希码计算方式采用了所谓的“Jenkins One-at-a-Time Hash”算法的变体。"对于结构体和对象的哈希值计算逻辑就不做具体介绍。 next通常用于处理哈希冲突,即多个键具有相同的哈希码的情况。next是一个索引,指向哈希表中下一个具有相同哈希码的元素。其中next=-1时,表示链表结束;next=-2 表示空闲列表的末尾,next=-3 表示在空闲列表上的索引 0,next=-4 表示在空闲列表上的索引 1,后续则依次类推。 Entry通过使用结构体而不是类,可以减少内存开销,因为结构体是值类型,而类是引用类型。结构体在栈上分配,而类在堆上分配。 以上介绍了Entry的结构和对应的属性字段,接下来我们再来看一下int[] buckets的结构和计算逻辑,buckets是一个简单的int类型的数组,这样的数组通常用于存储哈希桶的信息。每个桶实际上是一个索引,指向一个链表或链表的头部,用于解决哈希冲突。
1 private ref int GetBucket(uint hashCode) 2 { 3 int[] buckets = _buckets!; 4 #if TARGET_64BIT 5 return ref buckets[HashHelpers.FastMod(hashCode, (uint)buckets.Length, _fastModMultiplier)]; 6 #else 7 return ref buckets[(uint)hashCode % buckets.Length]; 8 #endif 9 }GetBucket()用于在哈希表中获取桶索引,其中参数hashCode为key对应的哈希码,在64位目标体系结构下,使用 HashHelpers.FastMod 方法进行快速模运算,而在32位目标体系结构下,使用普通的取模运算。那么为什么在Dictionary<TKey, TValue>中维护一个用来存储哈希表的桶呢?主要有以下4个目的: (1)、解决哈希冲突:两个或多个不同的键经过哈希函数得到相同的哈希码,导致它们应该存储在哈希表的相同位置。通过使用桶,可以在同一个位置存储多个元素,解决了哈希冲突的问题。 (2)、提供快速查找:通过哈希函数计算键的哈希码,然后将元素存储在哈希表的桶中,可以在常数时间内(平均情况下)定位到存储该元素的位置,实现快速的查找。 (3)、支持高效的插入和删除:当插入元素时,通过哈希函数确定元素应该存储的桶,然后将其添加到桶的链表或其他数据结构中。当删除元素时,同样可以快速定位到存储元素的桶,并删除该元素。 (4)、平衡负载:哈希表的性能与负载因子相关,而负载因子是元素数量与桶数量的比值。使用适当数量的桶可以帮助平衡负载,防止哈希表变得过度拥挤,从而保持其性能。在不同的哈希表实现可能使用不同的数据结构,如链表、树等,C#的Dictionary中使用一个int[]维护这个哈希表的桶索引。 三、Dictionary<TKey, TValue>的TryAdd的实现方式 以上主要介绍了Dictionary<TKey, TValue>的初始化、数据对应的存储和哈希表桶索引的存储结构,现在我们具体看一下Dictionary<TKey, TValue>的添加元素的实现方式,下面对C#的实现代码进行了精简,删除当前并不关注的部分。 本文实例中对key赋值的为整数类型,部分对于非数值类型、调试代码等进行删减。(由于对于对象或者设置了比较器逻辑相对繁琐,将在下文中进行介绍)
private bool TryInsert(TKey key, TValue value, InsertionBehavior behavior) { Entry[]? entries = _entries; uint hashCode = (uint) key.GetHashCode() ; uint collisionCount = 0; ref int bucket = ref GetBucket(hashCode); int i = bucket - 1; int index; if (_freeCount > 0) { index = _freeList; _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; } ref Entry entry = ref entries![index]; entry.hashCode = hashCode; entry.next = bucket - 1; entry.key = key; entry.value = value; bucket = index + 1; _version++; return true; }以上的源码中的实现逻辑中核心包含3个部分,分别是计算hashCode、计算哈希表桶索引的bucket、Dictionary扩容,上一节中已经介绍了前两个实现逻辑,本节重点介绍Dictionary<TKey, TValue>的扩容逻辑,我们来看一下Resize()的实现逻辑。
1 private void Resize() => Resize(HashHelpers.ExpandPrime(_count), false); 2 3 private void Resize(int newSize, bool forceNewHashCodes) 4 { 5 Entry[] entries = new Entry[newSize]; 6 int count = _count; 7 Array.Copy(_entries, entries, count); 8 _buckets = new int[newSize]; 9 #if TARGET_64BIT 10 _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)newSize); 11 #endif 12 for (int i = 0; i < count; i++) 13 { 14 if (entries[i].next >= -1) 15 { 16 ref int bucket = ref GetBucket(entries[i].hashCode); 17 entries[i].next = bucket - 1; 18 bucket = i + 1; 19 } 20 } 21 _entries = entries; 22 }由以上的源码(不涉及数值类型的部分做了删减)可以看出,HashHelpers.ExpandPrime(_count)计算新的Entry[]大小,那我们来具体看一下这个新的数组大小的计算逻辑是如何实现的。
1 public static int ExpandPrime(int oldSize) 2 { 3 int newSize = 2 * oldSize; 4 if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize) return MaxPrimeArrayLength; 5 return GetPrime(newSize); 6 }对于新的entries数组的扩容,首先按照原始数组大小*2,那么对于能够扩容的最大数值为MaxPrimeArrayLength=0x7FFFFFC3,对应32字节的最大值。计算新的数组大小时,会基于原始数组2倍的情况下,再取对应的最少素数相乘,即:realSize=2*oldSize*y(素数表中的最少素数)。 【备注:其实在整个C#的扩容逻辑中,绝大数大都是按照2倍进行扩容(按照2倍扩容的方式存在一定的弊端,假设第n次扩容分配了2^n的空间(省略常数C),那么之前释放掉的空间总和为:1 + 2 + 2^2 + ... + 2^(n-1) = 2^n - 1 正好放不下2^n的空间。这样导致的结果就是需要操作系统不断分配新的内存页,并且数组的首地址也在不断变大,造成缓存缺失。】 Array.Copy(_entries, entries, count)扩容后的新数组会将对旧数组进行Copy()操作,在C#中每次对数组进行扩容时,都是将就数组的元素全部拷贝到新的数组中,这个过程是比较耗时和浪费资源,如果在实际的开发过程中提前计算好数组的容量,可以极大限度的提升性能,降低GC的活动频率。 其中对于初始化为设置Dictionary的capacity时,第一次插入元素时,C#会对两个数组进行初始化,其中size=3,即维护的素数表中的最小值,后续超过该数组大小后,会按照以上的扩容逻辑进行扩容。 四、Dictionary<TKey, TValue>的FindValue的实现方式 介绍完毕Dictionary<TKey, TValue>的元素插入后,我们接下来看一下Dictionary<TKey, TValue>的查询逻辑,在Dictionary<TKey, TValue>中实现查询逻辑的核心方法是FindValue(),首先我们来看一下其实现的源码。
1 internal ref TValue FindValue(TKey key) 2 { 3 ref Entry entry = ref Unsafe.NullRef<Entry>(); 4 if (_buckets != null) 5 { 6 uint hashCode = (uint)key.GetHashCode(); 7 int i = GetBucket(hashCode); 8 Entry[]? entries = _entries; 9 uint collisionCount = 0; 10 i--; 11 do 12 { 13 if ((uint)i >= (uint)entries.Length) 14 { 15 goto ReturnNotFound; 16 } 17 entry = ref entries[i]; 18 if (entry.hashCode == hashCode && EqualityComparer<TKey>.Default.Equals(entry.key, key)) 19 { 20 goto ReturnFound; 21 } 22 i = entry.next; 23 collisionCount++; 24 } while (collisionCount <= (uint)entries.Length); 25 goto ConcurrentOperation; 26 } 27 goto ReturnNotFound; 28 ConcurrentOperation: 29 ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported(); 30 ReturnFound: 31 ref TValue value = ref entry.value; 32 Return: 33 return ref value; 34 ReturnNotFound: 35 value = ref Unsafe.NullRef<TValue>(); 36 goto Return; 37 }以上的源码中,对于计算hashCode和计算哈希索引的桶的逻辑就不再赘述,重点关注entry.hashCode == hashCode &&EqualityComparer.Default.Equals(entry.key, key)),在FindValue()中,对已经缓存的Entry[]? entries进行循环遍历,然后依次进行比较,其中比较的逻辑包含两部分。在判断取值key时,不仅需要判断传入key值的hashCode与对应Entry[]? entries中的元素的hashCode值相等,还需要判断key是否相同,通过EqualityComparer.Default.Equals(entry.key, key)进行比较,关于比较器的逻辑将在下一章中进行介绍。 五、学在最后的思考和感悟 上面介绍了Dictionary<TKey, TValue>的初始化、元素插入、元素插入时的扩容、元素取值的部分逻辑,我们可以发现在Dictionary<TKey, TValue>中维护了nt[] buckets和Entry[]? _entries两个数组,其中用于存储数据的结构为Entry[]? _entries,这个类型为一个结构体,在C#中结构体占用的内存要小于一个对象的内存占用。无论多么复杂的存储结构,其内部会尽量将其简化为一个数组,然后通过数组的存储和读取特性进行优化,规避了数组在某方面的不足,发挥了其优势。 以上的部分思考中,我们其实可以发现在实际的编码过程中,需要注意的几个事项: (1)、创建存储结构时,需要思考其对应的存储场景和对象,尽量选择合适的结构进行处理,降低内存的占用情况。 (2)、对于存储结构,尽量可以提前指定容量,避免频繁的扩容,每次扩容都会伴随数组的复制。 (3)、C#的扩容机制都是按照扩容2倍,在hash存储结构中,还会按照维护的素数表进行个性化的计算优化。 (4)、解读源码时,可以先选择一个简单的场景,尽量剔除与需要验证场景无关的代码,集中核心逻辑进行分析,然后再逐步进行扩展思考。 以上内容是对CoreFx中Dictionary<TKey, TValue>的存储和读取逻辑的简单介绍,如错漏的地方,还望指正。 标签:key,CoreFX,Dictionary,int,hashCode,源码,哈希,entries From: https://www.cnblogs.com/pengze0902/p/17830689.html