多线程Java程序中HashMap大小不一致的底层原因探究(OpenJDK17)
我编写了一个多线程Java程序,每个线程向共享的HashMap中插入唯一键(键由循环索引和线程名组合而成)。但所有线程执行完毕后,HashMap的大小与预期的插入总数(NUM_THREADS * NUM_INSERTIONS)不一致。我知道HashMap并非线程安全,使用ConcurrentHashMap可解决该问题,但希望了解OpenJDK17的HashMap实现中,具体是哪里的竞态条件导致了这种不一致,尤其是扩容(rehash)过程中的问题。
测试代码如下:
public class CurrentHashMapDemo { private static final int NUM_THREADS = 5; private static final int NUM_INSERTIONS = 100; private static HashMap<String, Integer> hashMap = new HashMap<>(); public static void main(String[] args) throws InterruptedException{ ExecutorService executorService = Executors.newFixedThreadPool(NUM_THREADS); for(int i=0; i< NUM_THREADS; i++){ executorService.execute(insertRecord()); } executorService.shutdown(); if(!executorService.isTerminated()){ Thread.sleep(1000); } System.out.println("Size of the hashmap="+ hashMap.size()); } private static Runnable insertRecord(){ return () -> { for(int i=0; i<NUM_INSERTIONS; i++){ System.out.println("Key:"+ i+Thread.currentThread().getName()); hashMap.put(i+Thread.currentThread().getName(), i); } }; } }
替换为ConcurrentHashMap后问题即可解决,但我需要明确为何HashMap在多线程环境下无法正常工作,以及导致大小不一致的具体原因。
在OpenJDK17的HashMap实现中,多线程环境下的竞态条件主要体现在以下几个方面,直接导致元素丢失、size统计错误:
1. 普通put操作的竞态
当多个线程同时执行put时,即使键不重复,也可能出现覆盖或元素丢失:
- HashMap的
put流程中,会先计算哈希值找到桶位,然后检查桶内是否存在目标键。如果不存在,会创建新节点插入到桶中(链表或红黑树)。 - 若两个线程同时在同一个桶位执行插入操作,可能会出现后插入的节点覆盖先插入的节点的情况:线程A刚计算好插入位置还未完成节点挂载,线程B已经完成了该位置的节点插入,最终线程A的插入操作会覆盖线程B的节点,导致B插入的元素丢失。
2. 扩容(rehash)过程中的致命竞态
这是导致元素丢失最常见的场景,OpenJDK17的HashMap扩容逻辑中存在多个线程不安全的点:
(1)扩容触发条件的竞态
HashMap会在size > threshold(threshold = capacity * loadFactor)时触发扩容。多个线程同时执行put时,可能都检测到当前size超过阈值,从而同时触发扩容操作。
- 两个线程各自创建新的扩容后的数组(
newTab),然后各自将旧数组的元素迁移到自己的newTab中。最终只有最后完成扩容的线程会将HashMap的table引用指向自己的newTab,另一个线程迁移的所有元素都会丢失。
(2)链表迁移时的节点丢失
在扩容迁移链表元素时,HashMap会将原链表拆分为两个链表(根据哈希值的高位决定放入新数组的哪个桶),然后将这两个链表分别挂载到新数组的对应桶位。
- 若多个线程同时迁移同一个链表,可能会出现链表节点被重复处理或遗漏的情况。比如线程A正在处理链表的某个节点,线程B同时移动了该节点到新数组,导致线程A后续的处理逻辑混乱,部分节点没有被正确迁移到新数组中,最终这些节点丢失。
(3)size变量的无原子性更新
HashMap的size变量是普通的int类型,没有用volatile修饰,也没有通过原子操作更新。多个线程同时执行put时,会对size进行size++操作,这是一个非原子操作(读-改-写)。
- 比如线程A和线程B同时读取到size=10,各自执行加1后写回,最终size变成11而不是12,导致size统计值小于实际插入的元素数(甚至在元素丢失的情况下,size可能比实际存在的元素数还大)。
3. 其他潜在问题
除了上述场景,还有比如红黑树转换、节点查找时的竞态,都可能导致元素无法被正确存储或读取,但最直接导致size不一致的还是前面提到的put操作覆盖、扩容时的元素丢失以及size的非原子更新。
内容的提问来源于stack exchange,提问作者Neelabh Singh

