大家好,我是小富。
《十万个why》系列持续更新中
很多人一听到"并发安全"就条件反射般地把 HashMap 换成 ConcurrentHashMap,然后安心地以为万事大吉。
但实际上,用了 ConcurrentHashMap 之后数据还是错乱的案例,多到数不过来。
先看一个经典的翻车代码
// 统计每个商品的访问次数
private final ConcurrentHashMap<String, Integer> visitCount = new ConcurrentHashMap<>();
public void recordVisit(String productId) {
Integer count = visitCount.get(productId);
if (count == null) {
visitCount.put(productId, 1);
} else {
visitCount.put(productId, count + 1);
}
}
看起来合理——用了线程安全的 Map,每次 get 再 put。但多线程压测一跑,最终的计数值比实际访问次数少得多。
为什么?
ConcurrentHashMap 保证的是"单个操作"的原子性
ConcurrentHashMap 的 get()、put()、remove() 等单个方法确实是线程安全的。但你的业务逻辑是:
读取旧值 → 判断 → 计算新值 → 写入新值
这是一个复合操作,由多个步骤组成。ConcurrentHashMap 保证每一步单独是安全的,但不保证多步组合在一起也是原子的。
两个线程同时执行时可能发生这种情况:
初始状态:visitCount["iPhone"] = 10
线程A:get("iPhone") = 10
线程B:get("iPhone") = 10 ← 两个线程读到了同一个旧值
线程A:put("iPhone", 10 + 1 = 11)
线程B:put("iPhone", 10 + 1 = 11) ← 覆盖了 A 的写入
最终结果:11(应该是 12,丢了一次计数)
经典的 check-then-act 竞态条件。和你用不用 ConcurrentHashMap 无关——哪怕你用 Hashtable 也一样会丢。
这跟没加锁有什么区别?
有人说:"那 ConcurrentHashMap 有什么用?跟普通 HashMap 有啥区别?"
区别在于:HashMap 在多线程下可能产生结构性破坏(链表成环导致死循环、数据丢失、size 不一致等),直接让 Map 本身不可用。
ConcurrentHashMap 保证了 Map 的内部结构始终是完好的——不会死循环,不会丢 entry,不会看到半写的数据。但它不替你保证业务逻辑的原子性。
类比一下:ConcurrentHashMap 像一个银行保险柜,保证你存取东西的过程不会被打断。但如果你的操作是"先查余额,再决定存多少",两步之间别人可能已经动了你的余额。保险柜保证的是单次操作安全,不是你整个业务流程安全。
正确的写法:用原子方法
ConcurrentHashMap 提供了一系列原子复合操作方法,专门解决这个问题:
方法一:compute()(推荐)
public void recordVisit(String productId) {
visitCount.compute(productId, (key, oldValue) ->
oldValue == null ? 1 : oldValue + 1
);
}
compute() 内部会对这个 key 加锁,保证"读旧值 → 计算新值 → 写新值"这三步是原子的。
方法二:merge()
public void recordVisit(String productId) {
visitCount.merge(productId, 1, Integer::sum);
}
语义是:如果 key 不存在就用默认值 1,存在就用 sum 函数合并。同样是原子的。
方法三:putIfAbsent()
// 适用于"初始化"场景
public void initIfAbsent(String productId) {
visitCount.putIfAbsent(productId, 0); // 只在 key 不存在时 put
}
方法四:使用 AtomicInteger 作为 value
private final ConcurrentHashMap<String, AtomicInteger> visitCount = new ConcurrentHashMap<>();
public void recordVisit(String productId) {
visitCount.computeIfAbsent(productId, k -> new AtomicInteger(0))
.incrementAndGet();
}
computeIfAbsent 原子地保证 AtomicInteger 只创建一次,incrementAndGet 原子地递增。双重原子性保证。
还有一类更隐蔽的坑:复合条件判断
// 库存扣减
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public boolean deductStock(String skuId, int quantity) {
Integer current = stock.get(skuId);
if (current != null && current >= quantity) {
stock.put(skuId, current - quantity); // 危险!
return true;
}
return false;
}
两个线程同时扣减:
库存 = 10,两个线程各扣 8
线程A:get = 10,10 >= 8 ✓
线程B:get = 10,10 >= 8 ✓
线程A:put(10 - 8 = 2)
线程B:put(10 - 8 = 2) ← 库存变成 2,但实际应该扣失败(10 - 8 - 8 = -6)
超卖了。
正确写法:
public boolean deductStock(String skuId, int quantity) {
return stock.computeIfPresent(skuId, (key, current) -> {
if (current >= quantity) {
return current - quantity;
}
return current; // 库存不足,不扣减
}) != null;
}
// 或者更精确地判断是否扣减成功
public boolean deductStock(String skuId, int quantity) {
AtomicBoolean success = new AtomicBoolean(false);
stock.computeIfPresent(skuId, (key, current) -> {
if (current >= quantity) {
success.set(true);
return current - quantity;
}
return current;
});
return success.get();
}
常见"线程安全容器"的同类陷阱
这不是 ConcurrentHashMap 独有的问题,所有线程安全容器都有:
| 容器 | 单操作安全 | 复合操作安全 |
|---|---|---|
| ConcurrentHashMap | 是 | 不是 |
| CopyOnWriteArrayList | 是 | 不是 |
| Collections.synchronizedMap | 是 | 不是 |
| Vector | 是 | 不是 |
// Vector 的经典坑
Vector<String> v = new Vector<>();
// 这段代码线程不安全!
if (v.size() > 0) { // 线程安全的 size()
v.remove(0); // 线程安全的 remove()
} // 但组合起来不安全:size > 0 之后、remove 之前,别的线程可能已经清空了
总结
| 误区 | 真相 |
|---|---|
| 用了 ConcurrentHashMap 就线程安全了 | 只保证单个操作安全,复合操作需要用原子方法 |
| get → 判断 → put 是安全的 | 不安全,三步之间可能被其他线程插入 |
| 线程安全容器 = 不用操心并发 | 容器保证自身结构安全,业务逻辑的原子性要开发者自己保证 |
一句话:ConcurrentHashMap 保证的是"你的每一步操作不会弄坏 Map",而不是"你的多步操作不会被别人插队"。 想要复合操作原子性,用 compute、merge、putIfAbsent 这些原子方法。
我是小富,下期见。
