HashMap 用在并发场景中会出现什么问题?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官问出这个问题,主要希望考察:

  1. 对基础数据结构线程安全性的理解:你是否清楚 HashMap 在设计上并非线程安全,以及 “非线程安全” 在并发场景下的具体表现。
  2. 对并发问题根因的探究能力:不仅仅是知道会出问题,更希望你能深入到源码层面,解释问题产生的机制,例如 JDK 7 和 JDK 8 中问题的异同。
  3. 解决实际问题的工程思维:面对并发场景,你如何选择合适的替代方案(如 ConcurrentHashMap),并了解其背后的设计原理与权衡。
  4. 排错与调试经验:是否能联想到此类问题在生产环境中可能导致的现象(如 CPU 飙升、数据错乱),这体现了你的实际项目经验。

核心答案

HashMap 在并发场景下直接使用会导致数据不一致程序死循环(主要在 JDK 7 中)等问题。因为它内部的状态(如数组、链表、红黑树)在被多线程同时修改时,缺乏同步保护,从而导致链表/树的结构被破坏。绝不能在多线程写操作(包括修改)的场景下直接使用非同步的 HashMap

深度解析

原理/机制

HashMap 的线程不安全主要体现在 “结构性修改” 上,即导致其内部数组 table 或链表/树结构发生变化的操作,主要包括 putresize (扩容) 等。

  1. 数据丢失或覆盖

    • 场景:两个线程同时执行 put,且计算出的桶(bucket)位置相同。
    • 原理:假设线程 A 和线程 B 都准备插入数据。它们可能同时检测到某个桶为空,然后都新建节点并尝试放入。后执行完毕的线程会覆盖前一个线程的节点,导致前一个线程插入的数据丢失。
    • 代码模拟
      // 线程A: map.put(key1, value1); // 假设key1哈希后定位到桶i,桶i初始为空
      // 线程B: map.put(key2, value2); // 假设key2与key1哈希冲突,也定位到桶i
      
      // 在HashMap的putVal方法中,大致流程如下:
      if ((p = tab[i = (n - 1) & hash]) == null) // 步骤1:检查桶是否为空
          tab[i] = newNode(hash, key, value, null); // 步骤2:如果为空,新建节点
      
      // 并发时,可能发生:
      // 线程A执行步骤1,发现桶i为空。
      // 线程B也执行步骤1,同样发现桶i为空(因为A还未执行步骤2)。
      // 线程A执行步骤2,将节点N1放入桶i。
      // 线程B执行步骤2,将节点N2放入桶i,覆盖了N1。结果:key1-value1丢失。
      
  2. JDK 7 中的扩容死循环(经典问题)

    • 场景:多个线程同时触发 HashMap 扩容(resize)。
    • 原理:JDK 7 的扩容采用 “头插法” 将旧链表节点转移到新数组。并发时,两个线程操作同一个链表进行反转,可能导致链表节点之间形成 环状链接(Circular Linked List)
    • 后果:后续任何对该链表的遍历操作(如 getput),都会因为进入死循环而耗尽 CPU 资源。
    • 机制简析
      // JDK 7 resize() 关键片段 (简化示意)
      void transfer(Entry[] newTable) {
          for (Entry<K,V> e : table) { // 遍历旧数组
              while(null != e) {
                  Entry<K,V> next = e.next; // 记录原链表下一个节点 <- 线程A、B都可能在此处记录自己的next
                  int i = indexFor(e.hash, newTable.length);
                  e.next = newTable[i]; // **头插法:将当前节点指向新桶的头节点**
                  newTable[i] = e;      // **将当前节点设为新桶的新头节点**
                  e = next;             // 移动到原链表的下一个节点
              }
          }
      }
      

      并发环状链表形成过程(极度简化):假设原链表为 A -> B -> null,两个线程同时扩容。

      • 线程 A 执行完 e = next 后被挂起,此时它持有:e = B, next = null (来自A视角)。
      • 线程 B 成功完成整个扩容,新链表变为 B -> A -> null(因为头插法会反转链表)。
      • 线程 A 恢复执行,它仍然持有 e = B, next = null(但其 B.next 已被线程 B 修改为指向 A)。接下来:
        • 它将 B 头插到新桶,并尝试将 e 指向 next(即 null)。
        • e = next 之前,会先判断 while(null != e),此时 eB,不为空,所以继续循环。
        • 它再次计算 next = e.next,此时 eB,而 B.nextA(线程B修改后的结果),所以 next = A
        • 它又将 B 头插一次(此时链表已乱),然后将 e 指向 A
        • 下一轮循环,对 A 操作,A.next 指向了 B(之前线程 A 自己头插时设置的),形成了 A <-> B 的环。从此,遍历此链表将永无止境。
  3. JDK 8 及之后的改进与遗留问题

    • 改进:JDK 8 将链表扩容时的 “头插法” 改为 “尾插法”,避免了链表反转,从而根治了由扩容引起的死循环问题
    • 遗留问题:但 数据不一致(丢失更新、覆盖)的问题依然存在。此外,JDK 8 引入了红黑树,并发下对树结构的错误修改同样会导致数据错乱或遍历异常。

对比分析与最佳实践

  • 如何安全地在并发环境下使用 Map?

    1. ConcurrentHashMap (首选):专为高并发设计。在 JDK 7 中采用 分段锁,JDK 8 及之后改为 synchronized 锁桶(链表头/树根节点) + CAS 实现更细粒度的同步,性能远超 Hashtable
    2. Collections.synchronizedMap(new HashMap(...)):返回一个所有方法都用 synchronized 修饰的包装类。它是全表锁,并发争抢激烈时性能较差,适用于并发度不高的场景。
    3. Hashtable (已过时):所有方法都用 synchronized 修饰,是全表锁,性能最差,不推荐使用。
  • 最佳实践

    • 读写分离场景:如果并发场景是多读少写,且数据一致性要求不是实时强一致,可以考虑使用 ConcurrentHashMap
    • 高并发写场景必须使用 ConcurrentHashMap
    • 绝对禁止:在任何可能涉及多个线程进行写操作(包括修改、删除)的场景下,直接使用 HashMap

常见误区

  • “我用 HashMap 只做并发读,所以是安全的。” —— 错误。如果 HashMap 在构建完成后(初始化、所有 put 操作)才被多线程读取,并且此后结构不再改变,那确实是安全的。但通常很难保证这一点,且一旦有线程尝试写操作,风险极大。
  • “JDK 8 的 HashMap 已经线程安全了。” —— 大错特错。JDK 8 只是修复了由特定扩容方式引起的死循环,其非同步的本质未变,数据竞争导致的各种不一致问题依然存在。

总结

HashMap 的线程不安全根源在于其未受保护的结构性修改操作,在并发写场景下必然导致数据错误,在 JDK 7 中甚至会引发致命死循环;解决之道是根据并发度选择 ConcurrentHashMap 或同步包装类,而 ConcurrentHashMap 是当今高并发场景下的绝对标准答案