HashMap
HashMap
一、定义
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
二、实现
2.1 数据结构
HashMap 的底层数据存储结构是“数组 + 链表 + 红黑树”,红黑树结构是 JDK8 之后进行的优化。
1 | transient Node<K,V>[] table; |
1 | static class Node<K,V> implements Map.Entry<K,V> { |
它的实际结构大概是这样子的:
1 | 0 1 2 3 4 5 6 7 |
整个数组长度为8,而2那里放的就是红黑树,6、7那边则放的是链表。
实际上HashMap
的数组长度这么短时,是不会有红黑树的,这里只是做个例子展示。
2.2 集合操作
1)查找元素
HashMap
底层就是“数组 + 链表 + 红黑树”,所以查找也不是很麻烦,只要遍历数组的每一个链表或树就可以。
1 | public V get(Object key) { |
查找的话,可以分为几步:
- 计算hash对应到table的位置
first = table[(n - 1) & hash]
,如果first
不为null,则进行下一步; - 如果
first
就是所要寻找的元素,那么就直接返回; - 如果
first
是一个树节点(TreeNode
),那么就按照树的查找来找目标元素; - 如果
first
是普通节点,就按照链表的顺序,从头到尾寻找目标元素。
理论上来说,只要知道了数据的结构,遍历寻找还是比较简单的。
2)删除元素
1 | final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { |
删除的处理逻辑也不复杂,就是源码写的花里胡哨:
- 先找到要删除的节点,以及它的父节点,查找过程和上面的类似;
- 如果是红黑树结构,就按照树结构来删除;
- 如果是链表结构,直接按照链表结构删除节点,即父节点指向孙子节点。
3)添加元素
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { |
添加的话,可以分为几步:
- table未初始化时,先初始化table
- hash所在的table位置未初始化。计算hash对应到table的位置
p = table[(n - 1) & hash]
,如果p
为null,则直接插入新节点到数组上 - 添加节点已存在时。如果
p
在table中已存在,不会新建节点,而是直接更新值 - 添加节点不存在时,分2种情况:
- 插入结构是红黑树,如果
p
是一个树节点(TreeNode
),也就是红黑树结构,那么就按照树结构来添加节点 - 插入结构是链表,如果
p
是普通节点,也就是链表结构,把新节点添加到链表末尾。另外如果此时超过了阈值,链表会转化为红黑树
需要说明的一点就是,如果添加的元素在HashMap
中已存在,那么就会更新对应节点的值,而不是插入新节点。
总的来说,集合的几个操作并不算复杂,复杂的是这些操作引起的扩容问题以及同hash值节点的数据结构变更(链表变树,树变链表)。
2.3 扩容机制
1)初始容量
HashMap
的初始容量是指数组的长度,它要求数组的长度始终是“2的倍数”,和ArrayDueue
的要求一样。
所以当传入的参数不符合要求时,就会对它进行修正:
1 | static final int tableSizeFor(int cap) { |
也就是说,如果传入的初始化容量不是“2的倍数”,会自动被修正为“2的倍数”。
比如,new HashMap(3)
实际上底层的数组长度的初始长度会是 4,new HashMap(14)
实际上底层的数组长度的初始长度会是 16。
至于为什么要取“2的倍数”,应该和ArrayDueue
的一样,其实就是计算从hash值映射到数组时可以更高效:
- 计算机对于2倍数计算是很快的,直接通过移位操作(
n << 1
)就可以实现; - 计算hash对应的数组位置时,一般需要取模,但如果容量是2的倍数,那么取模就可以用掩码(
table[i = (n - 1) & hash])
)来实现,会更高效一些。
2)负载因子
对于扩容,有一个问题,就是什么时候才需要重新调整数组容量?
因为理论上来说,“数组 + 链表 + 红黑树”是可以放置无数个节点的,是不是一直都不需要调整呢?
实际上不是,一旦数据多了起来,不论是链表,还是红黑树,它的节点都会变得越来越多,查找也会越来越慢,这个时候就需要减短它的节点数量,来提高性能。
在 HashMap
中,使用了负载因子来表示需要数组扩容的时机:
1 | final float loadFactor; |
为了了解负载因子的作用,先解释一下负载的意义。举个例子说明,比如当前结构如下:
1 | 0 1 2 3 4 5 6 7 |
此时的元素数量是 10,数组长度是 8,那么实际负载就是 10 / 8 = 1.25
。
负载因子在一定意义上,代表了当前数据结构的饱满程度
假设HashMap
的负载因子是0.75,那么对于上面的例子而已,它的负载值 1.25 > 0.75
,实际上早就已经需要扩容了。
也就是说,在设定好指定的负载因子后,如果不断添加元素,实际负载也会不断增大,直到大于负载因子时,就需要进行扩容。
另外,HashMap
为了减少负载的计算,一般都用阈值threshold
来提前计算好会超过负载因子的最大元素数量。
1 | threshold = table.length * loadFactor; |
当元素的数量size > threshold
时,就是实际负载超过负载因子的时候,这个时候就要进行扩容。
1 | if (size > threshold) { |
实际上,阈值threshold
并不是一个必要的变量,只是为了提高性能,减少负载的计算(除法计算),而添加的。
3)扩容容量
HashMap
为了保持“2的倍数”容量,每次扩容都是之前的2倍。也就是 “新容量 = 旧容量 * 2”。
直接看一下扩容代码:
1 | final Node<K,V>[] resize() { |
扩容容量的计算还算简单,主要可以分为几步:
- 如果不是第一次初始化数组,就对旧容量进行扩容,在没有达到最大容量的情况下,新容量 = 旧容量 * 2
- 如果是第一次初始化数组,并且初始化容量大于0,那么就用初始化容量作为新容量
- 如果是第一次初始化数组,并且初始化容量为0,那么就用默认初始化容量作为新容量
当然,代码中间还加上了一些溢出边界的判断,但是总体逻辑差不多就是这样。
4)数据迁移
扩容数组之后,还需要把旧数组的数据迁移到新数组中。先看它的代码:
1 | final Node<K,V>[] resize() { |
迁移过程可以分为几个步骤:
- 遍历旧数组的所有元素,重新计算里面每个元素在新数组中的位置,并把它移动到新数组中
- 因为是扩容了2倍,所以在旧数组中同位置的元素,映射到新数组上面时,会映射到2个位置
- 如果是红黑树结构,映射到新数组时,也会被拆分成2部分
- 如果是链表结构,映射到新数组时,也会被拆分成2条链表
举个例子说明一下,假设旧的数据结构如下:
1 | 0 1 2 3 4 5 6 7 |
当前的数组大小是 8,那么按照前面的规则,扩容后的新数组长度应该是 16,重新分配后的结构如下:
1 | 0 1 2 3 4 5 6 7 |
其实重新hash的计算也很简单,因为是2倍扩容,实际上重新分配时,一部分节点还是在原来的位置,而另一部分会被分出去。
1 | if ((e.hash & oldCap) == 0) { |
另外,HashMap
重新分配后的链表顺序还是和之前一样的。
这一点 HashMap
和 HashTable
不一样,HashTable
扩容后链表结构会反过来。
这是链表的重分配,实际上红黑树的重分配也是一样的,只不过调整树结构稍微麻烦一些,毕竟是有顺序的红黑树。
2.4 树转化
除了加上“红黑树”结构以外,HashMap
和 HashTable
的结构都差不多,一般情况下都是“数组 + 链表”。
而 HashMap
为了提高效率,增加了“红黑树”的功能,当然也给代码加了很多复杂操作~~
什么时候需要红黑树呢?这里先说明一下相关的几个属性:
1 | /** |
这里有3个变量和链表与树转化有关。
其中 MIN_TREEIFY_CAPACITY
和 TREEIFY_THRESHOLD
是链表转红黑树用到的,UNTREEIFY_THRESHOLD
是红黑树转回链表时用到的。
2.4.1 链表转到红黑树
HashMap 不是随便就把链表转到红黑树的,而是需要满足某些条件:
- 数组长度达到阈值
MIN_TREEIFY_CAPACITY
,即64 - 链表长度达到阈值
TREEIFY_THRESHOLD
,即8
注意,要同时满足这2个条件,才会将链表转成红黑树。
MIN_TREEIFY_CAPACITY
是转化树需要的数组长度阈值,这是用来干嘛的?
其实就是当 table
数组太小时,HashMap
宁愿进行扩容,也不愿意把链表转成树。
这个可能是考虑到性能问题,数组比较小时,维护红黑树反而比扩容的性能低。
下面是链表转成红黑树的逻辑判断:
1 | // 这个部分的源码上面添加元素写有备注了,就不重复写了 |
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
就比如说,当前数组长度是 16,然后其中某个位置的链表很长了,需要转成红黑树。
但此时数组长度还比较小 16 < 64
,所以它就不转化成红黑树了,而是直接把数组扩容为 32,然后把长链表拆分,并重新分配到不同位置。
2.4.2 红黑树转回链表
当红黑树的节点数量变少了,把它转成链表来维护,反而性能会更好。不过,这个转换回来也要满足某些条件才行:
- 删除节点后,如果此时红黑树节点数量已经很少了,红黑树将会转回链表
- 进行扩容时,此时红黑树会拆分成2棵子树,如果拆分后的2棵子树的节点数量低于某个阈值,即
UNTREEIFY_THRESHOLD
,红黑树将会转会链表
注意,满足其中1个条件,就会将红黑树转成链表。
首先是“红黑树的节点数量很少”的情况。
这种情况只会出现在删除节点的时候,具体看代码:
1 | final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) { |
节点数量很少,究竟多少算少呢?按照代码的判断,它有几种情况:
- 红黑树节点完全没有了
- 红黑树右子树没有了
- 红黑树左子树没有了
- 红黑树的左子树的左子树没有了
这几种情况下,就认定为红黑树的节点数量很少了。
这是为什么?红黑树本质上是一棵平衡树,如果某棵子树没了,另一边的子树肯定没几个节点,那就意味着整棵树就没几个节点了(至于更深层的原因,可以去深入了解红黑树的结构)。
其次是“进行扩容时,如果红黑树节点数量低于 UNTREEIFY_THRESHOLD
”。
这种情况只会出现在增加节点导致扩容的时候:
1 | final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { |
可以看到,扩容后,会将之前的节点拆分成2部分,如果某部分的节点数量不大于阈值 UNTREEIFY_THRESHOLD
,就会将红黑树转回链表。
2.4.3 测试代码
针对链表红黑树相互转换的几种情况,写个测试代码来测试一下。
测试的情况分为3种:
- 添加节点时,链表转红黑树
- 删除节点时,红黑树节点很少,转回链表
- 扩容时,红黑树节点少于阈值,转回链表
测试思路是这样的:
明确链表转红黑树时,
table
必须要达到阈值MIN_TREEIFY_CAPACITY
,即64。所以一开始就设定容量为64,这样是为了避免中间出现扩容的情况,一旦出现扩容情况就复杂了。为了测试链表和红黑树的转换时机,需要一直在同一个位置进行增删数据,同时打印 HashMap 的结构。
第3种情况是必须要扩容的,所以为了在同一个位置添加可以快速扩容,把负载因子改小了。但是这个负载因子也不是随便写的,因为必须要使得扩容后红黑树的节点数量不大于阈值
UNTREEIFY_THRESHOLD
,即6。同时为了看到红黑树转回链表和不转回链表的情况,最好就是一个大于6,一个小于6,所以这里取的是13,重新分配后刚好满足这样要求(7和6),然后这个负载因子就是根据13/64 --> 0.2
来算的。
因为 HashMap 的很多属性外部都访问不到,比如 table
成员,然后测试时又需要用到,所以这里通过反射拿到它里面的 table
属性。
1 | public class PrintHashMap extends HashMap<Integer, Integer> { |
然后下面是具体的测试代码:
1 | public static void main(String[] args) { |
最后输出结果是这样的:
1 | 测试添加节点时,链表转红黑树 |
测试结果基本和上面的源码分析一致:
增加节点,直到节点数量大于阈值
TREEIFY_THRESHOLD
时,即8时,链表就会转成红黑树。删除节点,直到节点数量很少时,这里是3,红黑树就会转回链表。
扩容时,重新分配得节点数量不大于阈值
UNTREEIFY_THRESHOLD
时,即6时,红黑树就会转回链表。
从这里可以看出了,实际上添加第9个元素时,即节点数量 > 8
时,链表才会转为红黑树。
小结
链表转到红黑树需要满足的条件:
- 添加节点后,数组长度达到阈值
MIN_TREEIFY_CAPACITY
,即64 - 添加节点后,链表长度达到阈值
TREEIFY_THRESHOLD
,即8 - 注意,要同时满足这2个条件,才会将链表转成红黑树
红黑树转回链表需要满足的条件:
- 删除节点后,红黑树节点数量很少时,红黑树就会转回链表
- 扩容时,重新分配得节点数量不大于阈值
UNTREEIFY_THRESHOLD
时,即6时 - 注意,满足其中1个条件,就会将红黑树转回链表
总结
数据结构
- 数据结构是“数组 + 链表 + 红黑树”
扩容机制
- 负载因子负责控制扩容的时机
- 当满足“大小 > 容量 * 负载因子”时,才触发扩容
- 每次扩容是2倍,这是为了维持hash的快速定位
- 扩容时的重新分配位置,倒序插入
树转化机制
- 链表转到红黑树
- 添加节点后,数组长度达到阈值
MIN_TREEIFY_CAPACITY
,即64 - 添加节点后,链表长度达到阈值
TREEIFY_THRESHOLD
,即8,但是实际上是下一次再添加时才会转为红黑树,即长度为9时 - 注意,要同时满足这2个条件,才会将链表转成红黑树
- 添加节点后,数组长度达到阈值
- 红黑树转回链表
- 删除节点后,红黑树节点数量很少时,红黑树就会转回链表
- 红黑树节点完全没有了
- 红黑树右子树没有了
- 红黑树左子树没有了
- 红黑树的左子树的左子树没有了
- 扩容后,重新分配得节点数量不大于阈值
UNTREEIFY_THRESHOLD
时,即6时 - 注意,满足其中1个条件,就会将红黑树转回链表
- 删除节点后,红黑树节点数量很少时,红黑树就会转回链表