HashMap

Posted qiuhaitang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap相关的知识,希望对你有一定的参考价值。

前言

  在我们开发中,HashMap是我们非常常用的数据结构,接下来我将进一步去了解HashMap的原理、结构。

1、HashMap的实现原理

  HashMap底层是基于Hash表(也称“散列”)的数据结构实现的,由数组链表组成,数组是HashMap的主体,链表主要是为了解决哈希冲突而存在的。

  数组里每个地方都存了Key-Value这样的实例,在Java7中叫 Entry,在Java8中叫 Node

技术图片

 

 

   他本身所有的位置都是 null,在 put 插入的时候会根据 key 的 hash 值去计算一个 index 值。

  例如,我 put(“兄弟”,“砍我”),我插入了为“兄弟”的元素,这个时候我们会同通过哈希函数计算插入的位置,计算出来的 index 是2,那结果如下。

hash(“兄弟”)=2

  技术图片

  以上就是我们前面说到的,数组是HashMap的主体。而为什么需要用到链表,这就需要提到哈希冲突了。

  我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,就是“兄弟”和“弟兄”我们都去hash,有一定的概率会一样,这就出现我们说的哈希冲突,就像上面的情况,我再次哈希“弟兄”极可能会hash到一个值上,这就形成了链表。

技术图片

 

 

 

  每一个节点都会保存自身的 hash、key、value、以及下个节点,我们看看 Node 的源码。

技术图片

 

  

 

2、关于链表,新的Entry节点插入链表的方式

  新增一个Entry节点,在 Java8 之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样,新增的"弟兄"会代替“兄弟”的位置,因为写这个代码的坐着认为后来的值被查找的可能性更大,有利于提升查找到的效率。

  但是,在Java8之后,都是使用尾部插入法。至于为何使用尾插法,这就跟我们的扩容机制有关了。

3、HashMap的扩容机制

  前面我们提过,数组容量是有限的,数据多次插入,到达一定数量就会进行扩容,也就是resize

  而扩容的时机主要取决于两个因素:

  • Capacity:HashMap当前长度;
  • LoadFactor:负载因子,默认值是 0.75f 。

技术图片

 

 

   这个比较好理解,比如我们当前容量大小是100,当你存进第76个的时候,判断发现需要进行 resize 了,那就进行扩容,但是HashMap的扩容也不是简单地扩大容量这么简单的。

  HashMap的扩容分为两步

  1. 扩容:创建一个新的 Entry 空数组,长度是原数组的2倍;
  2. ReHash:遍历原 Entry 数组,把所有的 Entry 重新 Hash 到新数组。

  有的朋友会问,为何要重新Hash,直接复制过去它不香吗?

  这是因为长度扩大以后,Hash 的规则也随之改变。Hash 的公式如下:

index = HashCode(key)&(Length - 1)

  原来长度(Length)是8,你位运算出来的值是2,新长度是16,你位运算出来的值明显不一样了。

  扩容前

技术图片

 

  扩容后

技术图片

4、为何之前用头插法,Java8之后改用尾法了?

  我们先举个例子,我们现在我那个一个容量大小为2的put两个值,负载因子是0.75,那么在我们put第二个的时候进辉进行resize。

  2 * 0.75 = 1,所以插入第二个就要 resize 了。

技术图片

   现在我们要在容量为2的容器里面用不同的线程插入A、B、C,假如我们在 resize 之前打个断点,那意味着数据都插入了,但是还没有 resize ,那扩容前可能是这样的。

  我们可以看到链表的指向:A --> B --> C

 Tip : A的下一个指针是指向B的。

技术图片

  以为 resize 的赋值方式,也就是使用了单链表的头插入方式,同一位置上的新元素总会被放在链表的头部位置,在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能放到了新数组的不同位置上。

  就可能出现下面的情况,你发现问题了没有?B的指针指向了A。

技术图片

 

   一旦几个线程都调整完成,就可能出现环形链表。

技术图片

 

   这个时候再去取值,悲剧就出现了 —— Infinite Loop;

 5、那JDK1.8的尾插是怎样的?

  在 Java8 之后的链表引入了红黑树的部分,我们可以看到代码已经多了很多 if else 的逻辑判断,红黑树的引入巧妙地将原本 O(n)的时间复杂度降低到 O(logn)。

  Tip:红黑树的部分也很重要,面试中经常会被问到,在今后写到数据结构的时候再讲。

  使用头插法会改变链表上的顺序,但是如果使用尾插,在扩容时会保持链表原本的顺序,就不会出现链表成环的问题。

  就是说,原本指向 A-->B,在扩容之后那个链表还是 A-->B。

