文章中如果有图看不到,可以点这里去 csdn 看看。从那边导过来的,文章太多,没法一篇篇修改好。

Java HashMap线程不安全的原因【源码&详细图解 rehash 并发操作出现的死循环】

HashMap在多线程下的线程不安全问题主要体现在数据丢失、死循环、size值错误等方面。本质原因在于其内部实现缺乏同步机制,且数据迁移(Rehash)过程中链表指针操作在多线程竞争下会产生问题。下面将结合关键源码(基于Java 8)和图解进行详细分析:


核心问题:并发修改导致内部结构破坏

1. 插入时数据覆盖(Lost Update)
  • 原因: 多个线程同时调用 put() 操作同一个桶位置(相同hash),且该位置为空。
  • 源码分析(简化版putVal):
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 计算桶下标 (关键点1)
        i = (n - 1) & hash;
        p = tab[i]; // 获取头节点
        if (p == null) {
            // 🚨 线程不安全点:多个线程可能同时判断 p == null 成立
            // 🚨 导致后续的 tab[i] = newNode(...) 覆盖其他线程写入的值
            tab[i] = newNode(hash, key, value, null);
        } else {
            // ...处理链表/红黑树冲突...
        }
        ++modCount;
        if (++size > threshold) // 🚨 线程不安全点(size计算不准确)
            resize();
        return null;
    }
    
  • 情景: 线程A和线程B同时检查发现 tab[i] == null 成立。A 执行 tab[i] = newNode_A,紧接着B也执行 tab[i] = newNode_B。结果是线程A插入的值被线程B覆盖丢失
2. 链表结构破坏(死循环或数据丢失)— JDK7及之前的经典问题
  • 核心原因: 在扩容(resize())并迁移数据(transfer())的过程中,链表采用头插法。多线程操作会导致链表反转形成环形链表
  • JDK7的 transfer() 方法源码(关键片段):
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K, V> e : table) { // 遍历旧表
            while (null != e) {
                Entry<K, V> next = e.next; // 🚨 线程A暂停点1: 记住next
                if (rehash) e.hash = null == e.key ? 0 : hash(e.key);
                int i = indexFor(e.hash, newCapacity); // 计算新位置
                // 🚨 关键头插法:将e放到新链表的头部
                e.next = newTable[i]; // 🚨 线程不安全的核心操作
                newTable[i] = e;      // 🚨 更新头节点
                e = next;             // 🚨 线程A暂停点2: 处理下一个节点
            }
        }
    }
    
  • 图解死循环形成过程:
    1. 初始状态: 旧表某个桶有链表 3->7->null。两个线程 A 和 B 同时进行扩容。
    2. 线程A执行:
    • e = 3, next = 7
    • 3.next 指向 newTable[i] (初始为null),然后 newTable[i] = 3。链表变为 3->null
    • e = 7 (从 next 来),执行暂停(假设此时线程A时间片用完)。
    1. 线程B执行完毕:
    • 线程B成功完成整个链表的迁移。由于头插法,新链表变为 7->3->null (newTable[i] 指向 7)。
    1. 线程A恢复执行:
    • e = 7, next = e.next。此时 e.next (即 7.next) 已被线程B修改为 3 (不再是原始 null)!所以 next = 3
    • 执行头插:7.next = newTable[i]。此时 newTable[i] 指向的是 7 (B迁移后的结果),即 7.next = 7 已经形成环!
    • newTable[i] = 7
    • e = next = 3
    • 处理 33.next = newTable[i] = 7 (又把 3 插到 7 前面)。
    • newTable[i] = 3
    • e = 3.next = 7 (因为上一步 3.next = 7)。
    • 线程A再次拿到 e = 7,然后重复之前的步骤 next = 7.next = 3 -> 头插 -> newTable[i] = 7 -> e = 3无限循环
    • 后果: 任何尝试访问这个桶的 get() 操作都会因为遍历环形链表而进入死循环!CPU利用率飙升。
  • JDK8的改进:
    • 使用 尾插法 替代头插法进行链表迁移。避免了反转链表,大大降低了形成环的概率。
    • 但不是线程安全! 尾插法仍可能导致部分数据丢失(例如两个线程同时迁移节点,一个线程的迁移结果被另一个覆盖)或链表断裂,只是不会形成环造成死循环。
3. size计数不准确
  • 原因: size 变量只是一个普通的 int 类型, ++size (size = size + 1) 操作不是原子操作
  • 源码 (putVal() 结尾):
    ++size; // 🚨 线程不安全点:自增操作本身是非原子的
    if (size > threshold)
        resize();
    
  • 情景:
    1. 线程A读取 size (假设当前值为10)。
    2. 线程B也读取 size (值仍为10)。
    3. 线程A计算 size + 1 = 11,并将结果写回 size (size变为11)。
    4. 线程B也计算 size + 1 = 11 (基于它之前读取的10),并将结果写回 size (size仍为11)。
  • 后果: 明明插入了两个元素,size 只增加了1。size() 方法返回错误值,resize() 判断也可能出错。
