HashMap并发问题与ConcurrentHashMap分段锁演进
引言
想象一下这样的场景:你正在开发一个电商系统的库存服务,使用 HashMap 缓存商品库存信息。上线初期一切正常,但随着双11大促的临近,系统开始频繁出现 CPU 飙升、死循环甚至服务宕机的问题。排查日志时,你发现 HashMap 在高并发写入时内部结构被破坏了。
这不是危言耸听。在 JDK 7 中,HashMap 在并发扩容时可能形成环形链表,导致 get 操作陷入死循环;即便在 JDK 8 中改进了扩容算法,put 操作仍可能导致数据丢失。如果你正在维护一个高并发的 Java 服务,理解这个问题并掌握 ConcurrentHashMap 的演进历程是必备技能。
核心概念
生活类比:餐厅后厨的储物柜
想象一家餐厅后厨,厨师们(线程)共用一个储物柜(Map)存放食材(键值对)。储物柜有很多格子(桶),每个格子可以放多个食材(链表)。
- HashMap 就像没有锁的储物柜,厨师们可以随意取放,但当储物柜需要扩建(扩容)时,多个厨师同时搬移食材,可能把食材放错格子甚至弄丢。
- Hashtable 给整个储物柜加了一把大锁,一次只允许一个厨师使用,虽然安全但效率极低。
- ConcurrentHashMap 则聪明地将储物柜分成多个独立的小柜子(分段/桶),每个小柜子有自己的锁(分段锁/CAS+锁),不同厨师可以同时操作不同的小柜子。
技术定义
- HashMap:基于哈希表的 Map 实现,非线程安全。JDK 7 使用数组+链表,JDK 8 使用数组+链表+红黑树。
- Hashtable:线程安全的 HashMap,使用
synchronized锁住整个表,并发度低。
- ConcurrentHashMap:线程安全的 HashMap,JDK 7 采用分段锁(Segment),JDK 8 抛弃分段锁,改用 CAS +
synchronized锁住单个桶节点,并发度更高。
源码/原理深度分析
JDK 7 HashMap 并发死循环的根源
JDK 7 的 HashMap 扩容采用头插法,在并发环境下,两个线程同时扩容同一个桶时,可能形成环形链表:
// JDK 7 HashMap.transfer() 核心逻辑(简化)
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while (null != e) {
// 线程A执行到这里被挂起
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}当线程A和线程B同时执行 transfer,线程A在 Entry 处被挂起,线程B完成扩容后,线程A恢复执行,此时链表顺序已被反转,可能导致 e.next 指向自己,形成死循环。
JDK 8 HashMap 的改进与遗留问题
JDK 8 将头插法改为尾插法,解决了死循环问题,但并发 put 仍会丢失数据:
// JDK 8 HashMap.putVal() 关键片段
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
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 {
// ... 省略后续逻辑
}
}两个线程同时检查到 tab[i] == null,然后都执行赋值操作,后写的线程覆盖先写的,导致数据丢失。
JDK 7 ConcurrentHashMap 分段锁设计
JDK 7 的 ConcurrentHashMap 将整个表分成 16 个 Segment(默认),每个 Segment 就是一个小型的 HashMap,拥有独立的锁:
// JDK 7 ConcurrentHashMap 核心结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
// ...
}
// 写操作需要获取 Segment 的锁
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
// ...
}设计要点:
- 锁粒度是 Segment,默认并发度 16
get操作不需要加锁(HashEntry 的 value 是 volatile)
- 扩容只在 Segment 内部进行,不会影响其他 Segment
JDK 8 ConcurrentHashMap 的 CAS + synchronized 演进
JDK 8 抛弃了 Segment,直接用 Node[] 数组 + CAS + synchronized 实现:
// JDK 8 ConcurrentHashMap.putVal() 核心逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,用 CAS 无锁插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 桶不为空,锁住当前桶的头节点
synchronized (f) {
// ... 链表或红黑树插入逻辑
}
}
}
// ...
}演进优势:
- 锁粒度从 Segment 细化到单个桶(Node),并发度从 16 提升到数组长度
- 使用 CAS 无锁操作处理桶为空的场景,减少锁竞争
synchronized在 JDK 6 优化后(偏向锁、轻量级锁),性能已不输 ReentrantLock
架构演进对比
实战代码
示例1:并发环境下的 HashMap 数据丢失复现
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 复现 HashMap 并发 put 导致的数据丢失问题
*
* 运行结果:实际 size 小于 10000,说明有数据丢失
*/
public class HashMapConcurrencyIssue {
private static final int THREAD_COUNT = 10;
private static final int PUT_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
// 模拟高并发场景下的 HashMap
Map<Integer, String> map = new HashMap<>(16);
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int t = 0; t < THREAD_COUNT; t++) {
final int threadId = t;
executor.submit(() -> {
try {
for (int i = 0; i < PUT_COUNT; i++) {
// 每个线程写入 1000 个键值对,键全局唯一
map.put(threadId * PUT_COUNT + i, "value-" + i);
}
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
executor.shutdown();
long cost = System.currentTimeMillis() - start;
int expectedSize = THREAD_COUNT * PUT_COUNT;
System.out.println("期望元素数量: " + expectedSize);
System.out.println("实际元素数量: " + map.size());
System.out.println("丢失元素数量: " + (expectedSize - map.size()));
System.out.println("耗时: " + cost + "ms");
// 输出结果示例(每次运行可能不同):
// 期望元素数量: 10000
// 实际元素数量: 9973
// 丢失元素数量: 27
// 耗时: 45ms
}
}运行结果分析:每次运行丢失的元素数量不同,这是因为线程调度具有随机性。丢失的原因包括:两个线程同时检查桶为空然后同时赋值、扩容时旧表数据被覆盖等。
示例2:ConcurrentHashMap 并发写入的正确姿势
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;
/**
* ConcurrentHashMap 在并发场景下的正确使用
*
* 对比三种并发写入策略:
* 1. put() - 直接覆盖,可能丢失中间状态
* 2. merge() - 原子合并,适合累加场景
* 3. compute() - 原子计算,适合复杂更新
*/
public class ConcurrentHashMapDemo {
private static final int THREAD_COUNT = 50;
private static final int OPERATIONS_PER_THREAD = 1000;
public static void main(String[] args) throws InterruptedException {
// 场景1:统计每个用户的下单次数
testIncrement();
// 场景2:合并复杂对象
testMerge();
// 场景3:使用 LongAdder 优化高频计数
testLongAdder();
}
/**
* 场景1:原子自增操作
* 注意:map.put(key, map.getOrDefault(key, 0) + 1) 不是原子的!
*/
private static void testIncrement() throws InterruptedException {
Map<String, Long> orderCount = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
for (int t = 0; t < THREAD_COUNT; t++) {
final String userId = "user-" + (t % 5); // 5个用户,10个线程抢
executor.submit(() -> {
try {
for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
// 错误写法:先读后写,不是原子的
// orderCount.put(userId, orderCount.getOrDefault(userId, 0L) + 1);
// 正确写法1:使用 merge,原子操作
orderCount.merge(userId, 1L, Long::sum);
}
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
executor.shutdown();
System.out.println("\n=== 场景1:原子自增 ===");
long totalOrders = orderCount.values().stream().mapToLong(Long::longValue).sum();
System.out.println("总下单次数: " + totalOrders);
System.out.println("期望总次数: " + (THREAD_COUNT * OPERATIONS_PER_THREAD));
System.out.println("结果正确: " + (totalOrders == THREAD_COUNT * OPERATIONS_PER_THREAD));
}
/**
* 场景2:合并复杂对象
* 使用 compute 实现原子更新复杂对象
*/
private static void testMerge() throws InterruptedException {
Map<String, UserStats> statsMap = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
for (int t = 0; t < THREAD_COUNT; t++) {
final int threadId = t;
executor.submit(() -> {
try {
for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
String userId = "user-" + (threadId % 5);
int amount = i % 100;
// 使用 compute 原子更新复杂对象
statsMap.compute(userId, (key, oldStats) -> {
UserStats newStats = oldStats == null ? new UserStats() : oldStats;
newStats.orderCount++;
newStats.totalAmount += amount;
return newStats;
});
}
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
executor.shutdown();
System.out.println("\n=== 场景2:复杂对象合并 ===");
statsMap.forEach((key, stats) ->
System.out.println(key + " 订单数=" + stats.orderCount +
" 总金额=" + stats.totalAmount));
}
/**
* 场景3:LongAdder 优化高频计数
* 对于高频更新,LongAdder 比 AtomicLong 性能更好
*/
private static void testLongAdder() throws InterruptedException {
ConcurrentHashMap<String, LongAdder> counterMap = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
for (int t = 0; t < THREAD_COUNT; t++) {
executor.submit(() -> {
try {
for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
// 先获取或创建 LongAdder,然后自增
counterMap.computeIfAbsent("total-requests", k -> new LongAdder()).increment();
}
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
executor.shutdown();
System.out.println("\n=== 场景3:LongAdder 高频计数 ===");
long total = counterMap.get("total-requests").sum();
System.out.println("总请求数: " + total);
System.out.println("期望请求数: " + (THREAD_COUNT * OPERATIONS_PER_THREAD));
System.out.println("结果正确: " + (total == THREAD_COUNT * OPERATIONS_PER_THREAD));
}
/**
* 用户统计实体
*/
static class UserStats {
int orderCount;
int totalAmount;
@Override
public String toString() {
return "UserStats{orderCount=" + orderCount + ", totalAmount=" + totalAmount + "}";
}
}
}核心要点:
put+getOrDefault的组合不是原子的,必须使用merge或compute
computeIfAbsent适合"先检查后插入"的场景,且是原子的
- 高频计数场景优先考虑
LongAdder而非AtomicLong
示例3:从 HashMap 迁移到 ConcurrentHashMap 的性能对比
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 不同 Map 实现性能对比基准测试
*
* 测试场景:8个线程并发读写,各执行 10000 次
* 测试结果(仅供参考,实际因机器而异):
* - HashMap: 最快但有数据丢失
* - Hashtable: 最慢,全局锁竞争严重
* - ConcurrentHashMap: 接近 HashMap 的性能,且线程安全
*/
public class MapPerformanceBenchmark {
private static final int THREAD_COUNT = 8;
private static final int OPERATIONS = 10000;
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Map 性能对比基准测试 ===");
System.out.println("线程数: " + THREAD_COUNT + ", 每线程操作数: " + OPERATIONS + "\n");
// 测试 HashMap(非线程安全)
benchmarkMap("HashMap", new HashMap<>(), false);
// 测试 Hashtable(全局锁)
benchmarkMap("Hashtable", new Hashtable<>(), true);
// 测试 ConcurrentHashMap(分段锁/CAS)
benchmarkMap("ConcurrentHashMap", new ConcurrentHashMap<>(), true);
}
private static void benchmarkMap(String name, Map<Integer, Integer> map,
boolean threadSafe) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch readyLatch = new CountDownLatch(THREAD_COUNT);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(THREAD_COUNT);
// 预热 JVM
for (int i = 0; i < 1000; i++) {
map.put(i, i);
}
map.clear();
long startTime;
for (int t = 0; t < THREAD_COUNT; t++) {
final int threadId = t;
executor.submit(() -> {
readyLatch.countDown();
try {
startLatch.await(); // 等待所有线程就绪
for (int i = 0; i < OPERATIONS; i++) {
int key = threadId * OPERATIONS + i;
// 混合读写操作
map.put(key, i);
map.get(key % OPERATIONS);
map.containsKey(i % 1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
});
}
readyLatch.await();
startTime = System.nanoTime();
startLatch.countDown(); // 同时启动所有线程
doneLatch.await(30, TimeUnit.SECONDS);
long cost = (System.nanoTime() - startTime) / 1_000_000;
executor.shutdown();
// 验证数据完整性
int actualSize = map.size();
int expectedSize = threadSafe ? THREAD_COUNT * OPERATIONS : map.size();
String dataIntegrity = threadSafe ?
(actualSize == expectedSize ? "✓ 数据完整" : "✗ 数据丢失!") :
"(非线程安全,不检查)";
System.out.printf("%-20s 耗时: %5dms 数据量: %d %s%n",
name, cost, actualSize, dataIntegrity);
// 清理
map.clear();
System.gc();
Thread.sleep(100);
}
}
// 典型输出(某次运行的参考值):
// === Map 性能对比基准测试 ===
// 线程数: 8, 每线程操作数: 10000
// HashMap 耗时: 78ms 数据量: 79982 (非线程安全,不检查)
// Hashtable 耗时: 245ms 数据量: 80000 ✓ 数据完整
// ConcurrentHashMap 耗时: 105ms 数据量: 80000 ✓ 数据完整性能分析:
- HashMap 最快,但不安全
- Hashtable 比 ConcurrentHashMap 慢 2-3 倍,因为全局锁限制了并发度
- ConcurrentHashMap 的性能接近 HashMap(约 1.3 倍耗时),但提供了线程安全保证
方案对比
ConcurrentHashMap vs 其他并发方案
| 方案 | 锁粒度 | 并发度 | 读性能 | 写性能 | 适用场景 |
|------|--------|--------|--------|--------|----------|
| Hashtable | 整表锁 | 1 | 低 | 低 | 不推荐使用 |
| Collections.synchronizedMap | 整表锁 | 1 | 低 | 低 | 简单包装现有 Map |
| ConcurrentHashMap (JDK7) | Segment | 16 | 高 | 中 | 旧系统维护 |
| ConcurrentHashMap (JDK8) | 单个桶 | 数组长度 | 高 | 高 | 首选方案 |
| ConcurrentSkipListMap | 无锁(CAS) | 高 | 中 | 中 | 需要有序键值对 |
ConcurrentHashMap 的局限性
- 不能存储 null 键值:
ConcurrentHashMap不允许 null 键和 null 值,因为无法区分"值为 null"和"不存在"。如果业务需要 null 值,考虑使用Optional包装。
- 弱一致性迭代器:
ConcurrentHashMap的迭代器是弱一致的,不保证迭代过程中看到最新数据。如果需要强一致性,需要额外同步。
- size() 和 isEmpty() 是估算值:并发环境下,
size()返回的是近似值,不保证精确。如果业务依赖精确计数,使用LongAdder单独维护计数。
- 复合操作需要自己保证原子性:如"先检查后操作"、"读取-修改-写入"等复合操作,需要配合
compute、merge等方法实现。
最佳实践与避坑指南
最佳实践
- 优先使用 ConcurrentHashMap:除非明确知道不需要并发访问,否则默认使用
ConcurrentHashMap替代HashMap。
- 利用原子方法:多线程更新同一个 key 时,优先使用
merge、compute、computeIfAbsent等原子方法,避免手动"先读后写"。
- 初始化容量:预估数据量,设置合理的初始容量和并发度(JDK 7 中为 Segment 数量,JDK 8 中为数组长度),避免频繁扩容。
- 大量批量写入时使用 putAll:
putAll内部会做优化,比逐个put更高效。
常见坑
- 不要用 HashMap 做缓存:如果缓存需要被多线程访问,使用
ConcurrentHashMap或Caffeine等专用缓存库。
- lambda 中的变量捕获:
computeIfAbsent的 lambda 中不要捕获可变的外部变量,否则可能产生不可预期的结果。
- 迭代时修改:虽然
ConcurrentHashMap支持迭代中修改,但弱一致性可能导致迭代结果不完整。如果业务需要一致的快照,先new HashMap<>(concurrentMap)再迭代。
- 不要依赖 size() 做精确判断:并发环境下
size()是估算值,如果业务需要精确计数,使用LongAdder或AtomicLong单独维护。
- 避免在锁内做耗时操作:虽然
ConcurrentHashMap的锁粒度很小,但在compute等方法的 lambda 中执行耗时操作(如 RPC 调用)仍然会阻塞其他线程访问同一个桶。
// 错误示例:在 compute 中执行耗时操作
map.compute(key, (k, v) -> {
String result = rpcClient.call(k); // 阻塞其他线程访问该桶
return result;
});
// 正确示例:先计算,再合并
String result = rpcClient.call(key);
map.put(key, result);总结
从 HashMap 的并发死循环,到 Hashtable 的全局锁,再到 ConcurrentHashMap 的分段锁和 CAS+synchronized 演进,我们看到了 Java 并发容器设计的精妙之处:
- JDK 7 ConcurrentHashMap 通过分段锁将竞争分散到 16 个 Segment,显著提升了并发度
- JDK 8 ConcurrentHashMap 进一步细化锁粒度到单个桶,结合 CAS 无锁操作,使得并发度达到数组长度,性能接近 HashMap
在实际开发中,理解这些底层机制能帮助我们做出更合理的技术选型。当面对高并发场景时,ConcurrentHashMap 无疑是首选;但也要认识到它的局限性,如弱一致性、不能存 null 等,在特定业务场景下可能需要额外的设计。
最后留一个思考题:Redis 的 Hash 结构与 ConcurrentHashMap 在并发控制上有哪些异同?如果让你设计一个分布式版本的 ConcurrentHashMap,你会怎么做?欢迎在评论区分享你的想法。