HashMap是Java集合框架中使用频率非常高的基于哈希表实现的Map,它在单线程读写场景下能够提供接近常数时间的查找与插入性能。然而,当多个线程同时对一个普通HashMap实例执行put、get或remove操作时,容器并不会对这些操作进行任何同步。它既不会阻止多个线程同时修改内部数组,也不会保证共享变量在并发环境下的可见性。因此,在多线程环境中使用HashMap很可能导致数据覆盖、元素丢失、计数错误,甚至在早期版本中出现链表成环后的死循环。要理解这些问题产生的根本原因,需要先梳理HashMap的内部结构和写入流程。

一、HashMap的底层结构与写入过程
HashMap内部维护一个Node类型的数组,每个数组位置称为桶。当多个key计算出的索引落在同一个桶时,通过链表或红黑树解决冲突。在JDK 1.8中,当链表长度达到阈值且数组容量满足条件时,链表会树化为红黑树,以降低最坏情况下的遍历时间复杂度。这种结构让HashMap在单线程中已经形成了相对高效的读写路径,但同时也意味着桶内存在多个节点时,写入操作需要沿着链表或树进行查找和指针调整。
调用put方法时,核心是先根据key的hashCode做一次扰动计算得到hash值,再通过(n - 1) & hash定位桶下标。随后检查该桶是否已有节点。若为空则直接放入新节点;若不为空则沿着链表或红黑树查找,如果找到相同key就替换value,否则追加新节点。写入完成后还需要判断当前元素数量是否超过扩容阈值,若超过则触发resize扩容。
上述流程在源码中由多个if判断、链表遍历和自增操作组成,每一步之间都可能被线程调度打断。由于这些操作没有使用锁或CAS保护,当两个线程同时执行putVal时,很可能读到相同的旧状态并分别做出写入动作,为数据覆盖埋下隐患。下面是一段简化后的putVal核心逻辑,可以看到其中存在多个可能被并发交错执行的步骤。
// JDK 1.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)
tab[i] = newNode(hash, key, value, null);
else {
// 省略链表或红黑树中的节点查找与更新逻辑
}
if (++size > threshold)
resize();
return null;
}
二、并发写入造成的数据覆盖
数据覆盖最容易发生在桶位为空的时候。假设线程A和线程B同时计算到同一个空桶,它们都判断tab[i]为null,于是各自创建一个新节点并赋值给tab[i]。后赋值的线程会直接覆盖先赋值的节点,最终该桶中只保留一个键值对,另一个键值对则完全丢失。整个过程不会抛出任何异常,程序继续运行,但数据已经不一致,这种问题在测试阶段可能很难被发现。
除了空桶覆盖,链表插入阶段同样可能发生覆盖。若桶中已经存在链表,两个线程可能同时遍历该链表并都判断目标key不存在,于是都准备追加节点。由于节点next指针的修改没有同步保护,可能造成某个线程插入的节点没有被前驱正确链接,或者在更新next引用时被另一个线程的写入覆盖,从而丢失一个或多个节点。这种问题比空桶覆盖更隐蔽,因为并不是简单覆盖整个桶,而是链表结构被破坏。
此外,HashMap内部使用一个int类型的size字段记录元素数量,每次新增元素后执行++size操作。这个自增不是原子操作,多个线程并发执行时可能发生读旧值、重复累加,最终size小于实际插入数量。即使两个key没有落在同一个桶,size统计仍然可能出错,导致后续扩容判断和迭代结果都出现偏差。
import java.util.HashMap;
import java.util.Map;
public class HashMapRaceDemo {
public static void main(String[] args) throws InterruptedException {
Map<String, String> map = new HashMap<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
// 使用线程名拼接key,多个线程可能写入同一桶
map.put(Thread.currentThread().getName() + "-" + i, "value");
}
};
Thread t1 = new Thread(task, "thread-A");
Thread t2 = new Thread(task, "thread-B");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("实际size: " + map.size());
// 理想情况下size应为2000,但并发put可能造成覆盖和计数错误
}
}
三、早期版本扩容中的环形链表与死循环
当HashMap中的元素数量超过容量与负载因子的乘积时,会触发resize扩容。扩容过程会创建一个更大的新数组,并将旧数组中的每个桶重新计算下标后迁移到新数组。迁移过程中,多个线程可能同时进入resize并操作同一组链表节点。JDK 1.7及之前的版本在迁移链表时采用头插法,即把原链表从头开始逐个取出节点,并把节点插入到新数组桶的头部。
单线程下头插法不会产生问题,但多线程交叉执行时可能出现两个线程同时操作同一节点的next指针。一个线程记录下一个节点后,另一个线程可能已经修改了该节点的next引用。极端情况下两个节点
的 next 指针相互指向对方,形成 A -> B -> A 的环形链表。当后续线程或其他操作遍历到该桶时,while (e != null) 循环将永远无法通过 e = e.next 到达 null,导致 CPU 持续空转,也就是常说的 HashMap 死循环问题。
要理解这个过程,可以把头插法迁移想象成不断把旧链表的节点摘下来放到新桶的最前面。假设旧链表为 A -> B -> null,线程 T1 和 T2 同时进入迁移逻辑。T1 取出 A,准备把它放入新桶,但还没来得及操作就被挂起;T2 也取出 A 并放入新桶,接着继续取出 B 放入新桶,此时新桶的顺序为 B -> A -> null。当 T1 恢复执行时,它仍然基于自己之前记录的旧状态继续操作 A,把 A 的 next 指向了自己记录中的下一个节点 B,但 B 的 next 实际上已经被 T2 改成了 A。这样,新桶中的链表就变成了 A 和 B 相互引用,访问该桶的 get 或 put 方法一旦进入该链表,就会陷入无限循环。
JDK 1.8 对 resize 过程的链表迁移方式做了调整,从头插法改为尾插法,并且通过局部变量保存原链表的遍历顺序。在迁移单个链表时,代码维护 loHead、loTail 和 hiHead、hiTail 两组引用,将节点依次追加到新链表的尾部。这种方式在单链表内部保持了原有的相对顺序,因此理论上不会再出现节点之间相互指向形成环的情况。但这并不意味着 JDK 1.8 的 HashMap 已经线程安全,尾插法只是避免了早期版本中最典型的死循环故障,并发 put 仍然可以造成数据覆盖、size 计数错误,甚至在红黑树与链表相互转换时出现结构异常。
除了环形链表,resize 过程中的另一个常见问题是数据丢失。数据丢失的根源在于多个线程同时执行迁移时,各自基于旧数组创建新数组,最后只有一个线程的新数组会被提交到 table 字段,其他线程完成的迁移工作会被静默丢弃。更隐蔽的丢失发生在没有触发 resize 的普通 put 场景中:两个线程同时计算得到相同的桶下标,并且都判断该桶为空,然后各自创建新节点写入桶中,后写入的节点会覆盖先写入的节点,导致前一个线程的 put 结果直接消失。即使桶不为空,线程在遍历链表寻找插入位置时,如果没有同步保证,也可能出现两个线程同时认为找到了链表末尾,随后一个线程的节点被另一个线程的节点覆盖。
size 字段同样无法在并发环境中保持准确。JDK 1.7 中 size 是一个普通的 int 字段,多个线程执行 size++ 并非原子操作,典型的读-改-写竞争会导致若干次自增被丢失。JDK 1.8 虽然引入了 sizeCtl 和 counter 数组等机制来分散计数压力,但这些设计仍然是在假定单线程或受控并发的前提下工作的。未经过同步的多线程同时 put,仍可能使 size() 返回一个小于实际元素数量的值。
针对这些并发问题,Java 提供了 Hashtable 和 ConcurrentHashMap 两种替代方案。Hashtable 通过在几乎所有方法上添加 synchronized 关键字来保证线程安全,代价是无论读操作还是写操作都需要竞争同一把锁,并发性能较差,目前已很少在新代码中使用。ConcurrentHashMap 则采用了更细粒度的锁策略。JDK 1.7 中它使用分段锁,将整个哈希表分成多个 Segment,每个 Segment 维护自己的锁,不同 Segment 之间的操作可以并行进行。JDK 1.8 进一步放弃了分段锁设计,改为对单个桶的头节点使用 synchronized 配合 CAS 操作。当桶为空时,通过 CAS 尝试写入节点;当桶不为空时,只对桶内的头节点加锁,其他桶的操作完全不受影响。这种细粒度设计使得 ConcurrentHashMap 在保证线程安全的同时,能够保持较高的并发吞吐量。
在日常开发中,如果 HashMap 仅在方法内部创建和使用,不会逃逸到多个线程中,则无需额外的同步措施。一旦 HashMap 作为共享对象被多个线程访问,就必须明确其线程安全策略。可以选择用 Collections.synchronizedMap 包装,也可以直接使用 ConcurrentHashMap。需要特别注意的是,Collections.synchronizedMap 虽然在单个方法调用上保证了原子性,但复合操作如 containsKey 之后接着 put、或者遍历整个 map,仍需要调用方自己进行额外的同步,否则整体操作依然不是原子的。相比之下,ConcurrentHashMap 提供了 putIfAbsent、compute、merge 等原子复合操作方法,可以更安全地表达常见的并发更新语义。
总结来说,HashMap 的非线程安全并不是某一种特定操作的问题,而是其整体设计假设了单线程访问模型。早期版本的头插法导致多线程扩容时可能形成环形链表,进而引发死循环,这是最严重的一类故障;JDK 1.8 改用尾插法后消除了环形链表问题,但数据覆盖、size 计数不准确、迁移结果丢失等问题依然存在。理解这些内部机制,有助于在并发编程中更准确地选择容器类型,也有助于在故障排查中快速定位 HashMap 使用不当带来的问题。