【多图预警,建议收藏】讲解HashMap原理和流程

210 阅读14分钟

大佬们如果觉得不错,点个赞呗支持下~~

1.前言

HashMap是开发中最常用的键值对集合类,也是面试中经常被问及的一个知识点,也是衡量java基础是否扎实的标准之一,是每个JAVA初学者入门必须跨过的槛。所以HashMap重要性不言而喻,需要彻底掌握。 但在开始学习HashMap时,如果文章中有一大堆源码和一大段文字,会让读者阅读起来十分枯燥以及苦涩难懂。特别是不太了解HashMap的读者,但为了深入理解肯定要结合源码学习。 所以打算先介绍HashMap流程,熟悉主要操作的流程步骤,再结合源码,加深对HashMap的理解。分为两篇文章讲解: 第一篇(本文)是介绍HashMap主要操作的流程。 第二篇是结合源码深入理解。 本文为第一篇文章,尽量以简短的文字+图片讲解HashMap,力求达到通俗易懂,也会叠加一小部分代码,方便第二篇文章源码阅读。

2.原理

HashMap底层基于拉链式的哈希算法实现。底层数据结构是数组+链表+红黑树组成。数据结构示意图如下:

当进行增删查操作时,首先确定元素在桶数组的位置,再遍历数组中的链表或红黑树定位元素。举个实例说明查找元素的过程,假设桶的数组长度为16,我们需要确认元素37是否在HashMap中,步骤如下:

  1. 定位元素37所在的桶数组位置,index = 37 % 16 = 5

  2. 遍历5号桶里的链表,发现37在链表中

以上就是HashMap定位元素的流程。集合中不管是增(put)、删(remove)、查(get)操作,第一步需要先定位元素的位置。接下来先详细分析这两个步骤具体实现。

3.定位元素

3.1确定桶数组的位置

3.1.1对数组长度取余

定位元素第一步要确定在桶数组中具体的位置。为了确保元素落在桶数组范围内,所以用元素对数组长度取余来确定位置。比如上面提到的例子,元素的hash值37在长度为16的桶数组中,37 % 16 = 5,最终会在数组索引值为5的桶中。

3.1.2优化:&运算替换%

在HashMap实际代码(如下所示)中,hash值并不是与桶数组长度进行取余运算,而是用&运算。

//index桶数组索引,tab为桶数组名,n为桶数组长度index = tab[(n - 1) & hash]

这里你肯定会问,为什么%要换成&运算呢?其实是因为&运算,是位运算,而%取余不是位运算,系统底层用的是位运算,对于%取余操作需要转换后才能运算,所以**&运算比取余操作效率高**。

你也肯定会问,为什么&能替代%操作?是因为HashMap中桶数组长度length总是2的次幂,此时 (length - 1) & hash 等价于对length取余。举个例子说明下,假如hash值为37,数组长度length为16,计算过程如下图所示。

3.1.3hash算法

在确定桶数组位置的过程,你或许会问hash值是元素的hashcode的值吗?答案:不是。实际代码(如下所示)中,是用hashcode的高位和低位进行异或操作得出hash值。

hash = (h = key.hashCode()) ^ (h >>> 16)

为什么要用hashcode值的高低位进行异或运算,而不是直接用hashcode的值呢?原因是在计算桶数组位置时,当数组长度length很小时,比如length=16,hash值只有低4位参与了运算,导致高4位没发挥作用。所以为了让高4位也参与进来,用hashcode的高4位与低4位进行异或运算,即下面图中的 hashcode ^ (hashcode >>>4)。计算过程如下:

上图只是举个例子演示了异或运算。在实际代码中,因为hashcode是int类型,而int类型是32位,所以异或运算是高16位和低16位参与运算。这就解释了在源码中hashcode为什么向右移16位,h >>> 16。

hash = (h = key.hashCode()) ^ (h >>> 16)

这样的好处也十分明显,增加高位数据的运算,使得经过hash算法后的hash值随机性更高,分布性越分散,即hash值相同概率变小,也降低了hash碰撞概率,从而提升了hashmap增删查操作的效率。什么是hash碰撞?为什么hash碰撞会影响hashmap增删查操作的效率?先不要往下看,回顾一下刚刚的内容,思考一下这两个问题,答案我会写在下面。为了流程连贯性,你可以选择跳过hash碰撞的部分,直接看遍历链表和红黑树部分。

3.1.4hash碰撞/冲突

hash碰撞是指元素插入到指定的桶中,而这个桶内有其他元素,那么此时新元素会与桶内的元素发生碰撞。假设新元素的hash值是33,桶数组长度为16,经过计算数组索引值为1,当插入索引值为1的桶中时,发现桶内已经有hash值为17的元素,此时插入会引起hash碰撞。计算①和演示②过程如下:

为什么减少hash碰撞会提高hashmap操作的效率呢?首先我们要知道碰撞前后有哪些不同,碰撞前元素位置只需要通过数组索引定位,时间复杂度为O(1)。而碰撞后元素的位置需要通过数组索引 + 遍历链表时间复杂度为O(n)。O(1) < O(n),发生碰撞前的操作效率高,所以减少hash碰撞会提高hashmap的操作效率。

举个例子说明下,假设插入hash值17的元素,桶数组长度length=16,桶1无元素。当插入hash值17时,索引值index = 17 % 16 = 1,即插入桶1中,此时桶1无元素,所以直接插入到桶1,tab[1] = 17,时间复杂度为O(1)。具体流程如下:

当插入hash值33的元素时,索引值 index = 33 % 16 = 1,即插入桶1中,此时桶内有元素。所以遍历链表查看是否有相同的元素,如果没有则插入到链表末尾。此时时间复杂度为O(n)。具体流程如下:(为了区别插入前的流程,在插入33前多插入了两个元素节点)

3.2遍历链表或红黑树定位元素

确定了桶数组的位置后,如果桶内是存放的是链表节点,则通过节点的next引用遍历链表,时间复杂度是O(n),但链表很长时,查询效率低。所以在JDK1.8中,加入红黑树,是一颗平衡二叉树,时间复杂度是O(logn),比链表的时间复杂度低,查询速度快。至于红黑树如何遍历,可以参考这篇文章-红黑树详细分析。假设要查找hash值37和110的元素节点,简易查找过程如下图:

定位元素在HashMap的位置,相当于是元素的查找流程,到这里已经讲完了。 先停下来思考一会,结合上面的示例图,回顾一下查找的主要流程,首先是用哈希算法算出元素的hash值,定位元素在桶数组的位置,接着遍历链表或红黑树查找元素。如果现在你对查找流程已经清楚了,那就可以接着往下看,接下来会介绍插入操作以及扩容机制。

4.插入元素

插入操作会与查找操作类似,不同的流程在于插入前后需要判断是否扩容以及插入链表后判断是否需要转成红黑树。这两个步骤会在下一章讲解,这章讲插入操作的主要流程,可分为4种情况下的插入(这4种情况在流程图中用序号标记)

  1. 如果某个桶内没有元素? 则直接放进桶内。若有元素,则跳到情况2

  2. 判断桶内元素是否相同? 如果相同则替换桶内的元素。否则跳到情况3

  3. 判断桶内元素是否为红黑树节点? 如果否,则遍历链表插入或替换节点。否则跳到情况4

  4. 交给红黑树遍历插入

插入流程图如下:

4.1情况①

如果某个桶内没有元素? 则直接放进桶内。若有元素,则跳到情况②。示意图如下:

4.2情况②

判断桶内元素是否相同? 如果相同则替换桶内的元素,否则跳到情况③。示意图如下:

4.3情况③

判断桶内元素是否为红黑树节点? 如果否,则遍历链表插入或替换节点,否则跳到情况④。示意图如下:

4.4情况④

交给红黑树遍历插入,示意图如下:

结合示意图会更容易理解插入流程的4种情况。而插入流程图中除了这4种情况外,还有扩容操作以及链表过长时会转成红黑树。接下来会详细讲述这两个操作具体实现。

5.扩容机制

5.1为什么需要扩容?

前面我们已经知道Hashmap底层数据结构是数组+链表+红黑树组成。其中数组是固定长度,链表和红黑树是可变长的,所以扩容指的是对数组扩容。假设没有扩容的话,桶数组长度不变,随着插入的元素增多,会比较频繁发生hash碰撞冲突,导致链表和红黑树长度过长,严重影响了Hashmap的效率,所以Hashmap必须需要扩容。

5.2什么情况下要扩容?

在前面的插入流程图中,我们可以知道在插入前后会判断是否需要扩容。第一处扩容是指插入前的桶数组为空时扩容,此时扩容是对Hashmap初始化,这里可以知道Hashmap初始化是懒加载。在put插入操作进行初始化而不是在new Hashmap对象中初始化。第二处扩容是指插入后Hashmap中元素总数大于阈值,则会发生扩容。阈值是对空间和时间复杂度的均衡值。阈值过大,桶数组里可存放元素的总数变多了,会比较频繁地发生碰撞,导致效率降低,定位元素位置的时间变长了。而如果阈值过小,桶数组可存放的元素变少了,导致空桶数量变多了,造成空间上的浪费。

5.2.1容量、阈值、负载因子

容量:是指桶数组的长度,默认初始容量为16。

阈值:元素总数超过这个值会对Hashmap扩容,阈值 = 容量 * 负载因子

负载因子:用来计算阈值的参数,默认是0.75,可在创建Hashmap时设置值。

容量和阈值很好理解,负载因子默认是0.75,也是对空间和时间复杂度的均衡考虑。

5.3如何扩容?

前面已经提到扩容实际上是对数组进行扩容,而数组的扩容对数据结构中的链表和红黑树有什么影响呢?实际上会将链表和红黑树进行拆分,然后重新映射到新的数组里。接下来我们将按照数据结构三个组成部分,桶数组、链表、红黑树三个方面讲解如何扩容。

  1. 桶数组扩容

  2. 链表拆分

  3. 红黑树拆分

5.3.1桶数组扩容

众所周知,数组长度是固定的,所以对桶数组扩容是创建新的桶数组,而新桶数组长度是原来长度的两倍。为什么是两倍呢?上面hash算法演示中,已经知道数组长度是2的次幂,而2正好是最小次幂,扩大了会浪费内存空间。而阈值也会扩大两倍,因为阈值公式是 阈值 = 数组长度 * 负载因子,因为数组长度扩大了两倍,所以阈值也随之扩大两倍。扩容示意图如下:

5.3.2链表拆分

你肯定会好奇链表为什么要被拆分,不是多此一举吗?还记得前面计算元素在桶数组位置的算法吗?桶索引index = hash &(length - 1),而此时桶数组长度length变了,意味着元素在桶数组的位置索引index也要发生改变,所以链表里的元素需要映射到新的数组里。首先我们来看看桶数组扩容前后,索引index会怎么变化?索引变化示例图如下:

从示例图我们可以看出,链表的节点元素拆分后要么在原先桶内,要么在新桶(原桶 + oldLength)内。回到图的右边部分,拆分后的计算过程,hash的第五位的数决定了节点最终索引index位置,若第五位数为0,则节点拆分后还是在原桶内,否则在新桶内。所以判断算法可以写成 hash & 16 == 0 ? 原桶 : 新桶;而16正好是原先数组长度oldLength,最终可以写成 hash & oldLength == 0 ? 原桶 : 新桶。同理,新桶索引位置也决定index = 5 + 16 = oldIndex + oldLength。链表拆分前后示意图如下:

链表拆分部分已经讲完了,为了加深理解,建议自己手写算出另外两个节点元素21、5.9的新索引值。

5.3.3红黑树拆分

讲到红黑树拆分,必须要先了解链表转成红黑树的流程。首先我们知道链表长度太长会转化成红黑树,优化插入和查询效率。但需要同时满足两个条件后才能转红黑树,两个条件如下:

  1. 链表长度大于8,如果节点插入链表后,长度大于8,则会转成红黑树

  2. 桶数组长度大于等于64。

在前面讲述插入流程里,我们已经知道链表长度大于8会转红黑树。但另外一个条件为什么需要桶数组长度大于等于64呢?在这里先抛出问题,接下来讲解红黑树拆分会解答这个问题。

链表树化的过程示意图如下,原先的链表节点会转成树节点,但这个树节点是一个特殊的树节点,除了会记录左右子节点的引用外,内部还保留了原先链表前后节点的引用,意味着树化后还保留了链表的顺序,图中橙色虚线箭头表示原先链表的下一个节点。

链表转成红黑树的操作不是一个廉价的操作。因为在时间方面,需要花费时间构建红黑树结构,其次是在空间方面,树节点所含的属性比链表节点多,所以树节点所占空间比链表节点大。

既然红黑树还保留原先链表中的顺序,意味着红黑树拆分与链表拆分的逻辑一致,也会拆分成两条链表,不同的是拆分后是两条由树节点组成的链表,如果链表长度小于红黑树链化的阈值6,会把链表转成普通链表。否则将树节点链表树化成红黑树结构。红黑树链化过程示意图如下:

红黑树拆分的链化过程已经讲完,可以来回答为什么需要桶数组长度大于等于64才能转成红黑树呢?我个人理解的原因是

当桶数组容量比较小,hash 的碰撞率会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马转成红黑树,因为高碰撞率是桶数组容量较小引起的。其次是桶数组容量太小,会比较频繁地扩容,导致红黑树不断拆分并重新映射,所以过早地将链表转成红黑树是一件得不偿失的事。

5.4小节

扩容机制是HashMap中最复杂的流程,了解透彻确实需要一点时间,但只要有清晰思路就不成问题,实际上所有操作都离不开底层的数据结构,所以先从扩容机制对底层数据结构的影响下手,其中可分为对桶数组、链表、红黑树的影响,接着再细究每个部分的变化。可能你会产生各种疑问,例如为什么负载因子默认是0.75?链表长度为什么要大于8而不是其他值才转成红黑树?桶数组长度为什么是大于等于64而不是其他值才选择链表树化?这些更深入的问题会在下一篇文章解答。

6.删除元素

删除操作与上面的操作步骤类似,多加了一步移除步骤。 首先是定位桶数组中的位置,其次是遍历链表或者红黑树找到相同的节点,最后是从链表或红黑树中删除节点,所以不展开细讲。

7.总结

本文对Hashmap的主要操作进行讲解分析,并以图片的形式演示了部分流程。不难发现,主要操作讲解都是围绕着底层数据结构展开分析的。其中定位元素位置的两个步骤比较容易理解,也不复杂。插入流程和扩容机制讲解的最多,也较为复杂,主要是涉及的知识点很多,所以需要读者反复阅读加以理解。当然,本篇文章不是详细分析Hashmap,所以没有加入源码分析,也因此挖了好几处坑没有填,比如Hashmap构造函数细节,红黑树增删查操作等等,但这些并不妨碍理解Hashmap原理和主要操作。

8.题外话

本文配图较多,目的是为了能更好的理解文字所描述的内容,希望能够帮助到大家。还有一点,为了画配图而长时间盯着电脑,眼睛有明显酸胀感,再加上时间和能力有限,所以难免在文字描述和配图会有所出错。如果有错误或讲解不够清晰的,希望大家在评论区里指出来,我会及时修改,先在这里谢谢大家了。

参考

1.HashMap源码分析 - segmentfault

2.美团-Java 8系列之重新认识HashMap - 美团技术博客