Java ConcurrentHashMap 高并发实战

Java ConcurrentHashMap 是并发编程里最常被用错也最该用对的集合:很多人在多线程里直接拿 HashMap 当缓存,结果数据错乱甚至死循环。Java ConcurrentHashMap 从 JDK 7 的分段锁演进到 JDK 8 的 CAS + synchronized 细粒度锁,在保证线程安全的同时把并发度拉满。本文结合Java 并发实战:线程池调优与异步编排的并发基础,把它的原理、API 与坑一次讲透。

一、为什么 HashMap 不能直接上并发

HashMap 的所有写操作都没有任何同步保护。多线程同时 put 时,扩容(resize)会让链表形成环,后续 get 触发死循环;即使没死循环,两个线程同时写入同一个桶也会相互覆盖,最终计数和结果都不一致。下面的代码在并发下几乎必然出问题:

// 错误示范:多线程共享 HashMap,线程不安全
Map<String, Integer> map = new HashMap<>();
Runnable task = () -> {
    for (int i = 0; i < 1000; i++) {
        map.put("k" + i, i);   // 并发写:覆盖、丢失、甚至死循环
    }
};
// 开 10 个线程跑,最终结果往往不是 10000 条

// 正确做法:换成线程安全的实现
Map<String, Integer> safe = new ConcurrentHashMap<>();

记住一条铁律:只要这块 Map 会被多个线程同时读写,就不要碰 HashMap。备选有 Hashtable、Collections.synchronizedMap 和 ConcurrentHashMap,三者的取舍在第五节细说。

二、ConcurrentHashMap 怎么做到既安全又高并发

2.1 JDK 7 的分段锁(已淘汰思路)

早期版本把数据分成 16 个 Segment,每个 Segment 各自持有一把锁,不同 Segment 的写可以并行。思路没错,但粒度仍偏粗:锁的数量固定、且读也要竞争,扩容时更是全局抖动。

2.2 JDK 8 的 CAS + synchronized 细粒度锁

JDK 8 彻底重写了实现,去掉了 Segment,直接在”桶”级别加锁:

  • 读操作完全无锁:用 volatile 修饰的数组和节点,get 不需要加锁,性能接近 HashMap。
  • 写操作按桶加锁:空桶用 CAS 无锁插入;桶已存在元素时,才对头节点用 synchronized 加锁,只锁当前这一个桶,其他桶照常并发。
  • 扩容协助:多线程 put 时如果发现正在扩容,会一起帮忙迁移数据(helpTransfer),迁移完再继续写入。

这让并发度从”最多 16 个 Segment”变成”和桶数量相当”,高并发下吞吐远超老实现。也正是因为读完全无锁,get 操作在百万 QPS 下也几乎零开销。和Java 21 虚拟线程配合使用时,海量轻量任务共享同一个 Map 也不会成为瓶颈。需要留意的是,size() 在并发下只是一个”瞬时近似值”,追求一致计数请用 mappingCount(),或干脆不要依赖它的精确值。

三、核心原子 API 实战

ConcurrentHashMap 真正的威力在”原子复合操作”——这些动作要么整体成功要么整体失败,不用你手动加锁就能保证安全:

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 1. 不存在才插入,返回旧值(原子)
Integer old = map.putIfAbsent("miss", 1);   // 已存在返回旧值,不存在返回 null

// 2. 不存在才计算并插入(经典缓存占位)
map.computeIfAbsent("total", k -> expensiveCalc(k));

// 3. 基于旧值计算新值(原子自增 / 合并)
map.merge("counter", 1, Integer::sum);       // 不存在设为 1,存在则 +1
map.computeIfPresent("counter", (k, v) -> v + 1);

这些 API 解决了最经典的两个并发问题:putIfAbsent 防止重复初始化merge/compute 防止”读-改-写”竞态。在Java Stream API 实战里做并行聚合时,配合它们能写出既简洁又安全的归约逻辑。

四、computeIfAbsent 的隐藏坑

