HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。
HashMap底层数据结构
在早期的JDK版本中,HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则主要是为了解决哈希冲突而存在的。向 HashMap 中存放元素时,哈希值相同的对象会形成一个链表放在对应的数组元素中。但是链表查询元素的时间复杂度是O(n),所以链表元素越多查询效率越低。
基于上面的背景,所以在JDK8中,HashMap 引入了第三种数据结构——红黑树,红黑树是一棵接近于平衡的二叉树,其查询时间复杂度为O(logn),比链表的查询效率高。那为什么不直接使用红黑树代替链表呢?这是因为红黑树自身维护的代价也是比较高的,每插入一个元素都可能打破红黑树的平衡性,这就需要红黑树在插入数据的时候,通过左旋、右旋、变色这些操作来保持自身平衡。所以在 HashMap 中链表转红黑树需要满足一定的条件。
1、HashMap中数组、链表、红黑树的源码定义与结构图示
如下源码定义基于JDK8版本,首先我们使用结构图示的方式了解一下HashMap的数据结构定义。
从图示中我们可以看出 HashMap 由数组、链表、红黑树共同构成,JDK8采用红黑树的数据结构来做优化,使得HashMap存取速度更快。
-
数组定义:
// 数组 transient Node<K,V>[] table;
-
单链表节点定义:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; // 对key的hashCode值进行hash运算后得到的值,存储在Node中,避免重复计算 final K key; V value; Node<K,V> next; // 存储指向下一个Node的引用,单链表结构 Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
-
红黑树节点定义:
TreeNode<K,V>
继承自LinkedHashMap.Entry<K,V>
,同时LinkedHashMap.Entry<K,V>
又是HashMap.Node<K,V>
的子类static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } // …… 省略其他函数方法 }
2、HashMap中链表、红黑树的使用
前面了解了数据结构的定义后,我们再来看看在 HashMap 中是怎么使用链表和红黑树这两种数据结构的?
-
使用链表处理哈希冲突问题 : 哈希值相同的对象通过寻址算法计算出的索引位置也会相同,那么要想在同一个元素位置插入多个对象就需要使用链表结构
-
链表树化: 当链表节点数量大于等于 8 (
TREEIFY_THRESHOLD
)时,会触发链表树化函数final void treeifyBin(Node<K,V>[] tab, int hash)
,但是在该函数中会判断数组长度是否小于 64 (MIN_TREEIFY_CAPACITY
),若小于64则只会进行数组扩容操作,也就是说只有数组容量大于等于64时才会进行链表树化操作。基于这样的机制,就避免了HashMap在数据量相对较少的情况下出现因链表树化而导致的性能消耗。也就是说HashMap在容量较小的时候,会通过扩容机制来让链表暂时维护更多的元素 -
红黑树转链表: 当红黑树节点元素小于等于 6 (
UNTREEIFY_THRESHOLD
)个时会将红黑树转成链表下面我们通过 HashMap 的putVal方法的源码及注释内容,着重理解上面提到的
使用链表处理哈希冲突问题
、链表树化
、红黑树转链表
。final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 如果数组 table 中没有元素或者长度为0,则调用扩容函数进行数组初始化操作,并将数组赋值给 tab,然后将数组长度赋值给 n if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash 就是hash寻址算法,将寻址后得到的元素赋值给 p tab[i] = newNode(hash, key, value, null); // 如果数组的当前索引位置(i)是 null,则直接将key-value插入到该位置 else { // 数组的当前索引位置不是 null Node<K,V> e; K k; // 重新定义一个Node,和一个K if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果该对应数据已存在(hash相等并且key值也相等),将该位置的 Node 赋值给 e else if (p instanceof TreeNode) // 判断当前位置的 Node 的类型是否为 TreeNode e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 向红黑树中插入一个元素 else { // 下面开始遍历数组中当前位置的链表 for (int binCount = 0; ; ++binCount) { // 无条件循环,binCount 用于记录链表中节点数量(它从0开始的,也可以看作是索引数) if ((e = p.next) == null) { // 当 node.next 为 null 时,表示找到链表尾节点 p.next = newNode(hash, key, value, null); // 直接将key-value插入到链表尾部 if (binCount >= TREEIFY_THRESHOLD - 1) // 完成插入动作后,判断链表索引数是否大于等于(8-1),即链表节点树是否大于等于8 treeifyBin(tab, hash); // true, 进行链表树化操作。 // 注意,在 treeifyBin 函数中会判断数组长度是否小于64,若小于64则只会进行数组扩容操作,也就是说只有数组容量大于等于64时才会进行链表树化操作 break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果该对应数据已存在(hash相等并且key值也相等),直接跳出。注意:该位置的 Node 已经赋值给 e 了 p = e; // 如果以上条件都不满足,则将 e 赋值给 p,进行后续节点的查找 } } // 如果e不为空,即找到了一个去存储 key-value 的 Node。就是key相同时,覆盖value值操作 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); // 当 HashMap 的 size 大于了临界值,需要调整数组的容量。注意:在数据扩容时,发现红黑树节点元素小于等于6个时会将红黑树转成链表 afterNodeInsertion(evict); return null; }
3、 实操验证
通过上面的源码分析,现在是不是已经对 HashMap 的底层数据结构有了一个比较清晰的认识了呢?下面我们趁热打铁,通过一段测试代码来进一步观察 HashMap 的内部数据结构的变化。
@Test
public void hashMap() {
Map<String, String> map = new HashMap<>(32); // 初始化容量为 64,感兴趣的朋友可以试试 16 和 32
List<String> list = Arrays.asList("A646", "9C9B", "FAF4", "674C", "5DCD", "C504", "555F", "F164"); // 数组索引位置均为 9
for (String key : list) { map.put(key, key); }
map.put("440A", "440A"); // 添加该元素到HashMap时,会触发链表树化操作,treeifyBin(tab, hash);
map.put("531D", "531D"); // 该key的索引位置也为 9,此时put的数据会在红黑树中维护, e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// map.put("FDF1", "FDF1"); // 当初始化容量为16时,观察插入和不插入该元素的区别
// 移除4个元素,此时树中还有 6 个节点
map.remove("A646"); map.remove("9C9B"); map.remove("FAF4"); map.remove("674C");
// 继续追加更多的元素,迫使HashMap进行扩容,注意后面添加的这些元素的索引位置都不是 9
List<String> list2 = Arrays.asList(
"A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X",
"Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP", "AQ");
for (String key : list2) { map.put(key, key); }
map.put("AR", "AR"); // 再添加元素会触发HashMap扩容,扩容时发现数组索引 9 位置的树节点长度等于6,所以会将红黑树退化为链表
System.out.println(map);
}
大家可以在编辑器中通过DeBug的方式一步一步的观察数据的变化情况。
HashMap扰动函数的实现原理
对象的 hashCode 值是int类型的,那么从理论上来说,我们可以直接使用 hashCode 值作为数组下标了,且不会出现碰撞。但是这个 hashCode 的取值范围是 [-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
既然字符串的 hashCode 值不能直接作为下标使用,那么我们是不是可以让 hashCode 值与数据长度进行取模运算得到一个下标值呢?理论上是可行的,但是这会导致元素的位置不够散列,元素碰撞严重的问题。
所以在HashMap源码中不是直接使用 hashCode 值,而是对 hashCode 值进行了一次扰动计算。这样就可以在数组长度比较小的时候,也能保证 hashCode 值的高位和低位都参与到 hash 的计算中,增大了随机性。
在JDK 8 中,扰动函数就是通过 key 的 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16)
。使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。源码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 把hashCode值右移16位,之后与原hashCode值做异或运算
}
下图以字符串“面试题”为例,展示了一个字符串进行扰动运算的过程。
HashMap 中 数组 table 的容量如何确定?
HashMap 中 table 的默认初始容量是16 (static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
),但是在初始化 HashMap 时,推荐指定初始值大小。最大限制是1<<30(static final int MAXIMUM_CAPACITY = 1 << 30
)。
1、 指定初始化容量
当我们 new HashMap 并且指定初始化容量 initialCapacity 时,JDK会帮我们计算第一个大于 initialCapacity 的幂次方数。源码即推演过程如下:
/**
* 取第一个大于 cap 的 2的幂次方数,如: 6 -> 8; 10 -> 16; 17 -> 32
* @param cap
* @return
*/
static final int tableSizeFor(int cap) {
// 首先减一操作的目的是将入参降为一个小于2的幂次方数(如:8,16,32等)
// 例如我们传入的10,对应的2进制为00000000 00000000 00000000 00001010
int n = cap - 1;
// 减后
// 00000000 00000000 00000000 00001001 -> 9
n |= n >>> 1;
// 右移1位
// 00000000 00000000 00000000 00000100
// 之后再与自身相‘或’,也就是上面两个二进制相‘或’。‘或’运算:参加运算的两个值只要有一个为1,其值为1
// 00000000 00000000 00000000 00001101
n |= n >>> 2;
// 再次右移2位
// 00000000 00000000 00000000 00000011
// 再次对上面两个二进制相‘或’
// 00000000 00000000 00000000 00001111 // 将低位全部置为1,此时我们可以看到我们想要的效果了
n |= n >>> 4;
// 再次右移4位
// 00000000 00000000 00000000 00000000
// 再次对上面两个二进制相‘或’
// 00000000 00000000 00000000 00001111
n |= n >>> 8;
// 运算同上
n |= n >>> 16;
// 运算同上
int result = (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
// 之后对运算完的数字进行+1操作
// 00000000 00000000 00000000 00010000 // +1之后变为了一个幂次方数
return result;
}
那么为什么数组长度要保证为2的幂次方呢?
- 只有当数组长度为2的幂次方时,
h & (length-1)
才等价于h % length
,并且位运算的效率远高于 hash 取模运算; - 如果 length 不是 2 的幂次方,比如 length 为 15,则
length - 1
为 14,对应的二进制为1110
,在与 h 的二进制做‘与’运算,最后一位都为 0 ,而0001
,0011
,0101
,1001
,1011
,0111
,1101
这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率;
2、数组动态扩容
随着 HashMap 中元素数量的增加,当数组容量达到某个临界值时就需要进行动态扩容。
装载因子(static final float DEFAULT_LOAD_FACTOR = 0.75f;
)的主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如 table 数组大小为 16,装载因子为 0.75 时,临界值就是12,即当 table 的实际大小超过 12 时,table 就需要动态扩容。
扩容时,会调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是临界值)。
HashMap数组扩容元素拆分问题
HasMap 数组扩容时最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程,在JDK7中需要重新计算哈希值,但在JDK8中已经进行了优化,不在重新计算哈希值,而是通过 (e.hash & oldCap)
是否等于 0 这个算法来确定元素在新数组中的位置。
- 等于 0 时,则索引位置的值不变,即在新数组中的索引位置等于其在旧数组是的索引位置。我们将其记为低位链表区(lo开头 - low)
- 不等于 0 时,则索引位置的值等于其在旧数组时的索引位置加上旧数组的长度。我们将其记为高位链表区(hi开头 - high)
1、(e.hash & oldCap)
算法的推导解析
-
前置知识:
a.e.hash
代表的是节点元素的 hash 值,该值是根据 key 的 hashCode值扰动计算后得到的。b. oldCap 为旧数组的数组长度,是2的n次幂的整数。那么其二进制表示就是1个1后面跟着n个0,如:
1000 …… 00
;并且2的n次幂的整数 - 1后,其二进制表示就是n个1,如:…… 1111
。c. 如果想要
e.hash & oldCap
的结果为 0,则e.hash
的二进制表示必须与对应的oldCap 的二进制中的 1 的位置为 0,其他位置可以为 0 或 1。d. 如果想要
e.hash & oldCap
的结果不为 0,则e.hash
的二进制表示必须与对应的oldCap 的二进制中的 1 的位置为 1,其他位置可以为 0 或 1。 -
推导
(e.hash & oldCap) == 0
a. 我们进行如下推算举例
b. 我们知道
数组长度 - 1
(也就是2的n次幂的整数 - 1
)的二进制表示就是n个1
所以,通过(数组长度 - 1) & e.hash
计算元素在数组中的索引位置结果如下:
由上面的计算可以看出,真正能够影响计算结果的只有低3位(这是因为 e.hash 只有低3位是有效位)。可是两者的低3位完全一样都是1。故,(2 * oldCap - 1) 和 (oldCap - 1) 两者与 e.hash 进行 & 运算之后的结果一样。c. 综上,可以得出
(e.hash & oldCap) == 0
时, 元素 e 在新旧数组中的索引位置不变 -
推导
(e.hash & oldCap) != 0
a. 我们进行如下推算举例
b. 我们知道
数组长度 - 1
(也就是2的n次幂的整数 - 1
)的二进制表示就是n个1
所以,通过(数组长度 - 1) & e.hash
计算元素在数组中的索引位置的结果如下:
同样由上面的计算可以看出,真正能够影响计算结果的只有从低到高数的第4位(这是因为(2 * oldCap - 1) 和 (oldCap - 1) 两者的二进制的低3位完全一样都是1)故,(2 * oldCap - 1) 和 (oldCap - 1) 两者与 e.hash 进行 & 运算之后的结果相差了oldCap。
c. 综上,可以得出(e.hash & oldCap) != 0
时, 元素 e 在新数组中的索引位置等于其在旧数组中索引位置 + 旧数组的长度这个偏移量
HashMap中put方法的执行流程
put<k, v>方法是 HashMap 中比较核心的一个方法,在put<k, v>方法中,HashMap 需要经历初始化、存值、扩容、解决冲突等操作。流程复杂但设计精巧,因此它也常常出现在Java面试中。下面我们从宏观层面梳理一下put<k, v>方法的核心流程,更为细节的地方先不考虑。
1、核心流程步骤:
- 先对key进行hash操作,计算hash值;
- 检查数组 table 是否为空,如果为空则进行一次resize()操作,初始化一个数组;
- 根据hash值计算key在数组中的索引位置;
- 如果索引指定的位置值为空,则新建一个k-v的新节点;
- 如果索引指定的位置值不为空,则说明该位置的已经存在元素,也就是说“哈希冲突”出现了;
5.1 判断key是否与索引位置中的首元素相等,若相等,说明key已存在,进行值覆盖操作,若不相等,则需要进行后续判断;
5.2 判断索引位置的元素是否为treeNode,若是,则需要将该key插入的红黑树中;
5.3 如果判断该索引位置的元素不是treeNode,说明这里存储的是链表结构,遍历链表并且链表中不存在与key相等的元素时,则新建一个k-v的新节点插入到链表尾部;
5.4 插入完元素后,如果链表长度是否大于等于 8,则此时会调用treeifyBin(),将链表转成红黑树,注意,如果此时数组长度小于 64,只会扩容不会转红黑树; - 完成插入动作后,HashMap 的 size 会加一,然后判断当前容量是否超过阈值,如果超过则会进行扩容操作;
- 在扩容操作中,会创建一个2倍的新数组,并将原数组中的元素拆分到新数组中,注意,如果在拆分时发现红黑树中的节点元素小于等于6,则该红黑树会转成链表。
2、put<k, v>方法流程图:
根据上面分析出的流程步骤,整理了一份流程图,方便大家理解。
(完)