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

一、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");