HashMap 源码剖析

前置知识

注意:该部分如果没有特殊提 JDK 版本,那么就都是基于 JDK8 中的 HashMap!!

扰动函数

HashMap 本质上是一种哈希表,在不考虑哈希冲突的情况下,增删改查操作的时间复杂度均为 O(1)。哈希表的本质是一个数组,每次操作都是根据下标,才能达到时间复杂度均为 O(1) 的效果

哈希表的底层原理是将对象的哈希值通过一个哈希函数映射成一个下标地址,然后将该元素存入数组中对应的下标中。但在实际中,可能存在多个对象的哈希值映射成下标后相等的情况,最简单的做法就是拉链法

上述说的情况被称之为哈希冲突,哈希冲突堆积越严重就会越影响增删改查的效率,所以最理想的情况就是元素均匀分布在哈希表中。下图中的第一种情况显然要比第二种情况查找效率高

1

我们知道 Java 中每个对象都有一个自己的 HashCode,可否直接用 HashCode % n 作为数组的下标,其中 n 为数组的长度

原则上是可以的,但是 HashMap 并没有直接使用对象的 HashCode,而是进行了一次扰动,被称之为扰动函数:

扰动函数的目的为了防止一些实现较差的hashCode()方法导致元素没有较为均匀的分布在哈希表中,增加了哈希冲突的概率,降低了数据存取的效率

初始容量

这里首先需要明确两个概念:数组长度和元素个数。数组长度表示哈希表的长度,即上图中绿色的部分,长度为 6;元素的个数表示哈希表中存放的元素个数,即上图中紫色的部分

所以可能出现元素个数大于数组长度的情况!!这一部分说的容量概念就是指数组的长度

HashMap 中有一个默认的初始容量,大小为 16:

上面的注释提到容量大小必须是 2n,HashMap 有一个可以自定义容量大小的构造函数:

可以看到如果传入的不是一个 2n,那么就会通过tableSizeFor()方法获得一个大于目标数且最小的 2n 的一个数作为初始容量

这里可以引出一个很经典的问题:HashMap 的初始容量为什么是 2n,以及扩容为什么是 2 倍的形式?

我们平时将一个数映射到 10 以内的话,都是直接取模,如:n % 10,这里我们也可以用相同的方式:hash % cap,但 HashMap 并没有使用这种方法,而是用位运算hash & (cap - 1)

这两种方式只有在 cap=2n 的时候才相等,不信的话自己可以尝试几个例子。HashMap 之所有使用位运算是因为&%的效率高很多

这里还有第二个原因,不得不提到一个阈值的概念threshold = capacity * loadFactor,loadFactor 是下面要介绍的负载因子。当 HashMap 中元素个数 > threshold 后就需要扩容:

既然扩容了,那么有些元素肯定需要重新计算哈希映射,然后移动到新的位置,如下图所示:

2

可以看到如果扩容后容量大小依旧保持 2n,那么将会很容易计算元素的新位置。如果扰动计算后的 hash 的高一位为 0,那么扩容后位置将不变;如果为 1,那么扩容后的位置将是原位置➕扩容前的容量

负载因子

负载因子控制数组存放元素的疏密程度,通过负载因子和容量大小可以计算得到一个阈值,即:threshold = capacity * loadFactor,当 HashMap 中元素个数 > threshold 后就需要扩容

所以当负载因子越接近于 1,那么数组中存放的元素就越多,也就越密集;当负载因子越接近于 0,那么数组中存放的元素就越少,也就越稀疏

负载因子越大就会导致查询元素效率低;越小就会导致空间利用率低,存放元素分散。官方给出的默认值为 0.75

通过默认的容量大小 16 和默认的负载因子 0.75,就可以计算出阈值为 12,表示当元素个数大于 12 后就需要进行扩容

数组扩容

关于数组扩容,在介绍上面三个概念的时候都提过,所以本部分就不再赘述。简单的从源码角度分析一下如何确定元素扩容后的位置:

底层数据结构

在 JDK8 之前 HashMap 底层是数组➕链表相结合的形式

在 JDK8 及之后 HashMap 底层数据结构有了变化。当链表长度大于阈值 (默认为 8) 时,首先会调用treeifyBin()方法来判断是否需要转换成红黑树。只有当数组长度 64 时,才会执行treeify()方法真正的转换成红黑树,否则知识简单的扩容

源码分析

构造函数

HashMap 的构造函数只负责一件事情,初始化负载因子 loadFactor 和初始容量大小 initialCapacity。它提供了三个构造函数:

注意:构造函数中并没有对数组进行初始化,HashMap 将对数组的初始化延迟到了调用put()方法时,在resize()中初始化数组,扩容也在resize()

插入

插入是 HashMap 使用非常频繁的操作,下面是插入的流程图:

3

接着结合源码分析一波上述流程:

查找

上面分析了插入操作,下面的查找操作就相对于简单很多。同样的,下面是查找的流程图:

4

接着结合源码分析一波上述流程: