HashMap - tenji/ks GitHub Wiki
在 Java 中,HashMap 从 Java 1.2 开始就是 Java 集合的一部分。该类位于 java.uti
l 包中。它提供了 Java Map 接口的基本实现。 Java 中的 HashMap 将数据存储在(键,值)对中,你可以通过其他类型(例如整数)的索引来访问它们。
- 1.8 以前:数组 + 单链表
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
...
}
- 1.8 以后:数组 + 单链表 + 红黑树(链表长度超过 8 就转化为红黑树,低于 6 就会退回链表)
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
...
}
为什么转变条件
8
和6
会有一个差值?
如果没有差值,都是 8
,那么如果频繁的插入删除元素,链表个数又刚好在 8
徘徊,那么就会频繁的发生链表转树,树转链表。
- 时间复杂度
- 插入:
O(1)
- 删除:
O(1)
- 搜索:
O(1)
- 插入:
- 空间复杂度:
O(n)
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是 hashcode
// ^:按位异或
// >>>:无符号右移,忽略符号位,空位都以 0 补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法流程:
- 计算 key 的哈希值:
h = key.hashCode()
; - 哈希值(int 类型,长度为
32
位)和右移16
位(前 16 位补零)的哈希值做异或运算,得到最终的哈希值并返回。(相当于混合了原哈希值中的高位和低位,增大了随机性)
怎么根据哈希值计算数组坐标?
使用以下方式计算数组坐标:(n 是数组长度)
i = (n - 1) & hash
本质上就是取模运算,而且是使用位运算 &
替代取模运算 %
,因为位运算的效率更高。但是需要注意的是,只有在 b 为 2 的 n 次方时,这个公式 a % b = a & (b-1)
才成立,也就是说,只有数组长度是 2 的 n 次方时,使用 & 才会取余成功,这也是为什么,我们对 hashmap 进行扩容时,必须是 2 倍扩容了。
方法流程:
- 计算 key 的哈希值;
- 根据哈希值通过
(n - 1) & hash
计算得到数组坐标; - 判断数组第一个元素是否刚好就是我们要找的(key 值是否一样),如果是的话,直接返回该元素;
- 第一个元素如果不是我们要找的,那么需要判断是链表还是红黑树实现
- 红黑树实现的,就在红黑树中查找
- 单链表实现的,就在单链表中查找
public V get(Object key) {
Node<K,V> e;
// 计算 key 的哈希值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 第一个元素是否刚好就是我们要找的,直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 元素是 TreeNode 类型,说明是红黑树实现的,在红黑树中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 不是红黑树实现的,在单链表中查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
方法流程:
- 第一次添加元素时,先调用
resize()
方法扩容(初始化容量是16
,负载因子默认0.75
); - 根据哈希值通过
(n - 1) & hash
计算得到数组坐标; - 如果此坐标元素为空,则新建节点加入,添加完成;
- 如果此坐标元素不为空时
- 如果坐标上的元素和要加入的元素的 key 完全一样,覆盖原有值,添加完成;
- 如果 key 不一样时,需要判断是链表还是红黑树实现:
- 红黑树实现时,调用红黑树的插值方法;
- 单链表实现时,新建节点添加到链表最后面,同时判断是否需要将单链表转成红黑树(链表长度超过 8);
和 Java7 稍微有点不一样的地方就是,Java7 是先扩容后插入新值的,Java8 先插值再扩容,不过这个不重要。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 第一次添加元素时,先调用 resize() 方法扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果此坐标元素为空,则新建节点加入
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果坐标上的元素和要加入的元素的 key 完全一样,覆盖原有值
e = p;
else if (p instanceof TreeNode)
// 元素是 TreeNode 类型,说明是红黑树实现的,红黑树实现时,调用红黑树的插值方法
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
// 插入到链表的最后面(Java7 是插入到链表的最前面)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度超过 8,需要将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e != null 说明存在旧值的 key 与要插入的 key "相等"
// 对于我们分析的 put 操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
方法流程:
- 确认扩容后的容量(capacity)和阈值(threshold),分别用 newCap 和 newThr 代替;
- 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的;
- 将键值对节点重新映射到新的桶数组里。
- 如果是红黑树实现,则需要拆分红黑树;
- 如果是单链表实现,则节点按原顺序进行分组。(链表拆分成高位和低位两个链表,根据
e.hash & oldCap
的值决定重新映射的索引位置)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果 table 不为空,表明已经初始化过了
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 对应使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 对应使用 new HashMap() 初始化后,第一次 put 的时候
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 如果该数组位置上只有单个元素,那就简单了,简单迁移这个元素就可以了
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 需要将此链表拆成两个链表
// 链表1存放在低位(原索引位置)
Node<K,V> loHead = null, loTail = null;
// 链表2存放在高位(原索引 + 旧数组长度)
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
为什么链表要拆分成高位和低位两个链表?拆分条件为什么是
(e.hash & oldCap) == 0
?
HashMap 的扩容都是扩大为原来大小的两倍,假设 HashMap 原来的长度是 8
,需要扩容。那么 oldCap = 8, newCap = 16
。也就是 8 --> 16,从二进制上看,1000 --> 10000;n - 1
就是 7 --> 15,从二进制上看,111 --> 1111,也就是高位多了一个 1;
而我们计算数组坐标的公式是:i = (n - 1) & hash
,那么扩容前和扩容后用这个公式计算的结果,主要是看 key 的哈希值和高位的 1 与运算的结果,如果运算的结果是零(也就是 (e.hash & oldCap) == 0
),那么扩容前和扩容后数组坐标是不变的。否则的话,扩容后数组坐标就是变化的,变成:原索引 + 旧数组长度。
下面看一个例子。先来回顾一下 hash 求余的过程:
上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后 4 位参与求余,所以结果相等。当桶数组扩容后,n 由 16 变成了 32,对上面的 hash 值重新进行映射:
扩容后,参与模运算的位数由 4 位变为了 5 位。由于两个 hash 第 5 位的值是不一样,所以两个 hash 算出的结果也不一样。上面的计算过程并不难理解,继续往下分析。
假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:
依次遍历链表,并计算节点 hash & oldCap
的值。如下图所示
如果值为 0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为 0 的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非 0 的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:
最后再将这两条链接存放到相应的桶中,完成扩容。如下图:
从上图可以发现,重新映射后,两条链表中的节点顺序并未发生变化,还是保持了扩容前的顺序。