DotNet Dictionary 实现的原理及特点有哪些?
- 内容介绍
- 文章标签
- 相关推荐
本文共计8675个文字,预计阅读时间需要35分钟。
一:前言本篇主要探讨笔者对DotNet中Hashtable与Dictionary的认识。一直以来,在直接使用上,两者都被集成在一起,直接使用object类型,并可通过泛型使用。以前也仅大致看过Hashtable的实现。最近查MSDN时发现,建议开发者使用Dictionary替代Hashtable。
一:前言 本来笔者对DotNet的Hashtable及Dictionary认识一直集中在使用上,一个直接用object 一个可以用泛型,以前也只大概看过Hashtable的实现。最近查MSDN时发现有建议开发者使用Dictionary代替Hashtable的描述,出于好奇测试了Hashtable及Dictionary读写性能,发现无论读还是写Dictionary都大幅领先Hashtable,然后就花时间整理了Dictionary操作逻辑试图找到这种性能提升的原因(最后会发现实现上的差异带来的性能明显提升也算的上是理所当然)。下文实际是介绍的Dictionary的实现(调试中使用的源是corefx 3.1),其中穿插着对比了Hashtable的实现逻辑。 二:Dictionary成员介绍 先简单介绍下Dictionary里的主要成员(source.dot.net/#System.Private.CoreLib/Dictionary.cs) 如果您之前很少有关注过DIctionary或类似集合的实现,下面对成员的解释可能看起来会有些跳跃,不过您还是可以通过查看这些成员介绍形成一个大概的印象,后面一章节的内容会较详细的向您介绍Dictionary的操作细节会反复涉及到这些关键成员。 下文将讲述的Dictionary集合的实现都是基于DotNet平台的,不过主流托管平台/语言对于BLC基础库的实现都有很多相似的地方,即使您不使用DotNet平台也可以看一下,文中会极少的直接粘贴BLC源代码。 2.1:主要成员 private int[]? _buckets; 槽列表(HashTable也是靠槽位的概念来快速找到元素的,不过不会有个专门的单独列表) private Entry[]? _entries; 实体列表 private int _count; 被填充的实体数量(不算被删除的数量,所以Count属性的值实际是 _count - _freeCount ) private int _freeList; 空实体索引(默认-1表示没有,如果有在TryInsert的时候就会往这个索引地址填充) private int _freeCount; 空实体数量(指定entry被填充,然后又被清除就出现一个空实体,后面还没有被填充的实体不计入此数量) private int _version; 版本(实体发生实质改动时++,用于遍历时确认列表有没有发生变化,如果有变化抛异常) private IEqualityComparer? _comparer; Key比较器 private KeyCollection? _keys; Key列表(数据源自_entries) private ValueCollection? _values; Value列表(数据源自_entries) private const int StartOfFreeList = -3; 计算free entry 的next 值的一个固定常量(next 默认0) 2.2:_buckets _buckets 上面已经提到,因为他是一个较重要的成员这里再单独列出来进一步说明。 _buckets是一个int数组,结构比较简单,数组大小是当前Dictionary的实际容量大小,不是Count的值(这个很大可能并不是初始化时用户指定的大小) _buckets数组里的每个元素实际上包含2个关键信息- index: 这个索引很重要,通过 [hashcode(key)%_buckets.len] 确定指定key应该落到的索引位置(不用遍历key,通过轻量计算可以快速直接找到数据)
- value: value为int类型实际上也是一个索引,这个索引指向了_entries数组里的真正目标实体(_buckets并没有直接放数据内容,但HashTable里是直接把内容都放到bucket[]里的)
private struct Entry { public uint hashCode; /// <summary> /// 0-based index of next entry in chain: -1 means end of chain /// also encodes whether this entry _itself_ is part of the free list by changing sign and subtracting 3, /// so -2 means end of free list, -3 means index 0 but on free list, -4 means index 1 but on free list, etc. /// </summary> public int next; public TKey key; // Key of entry public TValue value; // Value of entry }
_entries直接存放Dictionary数据实体,加上数组的索引,每个元素有5个关键信息(索引实际不属于Entry结构的内容,方便说明放在一起解释)- index:这个索引就是_buckets里value的对应的值,key算出hashcode后先找到指定的bucket,碰撞发送时通过其value定位到_entries指定实体
- hashCode:key的hashcode ,这里的hashcode是uint(HashTable的hashCode是不用最高位的,他的最高位1表示发生碰撞,而Dictionary使用next标记碰撞,所以会保留hashcode所有位,hashcode默认0,被填充后写入当前key的hashcode,存储hashcode是为了对比方便,key的比较先比较code会快很多,code不一样key肯定不一样,code一样key才可能一样,在当前entry被remove时,hashcode不更新,因为没有必要更新,int为值类型,数据0与12345654321消耗都是一样的,而且这里也没有用0表示特殊含义)
- key: 存储TKey
- value: 存储TValue
- next: 比hashcode多出来的一个数据,不仅标记碰撞还记录了碰撞数据的完整链路,同时也标记了空槽的完整链路。
- Dotnet Source Browser :source.dot.net/#System.Private.CoreLib/Dictionary.cs
- MSDN :docs.microsoft.com/zh-cn/dotnet/api/system.collections.generic.dictionary-2?redirectedfrom=MSDN&view=net-5.0
- 数据是以怎样的顺序存入的
- 如何高效查找数据(不通过遍历的方式)
- 如何处理碰撞
- 移除数据产生的空闲位如何重复利用
- 如何扩容
- 注意文中流程图是根据dotnet core 3.1 fx 的实际源代码省略了部分与元素存储关联不强的逻辑绘制出来的 。(不同版本coreFX 可能会略有不同)
- 涉及到流程图虽然有简化部分逻辑,但是重要核心逻辑已多次核验,这里建议您可以对照Dotnet Source Browser里的源代码一起看。
- 因为官方源代码相关逻辑篇幅大且有许多关联性,如果只贴一部分很难描述全面,所以下文中会尽量避免直接贴代码,而是选择将源代码转换为流程图或图表进行介绍。
现在如果没有发生碰撞,会进入「图-TryInsert」的步骤12开始找合适的地方进行插入。(这个我们待会再看)
在这之前我们先来看下碰撞发生时Dictionary的处理机制,现在请关注「图-TryInsert」的步骤7,步骤11,他们是处理碰撞的关键。 检查到碰撞(如果_buckets[hash%len]是一个大于0的数字即表示有碰撞发生,与这个Key碰撞的数据就是entries[_buckets[hash%len] -1] )可能确实是遇到了一个重复的Key,所以需要在步骤7里进行检查,如果Key确实是重复的那很简单直接更新掉,如果现在是插入模式就抛出异常即可扩容的逻辑相对简单,新的大小为GetPrime(2*oldSize),需要注意的是老的entries被复制到新数组后由于len发生了变化_bucket及entries[i].next都需要重新计算并更新。hashcode用新的size求余得到bucket(这里的bucket代表的是buckets数组的一个索引),并将entries[i].next指向bucket之前指向的数据,再更新bucket的值为当前entry的索引,注意这里并没有像TryInsert一样去碰撞链中对比Key的实际值是否相等,因为扩容的数据都是老数据是不可能有重复Key的。
- entry.hashCode = hashCode; //更新hashCode
- entry.next = bucket - 1; //更新next 如果没有碰撞初始buctet是0,next就会是-1,否则next会是碰撞链的下一个索引
- entry.key = key; //填充key
- entry.value = value; //填充value
- bucket = index + 1; //更新bucket的新值指向当前entry,下次再有hashcode命中这个bucket就会先找到当前entry开始碰撞流程
- _version++; //更新数据版本,遍历时需要确保版本一致
「图-删除元素」
上图简单的表述了一次删除中next在碰撞链及空闲链中的变化(上图省略了buckets的变化),上图entries上方的粉红虚线为空闲链,下方紫色实线为一条碰撞链(注意在entries中空闲链只会有一条,而碰撞链会有很多,上图只画了一条)。在「图-删除元素」我们称上面删除发生前的entries为状态1,下面删除后的为状态2。
现在我们先看状态1中的entries的空闲链,它的_freeList指向了entries[6],entries[6]就是空闲链的开始,然后-3-entries[6].next =3 就是空闲链的下一个索引为3即entries[3],这个时候我们发现entries[3].next已经是-2了,表示它已经是链尾了。 然后我们再来看下碰撞链,碰撞链从entries[10]开始,entries[10]的next是8,它就会链接到entries[8],以此类推一直找到链尾entries[2]「图-FindEntry」
如上图是dictionary中通过key查找元素的关键代码的流程图,可以看到上面的流程几乎都在TryInsert及Remove中出现过,因为在新增及删除时其实都需要通过Key在dictionary里查找一遍有没有同样的Key,而查找逻辑是一致的,这里就不再重复讲述了。 四:解析一组操作的实际处理过程 4.1:实例介绍 通过上文我们已经知道dictionary是如何通过buckets与entries维护数据的,但无论如何2个数组之间的关系的确有些绕,要完全搞清楚里面的逻辑最好还是有实际的数据实例,接下来我们通过一个简单的例子来看下buckets,entries里的内容是如何变化的。(实例代码代码会触发新增,删除,扩容,碰撞及对空槽位的重复利用等关键步骤)var dc = new Dictionary<string, string>(1); dc.Add("1", "11"); dc.Add("2", "22"); dc.Add("3", "33"); dc.Add("4", "44"); dc.Remove("1"); dc.Remove("3"); dc.Add("1", "11"); dc.Add("5", "55");
代码如上,我们每运行一行,为buckets,entries里的数据做一次快照,分析其中的关系。
当运行new Dictionary<string, string="">(1)时,dc完成初始化,执行Initialize,上文已经提到过这个初始化函数它并不是用1来作为dc的容量,它使用大于1的第一个素数即3,所以执行初始化后dc的size会变成3,bucket与entries里的数据也都是初始值,int类型全部为0,引用类型为null。同时上图左下角记录了部分关键变量的值,注意_freeList为-1表示没有由于删除导致的空位,其余的都是默认值0
- 扩容
- 插入
现在我们看一下删除逻辑同时跟踪一下空槽的逻辑,删除Key:“1”也是一样先计算hashcode(key)%len结果是3,通过buckets[3]的值3,可以先找到元素entries[2]进行对比(2是通过buckets[3]-1计算得出),对比Key后发现entries[2].key不是要找的值,继续查找entries[0](0是通过entries[2].next得到),确认entries[0]为目标元素后直接移除key,value对。
- _version的作用
现在我们再删除一个数据,看看空闲链的dictionary里是如何维护的,与前面一步的删除类似,我们先计算Key:“3”的hashcod,hashcod%7=3 我们找到buckets[3]的值3,用3-1的到2,那entries[2]就是我们要找的链首,直接对比entries[2].key发现就是我们要找的元素,与前面的步骤一样移除。不过这里entries[2].next在移除前是-1,代表entries[2]其实是没与集合里的其他元素有碰撞的,所以没有碰撞链需要更新。不过这里还有一点与之前删除Key:“1”不一样,删除Key:“1”时被删除的值是碰撞链里的元素且不是链首,所以其实buckets里的值是不用更新的,不过现在删除的数据没有碰撞链(如果是碰撞链首也是一样处理),所以需要将buckets[3]的值更新为entries[2].next+1即为0。
现在我们来看下空闲链,同样_freeList现在变成了2 指向刚刚被删除的元素,_freeCount也递增1,不过现在有2个空位了,_freeList指向第一个空位,那第二空位(后面的空位)在什么地方,如何标记呢,现在entry的next属性又要发挥作用了,他用StartOfFreeList-next的值来标记空闲链的下一个(StartOfFreeList就是-3 我们可以用 |next|-3 这个来方便表示及计算),这个处理方式前文已经介绍过,这里就不重复描述了。我们直接看到 entries[2].next他现在应该变为StartOfFreeList - _freeList (这个_freeList是之前的_freeList为0)即-3-0=-3,所以我们看到当前的next变成了-3。-3 的指向就是 |-3|-3=0,表示此空位的下一个空位是entries[0]- Dictionary当然除了在泛型上的优势外,由于使用了2个数组维护数据,数据利用率更高,查找,插入,删除都会更快(原因上文其实都有对比提到)。只有在数据量小的时候Hashtable少用一个数组,每个元素也少一个next的属性,其内存占用可能会小一点,不过随着数据存储的越来越多,这个优势会被抹平,因为Dictionary数据数组利用率高。
- 还有一个区别,默认版本Dictionary不是线程安全的,而Hashtable是线程安全的,这意味着Hashtable可以在多个线程在直接被操作,应用开发者不用考虑安全问题。不过这算不上是Hashtable的一个优点,只是它的一个特点,Hashtable在内部实现更新操作有加锁,而Dictionary没有,如果想在多线程条件下操作Dictionary需要自己加锁。
1 string[] dataStrs = new string[1000000]; 2 for (int i = 0; i < 1000000; i++) 3 { 4 dataStrs[i] = i.ToString(); 5 } 6 var testDc = new Dictionary<string, string>(); 7 //var testDc = new Hashtable(); 8 Stopwatch sw = new Stopwatch(); 9 Console.WriteLine("any key to start"); 10 Console.ReadLine(); 11 Console.WriteLine("ing......"); 12 13 for (int j = 0; j < 10; j++) 14 { 15 testDc = new Dictionary<string, string>(); 16 //testDc = new Hashtable(); 17 sw.Restart(); 18 sw.Start(); 19 for (int i = 0; i < 1000000; i++) 20 { 21 testDc.Add(dataStrs[i], dataStrs[i]); 22 } 23 sw.Stop(); 24 Console.WriteLine($"time {sw.ElapsedMilliseconds}"); 25 Console.ReadLine(); 26 } 27 Console.WriteLine("end of test"); 28 Console.ReadLine(); 使用以上代码对Dictionary与Hashtable的插入性能进行简单对比。 如上图测试结果左边为Hashtable,右边为Dictionary,可以看到仅从插入性能上来看Dictionary的确是要大幅优于Hashtable。 如果您对读取性能进行对比测试也会得到类似结果。 前面已经提过Dictionary借助将buckets槽位信息与entries数据数组分离及对新增next属性的利用让Dictionary的碰撞查找计算量大幅低于Hashtable,同时数据空间的利用率也得到了提高,这也是以上测试结果会有如此大差距的主要原因。 六:后记
篇幅比较长,但是一直都是围绕同一个内容进行讲述。笔者会尽力让描述前后有联系,并避免过多介绍孤立的无关信息。事实上文章的草稿(或者叫个人笔记)一年前就完成了(所以大家也看到调试时其实没有使用最新的.Net6)。而即便是自己写的内容时隔一年再来回看,也能发现很多细节也并不是读一遍自己就能秒懂的。如果之前没有关注过这些看完会花点时间,但我相信是会有价值的。
本文大多数图表,示例及描述其实也是经过反复修改,并对照实现代码核对或逐行调试而得出来的。但即便如此限于自身水平或认知上的限制也难免会有错误或不全面的表述,大家在阅读过程中如果发现有纰漏的地方,可以以任何方式提出(mycllq@hotmail.com因为自由互联似乎不允许访客留言,这里也留个邮箱方便未注册用户),我会在确认后第一时间更正。
本文共计8675个文字,预计阅读时间需要35分钟。
一:前言本篇主要探讨笔者对DotNet中Hashtable与Dictionary的认识。一直以来,在直接使用上,两者都被集成在一起,直接使用object类型,并可通过泛型使用。以前也仅大致看过Hashtable的实现。最近查MSDN时发现,建议开发者使用Dictionary替代Hashtable。
一:前言 本来笔者对DotNet的Hashtable及Dictionary认识一直集中在使用上,一个直接用object 一个可以用泛型,以前也只大概看过Hashtable的实现。最近查MSDN时发现有建议开发者使用Dictionary代替Hashtable的描述,出于好奇测试了Hashtable及Dictionary读写性能,发现无论读还是写Dictionary都大幅领先Hashtable,然后就花时间整理了Dictionary操作逻辑试图找到这种性能提升的原因(最后会发现实现上的差异带来的性能明显提升也算的上是理所当然)。下文实际是介绍的Dictionary的实现(调试中使用的源是corefx 3.1),其中穿插着对比了Hashtable的实现逻辑。 二:Dictionary成员介绍 先简单介绍下Dictionary里的主要成员(source.dot.net/#System.Private.CoreLib/Dictionary.cs) 如果您之前很少有关注过DIctionary或类似集合的实现,下面对成员的解释可能看起来会有些跳跃,不过您还是可以通过查看这些成员介绍形成一个大概的印象,后面一章节的内容会较详细的向您介绍Dictionary的操作细节会反复涉及到这些关键成员。 下文将讲述的Dictionary集合的实现都是基于DotNet平台的,不过主流托管平台/语言对于BLC基础库的实现都有很多相似的地方,即使您不使用DotNet平台也可以看一下,文中会极少的直接粘贴BLC源代码。 2.1:主要成员 private int[]? _buckets; 槽列表(HashTable也是靠槽位的概念来快速找到元素的,不过不会有个专门的单独列表) private Entry[]? _entries; 实体列表 private int _count; 被填充的实体数量(不算被删除的数量,所以Count属性的值实际是 _count - _freeCount ) private int _freeList; 空实体索引(默认-1表示没有,如果有在TryInsert的时候就会往这个索引地址填充) private int _freeCount; 空实体数量(指定entry被填充,然后又被清除就出现一个空实体,后面还没有被填充的实体不计入此数量) private int _version; 版本(实体发生实质改动时++,用于遍历时确认列表有没有发生变化,如果有变化抛异常) private IEqualityComparer? _comparer; Key比较器 private KeyCollection? _keys; Key列表(数据源自_entries) private ValueCollection? _values; Value列表(数据源自_entries) private const int StartOfFreeList = -3; 计算free entry 的next 值的一个固定常量(next 默认0) 2.2:_buckets _buckets 上面已经提到,因为他是一个较重要的成员这里再单独列出来进一步说明。 _buckets是一个int数组,结构比较简单,数组大小是当前Dictionary的实际容量大小,不是Count的值(这个很大可能并不是初始化时用户指定的大小) _buckets数组里的每个元素实际上包含2个关键信息- index: 这个索引很重要,通过 [hashcode(key)%_buckets.len] 确定指定key应该落到的索引位置(不用遍历key,通过轻量计算可以快速直接找到数据)
- value: value为int类型实际上也是一个索引,这个索引指向了_entries数组里的真正目标实体(_buckets并没有直接放数据内容,但HashTable里是直接把内容都放到bucket[]里的)
private struct Entry { public uint hashCode; /// <summary> /// 0-based index of next entry in chain: -1 means end of chain /// also encodes whether this entry _itself_ is part of the free list by changing sign and subtracting 3, /// so -2 means end of free list, -3 means index 0 but on free list, -4 means index 1 but on free list, etc. /// </summary> public int next; public TKey key; // Key of entry public TValue value; // Value of entry }
_entries直接存放Dictionary数据实体,加上数组的索引,每个元素有5个关键信息(索引实际不属于Entry结构的内容,方便说明放在一起解释)- index:这个索引就是_buckets里value的对应的值,key算出hashcode后先找到指定的bucket,碰撞发送时通过其value定位到_entries指定实体
- hashCode:key的hashcode ,这里的hashcode是uint(HashTable的hashCode是不用最高位的,他的最高位1表示发生碰撞,而Dictionary使用next标记碰撞,所以会保留hashcode所有位,hashcode默认0,被填充后写入当前key的hashcode,存储hashcode是为了对比方便,key的比较先比较code会快很多,code不一样key肯定不一样,code一样key才可能一样,在当前entry被remove时,hashcode不更新,因为没有必要更新,int为值类型,数据0与12345654321消耗都是一样的,而且这里也没有用0表示特殊含义)
- key: 存储TKey
- value: 存储TValue
- next: 比hashcode多出来的一个数据,不仅标记碰撞还记录了碰撞数据的完整链路,同时也标记了空槽的完整链路。
- Dotnet Source Browser :source.dot.net/#System.Private.CoreLib/Dictionary.cs
- MSDN :docs.microsoft.com/zh-cn/dotnet/api/system.collections.generic.dictionary-2?redirectedfrom=MSDN&view=net-5.0
- 数据是以怎样的顺序存入的
- 如何高效查找数据(不通过遍历的方式)
- 如何处理碰撞
- 移除数据产生的空闲位如何重复利用
- 如何扩容
- 注意文中流程图是根据dotnet core 3.1 fx 的实际源代码省略了部分与元素存储关联不强的逻辑绘制出来的 。(不同版本coreFX 可能会略有不同)
- 涉及到流程图虽然有简化部分逻辑,但是重要核心逻辑已多次核验,这里建议您可以对照Dotnet Source Browser里的源代码一起看。
- 因为官方源代码相关逻辑篇幅大且有许多关联性,如果只贴一部分很难描述全面,所以下文中会尽量避免直接贴代码,而是选择将源代码转换为流程图或图表进行介绍。
现在如果没有发生碰撞,会进入「图-TryInsert」的步骤12开始找合适的地方进行插入。(这个我们待会再看)
在这之前我们先来看下碰撞发生时Dictionary的处理机制,现在请关注「图-TryInsert」的步骤7,步骤11,他们是处理碰撞的关键。 检查到碰撞(如果_buckets[hash%len]是一个大于0的数字即表示有碰撞发生,与这个Key碰撞的数据就是entries[_buckets[hash%len] -1] )可能确实是遇到了一个重复的Key,所以需要在步骤7里进行检查,如果Key确实是重复的那很简单直接更新掉,如果现在是插入模式就抛出异常即可扩容的逻辑相对简单,新的大小为GetPrime(2*oldSize),需要注意的是老的entries被复制到新数组后由于len发生了变化_bucket及entries[i].next都需要重新计算并更新。hashcode用新的size求余得到bucket(这里的bucket代表的是buckets数组的一个索引),并将entries[i].next指向bucket之前指向的数据,再更新bucket的值为当前entry的索引,注意这里并没有像TryInsert一样去碰撞链中对比Key的实际值是否相等,因为扩容的数据都是老数据是不可能有重复Key的。
- entry.hashCode = hashCode; //更新hashCode
- entry.next = bucket - 1; //更新next 如果没有碰撞初始buctet是0,next就会是-1,否则next会是碰撞链的下一个索引
- entry.key = key; //填充key
- entry.value = value; //填充value
- bucket = index + 1; //更新bucket的新值指向当前entry,下次再有hashcode命中这个bucket就会先找到当前entry开始碰撞流程
- _version++; //更新数据版本,遍历时需要确保版本一致
「图-删除元素」
上图简单的表述了一次删除中next在碰撞链及空闲链中的变化(上图省略了buckets的变化),上图entries上方的粉红虚线为空闲链,下方紫色实线为一条碰撞链(注意在entries中空闲链只会有一条,而碰撞链会有很多,上图只画了一条)。在「图-删除元素」我们称上面删除发生前的entries为状态1,下面删除后的为状态2。
现在我们先看状态1中的entries的空闲链,它的_freeList指向了entries[6],entries[6]就是空闲链的开始,然后-3-entries[6].next =3 就是空闲链的下一个索引为3即entries[3],这个时候我们发现entries[3].next已经是-2了,表示它已经是链尾了。 然后我们再来看下碰撞链,碰撞链从entries[10]开始,entries[10]的next是8,它就会链接到entries[8],以此类推一直找到链尾entries[2]「图-FindEntry」
如上图是dictionary中通过key查找元素的关键代码的流程图,可以看到上面的流程几乎都在TryInsert及Remove中出现过,因为在新增及删除时其实都需要通过Key在dictionary里查找一遍有没有同样的Key,而查找逻辑是一致的,这里就不再重复讲述了。 四:解析一组操作的实际处理过程 4.1:实例介绍 通过上文我们已经知道dictionary是如何通过buckets与entries维护数据的,但无论如何2个数组之间的关系的确有些绕,要完全搞清楚里面的逻辑最好还是有实际的数据实例,接下来我们通过一个简单的例子来看下buckets,entries里的内容是如何变化的。(实例代码代码会触发新增,删除,扩容,碰撞及对空槽位的重复利用等关键步骤)var dc = new Dictionary<string, string>(1); dc.Add("1", "11"); dc.Add("2", "22"); dc.Add("3", "33"); dc.Add("4", "44"); dc.Remove("1"); dc.Remove("3"); dc.Add("1", "11"); dc.Add("5", "55");
代码如上,我们每运行一行,为buckets,entries里的数据做一次快照,分析其中的关系。
当运行new Dictionary<string, string="">(1)时,dc完成初始化,执行Initialize,上文已经提到过这个初始化函数它并不是用1来作为dc的容量,它使用大于1的第一个素数即3,所以执行初始化后dc的size会变成3,bucket与entries里的数据也都是初始值,int类型全部为0,引用类型为null。同时上图左下角记录了部分关键变量的值,注意_freeList为-1表示没有由于删除导致的空位,其余的都是默认值0
- 扩容
- 插入
现在我们看一下删除逻辑同时跟踪一下空槽的逻辑,删除Key:“1”也是一样先计算hashcode(key)%len结果是3,通过buckets[3]的值3,可以先找到元素entries[2]进行对比(2是通过buckets[3]-1计算得出),对比Key后发现entries[2].key不是要找的值,继续查找entries[0](0是通过entries[2].next得到),确认entries[0]为目标元素后直接移除key,value对。
- _version的作用
现在我们再删除一个数据,看看空闲链的dictionary里是如何维护的,与前面一步的删除类似,我们先计算Key:“3”的hashcod,hashcod%7=3 我们找到buckets[3]的值3,用3-1的到2,那entries[2]就是我们要找的链首,直接对比entries[2].key发现就是我们要找的元素,与前面的步骤一样移除。不过这里entries[2].next在移除前是-1,代表entries[2]其实是没与集合里的其他元素有碰撞的,所以没有碰撞链需要更新。不过这里还有一点与之前删除Key:“1”不一样,删除Key:“1”时被删除的值是碰撞链里的元素且不是链首,所以其实buckets里的值是不用更新的,不过现在删除的数据没有碰撞链(如果是碰撞链首也是一样处理),所以需要将buckets[3]的值更新为entries[2].next+1即为0。
现在我们来看下空闲链,同样_freeList现在变成了2 指向刚刚被删除的元素,_freeCount也递增1,不过现在有2个空位了,_freeList指向第一个空位,那第二空位(后面的空位)在什么地方,如何标记呢,现在entry的next属性又要发挥作用了,他用StartOfFreeList-next的值来标记空闲链的下一个(StartOfFreeList就是-3 我们可以用 |next|-3 这个来方便表示及计算),这个处理方式前文已经介绍过,这里就不重复描述了。我们直接看到 entries[2].next他现在应该变为StartOfFreeList - _freeList (这个_freeList是之前的_freeList为0)即-3-0=-3,所以我们看到当前的next变成了-3。-3 的指向就是 |-3|-3=0,表示此空位的下一个空位是entries[0]- Dictionary当然除了在泛型上的优势外,由于使用了2个数组维护数据,数据利用率更高,查找,插入,删除都会更快(原因上文其实都有对比提到)。只有在数据量小的时候Hashtable少用一个数组,每个元素也少一个next的属性,其内存占用可能会小一点,不过随着数据存储的越来越多,这个优势会被抹平,因为Dictionary数据数组利用率高。
- 还有一个区别,默认版本Dictionary不是线程安全的,而Hashtable是线程安全的,这意味着Hashtable可以在多个线程在直接被操作,应用开发者不用考虑安全问题。不过这算不上是Hashtable的一个优点,只是它的一个特点,Hashtable在内部实现更新操作有加锁,而Dictionary没有,如果想在多线程条件下操作Dictionary需要自己加锁。
1 string[] dataStrs = new string[1000000]; 2 for (int i = 0; i < 1000000; i++) 3 { 4 dataStrs[i] = i.ToString(); 5 } 6 var testDc = new Dictionary<string, string>(); 7 //var testDc = new Hashtable(); 8 Stopwatch sw = new Stopwatch(); 9 Console.WriteLine("any key to start"); 10 Console.ReadLine(); 11 Console.WriteLine("ing......"); 12 13 for (int j = 0; j < 10; j++) 14 { 15 testDc = new Dictionary<string, string>(); 16 //testDc = new Hashtable(); 17 sw.Restart(); 18 sw.Start(); 19 for (int i = 0; i < 1000000; i++) 20 { 21 testDc.Add(dataStrs[i], dataStrs[i]); 22 } 23 sw.Stop(); 24 Console.WriteLine($"time {sw.ElapsedMilliseconds}"); 25 Console.ReadLine(); 26 } 27 Console.WriteLine("end of test"); 28 Console.ReadLine(); 使用以上代码对Dictionary与Hashtable的插入性能进行简单对比。 如上图测试结果左边为Hashtable,右边为Dictionary,可以看到仅从插入性能上来看Dictionary的确是要大幅优于Hashtable。 如果您对读取性能进行对比测试也会得到类似结果。 前面已经提过Dictionary借助将buckets槽位信息与entries数据数组分离及对新增next属性的利用让Dictionary的碰撞查找计算量大幅低于Hashtable,同时数据空间的利用率也得到了提高,这也是以上测试结果会有如此大差距的主要原因。 六:后记
篇幅比较长,但是一直都是围绕同一个内容进行讲述。笔者会尽力让描述前后有联系,并避免过多介绍孤立的无关信息。事实上文章的草稿(或者叫个人笔记)一年前就完成了(所以大家也看到调试时其实没有使用最新的.Net6)。而即便是自己写的内容时隔一年再来回看,也能发现很多细节也并不是读一遍自己就能秒懂的。如果之前没有关注过这些看完会花点时间,但我相信是会有价值的。
本文大多数图表,示例及描述其实也是经过反复修改,并对照实现代码核对或逐行调试而得出来的。但即便如此限于自身水平或认知上的限制也难免会有错误或不全面的表述,大家在阅读过程中如果发现有纰漏的地方,可以以任何方式提出(mycllq@hotmail.com因为自由互联似乎不允许访客留言,这里也留个邮箱方便未注册用户),我会在确认后第一时间更正。

