DotNet Dictionary 实现简介
一:前言
二:Dictionary成员介绍
2.1:主要成员
2.2:_buckets
- index: 这个索引很重要,通过 [ hashcode(key)%_buckets.len ] 确定指定key应该落到的索引位置(不用遍历key,通过轻量计算可以快速直接找到数据)
- value: value为int类型实际上也是一个索引,这个索引指向了_entries数组里的真正目标实体(_buckets并没有直接放数据内容,但HashTable里是直接把内容都放到bucket[]里的)
2.3:_entries
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
}
- 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多出来的一个数据,不仅标记碰撞还记录了碰撞数据的完整链路,同时也标记了空槽的完整链路。
2.4:官方文档
- Dotnet Source Browser : https://source.dot.net/#System.Private.CoreLib/Dictionary.cs
- MSDN : https://docs.microsoft.com/zh-cn/dotnet/api/system.collections.generic.dictionary-2?redirectedfrom=MSDN&view=net-5.0
三:Dictionary 运作过程介绍
- 数据是以怎样的顺序存入的
- 如何高效查找数据(不通过遍历的方式)
- 如何处理碰撞
- 移除数据产生的空闲位如何重复利用
- 如何扩容
3.1:新增元素(TryInsert)
插入流程概要
- 注意文中流程图是根据dotnet core 3.1 fx 的实际源代码省略了部分与元素存储关联不强的逻辑绘制出来的 。(不同版本coreFX 可能会略有不同)
- 涉及到流程图虽然有简化部分逻辑,但是重要核心逻辑已多次核验,这里建议您可以对照Dotnet Source Browser里的源代码一起看。
- 因为官方源代码相关逻辑篇幅大且有许多关联性,如果只贴一部分很难描述全面,所以下文中会尽量避免直接贴代码,而是选择将源代码转换为流程图或图表进行介绍。
分析插入如何执行
GetPrime(capacity) 实际是大于capacity的最小素数
因为这个数组的容量len会用在 hashcode%len 上计算元素应优先该落在哪个槽位,len使用素数会减少碰撞的发生从而提升读写效率(其实自己也没有搞明白为什么len使用素数求余就分布的更均匀,期待有了解的同学解答)
|
在Dictionary使用中您会发现,如果只插入数据不删除数据,那遍历的结果其实是有序的,它会与您插入时的顺序维持一致,不过MSDN上明确说明“返回项的顺序是不确定的”,因为在删除发生时,顺序就会变的不那么可控(不过本文将向您描述这种不确定的规则,您会发现它的顺序虽然不是完全按照插入的时序排列的,但它是有一个确定的规则的,最终您会发现您在某种程度上是可以完全控制他的顺序的)
|
这里_buckets[hash%len] -1 之所以还要-1,也是因为int默认值是0,而0也是一个正常的索引位置,为了让默认值不能表示任何数组索引需要-1
|
现在如果没有发生碰撞,会进入「图-TryInsert」的步骤12开始找合适的地方进行插入。(这个我们待会再看)
注意更新时直接更新value即可,槽位信息_buckets及entry的hashcode,next值都是不用更新的,因为他们都没有发生改变,不过注意步骤7里_version此时会+1 ,这个_version用于遍历时数据版本的检查,后面会单独提到。
|
关于next的值,在数据被插入时会进行赋值entry.next = bucket - 1
前面讲过bucket的默认值是0,那next的默认值就是0-1=-1
而当有碰撞的情况下,bucket的值就是直接发生的那一个entry的索引+1,这种情况下next值就正好会是与当前entry发生碰撞的第一个entry的索引,entries也就是靠next维持着碰撞链路,一个hashcode可能会碰撞许多次,他们利用next形成一个链表,查询时只需要查询这个链表即可,如果链表查到底都没有发现重复的Key,那这个Key就是一个新的Key,直接插入即可。同时新插入的entry会成为前面碰撞链表的头一个数据。
|
扩容的逻辑相对简单,新的大小为GetPrime(2*oldSize),需要注意的是老的entries被复制到新数组后由于len发生了变化_bucket及entries[i].next都需要重新计算并更新。hashcode用新的size求余得到bucket(这里的bucket代表的是buckets数组的一个索引),并将entries[i].next指向bucket之前指向的数据,再更新bucket的值为当前entry的索引,注意这里并没有像TryInsert一样去碰撞链中对比Key的实际值是否相等,因为扩容的数据都是老数据是不可能有重复Key的。
entries复制到新数组后,看起来next似乎是不用更新的,因为这些元素的碰撞链看似不会变。但实际上并非如此,因为数组的长度变了,那每个元素的hashcode%len的值会发生变化,这个碰撞链也会随之发生变化
|
dictionary扩容是发生在entries被耗尽的下一次插入时,而HashTable的扩容是发生在count>hashsize*loadFactor时,负载因子默认0.72。因为HashTable没有单独的_buckets维护槽位信息,在元素数量接近hashsize时由插入位已被使用而导致碰撞概率将显著提高,所以需要提前扩容。
|
这里还有一个细节,在使用空位时,_count其实并没有+1,实际上_count只是标记着_entries被使用到的位置,并不是整个dictionary的大小,dictionary的Count属性是通过_count - _freeCount 计算得出来的。
|
- 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++; //更新数据版本,遍历时需要确保版本一致
3.2:删除元素(Remove)
我们知道bucket默认值是0,一旦发生数据插入他的值会被改为entries的index+1。如果一个hashcode对应的bucket的值是0,那说明这个key不可能曾经被插入过,所以不用搜索也知道key在dictionary里肯定不存在,直接返回false就可以了。
|
这里的空闲链也是通过next来维护的,我们知道next在entries中已经被用来维持碰撞链,不过entries对next的利用十分充分,它将同时用来维护空闲链。
next=-1 代表当前元素有值且没有任何碰撞或位于碰撞链尾部
next>=0 代表当前元素有值且处于碰撞链中,next的值就是下一个碰撞元素的索引
next=-2 代表当前元素已经被删除,且当前空位位于空闲链尾部(如果只有一个空闲,它同时也是首部)
next<-2 代表当前元素已经被删除, |next|-3 表示空闲链的下个(最后一个next一定是-2)
|
「图-删除元素」
上图简单的表述了一次删除中next在碰撞链及空闲链中的变化(上图省略了buckets的变化),上图entries上方的粉红虚线为空闲链,下方紫色实线为一条碰撞链(注意在entries中空闲链只会有一条,而碰撞链会有很多,上图只画了一条)。在「图-删除元素」我们称上面删除发生前的entries为状态1,下面删除后的为状态2。
虽然我们现在要删除的是entries[8],不过我们通过key只能先找到entries[10],这是因为我们在插入entries[10]的时候,利用hashcode检查到碰撞将会将entries[10]放在碰撞链首
|
不难发现对应dictionary来说是通过bucket的值,碰撞链来共同确认元素是不是被删除(存在),而对HashTable来说他主要通过使用其Key的值是不是null来确认(当然HashTable也需要借助自己的碰撞链完成确认)
|
3.3:元素的获取(FindEntry)
「图-FindEntry」
四:解析一组操作的实际处理过程
4.1:实例介绍
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里的数据做一次快照,分析其中的关系。
借助微软Soucrce Link,现在您不用自己编译corefx,就可以直接调试fx里基础库的源代码。
下文图例中buckets,entries结构里的数据都是真实的数据(内存里就是这些确切的值),不是为了演示而创造的示例数据。
|
4.2:new Dictionary
当运行new Dictionary<string, string="">(1)时,dc完成初始化,执行Initialize,上文已经提到过这个初始化函数它并不是用1来作为dc的容量,它使用大于1的第一个素数即3,所以执行初始化后dc的size会变成3,bucket与entries里的数据也都是初始值,int类型全部为0,引用类型为null。同时上图左下角记录了部分关键变量的值,注意_freeList为-1表示没有由于删除导致的空位,其余的都是默认值0
这里还有一点需要说明,我们在实际应用中我们一般不会指定Dictionary的大小,默认他会使用0去初始化,在这种情况下初始化时是不会去创建buckets及entries数组的,dictionary会在真实发生插入时再去创建这2个数组,创建出来的内容与上图的一致。
|
4.3:Add("1", "11")
4.4:Add("2", "22")
其实可以看出来在没有空位的情况下,插入就是按顺序一个接一个存放在entries数据数组里的,这与HashTable就有很大区别,HashTable实际只有一个buckets数组,数据及槽位信息都是放在一起的,HashTable的索引同时指向他们,所以HashTable数据不可能是以类似顺序的形式插入。
|
4.4:Add("3", "33")
4.6:Add("4", "44")
- 扩容
可以看到因为dictionary的插入是尽可能的顺序插入的,它可以充分利用数组的容量,可以在数组完全满了之后再进行扩容,而HashTable会按负载因子提前很多进行扩容,并且在扩容时dictionary可以尽可能的使用数组复制来完成,而HashTable则几乎是对所有元素重新进行一遍插入。
|
- 插入
4.7:dc.Remove("1")
现在我们看一下删除逻辑同时跟踪一下空槽的逻辑,删除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对。
有个细节我们通过上图可以看到虽然entries[0]元素是被删除的数据位,不过它的hashCode确并没有更新,因为对于删除后的空位其hashCode不会有任何作用,而我们知道对于值类型的数据entry是直接存储数据本身的,把这个数据置为0会增加开销而产生不了任何作用,而对于引用类型的数据则一定要置为null,因为他们是以引用索引的形式存在entry上的,如果不把这个引用指针断开,这些对象在GC时是将无法被释放。
|
- _version的作用
看来之前的认知要改一下了,至少在dotnet core3.0 及以后的版本Dictionary里foreach时是可以Remove的。(其他的版本没有去尝试,大家有兴趣也可以去验证下)
|
4.8:dc.Remove("3")
现在我们再删除一个数据,看看空闲链的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。
讲到这里我们可以看出来住dictionary里的entries数组中仅通过next属性就可以完全确认碰撞及空位,整个查找过程都非常简单只是简单的+-操作,而在HashTable中就会相对复杂,由于没有next来标记链HashTable里会反复通过(int)(((long)bucketNumber + incr) % (uint)_buckets.Length类似计算寻找下一个元素进行对比。
|
4.9:Add("1", "11")
4.10:Add("5", "55")
五:Dictionary与Hashtable执行速度简单对比
- 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();
六:后记
篇幅比较长,但是一直都是围绕同一个内容进行讲述。笔者会尽力让描述前后有联系,并避免过多介绍孤立的无关信息。事实上文章的草稿(或者叫个人笔记)一年前就完成了(所以大家也看到调试时其实没有使用最新的.Net6)。而即便是自己写的内容时隔一年再来回看,也能发现很多细节也并不是读一遍自己就能秒懂的。如果之前没有关注过这些看完会花点时间,但我相信是会有价值的。
本文大多数图表,示例及描述其实也是经过反复修改,并对照实现代码核对或逐行调试而得出来的。但即便如此限于自身水平或认知上的限制也难免会有错误或不全面的表述,大家在阅读过程中如果发现有纰漏的地方,也十分还原您以任何方式提出( mycllq@hotmail.com 因为博客园似乎不允许访客留言,这里也留个邮箱方便未注册用户),我会在确认后第一时间更正。
DotNet Dictionary 实现简介的更多相关文章
- .net学习网站汇总
http://chs.gotdotnet.com/quickstart/简介:本站点是微软.NET技术的快速入门网站,我们不必再安装.NET Framework中的快速入门示例程序,直接在网上查看此示 ...
- .NET 2.0 参考源码索引
http://www.projky.com/dotnet/2.0/Microsoft/CSharp/csharpcodeprovider.cs.htmlhttp://www.projky.com/do ...
- Redis小白入门系列
一.从NoSQL说起 NoSQL 是 Not only SQL 的缩写,大意为"不只是SQL",说明这项技术是传统关系型数据库的补充而非替代.在整个NoSQL技术栈中 MemCac ...
- C#中的Dictionary简介
简介在C#中,Dictionary提供快速的基于兼职的元素查找.当你有很多元素的时候可以使用它.它包含在System.Collections.Generic名空间中. 在使用前,你必须声明它的键类型和 ...
- DotNet基础
DotNet基础 URL特殊字符转义 摘要: URL中一些字符的特殊含义,基本编码规则如下: 1.空格换成加号(+) 2.正斜杠(/)分隔目录和子目录 3.问号(?)分隔URL和查询 4.百分号(%) ...
- 你真的了解字典(Dictionary)吗? C# Memory Cache 踩坑记录 .net 泛型 结构化CSS设计思维 WinForm POST上传与后台接收 高效实用的.NET开源项目 .net 笔试面试总结(3) .net 笔试面试总结(2) 依赖注入 C# RSA 加密 C#与Java AES 加密解密
你真的了解字典(Dictionary)吗? 从一道亲身经历的面试题说起 半年前,我参加我现在所在公司的面试,面试官给了一道题,说有一个Y形的链表,知道起始节点,找出交叉节点.为了便于描述,我把上面 ...
- Python GUI之tkinter窗口视窗教程大集合(看这篇就够了) JAVA日志的前世今生 .NET MVC采用SignalR更新在线用户数 C#多线程编程系列(五)- 使用任务并行库 C#多线程编程系列(三)- 线程同步 C#多线程编程系列(二)- 线程基础 C#多线程编程系列(一)- 简介
Python GUI之tkinter窗口视窗教程大集合(看这篇就够了) 一.前言 由于本篇文章较长,所以下面给出内容目录方便跳转阅读,当然也可以用博客页面最右侧的文章目录导航栏进行跳转查阅. 一.前言 ...
- C#刷遍Leetcode面试题系列连载(1) - 入门与工具简介
目录 为什么要刷LeetCode 刷LeetCode有哪些好处? LeetCode vs 传统的 OJ LeetCode刷题时的心态建设 C#如何刷遍LeetCode 选项1: VS本地Debug + ...
- .NET Core 跨平台资源监控库及 dotnet tool 小工具
目录 简介 dotnet tool 体验 CZGL.SystemInfo SystemPlatformInfo ProcessInfo 内存监控 NetworkInfo DiskInfo 简介 CZG ...
随机推荐
- 生产环境上,哨兵模式集群Redis版本升级应用实战
背景: 由于生产环境上所使用的Redis版本并不一致,好久也没有更新,为了避免版本不同对Redis集群造成影响,从而升级为统一Redis版本! 1.集群架构 一主两从三哨兵: 2.升级方案 (1)升级 ...
- 用格里高利公式求给定精度的PI值
本题要求编写程序,计算序列部分和 4∗(1−1/3+1/5−1/7+...) ,直到最后一项的绝对值小于给定精度eps. 输入格式: 输入在一行中给出一个正实数eps. 输出格式: 在一行中按照&qu ...
- 内核内存分配器SLAB和SLUB
内核分配器的功能 在操作系统管理的虚拟内存中,用于内存管理的最小单位是页,大多数传统的架构是4KB.由于进程每次申请分配4KB是不现实的,比如分配几个字节或几十个字节,这时需要中间机制来管理页面的微型 ...
- 如何管理leader对你的能力预期?
在内网看到一个讨论帖,原文如下: 如何管理leader对你的能力预期? 你一个项目做得好,之后类似项目,leader认为你也就是合格水平,而且认为你只会做这种项目. SAD.. 思考 在开始之前先想下 ...
- SQL语句的分类:DQL、DML、DDL、DCL、TCL的含义和用途
MySQL中提供了很多关键字,将这些关键字 和 数据组合起来,就是常说的SQL语句,数据库上大部分的操作都是通过SQL语句来完成.日常工作中经常听到 DML.DDL语句这些名词,使用字母缩写来表达含义 ...
- Java Selenide 介绍&使用
目录 Selenide 介绍 官方快速入门 元素定位 元素操作 浏览器操作 断言 常用配置 Selenide 和 Webdriver 对比 Selenide 介绍 Selenide github Se ...
- update(修改,DML语句) 和 delete(删除数据,DML语句)
7.7.修改update(DML) 语法格式: update 表名 set 字段名1=值1,字段名2=值2,字段名3=值3....where 条件; 注意:没有条件限制会导致所有数据全部更新 upda ...
- java多态转型II
1 package face_09; 2 3 /* 4 * 毕老师和毕姥爷的故事. 5 */ 6 class 毕姥爷 { 7 void 讲课() { 8 System.out.println(&quo ...
- iptables匹配条件总结1
源地址 -s选项除了指定单个IP,还可以一次指定多个,用"逗号"隔开即可 [root@web-1 ~]# iptables -I INPUT -s 172.16.0.116,172 ...
- linux文件权限全面解析
目录 linux文件权限全面解析 一:linux文件的权限有哪些? 1,权限分为3个部分 2,权限位 3,每一个权限拥有一个数字编号 4,在添加权限的时候,可以将权限加起来 5,linux添加权限命令 ...