技术图片

 

  Java7 的多线程操作 HashMap 时可能引起死循环,原因是扩容转移后,前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

  Java8 在同样的前提下并不会引起死循环,原因是扩容转移前后链表的顺序不变,保持之前节点的引用关系。

 6、HashMap多线程的应用

  上面提到,Java8不会引起死循环,是不是意味着可以把 HashMap 用在多线程中?

  我认为,即使不会出现死循环,但是通过源码看到 put/get 方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒 put 的值,下一秒 get 的时候还是原值,所以线程安全还是无法保证。

 7、HashMap的默认初始化长度

  在源码中有提示,初始化大小是16。在JDK1.8 的 236 行,这么写着 1<<4就是16,这里为何运用了位运算呢?直接写16不香吗?

技术图片

 

 

   因为我们在创建 HashMap 的时候,阿里规范插件会提醒我们最好赋初值,而且最好是 2 的幂。

技术图片

 

 

   这样是为了位运算的方便,位运算比算数计算的效率高了很多,之所以选择16,是为了服务将 Key 映射到 index 的算法。

  前面说过了,所有的 Key 我们都会拿到它的 hash 值,但是我们怎么尽可能地得到一个均匀分布的 hash 值呢?

  这里就需要我们通过 Key 的 HashCode 值去做位运算。

  例如,key为“兄弟”的十进制为669275,那二进制就是10100011011001011011。

String key = "兄弟";
int hashCode = key.hashCode();
//669275

  我们再看下index的计算公式:index = HashCode(Key)&(Length - 1)

index = (n-1)&hash

  15的二进制是 1111,那 10111011000010110100 &1111 十进制就是4。

  之所以用位与运算效果取模一样,性能也提高了不少!

8、那为何用16,而不是其他的?

  因为在使用不是2的幂的数字是,Length - 1 的值是所有二进制位全为1,这种情况下,index 的结果等同于 HashCode 后几位的值。

  只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

  这是为了实现均匀分布。

9、我们重写equals方法的时候,为什么需要重写hashCode方法?

  在Java中,所有的对象都是继承于Object类。Object类中有两个方法 equals、hashCode,这两个方法都是用来比较两个对象是否相等的。

  在未重写 equals 方法,我们是继承了 Object 的equals方法,那里的 equals是比较两个对象的内存地址,显然我们 new 了2个对象,内存地址肯定不一样。

  • 对于值对象,==比较的是两个对象的值;
  • 对于引用对象,比较的是两个对象的地址;

  是否还记得前面说过的HashMap是通过 key  的hashCode去寻找index的,那index一样就形成了链表了,也就是说“兄弟”和“弟兄”的index都可能是2,在一个链表上。

  我们去 get 的时候,他就是根据 key 去 hash,然后计算出 index,找到了 2,那我怎么找到具体的“兄弟”还是“弟兄”呢?

  equals!!!是的,所以如果我们对 equals 方法进行了重写,建议一定要对 hashCode 方法重写, 以保证相同的对象返回相同的 hash 值,不同的对象返回不同的 hash 值。

  不然一个链表的对象,你怎么知道你要找哪个?到时候发现 hashCode 都一样,这不完犊子了嘛。

10、既然前面说到HashMap是线程不安全的,那我们应该怎么处理HashMap在线程安全的场景呢?

  在这样的场景,我们一般都会使用 HashTable 或者 CurrentHashMap,但是因为前者的并发度的原因,基本上没什么使用场景,所以存在线程不安全的场景,我们都是用的是CurrentHashMap。

  我看过 HashTable 的源码,非常简单、粗暴,直接在方法上加锁,并发度很低,最多同时允许一个线程访问;CurrentHashMap 就好很多了, 1.7 和 1.8 有较大的不同,不过并发度都比前者好很多。

技术图片

 

 

 

总结

HashMap 绝对是最常问的集合之一,基本上所有的点都要烂熟于心

下面引入几个常见的HashMap面试题(答案后面再补)

问一:HashMap的底层数据结构?

  答:

问二:HashMap的存取原理?

  答:

问三:Java7 和 Java8 的区别?

  答:

问四:为什么HashMap是线程不安全的?

  答:

问五:有什么线程安全的类代替吗?

  答:

问六:默认初始化大小是多少?为什么是这么多?为什么大小都是2的幂?

  答:

问七:HashMap的扩容方式?负载因子是多少?为什么这么多?

  答:

问八:HashMap的主要参数有哪些?

  答:

问九:HashMap是怎么处理hash碰撞(冲突)的?

  答:

问十:HashMap的计算规则?

  答:

 

以上是关于HashMap的主要内容,如果未能解决你的问题,请参考以下文章

HashMap原理:哈希函数的设计

HashMap深度解析

JDK源码阅读之 HashMap

ArrayList 和 HashMap 的默认大小是多数?

如何将 Parcelable 与 HashMap 一起使用

hashmap冲突的解决方法以及原理分析: