一、前言
Java
的容器是是JavaSE
的重中之重,同时也是面试中的必考点,所以对容器源码的研究必不可少。今天我研究了一下HashMap
的源码,颇有心得,所以写篇博客分享一下HashMap
的实现原理。内容主要包括HashMap
的底层结构,hash
函数的原理,以及HashMap
的容量机制等内容。内容很多,但是这些内容彼此相辅相成,并不适合分开来叙述,所以将它们放在一起进行讲解。相信大家看完这篇博客,将清楚的理解HashMap
高效的秘诀。
二、解析
2.1 什么是Hash
Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数(此处引用其他博客)。简单来说,就是将一个任意类型的数据,根据一定的算法,计算出一个标识它的int
类型的整数,也就是hash
值。
注意:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同;但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做hash碰撞。
2.2 HashMap的底层结构
我们首先来谈一谈HashMap
的底层结构,即HashMap
是如何保存数据的,若连这个都不清楚,那其余的也无从谈起。HashMap
的结构概括说来就是:数组 + 链表。
我们知道,HashMap
中的元素都是Key - Value
类型的,我们姑且将每一个元素称为一个节点Node。在HashMap
中,所有的Node
都是存在一个数组中,而这个数组的声明如下:
1 | /** |
可以看到,这个数组table
的类型是Node
类型,其实就是我们说的Key - Value
。那当我们进行put
操作时,元素将如何存入这个数组中呢?这时候就要用到我们前面提到的Hash
了。当我们往HashMap
中存入一个元素时,HaspMap
底层会调用hash
函数,计算出元素key
的hash
值,然后再用这个hash
值与HashMap
的总容量进行求余,得到的余数就是这个元素在数组中存放的下标。
既然如此,那就可能会出现hash碰撞
的情况——即两个不同的元素,根据以上方法求出的下标值却相等。这要如何解决呢?HashMap
的做法就是采用 数组+链表 的方式解决:在存储元素的数组中,每个位置并不是存储一个单独的Node
,而是存储一个链表,而这个Node
就是链表中的一个节点,当一个元素要放入数组的某个位置时,若这个位置已经有元素了,那就将这个元素接在最后一个元素的后面。如下图所示,数组下标为1的位置有三个元素,它们共同形成一个链表。
我们来看看HashMap
中Node
的代码,帮助我们理解数组+链表的结构。通过下面的代码可以看到,Node
是HashMap
的一个内部类,他有四个成员变量:
- hash:记录节点的
hash
值; - key:记录节点的
key
值; - value:记录节点的
value
值; - next:记录当前节点的下一个节点;
而链表的结构,就是通过next
成员变量来实现的。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
2.3 HashMap的容量机制——高效秘诀
理解了HashMap
的底层结构之后,我们再来探索它高效的秘诀。我们知道,HashMap
是优化了查找速度的一种集合,查询效率极高。而在HashMap
中,查询一个元素的步骤如下:
- 首先通过向
hash
函数传入需要查找的元素的key值,hash函数计算出key的hash值; - 将
hash
值与总容量进行取模运算,计算出数组元素在数组中的下标; - 根据数组下标获得元素所在的链表;
- 从链表的第一个节往后依次比较
key
值; - 找到
key
值相等的节点返回;
以上步骤可以归结为以下代码(注意:以下代码是我从源码中抽取出来组合在一起的,实际上它们并不在一个方法中):
1 | Object get(Object key){ |
上面的代码只有一个地方可能让人疑惑,那就是取模操作%被按位与运算&所取代。上面的代码中,数组的中括号中本应该是h%len
,但是大家去查阅源码,会发现实际写的是h & (len-1)
。这是什么意思呢,其实在特殊情况下,这就是取模运算。下面我们就来讲解一下满足 h & (len-1) == h % len
的特殊情况。
这种特殊情况就是:一个数对2^n
取模,等价于这个数对2^n - 1
做与运算,即num % 2^n == num & (2^n -1)
。我们举个例子来说明这个公式的原理:假设上面的公式中,n==3
,即我们要对2^3
,也就是8取模,8转换成二进制是1000
,而2^3-1 == 7
,转换成二进制就是0111
,然后与一个数做与运算,即num & (2^3 -1)
,结果将得到num
转换成二进制后的末尾三位。而我们看num / 8
,实际上就是二进制的num
向右移动三位,移掉掉的那三位就是num / 8
的余数,即num % 8
。而移掉的三位数,不正是我们通过num & (2^3 -1)
获得的吗。比方说10 % 8 == 2
,而10 & (7) = 1010 & 0111 == 0010 == 2
。这个地方需要好好理解一下,如果实在不理解,那就记住这个结论。
在HashMap
中,保证了存储元素的数组的大小一定是2^n
,所以在内部,通过hash值与数组容量取余的操作,都用上面说的与运算取代了。这样做的好处是,与运算直接操作内存,效率极高,而在HashMap
中,获取数组下标是一个非常频繁的操作,无论是get
还是put
都要用上,所以这种优化对HashMap
的查询效率有很多的提升。在HashMap
中,有两个静态变量,分别是默认初始容量和最大容量,可以看到,它们都是都是2的n次方,而且没有直接写成数字,而是一个移位公式,如 1 << 4
,就是为了提醒大家HashMap
的容量机制。
1 | /** |
说到这里,可能有人会有疑问了:HashMap
不是有构造器,可以指定初始容量吗,如果我们指定一个不是2^n
的容量,不就破坏了这种机制吗?答案当然是不会的,我们虽然可以指定HashMap的初始容量,但是不代表它会直接使用我们指定的容量。当我们为HashMap
指定一个初始容量时,它不会直接使用这个容量,而是计算出第一个大于等于这个容量的且满足2^n
的数,若这个数大于HashMap
运行的最大值,则直接使用最大值。而且我们知道,Java中的大多数容器都有自动扩容机制,包括HashMap
,而HashMap
为了满足容量一定是2^n
,扩容时是在原来的基础上乘2,因为2^n
乘以2还是满足2^n
。
其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。(此段引用参考博客)
2.4 解析hash方法
接下来,我们再来看看HashMap
源码中的计算哈希值的hash函数是如何实现的:
1 | static final int hash(Object key) { |
以上是JDK1.8
中hash
函数的实现(其他版本的hash方法有所差异,但是原理是一样的),简介明了:方法接收一个参数,也就是Node
的key
值,然后判断key
值是否为null
,若为null
,则hash
值为0;否则调用key
的hashCode
方法获取hash
值,并将hash
值右移16位后与原hash
值做按位与运算。这个方法还是很好理解的,除了一个地方,就是为什么要将hash
值右移16位后做与运算呢,调用hashCode
方法获取的hash
值不能直接用吗?这么做的原因还是为了优化。
我们如何定义一个hash
算法的优劣?其中的一个重要因素就是尽量少发生hash碰撞
。大家可以试想一下,HashMap
最坏的情况是什么样子:所有存入其中的元素,通过hash
值计算出来的下标都是一样的,都放在数组的同一个位置,组成一个链表。这样的情况下,HashMap
便完全失去了意义,和一个普通的链表又有什么区别。而好的hash
函数,可以使碰撞发生的概率大大减少,让元素在数组中分别均匀,从而提高查找效率。
而源码中的按位与运算,实际上就是为了降低hash
碰撞进行的扰动计算。为什么这么说呢,举个简单的例子:
1 | HashMap的容量:8 -> 转换成二进制:1000 |
可以看到,上面例子中的两个hash
值差别巨大,但是它们和容量8进行取模后的结果却是一样的,结果发生了hash
碰撞。因为容量对于容量8
来说,取模的做法是与8-1
也就是7
做按位与运算,而7转换成二进制的结果是0111
,也就是说,取模的结果实际上就是取hash
值的后3位,而hash
值的前29位无论怎样,都不会影响结果。所以尽管上面两个hash
值差异巨大,但是后三位相同,导致它们求出的下标是相同的。这种情况下,发生hash
碰撞的几率将会大大增加。所以,为了充分利用计算出的hash
值的每一位,HashMap
的源码做出了一个优化,将计算出的hash
值向右移动16
位,然后让移动后的值与原hash
值做与运算,计算出新的值。为什么是16
位呢,因为int
是32位
的,16位
正好是32
的一半。这样,就充分利用了hash
值的每一位,而不是像之前一样,只有最后几位对结果有影响,从而减少了hash
碰撞的发生。
2.5 JDK1.8对HashMap结构的优化——红黑树
其实从JDK1.8
开始,HashMap
已经不再是简单的数组+链表的存储结构,而是做出了一个巨大的变动,在HashMap
的数据存储中引入了红黑树,变成了数组+链表+树的结构。下面我们来简单的谈一谈这种结构。
首先我们还是要回归之前谈过的HashMap
最坏情况的问题:HashMap
中,所有的元素都在数组的同一个位置,在一条链表上。这时候,HashMap
和一个链表基本上没什么区别,之前的那些查询优化也就没效果了。这时候查询一个元素的时间复杂度是多少?当然是和遍历链表一样——O(n)
。当然,这只是极端的情况,正常情况下不会出现,但是大部分元素集中在少数几条链表上这种情况还是很常见的,比如key是自定义类型,而程序员提供了不好的hashCode
方法,得到的hash
值经常发生碰撞。
为了当发生以上情况时效率不至于太慢,JDK1.8
改变了HashMap
的存储结构——当HashMap
中的某一条链表元素过多时,底层就会将其转换为一棵红黑树。而红黑树的查询时间复杂度为O(log n)
,相比于链表的O(n)
来说要快上不少。在HashMap
中有下面三个带有默认值的静态变量,用来控制树化过程:
1 | /** |
三、总结
上面的内容对HashMap的底层存储,效率优化机制做了一个较为详细的介绍,相信看完之后会对HashMap有一个较为深入的理解。但是,这些只是HashMap的一部分,想要真正了解HashMap,还是要自己结合源码,仔细的阅读。希望我写的这篇博客能够对一些人有所帮助。
四、参考
以上大部分内容参看下面两篇博客后,根据自己的理解编写:
- https://mp.weixin.qq.com/s/qCHkzs4JPOipB-ZzqrfbeQ —— 此篇分析hash函数
- https://mp.weixin.qq.com/s/8a5BTWomhe3R-jqT1ffwYw —— 此篇分析HashMap容量