导读:本期聚焦于小伙伴创作的《Java里的HashMap在多线程环境下为什么不安全?数据覆盖与扩容异常详解》,敬请观看详情。把HashMap丢进多线程场景,最常踩的坑就是某次put之后值莫名其妙丢了,或者程序直接抛出死循环式的CPU飙高。这背后的根因并不复杂:JDK1.7及之前版本在扩容时采用头插法迁移链表,并发下可能让节点互指形成环;而put操作本身不保证原子性,两个线程同时算到同一桶位就会相互覆盖。JDK1.8虽把头插改成尾插避免了成环,但数据覆盖问题依旧存在,因为table数组的赋值和size自增都没有同步控制。理解这些底层机制,才能明白为什么高并发该用ConcurrentHashMap而不是自己加锁的HashMap。

HashMap是Java开发中使用频率极高的基于哈希表的Map实现,它在单线程中表现优异,但在多线程并发读写时会出现数据覆盖与扩容异常等问题。要理解这些现象,必须从它的内部结构和操作逻辑入手。

Java里的HashMap在多线程环境下为什么不安全?数据覆盖与扩容异常详解

一、HashMap的基本结构与put流程

HashMap底层是一个Node数组,每个数组元素称为桶(bucket),发生冲突时以链表或红黑树形式存储。当我们调用put方法时,首先根据key的hash值计算出桶下标,然后判断该位置是否为空,为空则直接放入,不为空则遍历链表或树进行插入或替换。

在JDK1.8中,put方法的核心逻辑包含以下几个步骤:计算hash、定位桶、检查容量、插入数据、判断是否需要扩容。这些步骤在源码中并不是原子操作,也就是说,线程A执行到一半,线程B也可能同时执行相同的逻辑,从而导致相互干扰。

// JDK1.8 HashMap putVal 方法简化逻辑
final V putVal(int hash, K key, V value, boolean onlyIfAbsent) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算桶位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 线程A和B可能同时进入这里,都认为桶为空
        tab[i] = newNode(hash, key, value, null);
    else {
        // 遍历链表或树插入
    }
    //  size自增不是原子操作
    if (++size > threshold)
        resize();
    return null;
}

二、数据覆盖问题的产生

数据覆盖是指两个线程同时向HashMap中放入键值对,最终只有一个生效,另一个被无声无息地丢弃。这种情况在桶位为空时最容易发生。假设线程A和线程B同时计算到同一个空桶i,它们都读到tab[i]为null,于是各自创建节点并赋值给tab[i]。后赋值的线程会直接覆盖先赋值的结果。

除了空桶覆盖,在链表插入阶段也可能覆盖。当两个线程同时遍历同一个链表,且发现key不存在需要追加节点时,由于指针操作没有同步,可能出现其中一个线程的节点没有被正确链接,甚至被另一个线程的写操作覆盖。这种问题不会抛出异常,但数据已经不对了,非常难排查。

// 模拟多线程数据覆盖
Map<String, String> map = new HashMap<>();
Runnable task = () -> {
    for (int i = 0; i < 1000; i++) {
        // 不同key但可能hash到同一桶
        map.put(Thread.currentThread().getName() + i, "v");
    }
};
// 两个线程并发执行,最终size可能小于2000

三、扩容异常与死循环(JDK1.7及之前)

当HashMap元素数量超过阈值(容量乘负载因子)时会触发resize扩容,新建一个更大的数组,并把旧数据迁移过去。在JDK1.7及更早版本中,迁移采用头插法,即把链表节点一个个取下来插到新数组的头部。这种方式在单线程下没有问题,但在多线程并发扩容时,可能让两个节点相互指向,形成环形链表。

一旦形成环,后续调用get方法去遍历这个链表时,就会陷入无限循环,导致CPU使用率飙升到100%。这不是数据错误,而是直接让线程卡死。下面的代码展示了头插法迁移的核心逻辑,可以想象两个线程交叉执行时指针的变化。

// JDK1.7 迁移逻辑简化
void transfer(Entry[] newTable) {
    Entry[] src = table;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        while (e != null) {
            Entry<K,V> next = e.next;
            int i = indexFor(e.hash, newTable.length);
            // 头插:把e插到newTable[i]头部
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

四、JDK1.8的改进与遗留问题

JDK1.8将头插法改为尾插法,在扩容迁移时保持链表原有顺序,这从根本上消除了并发成环导致死循环的问题。但HashMap本身仍不是线程安全的容器,put方法没有加锁,size自增使用++操作而非AtomicInteger,因此数据覆盖和size统计错误依然存在。

此外,虽然不会成环,但多线程同时触发扩容时,可能出现多个线程各自创建新数组,然后相互覆盖引用,导致部分数据丢失。因此在并发场景中,依然不应该把普通HashMap当作共享变量使用。

版本扩容方式并发成环数据覆盖
JDK1.7及之前头插法可能可能
JDK1.8及之后尾插法不会可能

五、正确的并发替代方案

如果必须在多线程间共享Map,应使用ConcurrentHashMap。它在JDK1.8中采用CAS加synchronized锁单个桶的方式,既保证了线程安全,又维持了较高并发度。相比给HashMap外部包一层Collections.synchronizedMap,ConcurrentHashMap的锁粒度更细,性能更好。

另一种临时方案是使用Hashtable,但它对整个方法加锁,并发性能很差,现在已经很少在新代码中使用。实际开发中,明确区分单线程容器与并发容器,是从源头避免HashMap多线程问题的关键。

// 使用ConcurrentHashMap保证安全
Map<String, String> safeMap = new ConcurrentHashMap<>();
safeMap.put("k", "v");
String val = safeMap.get("k");

HashMap多线程安全扩容异常修改时间:2026-08-02 16:09:32

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。