C#教程

关注公众号 jb51net

关闭
首页 > 软件编程 > C#教程 > C# Dictionary<TKey,TValue>

详解C#中Dictionary<TKey,TValue>的存储结构

作者:彭泽0902

无论是实际的项目中,还是在我们学习的过程中,都会重点的应用到Dictionary<TKey, TValue>这个存储类型,所以本文就来为大家介绍一下这一存储结构的相关知识,希望对大家有所帮助

无论是实际的项目中,还是在我们学习的过程中,都会重点的应用到Dictionary<TKey, TValue>这个存储类型。每次对Dictionary<TKey, TValue>的添加都包含一个值和与其关联的键, 使用键检索值的速度非常快,接近 O (1) ,因为 Dictionary<TKey, TValue> 类是作为哈希表实现的。首先我们来从一个简单的例子开始,以下是对一个字典的创建和赋值。

Dictionary<int, string> openWith = new Dictionary<int, string>();
openWith.Add(1000, "key值为1000");
openWith.Add(1001, "key值为1001");

相信绝大部分的开发人员对以上示例不是会陌生,那么Dictionary<TKey, TValue>的实现原理是什么样的呢?在字典的初始化、赋值、取值、扩容的实现原理是什么样的呢?很多时候我们需要知其然,更需要知其所以然。接下来我们将从其内存的存储的数据结构、取值的逻辑、扩容原则等几个视角进行仔细的了解 。那我们就沿着CoreFX中Dictionary<TKey, TValue>的实现源码来做一个简单的学习和思考,这里需要特别注意一下:

学习和分析源码时,不要先入为主,要按照框架和源码的逻辑进行解读,记录下不懂的地方重点分析,最后将整个逻辑串联起来。如果我们一开始就设定了逻辑为A-B-C,但是读到一个阶段的时候发现变成了C-B-A,这个时候就无法再继续进行下去,因为具体的实现过程中会有很多因素造成局部调整,我们可以在解读完毕之后,将实际的逻辑与个人前期理解的逻辑的差异进行比较,找出原因并做分析。

一、Dictionary<TKey, TValue>初始化

Dictionary<TKey, TValue>的构造方法较多,我们来看一下其中的基础实现方法,首先看一下对应的源码(源码中不必要的部分已经做了部分删减,保留了核心的实现逻辑)。

public Dictionary(int capacity, IEqualityComparer<TKey>? comparer)
  {
      if (capacity > 0) Initialize(capacity);
      if (!typeof(TKey).IsValueType)
      {
         _comparer = comparer ?? EqualityComparer<TKey>.Default;
         if (typeof(TKey) == typeof(string) && NonRandomizedStringEqualityComparer.GetStringComparer(_comparer!) is IEqualityComparer<string> stringComparer)
         {
          _comparer = (IEqualityComparer<TKey>)stringComparer;
         }
      }
      else if (comparer is not null && comparer != EqualityComparer<TKey>.Default)
      {
         _comparer = comparer;
     }
 }

以上的实现逻辑重点包含了两个部分,第一部分:对Dictionary<TKey, TValue>的容量初始化;第二部分是Dictionary<TKey, TValue>的IEqualityComparer? comparer的初始化,本文重点是对Dictionary<TKey, TValue>的存储结构进行分析,涉及到比较器的实现逻辑,将放在后续的章节中进行重点介绍。

我们接下来看一下Initialize()的实现逻辑进行一个简单的介绍,首先一起来看一下对应的源码实现(非必要部分已做删减,方便大家可以直观的查看)。

private int Initialize(int capacity)
{
  int size = HashHelpers.GetPrime(capacity);
  int[] buckets = new int[size];
  Entry[] entries = new Entry[size];
  _freeList = -1;
#if TARGET_64BIT
  _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)size);
#endif
  _buckets = buckets;
  _entries = entries;
  return size;
}

从上面的源码可以看出,根据传入的capacity参数来设定字典对应的相关容量大小,其中包含两部分,第一部分: 根据设定的容量(capacity)大小,计算对应的buckets和entries大小,关于为什么使用buckets和entries两个数组结构,我们将在下一节重点介绍;第二部分:判断当前机器的位数,计算对应的_fastModMultiplier。我们看一下HashHelpers.GetPrime(capacity)的计算逻辑。(该类在System.Collections命名空间下,其对应的类型定义为:internal static partial class HashHelpers)