4. 红黑树操作问题(JDK8+)
  • HashMap在链表长度过长时会转换为红黑树(treeifyBin())。
  • 原因: 红黑树本身插入、删除、旋转平衡的操作非常复杂,涉及多个节点指针的修改(父、左、右、颜色等)。
  • 后果: 多个线程同时修改红黑树结构(插入/删除),极大概率导致树结构被破坏,可能抛出 ClassCastException (在后续操作中误认为链表处理红黑树节点),或导致查找失败、死循环等。

图解:JDK7 HashMap 扩容时并发操作死循环

  1. 初始状态:
    初始状态
  • 旧表:桶 i 处的链表:Entry 3 -> Entry 7 -> null。
  • 新表:准备扩容后的新数组,对应桶 i 初始为空。
  1. 线程A开始迁移(暂停前):
  • e = Entry3, next = Entry7.
    在这里插入图片描述

  • 执行头插法迁移 Entry3

    • Entry3.next = newTable[i] (null)
    • newTable[i] = Entry3 => 新表桶 i3 -> null
  • e = next = Entry7. (此时线程A被挂起)
    在这里插入图片描述

  1. 线程B完整迁移:
  • 线程B没有中断,完整迁移了链表。
  • 使用头插法:
    • 迁移 Entry3:新表桶 i3 -> null
    • 迁移 Entry7
      • Entry7.next = newTable[i] (目前指向 3) => 7.next = 3
      • newTable[i] = Entry7 => 新表桶 i7 -> 3 -> null (链表反转)
  • 结果:新表结构稳定。
    在这里插入图片描述
  1. 线程A恢复执行:
  • e = Entry7 (线程A挂起时的状态)

  • next = e.next此时关键e (Entry7) 的 next 已经被线程B在迁移过程中修改为指向 Entry3 了! 所以 next = Entry3.
    在这里插入图片描述

  • 迁移 Entry7 (头插法):

    • Entry7.next = newTable[i]。此时 newTable[i] 是线程B迁移后指向 Entry7 的头节点!所以:
      Entry7.next = 7 (指向了自己!)。
    • newTable[i] = Entry7 => 桶 i 的头变为 7
  • e = next = Entry3.
    在这里插入图片描述

  • 迁移 Entry3 (头插法):

    • Entry3.next = newTable[i] (7) => 3.next = 7.
    • newTable[i] = Entry3 => 桶 i 的头变为 3
  • e = next = Entry3.nextEntry3.next 已经被上面一步设置为 7, 所以 e = Entry7.
    在这里插入图片描述

  • 关键点: 现在 e = Entry7,进入下一轮循环:

    • next = e.next = Entry7.nextEntry7.next 在上面被设置为 7 (自己), 所以 next = 7 (Entry7).
    • 迁移 Entry7
      • Entry7.next = newTable[i] (现在指向 3) => 7.next = 3 (这和线程B创建的结构一样,但不是终点)。
      • newTable[i] = Entry7.
    • e = next = 7 (Entry7)循环回到当前 e 的状态!

在这里插入图片描述

  1. 形成死循环:
  • 程序会不断尝试迁移 Entry7Entry3,但因为指针相互指向,永远无法结束。任何遍历 (get()) 此桶的操作都将进入死循环。

总结:为什么 HashMap 线程不安全?

  1. 缺乏同步锁: 所有内部操作(putgetremoveresize)都没有使用 synchronized 或其他显式锁保护。
  2. 复合操作非原子性:
  • 检查桶空 + 创建新节点 (putValif (p == null) 块)
  • 链表节点指针修改 (next 赋值,头插/尾插)
  • size 变量的自增 (++size)
  • 红黑树结构调整
  1. Rehash过程的致命性: 特别是 JDK7 的头插法在多线程 resize 时极易导致环形链表和死循环。JDK8 的尾插法缓解了死循环问题,但数据丢失和结构破坏风险仍在。
  2. 内部状态维护易受并发干扰: modCountsizetable 数组、单个桶的头节点指针都可能被多个线程同时修改,导致状态不一致。

解决方案: 在多线程环境下,务必使用线程安全的 Map 实现:

  • ConcurrentHashMap (首选): 高并发性能优秀,使用分段锁(JDK7)或 CAS + synchronized(JDK8+)保证线程安全。
  • Collections.synchronizedMap(Map<K,V> m) 返回一个用 synchronized 包装的Map,所有方法加锁,适合低并发或需要强一致性遍历的场景(性能通常低于 ConcurrentHashMap)。
posted @ 2025-08-14 10:39  NeoLshu  阅读(3)  评论(0)    收藏  举报  来源