如何优雅地回答:HashMap为什么不是线程安全的?

116 阅读6分钟

“HashMap不是线程安全的”这句话你可能听过无数遍了,但是当别人真的让你讲清楚它在JDK1.7中是如何出现扩容死循环的?JDK1.8中它发生了什么变化?你回答的出来吗?本文将带你深入的看看它,彻底搞懂HashMap在多线程时,为什么是不安全的,会出现什么情况。

一、HashMap不是线程安全的

首先,我们需要明确的知道:无论哪个版本的HashMap,它都是线程不安全的。 在多线程环境中,会导致下面三大主要的问题:

  1. 死循环(JDK1.7及更早版本):会在扩容的时候导致CPU100%。
  2. 数据覆盖:两个线程同时put(),可能导致其中一个线程插入的数据被覆盖,从而出现数据丢失。
  3. size()不准:底层size变量的累加操作非原子性,导致并发更新后大小远小于实际大小。

二、为什么HashMap在多线程下会死循环?

在JDK1.7及更早的版本中,HashMap的扩容机制采用 “头插法” ,在多线程并发扩容的场景下,极容易发生线程调度和引用错乱,导致出现环形链表,之后的get()和put()操作陷入死循环,最终导致出现CPU100%的经典bug。

2.1 JDK1.7 HashMap扩容机制回顾

JDK1.7的HashMap采用数组+单向链表结构,每个数组元素是一个单向链表的头结点,每当发生哈希冲突的时候,新元素通过链表连接;扩容时,会创建一个容量为原数组2倍的新数组,遍历旧数组,对每个节点重新计算其在新数组的位置,使用头插法插入新链表(头插法会导致链表反转)。

JDK1.7与JDK1.8数据结构对比图:

HashMap数据结构对比.drawio.png

JDK1.7 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;  // ① 记录下一个节点
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];      // ② 头插法:新节点指向原头节点
            newTable[i] = e;           // ③ 新节点成为头节点
            e = next;                  // ④ 继续处理下一个
        }
    }
}

问题根源:在①和③之间如果发生线程切换,另一个线程完成扩容后链表已反转,原线程恢复后持有的引用就会形成环。

2.2 两个线程如何形成一个环形链表

假设我们有两个线程(T1和T2),同时对一个HashMap进行put操作并触发扩容。初始状态,旧数据某个桶的链表是:A->B->null

环形链表形成过程图:

环形链表形成过程.drawio.png

详细步骤分析:

步骤T1线程状态T2线程状态链表状态
1. 初始--A→B→null
2. T1执行transfere=A, next=B, 被挂起-A→B→null
3. T2完成扩容挂起中头插A,再头插BB→A→null
4. T1恢复继续处理e=A-T1劈向新数组
5. T1插入AA.next=B(老引用)-A→B, 但B.next=A!
6. 环形成e=B, 插入后死循环-A↔B 无限循环

关键理解点:

  1. T1挂起时保存的next=B是指向旧链表中的B节点
  2. T2完成扩容后,同一个B节点的next已经指向A
  3. T1恢复后继续处理,将A插入新数组,此时A.next指向B
  4. 而B.next已经指向A,形成 A↔B 环形链表

2.3 数据覆盖情况复现

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class HashMapConcurrentDemo {
    public static void main(String[] args) throws InterruptedException {
        final Map<Integer, String> unsafeMap = new HashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(100);

        // 100个线程同时向map中放入1000个键值对
        for (int i = 0; i < 1000; i++) {
            final int key = i;
            executor.execute(() -> {
                unsafeMap.put(key, "value-" + key);
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        // 理论大小应为1000,实际结果远小于1000
        System.out.println("实际运行结果: " + unsafeMap.size()); 
    }
}

运行结果如图所示:

image.png

2.4 JDK1.8的改进

JDK1.8对HashMap进行了大的优化:

  • 数据结构:数组 + 单链表/红黑树(树化之后提升查询效率)
  • 插入方式:将头插法改为尾插法,从而在扩容时保持了链表的原有顺序
JDK1.7JDK1.8
数据结构数组+链表数组+链表/红黑树
插入方式头插法尾插法
扩容死循环会发生不会
线程安全性不安全不安全

需要注意的是,尾插法确实解决了JDK1.7的死循环问题,但是并没有让HashMap变得安全! 数据覆盖和大小不准问题还是存在。

2.5 JDK1.8数据覆盖的源码分析

看一下JDK1.8 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 ((p = tab[i = (n - 1) & hash]) == null)
        // 问题点:两个线程同时判断tab[i]为null,都会执行newNode
        tab[i] = newNode(hash, key, value, null);
    else {
        // ...
    }
    // 问题点:size++不是原子操作
    ++modCount;
    if (++size > threshold)
        resize();
    // ...
}

并发场景下的问题:

  1. 数据覆盖:线程A和B同时判断tab[i]==null,都执行newNode(),后执行的会覆盖先执行的
  2. size不准++size不是原子操作,会丢失计数

三、多线程下的正确选择

线程安全Map方案对比图:

线程安全Map对比.drawio.png

方案1:ConcurrentHashMap(推荐)

ConcurrentHashMap是JDK提供的线程安全的HashMap实现,是多线程环境下的首选。

ConcurrentHashMap的演进:

版本实现机制并发度
JDK1.7分段锁(Segment):将数据分成16个段,每段一把锁16
JDK1.8CAS + synchronized:锁粒度细化到单个Node数组长度

JDK1.8 ConcurrentHashMap的核心优化:

  1. 放弃Segment:不再使用分段锁,而是对每个桶头节点加synchronized锁
  2. CAS操作:使用CAS进行无锁化的桶初始化和元素插入
  3. 红黑树:与HashMap一样,链表长度≥8时转为红黑树
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ConcurrentHashMapDemo {
    public static void main(String[] args) throws InterruptedException {
        // 推荐:ConcurrentHashMap
        final ConcurrentHashMap<Integer, String> safeMap = new ConcurrentHashMap<>();
        ExecutorService executor = Executors.newFixedThreadPool(100);

        // 100个线程同时向map中放入1000个键值对
        for (int i = 0; i < 1000; i++) {
            final int key = i;
            executor.execute(() -> safeMap.put(key, "value-" + key));
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        // 结果一定是1000
        System.out.println("实际运行结果: " + safeMap.size());
    }
}

运行结果:

image.png

方案2:Collections.synchronizedMap()(不推荐)

这个方案是使用一个全局的互斥锁包装HashMap,每次只允许一个线程读写这个HashMap,实现简单但性能极差,不推荐在高并发场景使用。

Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

synchronizedMap的实现原理:

// Collections.SynchronizedMap内部实现
public V put(K key, V value) {
    synchronized (mutex) {  // 全局锁,所有操作串行化
        return m.put(key, value);
    }
}

方案对比

方案线程安全性能适用场景
HashMap⭐⭐⭐⭐⭐单线程
ConcurrentHashMap⭐⭐⭐⭐多线程(推荐)
synchronizedMap⭐⭐低并发场景
Hashtable⭐⭐已过时,不推荐

总结

核心结论

HashMap是线程不安全的,主要体现在三个方面:

问题JDK版本原因后果
扩容死循环JDK1.7头插法导致链表反转CPU 100%
数据覆盖全版本并发put无同步数据丢失
size不准全版本++size非原子操作计数错误

JDK1.8改用尾插法解决了死循环问题,但数据覆盖和size不准的问题依然存在,因为HashMap内部实现无任何同步机制