HashMap 的 remove 方法是如何实现的?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
面试官提出这个问题,通常旨在考察以下几个层面,而不仅仅是一个简单的 API 调用:
- 对
HashMap基础操作的了解:候选人是否清楚remove方法的签名、返回值含义。 - 对
HashMap核心数据结构的理解:是否理解其底层 数组 + 链表/红黑树 的结构,以及remove操作如何在这个结构上定位元素。 - 对哈希冲突解决机制的掌握:当发生哈希冲突形成链表或树时,
remove如何正确地从链表或红黑树中删除节点,并维护数据结构的完整性。 - 对
HashMap动态特性与相关机制的认识:是否了解删除操作可能触发的 树退化(untreeify) 机制,以及其与扩容、树化机制的关联。 - 源码阅读与底层实现原理的探究能力:通过此问题,可以侧面了解候选人是否深入研究过 JDK 源码,以及其理解复杂系统实现细节的能力。
核心答案
HashMap.remove(key) 方法的实现核心分为三步:
- 定位桶(Bucket):根据键(key)的哈希值,通过
(n - 1) & hash计算其在底层数组(table)中的索引位置。 - 查找并删除节点:在定位到的桶中(可能是一个链表或一棵红黑树),遍历查找
key相等的节点。找到后,根据节点所在数据结构是链表还是红黑树,执行相应的删除操作。 - 维护数据结构:删除节点后,更新相关引用。如果是红黑树,删除后可能触发 退化(untreeify) 操作,即当树根或其子节点满足特定条件(如根节点为空、根节点的右子节点或左子节点为空等)时,会将红黑树转换回链表,以节省空间。
该方法返回被删除键值对中的 value,如果 key 不存在则返回 null。
深度解析
以下解析基于 JDK 17 的源码,核心逻辑与 JDK 8 及之后版本保持一致。
原理与流程
remove 方法的核心是 removeNode 方法。其执行流程如下:
-
哈希计算与定位:首先对
key进行哈希计算(调用hash(key)方法,内部是(h = key.hashCode()) ^ (h >>> 16)),目的是将高比特位的影响扩散到低比特位,减少哈希冲突。然后用hash & (table.length - 1)得到数组下标。 -
节点查找:定位到数组元素(第一个节点)。然后开始匹配,匹配条件包括:哈希值相等、
key引用相等或equals方法返回true。 -
节点删除:
-
链表删除:如果要删除的是链表的头节点,直接将数组槽位指向头节点的下一个节点即可。否则,在遍历链表过程中,维护一个前驱节点(
prev)的引用,找到目标节点后,执行prev.next = node.next。 -
红黑树删除:调用红黑树专用的删除方法
TreeNode.removeTreeNode。这个方法会执行标准的红黑树删除逻辑,包括查找、删除节点以及必要的旋转和重新着色以保持红黑树平衡。
-
-
结构维护:删除成功后,
HashMap的size减 1,并记录一次结构修改(modCount++)。对于树节点删除,在removeTreeNode内部会检查是否需要进行 退化。退化条件相对复杂,简单来说是当树的节点数太少(小于等于UNTREEIFY_THRESHOLD,默认为 6)时,会将红黑树转换为链表,以优化内存使用。
代码示例(关键逻辑摘录)
// HashMap.remove(Object key) 的核心逻辑片段
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 1. 检查 table 不为空且长度>0,且计算出的索引位置有节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 2. 检查第一个节点是否就是目标
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 3. 如果该桶是树结构,则调用 getTreeNode 查找
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 4. 如果是链表结构,则遍历查找
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e; // p 在这里充当了前驱节点的角色
} while ((e = e.next) != null);
}
}
// 5. 找到节点后,执行删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode) // 树节点删除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p) // 要删除的是链表头节点
tab[index] = node.next;
else // 要删除的是链表中间节点
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node); // 空方法,LinkedHashMap 等子类可扩展
return node; // 返回被删除的节点
}
}
return null; // 未找到,返回 null
}
最佳实践与常见误区
- 键对象不可变:作为
HashMap键的对象,其hashCode()和equals()所依赖的属性必须是不可变的,否则在将其放入 HashMap 后修改这些属性,会导致无法通过get或remove再次找到该对象,造成内存泄露。 - 返回值判断:
remove(key)返回null有两种可能:- 1)该
key对应的value本来就是null; - 2)该
key不存在。如果需要严格区分,应先用containsKey判断。
- 1)该
- 并发场景:
HashMap非线程安全。在迭代过程中直接调用remove方法(而非迭代器的remove)会抛出ConcurrentModificationException。多线程环境下的删除操作需要使用ConcurrentHashMap或进行外部同步。
总结
HashMap.remove 的本质是在其“数组-链表/红黑树”的复合结构中,精准定位并安全摘除目标节点,同时智能地维护底层数据结构(如树退化),是一个集哈希定位、数据结构操作与动态优化于一体的典型实现。