今天给同学们讲讲一个面试经常遇到的高频问题,HashMap实现原理,希望在金三银四的季节对同学们有帮助。
HashMap结构图目录
一、唠叨
二、解析思路
三、get方法
四、put方法
五、resize方法
一、唠叨
认真阅读了下HashMap的实现方式,也参考了网上别人的一些解析,个人觉得还是有些东西想说。网上有的文章名字为HashMap源码解析,实际上就是给它里面的一些方法加上一些注释而已,有不少都是这样的。
我自己看源码的时候,发现不是别人不想解析,而是它的实现真的需要亲自研读,多理顺几遍才知道怎么回事。
我在这里解析的文字描述也较多,不管谁的解析,自己也都要看一下JDK源码的具体实现,我们仅提供参考而已。
二、解析思路
源码不太方便看,先说明一下我的阅读思路。
1.把常用的几个方法拷贝到文本编辑器里面。
2.HashMap中不同的时候会有不同的流程,梳理方法中的逻辑流程。就像采用极端法,采用特殊的数据,然后查看方法执行语句。未执行的语句暂时不考虑。
3.注释源码...我觉得HashMap的实现方式不够好,关键的几个方法里面包含的情况太多了,阅读起来是有难度的,而写程序的目的之一不就是让其他开发者阅读吗?一个方法内部做了太多的事情,违反了代码整洁的规则,一个函数做要尽量少的事情。
解析
之前稍微介绍了一些HashMap的特性,HashMap初探。
(https://www.jianshu.com/p/be9ffb76db30)这里接着深入。
三、get方法
先挑最简单的说
1 先从数组下标,找到对应的Node2.
2 如果Node里的第一个节点命中,直接返回
3 如果有冲突,则通过key.equals(k)去查找对应的entry
4 若为树,则在树中通过key.equals(k)查找,O(logn);
5 若为链表,则在链表中通过key.equals(k)查找,O(n)。put方法这个中间涉及的逻辑多一些,方法需要分不同的步骤看。
四、put方法
这个中间涉及的逻辑多一些,方法需要分不同的步骤看。
思路:
1对key的hashCode()做hash,然后再计算index;
2如果没碰撞直接放到bucket里;
3如果碰撞了,以链表的形式存在buckets后;
4如果节点已经存在就替换old 5value(保证key的唯一性)
6如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
7如果Node的容量满了(超过load factor*current capacity),就要resize。
一般不发生碰撞的时候,相对简单,数据量较小的情况下。
我解释下关于碰撞冲的循环。
1.查看是否存在相同的key,存在相同的key跳出循环,覆盖key的value。
2.如果不存在相同的key,在链表末尾插入新的Node如果链表节点过长,转换为树。
3.如果链表节点过长,转换为树。
红黑树的部分,我们下次单独解析
五、resize方法
这个涉及的内容,有不少线需要捋一捋。首先看申明时候会resize()。它们都在调用put的时候执行的。
1.table == 的时候
2.键值映射的的数目大于临界值的时候。
六、resize具体方法
如果是第一次resize,我们抽出来会执行到的语句。
1.初始化容量
2.初始化threshold,也就是初始化临界值,决定了table的键值对数目到什么时候会再次resize()
第二次及后续的resize执行流程
resize中对有碰撞的链表的操作写的很有意思,再叙述一下。在重新分配索引的时候,有重新组建链表的操作。
举个比较夸张的例子,读者就明白了。
1.e.hash < 2,那么e.hash&oldCap就等于0,索引为小于之前hash表大小以内的索引。也就是当初的索引不变。
2.e.hash > 2的时候,e.hash&old不等于0,那么它的索引就为当前表的索引再加上新扩容的大小。
1 unsafe
2 {
3 var random = new Random();
4 var str = "";
5 for (int i = 0; i <www.tdcqpt.cn= 5; i++)
6 {
7 str += Convert.ToChar(random.Next('A', 'Z'));
8 fixed (char* p = str)
9 Console.WriteLine((int)p);
10 }
11 Console.WriteLine(str);
12 }
复制代码
上述的代码是在做字符串修改,你可能会觉得这种修改返回一个新值没问题。
但是下面的这种情况对于解析器来说就是一种致命伤了。在截取字符串时,你会发现每一次值都是不一样的,纵使你截取的位置是相同的,Substring始终如一的返回一个新对象给你。
复制代码
1 unsafe
2 {
3 var str = "123456";
4 fixed (char* p = str)
5 Console.WriteLine((int)www.chengmyuLegw.cn);
6 fixed (char* p = str.Substring(1, 2))
7 Console.WriteLine((int)p);
8 fixed (char* p = str.Substring(1, 2))
9 Console.WriteLine((int)p);
10 Console.WriteLine(www.qilinchengdl.cn str);
11 }
复制代码
对解析过程而言,可能会有频繁截串的场景,比如随时都可能要将表达式中的一段数字转换为一个数值。这种情况,每次都返回一个新的字符串对象,无论性能还是内存都是难以接受的。
你可能有想到C#中的StringBuilder对象,它确实是维护一个缓冲区,可以在做字符串修改的时候保证始终如一的使用同一块地址,但是这玩意是用来构建字符串的,读取字符串这货不行的,所以你看官方连个Substring都不给你。
难道必须使用非托管代码了么?为了保证更快的内存读取以及更低的内存消耗,难道我要去PInvoke???
这种问题,微软的码农肯定已经意识到了,不然这部分的随笔。。。我怎么写下去。
微软提供了System.Memory程序集用来帮助我们更方便也更安全的操作内存。
我们可以使用ReadOnlySpan来解决上述问题。
ReadOnlySpan在程序集System.Memory中,是Span的只读表示。将字符串转换为一个ReadOnlySpan对象,接着使用ReadOnlySpan来处理字符串,那么上述的问题都可以被解决。
然后大致说一下Span,Span可以用于表达任何一段连续内存空间,无论是数组,非托管指针,可获取到指针值的托管内存等等等等(是不是回忆起当初被指针支配的恐惧感),其实在它内部的实现就是一个指针。相对于C/C++里面的指针需要各种小心翼翼,不敢有一丝怠慢忘记释放,或访问到离奇的地址,或因为各种原因变成野指针。Span会在内部维护这个指针的地址,在做指针运算时,会做边界检查,在更新引用时,垃圾回收器也能判断出该如何回收内存。
对于Span的解读,推荐阅读下面这个系列,作者的解读非常赞。现在愿意写博文讲清 What、How 和 Why的博主不多了,且读且珍惜。
https://www.cnblogs.com/justmine/p/10006621.html
2.3 利用表达式树和Emit生成表达式执行代理
动态生成代理是一个古老的话题。最开始是因为大家都觉得.NET自带的那个反射操作太慢,怎么说呢,其实对于大部分场景是够用的,某知名大佬说:
每每看人在谈论代码时,都说那反射操作是极慢的,万万不可取。
在我自己,却认为反射之慢不过毫秒。
用不正当的思路写出的代码才会引起真正的慢。
-- 树人Groot
之所以会成为一个古老的话题,是因为动态生成代理会出现在太多的业务场景中。
最常见的就是快速获取一个对象指定名称的成员值,你会看到各色爱写库爱造轮子的大佬非常热衷去搞的个快速对象访问器什么的。
多年前博客园大佬赵姐夫还参与过此事写过一个库,地址如下:
http://blog.zhaojie.me/2009/02/fast-reflection-library.html
注:虽然老赵已经很久没有更新博客了,但是他的博客还是非常推荐去阅读一下,内容很丰富,干货特别多。
博客地址:http://blog.zhaojie.me
好了,回过头来。现在对使用动态生成代理的场景做一个汇总,常出现的场景如下:
对象序列化反序列化,这种场景多出现于RPC中,代理要把stream转换为object
实现ORM,代理要把reader转换为object(这种其实也是rpc)
实现AOP,代理要为具体类生成一个包含一系列切面函数的类
绑定求值,比如模板渲染或YACEP这种对编译结果调用执行获取结果的过程
动态生成代理实现方式有如下四种:
利用Expression构造代理方法
DynamicMethod生成动态函数构造代理方法
DynamicAssembly构建代理类
利用CodeDom动态生成程序集,生成代理
下面大致对上面的四种方式做一个比较
Expression DynamicMethod DynamicAssembly CodeDom
优点
实现的代码简单易读
原生支持绕过CLR访问修饰符检查 支持生成类型 支持生成类型
缺点 不支持生成类型 需要对IL指令有一定了解 需要对IL指令有一定了解 代码臃肿
适用场景 逻辑稍简单的代理 逻辑稍简单的代理 功能更完备的代理 处理模板化的代码
YACEP在定义可执行对象时, 没有使用.NET内置的委托,而是定义了两个接口。
复制代码
public interface IEvaluator
{
object Evaluate(object state);
}
public interface IEvaluator<in www.yuntianyugw.com TState>
{
object Evaluate(TState state);
}
复制代码
那为什么使用接口而不是用委托来定义可执行对象,委托才更符合对可执行对象表述的直觉啊 ?
这是因为YACEP需要支持自定义字面量,自定义函数。
如果生成的是委托,最佳的做法是生成闭包函数,在闭包中保存这些自定义字面量和函数,用EMIT生成一个返回闭包函数的函数。
这种做法确实可以做到,问题是写出来的EMIT代码会更多,更难调试和定位错误,如何知道是函数的闭包问题还是函数自身问题呢。
那如果不用EMIT的方式生成委托,还可以使用表达式树。是的,表达式树在这个方面处理起来比EMIT更有优势,代码可读性更好。而且使用表达式树还有个巨大的优势,YACEP编译的本质其实是将YACEP自定义的抽象语法树转换为C#抽象语法树,表达式树所定义的抽象语法树几乎和YACEP自定义的抽象语法树是一比一的,这种转换要比生成IL更简单。
那为什么YACEP不使用表达式树呢?
在实现YACEP的编译过程时,我会先脑补出抽象语法树转换出的IL代码,然后是最终的C#代码。如何检验YACEP生成的结果与我脑补的结果是一致呢?
如果使用表达式树,我需要debug看表达式树的树结构。如果时间允许,我倒是十分愿意去做这样的事情,可惜YACEP只是我换工作间隙拿来练手的小玩意。EMIT支持生成动态程序集,然后再用ILSpy去检查生成IL代码是否符合我预期,读生成的DLL代码可是比去看表达式树的树结构来的简单。
所以最终在YACEP的代码里,你可以看到即有表达式树的代码也有EMIT的代码。按照上表的适用场景,表达式树用于在处理按照给你名称获取或设置对象成员值,EMIT为表达式生成最终的执行代理。
3. 工具篇
3.1 测试覆盖率工具 - Coverlet
测试覆盖率是啥就不解释了。YACEP使用xUnit.net做单元测试,在当前以及未来可能的版本中,YACEP始终要求100%的行覆盖率,99%以上的分支覆盖率。
如何做测试覆盖率统计?
目前 Visual Studio是支持查看测试覆盖率的,如果安装了JetBrains的dotCover插件,可以获得更好的体验(要钱的)。
这些是用来看测试覆盖率的,如何搞到测试覆盖率报表呢?
dotnet在跑测试时,可以通过配置数据收集器来生成测试覆盖率报告(更详细配置文档),示例命令如下:
dotnet test --collect:"Code Coverage"
在项目中运行此命令,就会生成一个*.coverage的文件在TestResults文件夹里面。
哒哒哒哒!我来打开这个文件,看看我的覆盖率是不是已经100%了呢!
二进制的,还是专属文件格式???
没事,兴许在命令的执行结果里面能找到覆盖率的值!
复制代码
Microsoft (R) Test Execution Command Line Tool Version 16.0.1
Copyright (c) Microsoft Corporation.www.yuntianyuL.cn All rights reserved.
Starting test execution, please wait...
Total tests: 80. Passed: 80. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 4.4614 Seconds
这个图说的是,当hashmap的表大小为2扩充到4的时候,原本挂载在1位置的链表,重新分配之后的样子。
最后
篇幅有限,我这里仅仅介绍了get方法,put方法,resize方法的具体原理,文章就已经非常长了,不利于阅读。
下次再补充一下HashMap的hash方法原理,其余的相关注意事项。
转载于:https://www.cnblogs.com/qwangxiao/p/10933573.html