面试之HashMap
JDK1.7
public V put(K key, V value) {// 1. 如果table为空,则进行初始化创建(惰性加载)if (table == EMPTY_TABLE) {inflateTable(threshold);}// 2. 如果key为null,专门处理,放在table[0]的链表中if (key == null)return putForNullKey(value);// 3. 计算key的hash值int hash = hash(key);// 4. 根据hash值计算其在数组中的下标iint i = indexFor(hash, table.length);// 5. 【核心】遍历table[i]处的链表for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;// 5.1 如果找到了已存在的key(哈希值相同且equals为true)if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value; // 覆盖旧值e.recordAccess(this); // 空方法,用于LinkedHashMapreturn oldValue; // 返回旧值}}// 6. 如果未找到相同的key,执行添加操作modCount++;// 7. 将新Entry添加到链表头部,同时可能需要扩容addEntry(hash, key, value, i);return null;
}void addEntry(int hash, K key, V value, int bucketIndex) {// 1. 判断是否需要扩容:size达到阈值,并且目标桶的位置已经有元素(轻微优化)if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length); // 扩容为原来的2倍// 扩容后重新计算hash和新的桶下标hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}// 2. 创建新Entry节点,并插入到链表的头部createEntry(hash, key, value, bucketIndex);
}void createEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex]; // 获取当前链表的头节点// 创建新节点,其next指向原来的头节点e -> 头插法!table[bucketIndex] = new Entry<>(hash, key, value, e);size++; // 大小增加
}
插入流程总结:
计算索引: 计算 key 的 hash 值,再通过
indexFor
(即hash & (table.length-1)
) 得到桶下标i
。遍历链表: 遍历
table[i]
位置上的链表,检查 key 是否已存在。覆盖或插入:
存在: 覆盖 value,返回旧值。
不存在: 调用
addEntry
。
判断扩容: 在
addEntry
中,先判断是否需要扩容。如果需要,先进行扩容并重新计算 key 的新位置。头插法插入: 无论是否扩容,最后都会调用
createEntry
,将新节点作为头节点插入到链表中(new Entry<>(..., next)
的next
参数指向原头节点)。
扩容流程总结:
创建新数组: 容量为指定的
newCapacity
(通常是旧容量的 2 倍)。数据迁移(Transfer): 这是最核心的步骤。
遍历旧数组的每一个桶(链表)。
遍历桶中的每一个节点
e
。对每个节点
e
,重新计算其在新数组中的下标i
。使用头插法,将当前节点
e
插入到新数组[i]
位置链表的头部。
更新引用和阈值: 将
HashMap
内部的table
引用指向新数组,并更新扩容阈值threshold
。
头插法(Head-Insertion):
优点: 实现简单,新插入的元素很可能被再次访问,放在头部可以更快被找到(借鉴了 LRU 思想)。
致命缺点: 在并发扩容时,会导致链表形成环(死链)。一旦后续有查询操作定位到这个环状链表,就会陷入死循环,导致 CPU 100%。 假设线程 A 和 B 同时执行
transfer(
将旧数组中的所有元素转移到新数组中)
,在操作同一个链表时,由于头插法会改变节点的顺序,两个线程交替执行,很容易使节点的next
指针形成循环引用。
JDK1.8
添加元素 + 扩容 + 链表转红黑树
插入触发扩容,扩容可能触发树化或反树化,而树化的最终目的是为了解决因扩容或哈希冲突导致的链表过长问题,以提升查询效率。
1. 元素插入 (put)
当调用 map.put(key, value)
时,大致步骤如下:
计算哈希值: 对 key 的
hashCode()
进行二次哈希((h = key.hashCode()) ^ (h >>> 16)
),目的是让高位也参与运算,减少哈希冲突。计算桶索引: 使用
(n - 1) & hash
计算键值对应该放入的数组下标(n 是数组长度)。处理桶内元素: 根据目标桶位置的情况,分为多种处理方式:
桶为空: 直接创建一个新的
Node
对象放入该位置。桶不为空(哈希冲突):
首节点匹配: 如果首节点的
key
与待插入key
相同(hash
相等且equals
为true
),则直接覆盖 value。首节点是 TreeNode: 说明该桶已经是红黑树结构,则调用红黑树的
putTreeVal
方法进行插入。首节点是普通 Node: 说明是链表结构。遍历链表:
如果找到相同的
key
,则覆盖value
。如果到链表尾部都没找到,则将新节点插入链表尾部(JDK 1.7 是头插法,1.8 改为尾插法)。
插入后,如果链表长度达到 8,则调用
treeifyBin(tab, hash)
方法尝试进行树化或者扩容。
2. 树化 (treeifyBin)
final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;// 关键判断:如果数组 table 为 null 或它的长度小于 MIN_TREEIFY_CAPACITY(默认64)if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize(); // 优先进行扩容!else if ((e = tab[index = (n - 1) & hash]) != null) {// 只有当数组长度 >= 64 且链表长度 >= 8 时,才真正进行树化TreeNode<K,V> hd = null, tl = null;do {// 将普通 Node 节点替换为 TreeNode 节点TreeNode<K,V> p = replacementTreeNode(e, null);if (tl == null)hd = p;else {p.prev = tl;tl.next = p;}tl = p;} while ((e = e.next) != null);if ((tab[index] = hd) != null)hd.treeify(tab); // 调用 TreeNode 的方法构建红黑树}
}
链表长度达到 8 时,并不一定会立刻转为红黑树,这是关键点。
树化的条件有两个,必须同时满足:
链表长度 >= 8
数组(桶)的长度 >= 64
设计理念:
如果链表很长(冲突严重),但数组长度还很短(比如 16),这通常意味着扩容不充分。此时优先选择扩容(
resize
)。因为扩容可以重新分散元素,可能直接让这个长链表分裂成两个短链表,问题就解决了,成本比转换为树结构要低。只有当数组容量已经很大(>=64),但哈希冲突依然非常严重,导致某个桶的链表长度超过 8,这时才认为需要转换为红黑树来保证极端的性能(将查找时间从 O(n) 优化到 O(log n))。
3. 扩容 (resize)
当 HashMap
中的元素数量超过阈值(threshold = capacity * loadFactor),或者遇到上面的未满足树化条件的情况,就会进行扩容。
创建新数组: 容量扩大为原来的 2 倍(保证容量永远是 2 的幂次)。
重新哈希(Rehash): 遍历旧数组的每一个桶,将每个节点重新计算索引位置,并移动到新数组中。
JDK 1.8 的优化: 由于容量是 2 的幂次,扩容后元素的新位置要么是原位置,要么是原位置 + 旧容量。通过
(e.hash & oldCap) == 0
这个判断可以高效地将一个链表拆分成两个链表。
扩容对树的影响:
树拆分: 在扩容
resize()
过程中,原本是红黑树的桶会被拆分成两个链表。反树化(Untreeify): 拆分后,如果新链表的长度小于等于 6,则会调用
untreeify
方法,将TreeNode
转换回普通的Node
,变回链表结构。这是因为 shorter list 的情况下,链表的性能已经足够好,无需维护更复杂的树结构。
关键参数记忆
链表树化阈值:
TREEIFY_THRESHOLD = 8
树退化为链表阈值:
UNTREEIFY_THRESHOLD = 6
最小树化容量:
MIN_TREEIFY_CAPACITY = 64
默认负载因子:
DEFAULT_LOAD_FACTOR = 0.75
补充说明, 扩容后元素的问题为什么是原位置或原位置+旧容量 呢?
原理:
元素的位置是通过 hash & (length - 1)
计算得到的。因为 length
是2的幂次方,所以 length - 1
的二进制就是一连串的 1
(比如容量16:...0000 1111
)。
扩容后,length
变成了原来的两倍,newLength - 1
的二进制比 oldLength - 1
多了一个高位的 1
(比如容量32:...0001 1111
)。
hash & (newLength - 1)
的结果取决于 hash
对应这个新增位(我们称之为“扰动位”)的值是 0
还是 1
。
如果扰动位是
0
,那么hash & (newLength - 1) = hash & (oldLength - 1)
,即新位置 = 原位置。如果扰动位是
1
,那么hash & (newLength - 1) = hash & (oldLength - 1) + oldLength
,即新位置 = 原位置 + 旧容量。
这个过程避免了重新计算每个元素的 hashCode()
,只需判断哈希值新增的参与运算的位是0还是1即可,效率非常高。