当HashMap遇上多线程:一场链表跳舞引发的血案

92 阅读2分钟

🧩 第一幕:舞台搭建(HashMap基础结构)

想象一个巨大的储物柜(Entry数组),每个柜门后挂着一条珍珠项链(链表结构)。每个珍珠(Entry节点)都刻着:

static class Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next; // 下一颗珍珠
    int hash; // 柜门编号
}

当我们存入新珍珠时,如果发现项链太长(链表长度超过阈值),储物柜就会扩建为原来的两倍大(resize),然后把所有珍珠重新挂到新柜门后(rehash)。


💣 第二幕:致命舞步(多线程扩容)

经典Java7的transfer方法(注意这个魔鬼代码):

void transfer(Entry[] newTable) {
    for (Entry<K,V> e : table) { // 遍历旧储物柜
        while(null != e) {
            Entry<K,V> next = e.next; // 记住下一颗珍珠
            int i = indexFor(e.hash, newTable.length); // 计算新柜门号
            e.next = newTable[i]; // 头插法!新珍珠挂在最前面
            newTable[i] = e;      // 更新柜门后的项链
            e = next;             // 处理下一颗珍珠
        }
    }
}

🕺💃 第三幕:双线程死亡之舞(图解)

假设旧数组大小2,新数组大小4,链表A→B→null

线程1线程2同时开始扩容:

  1. 线程1执行到Entry<K,V> next = e.next;(此时e=A, next=B) ⚠️ 突然被调度器暂停!

  2. 线程2完整执行完扩容,新数组中的链表变成B→A→null(头插法导致反转)

  3. 线程1恢复执行,但它的内存快照还停留在旧世界:

    • e=A, next=B(但此时B.next已经变成A!)
  4. 线程1继续操作:

    e.next = newTable[i]; // 把A.next指向新数组位置(此时是null)
    newTable[i] = e;      // 新位置存入A
    e = B                 // 处理下一个节点
    
  5. 第二轮循环处理B:

    Entry<K,V> next = B.next; // 此时B.next是A!
    e.next = newTable[i];     // B.next指向已存入的A
    newTable[i] = B           // 存入B
    e = A                     // 下一轮处理A
    
  6. 第三轮处理A:

    Entry<K,V> next = A.next; // A.next是null
    e.next = B                // A.next指向B(形成B↔A循环!)
    newTable[i] = A
    

此时新数组中的链表变成A→B→A→B→A...,一个完美的死亡循环诞生了!


🔍 第四幕:案发现场重现(为何会死循环?)

当其他线程调用get()方法时:

  1. 遍历链表查找key
  2. 进入A→B→A→B→A...的无限循环
  3. CPU占用率飙升到100%
  4. 系统仿佛被施了定身咒

🛡️ 第五幕:防御指南

  1. 使用ConcurrentHashMap:真正的线程安全战士
  2. Collections.synchronizedMap:给HashMap穿上锁子甲
  3. 升级Java8+:改用尾插法+红黑树,但依然非线程安全!
  4. 终极奥义:严格避免在多线程环境直接使用HashMap

🎓 技术总结

问题根源在于:

  1. 头插法导致链表反转
  2. 多线程可见性不同步
  3. 执行过程非原子性
  4. 环形引用的多米诺骨牌效应

记住:HashMap就像玫瑰,单线程时娇艳欲滴,多线程时暗藏尖刺。下期我们继续解剖ConcurrentHashMap的魔法防御体系,敬请期待!