HashMap 头尾插和并发环链问题如何理解?
未标记
考查重点:迁移交错与并发安全。JDK7 并发扩容交错改 next 可成环,头插本身不必然成环;JDK8变迁移方式仍不线程安全
本题目录
参考答案
核心回答
HashMap 的头插、尾插是链表连接方式;并发环链则是多个线程无同步迁移同一批节点,交错改写 next 指针造成的结构破坏。正常的单线程头插本身不会把无环链表变成环。
以 OpenJDK 7u80 为例,普通新增节点采用桶首插入,扩容迁移也把旧节点头插到新桶,可能反转同一目标桶的链表。多个线程并发扩容时,一个线程保存的“下一个节点”可能已被另一个线程重新连接,最终形成环,某些沿该桶遍历的查询便可能无法结束。
OpenJDK 8u412 的普通链表新增使用尾部追加,扩容则按新增的一位拆成两组,并保持各组相对顺序,避开了这条经典的头插反转路径。但 HashMap 仍非线程安全,尾插不会防止丢条目、计数竞争或读取不一致;共享更新应使用并发容器或统一同步。
一个两节点交错,环是怎样形成的
假设旧桶为 A→B→null,A、B 是不同键,扩容后仍落在同一个目标桶。两个线程各有自己的新数组,但迁移操作复用了同一批旧节点:
- 线程一处理 A,先保存“下一个是 B”,随后暂停。
- 线程二完整迁移这个桶。先把 A 接到自己的空桶,再把 B 插到 A 前面,旧节点关系变为 B→A→
null。 - 线程一恢复,把 A 接到自己的空桶,然后沿之前保存的 B 继续。此时从 B 读到的
next已经是线程二写入的 A。 - 线程一处理 B 时,保存“下一个是 A”,把 B 放到 A 前面。接着又处理了一次 A,将 A 的
next接到当前桶首 B。 - 线程一的新桶因此变成 A→B→A,形成环。
关键在于:新数组是各线程的,节点对象却是共享的;线程一保存的局部引用,并没有保存一份不会变化的链表快照。查找一个落在该桶但不存在的键时,若遍历逻辑一直沿 next,就可能在环中无限循环。并不是每次并发 put 都必然形成环,命中首节点的查询也未必表现为卡死。
JDK 8 为什么不能简化成“尾插就安全”
普通插入和扩容迁移是两处不同逻辑。JDK 8 普通 put 会先检查是否存在相等键,已有键更新值,只有新键才追加节点;树桶走树的处理路径。扩容时维护高低两组的头尾指针,分别接到新表,而不是把所有节点简单接到一个新尾部。
保持分组内顺序减少了旧反转迁移相关的问题,但这些操作本身仍没有 CAS 或完整加锁协议。两个线程都判断同一桶为空后赋值,仍可能覆盖入口;size 的递增也不是并发原子计数。甚至自行写一套尾插迁移,只要没有解决共享指针竞争,就不能据此宣称线程安全。
此外,分组内相对顺序不是 HashMap 的全局迭代顺序契约。扩容会改变桶分布,程序不能依赖偶然观察到的遍历顺序。
追问:线上出现卡死或丢数据,怎么判断
先确认实际 JDK 版本和具体访问路径,检查是否有共享 HashMap 被无同步修改。历史版本若出现长时间卡在桶链遍历、CPU 持续忙等现象,线程栈和结构证据可以帮助判断是否涉及环链;不能只凭 CPU 高就断言是这个问题。
修复应消除无保护共享修改,例如使用 ConcurrentHashMap,或者让所有相关读写遵守同一同步协议。fail-fast 仅是尽力发现某些违规修改,不保证检测这种结构问题。升级 JDK 有价值,但不能代替修正并发访问方式。
参考资料
- 国内:JavaGuide:Java 集合常见面试题(下):用于定位 JDK 7 扩容环链的版本范围,并说明 JDK 8 仍存在非线程安全访问风险。
- 国内:美团技术团队:Java 8 系列之重新认识 HashMap:采用旧 transfer 头插反转及线程暂停恢复的节点变化,解释正文中 JDK 7 并发扩容形成环链的交错过程。
- 国内:沉默王二:Java HashMap 详解:用于区分环链、并发 put 丢数据等问题,并解释扩容中的共享节点和局部 next 引用。
- 国外:Oracle:HashMap,Java SE 8:用于核对 JDK 8 的外部同步要求、无迭代顺序保证,以及 fail-fast 只能用于诊断的边界。
- 国外:Java 语言规范第 17 章:Threads and Locks:用于说明无 happens-before 约束的共享读写构成数据竞争,以及为什么需要完整的同步协议。
