dbhllnr 2020-06-09
本篇涉及到的知识面比较广,无论你是小白还是有经验人士,都可快速理解hashmap,目的是理解知识并能通过面试,一些过于深入和细节的东西不在这里讨论。
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。
简单点说,hash就是把任意的内容通过计算转换成一个特殊的值。文章也好,数字也好,视频也好……不管你的原内容有多大,转换后的结果都是同一个格式的值,这个值可以是字符串,可以是数字,具体取决于调用的算法。就像不管你是电影明星,还是社会大佬,还是企业高管,不管你有多少头衔,进了监狱,你就只有代号。
哈希是音译过来的,密码学中称其为摘要算法,这个词就好理解多了,做摘要嘛,就像一本书或者一部电影的介绍一样,提取特点用简短的语言描述而已。
比如学校里有一千个学生,校长让我给他找几个学习好的同学,或者让我给他找几个会拉格朗日中值定理的同学,每个学生掌握的知识程度都不一样,我他妈上哪找去,一个一个问啊,这也太慢了,而且万一他明明不会缺骗我说他会怎么办?
考试!!!通过考试,把每个学生的学习成果由抽象概念转换成了一个真实的分数,现在校长让我找学习最好的同学,我就可以去找考试满分的同学,让我找会拉格朗日的同学,我就可以找相关题目答满分的同学。这样是不是就很方便了,第一减少了我的查询时间,第二学生不能不懂装懂。
以上例子就可以理解为一个摘要算法(哈希),你交白卷也好,你全写了也好,你填写的内容就是原文,考试的打分规则就是这里的算法,经过计算后给你得出你的分数,这就是你的哈希值。不管你原文有多长,不管你本人有多吊或者你爸是校长,你得到的分数都是0-满分之间。
总结一下hash的特点和用法:
内存中的一片连续区域,同类型数据的集合,有索引,查询快,增删慢,不可扩容。
不连续的区域,每个节点放值和指向下一个节点的指针。查询慢,增删块。
可以理解为数组和链表的组合。即一个一维数组,但是数组中的每个元素是一个链表。
为什么要用哈希表?可以看这篇博客 http://m.elecfans.com/article/1194442.html
简单来说,为了在保存数据的时候更方便,达到空间和时间的一个平衡。
向哈希表里存数据时,这里以s代表数据,要先计算s的hash值,然后根据这个hash值去计算它应该对应哪个索引,也就是放在数组的哪一个单元格内。
我们知道hash是不同长度的输入对应固定长度的输出,也就是说肯定会有某些数据计算出的hash是一样的,这个就叫做hash冲突或者碰撞。
插入过程中当发现两个数据的hash一致,那么对应在数组里的索引也一致,这是就会采用链表式的插入方法,将新数据插入后,让该位置原来的数据指向新数据(这里涉及到头插法和尾插法)
查询的时候类似于查字典,我们查字典的时候都是先去找拼音,然后会找到对应的页数,这一步就相当于根据索引直接找到了数据,但是同音字有很多,所以我们还得一个一个的看是不是我们要的字,这里就相当于遍历链表了。
树结构,但是呢,每个节点最多只能有两个分叉
自平衡的二叉树
什么叫平衡,简单理解就是任意节点的左右两个子树高度差都小于等于1,这样便利起来会更均匀
红黑树的自平衡,就是他会通过变色和旋转,使自己可以动态的平衡,也就是说不用你来平衡我,我可以自己变化。类比的话,平衡二叉树就像学校里的女朋友,比较孩子气,一不开心就需要你哄,需要你去让她平衡。红黑树相当于比较成熟的贤妻良母,当她不开心的时候,可以自己调节。
推荐个视频,专讲红黑树
https://www.bilibili.com/video/BV11E411Y77y?from=search&seid=16749414293990551107
基本参数
构造方法
一般我们常用的是第一种,无参构造,这时默认初始容量就是16,默认负载因子是0.75。
这里我们只讨论上面的三个构造,通过查看源码得知,前两个构造方法内部都使用的是第三个构造
第三个构造内部首先对传进来的容量和负载因子做判断,主要是看数据是否合规,此时,让扩容阈值等于了初始容量。注意这个时候仅仅是给一些必要的参数赋值,并没有在此时初始化HashMap,没有分配空间。
public V put(K key, V value) { if (table == EMPTY_TABLE) { //判断是否内容为空,这里的table是一个键值对数组,默认是空的。 inflateTable(threshold); //如果内容为空,则首先初始化 } if (key == null) //如果传入的key是null,则调用下面的方法 return putForNullKey(value); int hash = hash(key); //如果key是存在的,那么计算key的hash int i = indexFor(hash, table.length);//计算完哈希后,根据哈希和数组长度去计算对应的索引,也就是这个key应该在数组里哪里存储 for (Entry<K,V> e = table[i]; e != null; e = e.next) {//数组中的每个元素是一个链表,已经计算除了索引,所以遍历该索引位置上的链表,目的是为了检查传进来的key是否已经存在在表里 Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//如果发现链表上的元素和传进来的key各方面都一样(hash,地址,内容)那么就说明表里已经有了,那么就保持key不变,更新value,并返回旧的value V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; //被修改次数加一,这个值主要是为了线程安全考虑,和本方法的业务无关 addEntry(hash, key, value, i); //如果是一个新key,则添加元素。 return null; }
接下来一条一条分析
1.初始化具体做了哪些东西“inflateTable(threshold);”
private void inflateTable(int toSize) { // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize);//这里可以看到,初始化时,无论传进来的初始容量是多少,都会向上取整为2的幂。也就是说,虽然构造方法中可以让用户自定义容量大小,但是进来后也会向上转成2的幂。当然,如果本身就是2的幂,那么就不会转换了,这里看源码,会发现他做了一个-1操作,目的就是为了防止把正确容量也翻一倍。比如你传进来的是15,那么向上取整为16.如果传进来的是16,向上取整就成了32,这是不合理的,所以任何数在取整时都先-1. threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算扩容阈值 table = new Entry[capacity];//初始化 initHashSeedAsNeeded(capacity); }
2.如果key为null怎么办“ return putForNullKey(value);”
插队!!!
这里插队讲一下计算hash和索引的方法
每次插入key时,都要计算对应的hash值,那么如何根据hash值去计算索引呢?从应用的角度考虑,我们肯定希望这些元素能均匀的分布在数组的不同格子里,这样做查询的时候就会快。如果分布不均匀,比如所有元素都分布到了同一个格子里,那岂不是退化成了一个链表,查询效率就低了。所以怎么才能保证把所有元素平分到每个格子里呢?
想想平时我们写代码时,遇到想分为几种情况讨论时是如何做的?聪明的你一定想到了,取余。什么?你没想到?那你写代码太少了。本篇不适合你。
看上面这段代码,i是在递增,对三取余就三种情况。对几取余就是几种情况。
对于我们计算出的hash来说,先不要管他计算出的显示形式是数字还是字符串还是其他,反正肯定可以转成二进制,能转成二进制那是否就能转成十进制,反正你是个数字,对吧,每个key的hash不一样,那么对数组长度取余是否就算是平均分了。
但是这里存在两个问题,数组的索引是从0开始的,但是我们计算出的hash有可能是个负数,同时,取余操作太慢了,既然要做计算,直接用二进制肯定是最快的。
所以,计算索引的时候我们是这样做的,hash & 数组长度(可以这样思考,任何两个数做&操作,得到的结果是否一定小于等于这两个数中的任意一个?废话肯定啊,没有概念的话可以自己画个图)
假设数组长度默认16,那么转换成二进制是10000
传进来的key也要计算hash,假设计算出来的hash是10111
那么&完后的结果是10000,我草,怎么还是16,尼玛这不索引越界了。
那么假设数组长度不是16,是17,也就是10001,传进来的hash为111,那么&完后,结果为0001,这个key会被放在所以为1的地方,但是再看,传进来的key如果是101,001,都被分配到了索引为1的地方,这显然就违背了我们的均匀分配的原则了。为什么呢?因为0和任何数&都是0,也就是说,我们只有保证数组长度的二进制里1越多越好。
基于以上,最终做求索引操作时,用的是(hash&(数组长度-1))
所以hashmap要求容量必须是2的幂,因为2的幂转换成二进制是1后面若干个0,-1后,结果为0后面若干个1.既能保证分的均匀,也能保证不越界。
再看这个,最后一行是数组长度-1,上面是我假定的两个key的hash,发现了什么?因为数组长度有限,所以会导致这样一种情况,key的hash值高位不同,低位相同,也会被分到同一个索引,这就又不均匀了。怎么办呢?
3.计算hash时都做了哪些操作?
从上图可以看出,计算hash时不仅仅是得到hash这么简单,还做了一系列复杂的运算,其目的是通过各种右移能够让高位也参与运算,最大化的避免高位相同低位不同分到同一个索引。这里的注释也说明了这样操作的原因。
4.计算索引?
5.添加元素时做了哪些操作“ addEntry(hash, key, value, i);”
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) {//当当前的元素个数大于等于扩容阈值的时候,并且分配给新元素的这个位置以及有值,则扩容 resize(2 * table.length);//扩容为原来的数组长度乘2 hash = (null != key) ? hash(key) : 0;//如果key=null,则hash为0 bucketIndex = indexFor(hash, table.length);//根据新数组长度重新计算索引 } createEntry(hash, key, value, bucketIndex);//插入该节点 }
扩容,很多人理解就是装不下了,所以要扩容。但是这里扩容的判断并不是达到数组最大值,而是数组长度 * 扩容因子,对于初始容量来说就是16 * 0.75 = 12 ,当hashmap里保存的元素个数达到12时就满足了扩容条件。为什么定0.75,看类的注释,可以发现,0.75是经过开发者测试得到的,达到这个值后出现碰撞的概率比较大啊,所以定了这个数。以下是原文,可以清楚的看到是为了达到空间和时间的一个平衡。
同理,初始容量默认是16,应该也是一个经验值,规定容量必须是2的n次方,初始太小了要频繁扩容,太大了又浪费,所以为了平衡选择16
这里需要注意,很多博客都说扩容的条件是达到元素个数达到扩容阈值,这其实不全面。通过看代码可以发现,if里的判断条件是两个,在元素个数达到阈值的同时,如果发现分配给新元素的这个索引已经被占用了,才扩容。单纯达到阈值是不扩容的。简单总结,就是元素个数达到了临界值并且新元素碰撞了,才扩容。
因为规定容量是2的幂,所以扩容时把原容量乘2.
扩容后,相当于重新开辟了一个更大的数组空间,然后要重新计算原数组中元素的哈希,并把原数组中的内容迁移过来。
原模原样搬过来不香吗,为什么要重新计算hash?
大哥,你再想想为啥要扩容,为啥达到容量乘扩容因子扩容?是放不下吗?是为了减少碰撞啊。
想一下你的原数组里,可能某一个索引位置上的链表已经很长了,这些元素可能都是高位不同低位相同的,新数组的容量更大,转换成2进制是不是相当于高位也有了值,这时重新计算哈希,不就把原来一个索引位置下链表里的内容给分散开了吗。
注意:按照源码来说,每次扩容后,会根据哈希值和新的容量去计算新的索引,但不一定每次都会重新计算哈希。是否重新计算hash,源码里写了和哈希种子还有int最大值有关系,但是具体怎么算,就不知道了。只要记住一般情况下并不会rehash就好了。
jdk1.7中插入数据采用的是头插法,也就是新来的元素会加在链表的开头,类似于栈,后来居上。因为开发者认为后加的元素可能被用到的几率更大,所以头插法可以快速查询。
当然这也带来了安全隐患,就是在多线程环境下,可能会出现死循环。想了解的话可以参考 https://coolshell.cn/articles/9606.html
为了解决这个问题,jdk8采用了尾插法。
不过HashMap本身就不是线程安全的,所以不建议在多线程下用。
如果key是null,则直接到索引为0的位置去找。
咳咳,emm,8的实现有点复杂,我这里直接白嫖别人的博客了
以下为我觉得讲的不错的几篇:
https://www.jianshu.com/p/fb282d3d2e87
https://www.jianshu.com/p/28b18dbb00d6
https://blog.csdn.net/zxt0601/article/details/77413921
总结一下:
8的实现在7的基础上,增加了红黑树,HashMap的底层数据结构为数组+链表+红黑树
为什么要加红黑树呢,因为发现不管扩容机制有多好,依然会出现大量链表导致查询效率低下,所以在插入时,依然按照链表插入,这里不同于7,8里的插入时插入在尾部。当发现链表节点数到达8时(同时满足数组长度达到64,如果数组长度没达到64会先扩容),会将此链表转成红黑树。如果扩容后发现红黑树节点个数减少到了6,那么又会转换成链表。
除了底层数据结构,8还做了很多性能上的优化,比如扩容时不再重新计算哈希值和索引,直接用高低位的一种比较可直接得出索引,速度会更快。详细点可以看上面推荐的博客。
HashMap的底层数据结构?
如何存入元素
如何取值
jdk7和8的区别
为什么线程不安全
有线程安全的替代类吗
默认初始化大小?为什么?为什么大小是2的幂?
扩容方式?负载因子?为什么?
主要参数有哪些?
如何处理hash碰撞的?
索引是如何算出来的?
哈希表初始化时机?
我用LinkedList代替数组结构可以么
哈希冲突的解决方法?
为什么不用Hashtable而用ConcurrentHashMap?
8中对HashMap做了哪些修改?
为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
不用红黑树,用二叉树可以吗?
一般用什么作为key?
可变类作为key可能发生什么问题
如何实现自定义类作为key?
linkedhashmap:https://www.jianshu.com/p/a2c5397e9b22
参考面试题: https://zhuanlan.zhihu.com/p/76735726
https://zhuanlan.zhihu.com/p/87929020