computeIfAbsent 很好用,但有两个坑必须知道:

  • 重复计算:传入的 mapping 函数如果在计算过程中又去调用同一个 map 的 computeIfAbsent(递归/循环依赖),JDK 8 会出现重复执行甚至死锁风险。函数体内不要回写同一个 key。
  • 别拿它当锁:computeIfAbsent 对同一个 key 会加锁,把它当分布式锁用会严重拖慢并发。需要锁语义请用专门的并发工具。
// 正确:函数体只做纯计算,不回写同一个 map
map.computeIfAbsent("config", k -> loadFromDb(k));

// 错误:在 compute 里又操作本 map 同一 key,可能引发重复计算
map.computeIfAbsent("a", k -> {
    map.put("a", 1);   // 危险:递归回写
    return 1;
});

五、三种线程安全 Map 怎么选

不是所有”线程安全”都等价,选型前先看清代价:

实现线程安全机制读性能写并发度适用场景
Hashtable方法级 synchronized(全表锁)极低遗留代码,新项目别用
Collections.synchronizedMap包装一层,全表锁低频写、要兼容 Map 接口
ConcurrentHashMapCAS + 桶级 synchronized高(无锁)高(按桶)高并发读写缓存、计数器

结论很清晰:并发读写场景无脑选 ConcurrentHashMap;只有需要”整个 Map 加锁做复合操作”(比如迭代期间禁止任何写入)时才考虑 synchronizedMap。

六、高并发本地缓存实战

最常见的用法是本地缓存:把”查库结果”按 key 缓存起来,并发请求不会重复击穿到数据库:

private final ConcurrentHashMap<String, User> cache = new ConcurrentHashMap<>();

public User getUser(String id) {
    // 不存在才查库并写入,多个线程同时请求同一 id 也只会查一次
    return cache.computeIfAbsent(id, this::queryFromDb);
}

// 高并发计数:用 merge 原子累加,比 synchronized 更快
private final ConcurrentHashMap<String, Long> pv = new ConcurrentHashMap<>();
public void addPv(String page) {
    pv.merge(page, 1L, Long::sum);
}

如果计数频率极高(每秒几十万次),merge 仍有 CAS 竞争,此时换用 LongAdder(分段累加,最后再汇总)会更合适——这是 JDK 专为”高并发计数”设计的利器。另外,纯内存缓存要补上过期与上限机制:computeIfAbsent 不会自动淘汰,长期运行会让 Map 无限膨胀。简单场景可以外挂一个定时任务清理冷 key,或改用 Caffeine 这类带容量/过期策略的专业缓存库,把 ConcurrentHashMap 当作”并发原语”而非完整缓存方案。

七、避坑清单

现象正确做法
并发用 HashMap数据丢失、死循环换成 ConcurrentHashMap
computeIfAbsent 里回写同 key重复计算、死锁风险函数体只做纯计算
拿 computeIfAbsent 当锁并发大幅下降用专门并发工具或 synchronized
超高并发计数用 mergeCAS 竞争激烈改用 LongAdder
依赖 size() 做判断并发下只是近似值用 mappingCount() 或别依赖精确值

八、小结

Java ConcurrentHashMap 用”读无锁 + 写按桶加锁(CAS + synchronized)”把线程安全和高并发同时拿下,是并发集合的首选。实战记住四件事:① 任何多线程共享的 Map 都别用 HashMap;② 用 putIfAbsent / computeIfAbsent / merge 代替手写”读-改-写”锁;③ computeIfAbsent 的函数体不要回写同一个 key,也别拿它当锁;④ 超高并发计数交给 LongAdder。把它和Spring Boot 3应用里的本地缓存、限流计数器结合,既能扛住流量又写得更干净。把它和线程池、虚拟线程搭配,才是现代 Java 高并发的正确打开方式。

上一篇 Ollama 模型管理:Modelfile 自定义与调优
下一篇 前端工程化进阶:pnpm Monorepo 与微前端实战