public static int GetPrime(int min)
{
  foreach (int prime in Primes)
  {
    if (prime >= min) return prime;
    for (int i = (min | 1); i < int.MaxValue; i += 2)
    {
        if (IsPrime(i) && ((i - 1) % HashPrime != 0)) return i;
     }
     return min;
   }
}

HashHelpers用于计算和维护哈希表容量的素数值,为什么哈希表需要使用素数?主要是为了减少哈希冲突(hash collisions)的发生,素数的选择能够减少共同的因子,减小哈希冲突的可能性。此外,选择素数还能够确保在哈希表的容量变化时,不容易出现过多的重复。如果容量选择为一个合数(非素数),那么在容量变化时,可能会导致新容量与旧容量有相同的因子,增加哈希冲突的风险。

接下来我们沿着GetPrime()的调用关系来看整个哈希表容量的计算逻辑,HashHelpers设定了一个Primes[]的只读素数数组,具体的元素如下,至于什么使用这样的素数的数组,主要是这些素数在实践中已经被证明是有效的,适用于许多常见的使用场景,更多的是有助于在哈希表等数据结构中提供更好的性能。

internal static ReadOnlySpan<int> Primes => new int[]
{
  3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919,
  1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,
  17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437,
  187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263,
  1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369
};

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的实际的数据结构:

private struct Entry
{
  public uint hashCode;
  public int next;
  public TKey key;
  public TValue value;
}

在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类型的数组,这样的数组通常用于存储哈希桶的信息。每个桶实际上是一个索引,指向一个链表或链表的头部,用于解决哈希冲突。

private ref int GetBucket(uint hashCode)
{
   int[] buckets = _buckets!;
 #if TARGET_64BIT
   return ref buckets[HashHelpers.FastMod(hashCode, (uint)buckets.Length, _fastModMultiplier)];
 #else
   return ref buckets[(uint)hashCode % buckets.Length];
 #endif
 }

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()的实现逻辑。

private void Resize() => Resize(HashHelpers.ExpandPrime(_count), false);

private void Resize(int newSize, bool forceNewHashCodes)
{
   Entry[] entries = new Entry[newSize];
   int count = _count;
   Array.Copy(_entries, entries, count);
   _buckets = new int[newSize];
#if TARGET_64BIT
   _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)newSize);
#endif
   for (int i = 0; i < count; i++)
   {
      if (entries[i].next >= -1)
      {
        ref int bucket = ref GetBucket(entries[i].hashCode);
        entries[i].next = bucket - 1;
        bucket = i + 1;
       }
    }
   _entries = entries;
}

由以上的源码(不涉及数值类型的部分做了删减)可以看出,HashHelpers.ExpandPrime(_count)计算新的Entry[]大小,那我们来具体看一下这个新的数组大小的计算逻辑是如何实现的。

public static int ExpandPrime(int oldSize)
{
   int newSize = 2 * oldSize;
   if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize) return MaxPrimeArrayLength;
   return GetPrime(newSize);
}

对于新的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(),首先我们来看一下其实现的源码。

internal ref TValue FindValue(TKey key)
{
  ref Entry entry = ref Unsafe.NullRef<Entry>();
  if (_buckets != null)
  {
    uint hashCode = (uint)key.GetHashCode();
    int i = GetBucket(hashCode);
    Entry[]? entries = _entries;
    uint collisionCount = 0;
    i--;
    do
      {
        if ((uint)i >= (uint)entries.Length)
        {
           goto ReturnNotFound;
        }
        entry = ref entries[i];
        if (entry.hashCode == hashCode && EqualityComparer<TKey>.Default.Equals(entry.key, key))
        {
           goto ReturnFound;
        }
        i = entry.next;
        collisionCount++;
      } while (collisionCount <= (uint)entries.Length);
         goto ConcurrentOperation;
    }
      goto ReturnNotFound;
       ConcurrentOperation:
            ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
        ReturnFound:
            ref TValue value = ref entry.value;
        Return:
            return ref value;
        ReturnNotFound:
            value = ref Unsafe.NullRef<TValue>();
            goto Return;
}

以上的源码中,对于计算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)、解读源码时,可以先选择一个简单的场景,尽量剔除与需要验证场景无关的代码,集中核心逻辑进行分析,然后再逐步进行扩展思考。

到此这篇关于详解C#中Dictionary<TKey,TValue>的存储结构的文章就介绍到这了,更多相关C# Dictionary<TKey,TValue>内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文