jdk 1.7 hashmap 多线程 put 死循环的根本原因是扩容时头插法与非原子操作结合导致链表成环;jdk 1.8 改为尾插并引入红黑树,仅缓解死循环但不保证线程安全。

为什么 JDK 1.7 的 HashMap 多线程 put 会触发死循环
根本原因是扩容时的头插法 + 非原子性操作,在多线程竞争下可能让链表节点形成环。这不是偶发 bug,而是确定性可复现的逻辑缺陷。
典型现象是 CPU 突升 100%,线程卡在 get() 或 put() 的遍历逻辑里无限循环——因为 Entry.next 指向了自己或前面的节点。
- 触发条件:多个线程同时触发扩容(
size > threshold),且操作的是同一个桶(bucket)上的链表 - 关键动作:旧链表逐个摘下节点,用头插法塞进新数组对应位置 —— 这一步在多线程下会交叉执行
- 结果:比如 A 线程刚把节点 X 插到新桶头,B 线程紧接着把 X 的原 next 节点 Y 插到同个桶头,而 Y 的 next 又指回 X,环就形成了
JDK 1.7 vs JDK 1.8 的链表处理差异直接影响线程安全表现
JDK 1.8 把头插改成了尾插,单次 put 不再反转链表顺序,但这只是「缓解」死循环,并不等于线程安全。
真正改变的是:JDK 1.8 在链表过长时会转为红黑树(treeifyBin()),但树化过程本身不是原子的;而且所有非 final 字段(如 size、modCount)依然无锁更新,多线程下仍会丢数据、读到脏值。
-
put()操作在 JDK 1.8 中仍是非原子的:两个线程对同一 key 调用put(),后写会覆盖前写,无任何提示 -
size()返回的可能是错的:它只是简单返回 volatile 字段,不加锁也不重算,高并发下误差可达 10% 以上 - 即使没死循环,
get()也可能读到 null 或旧值,因为节点字段(如value)没用 volatile 修饰(JDK 1.8 中部分字段加了,但整体结构未保证可见性)
什么情况下你以为用了 ConcurrentHashMap 其实还是线程不安全
常见错觉是“只要换掉 HashMap 就万事大吉”,但 ConcurrentHashMap 的线程安全是有边界的,越界操作照样出问题。
- 复合操作非原子:比如
if (!map.containsKey(k)) map.put(k, v),中间可能被其他线程插入同 key,导致重复写或逻辑错乱 - 迭代器弱一致性:
entrySet().iterator()不抛ConcurrentModificationException,但可能漏掉刚 put 的项,或重复看到某项 —— 它不保证快照视图 - 默认并发级别(
concurrencyLevel=16)只控制分段锁粒度,不是线程数上限;实际锁的是桶数组的某一段,若所有 key 都哈希到同一段,性能退化成类似Hashtable
替代方案选型:别只盯着 Map 接口,要看真实场景需求
线程安全不是非黑即白的选择题,得看你要保什么:是不能丢数据?不能死循环?还是不能读到中间态?
- 纯读多写少 + 可接受短暂不一致 →
ConcurrentHashMap(JDK 1.8+)够用,注意避开复合操作 - 写极少、读极多、要求强一致性 →
Collections.unmodifiableMap(new HashMap())配合外部同步,或用CopyOnWriteArrayList思路自建不可变快照 - 需要原子复合操作(如计数、累加)→ 直接上
LongAdder、AtomicInteger,或用ConcurrentHashMap.computeIfAbsent()这类 CAS 封装方法 - 涉及跨 key 逻辑(如转账、库存扣减)→ 别硬靠 Map,该上数据库事务或分布式锁就上,Map 不是通用同步原语
最常被忽略的一点:很多所谓“线程安全问题”,其实源于把 Map 当作状态协调中心来用,而它天生就不适合做这个角色。真要共享状态,优先考虑明确的同步边界或专用工具类。










