C#中Dictionary<TKey, TValue>的存储结构分析

  无论是实际的项目中,还是在我们学习的过程中,都会重点的应用到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>的存储和读取逻辑的简单介绍,如错漏的地方,还望指正。
posted @ 2023-11-14 12:27  彭_泽  阅读(2586)  评论(6编辑  收藏  举报