上一篇用BlockingQueue把任务从生产者交给消费者。如果消费者需要统计每个接口处理了多少次请求,就会遇到另一个问题:多个线程同时修改同一张Map,怎样才能保住每一次更新?
ConcurrentHashMap提供了并发访问能力。不过,把HashMap换成它之后,业务中的“读取、计算、写回”仍需要认真设计。这篇从计数场景出发,梳理原子方法、可变对象和统计结果之间的关系。
本文基于Java 21,文档核对日期为2026年9月26日。示例只使用JDK标准库。
一、容器安全与业务操作的边界
先看一段容易出现的写法:
Integerold=counts.getOrDefault("/orders",0);counts.put("/orders",old+1);假设初始值为7,两个线程都读取到7,再分别写入8。最终只增加了一次。这里get和put各自能够安全执行,但两次调用之间,其他线程仍然可以进入。
可以把业务要求写成一句话:针对同一个key,将旧值加一并保存,作为一次完整更新完成。这时适合用merge:
counts.merge("/orders",1,Integer::sum);key还没有映射时,存入1;已经存在时,合并旧值和本次增量。不要把业务条件散落在多个独立调用中,再期待容器自动把它们合成事务。
二、先认识这张并发Map
ConcurrentHashMap实现了ConcurrentMap接口。日常使用可以分成三个层次:读取映射、原子更新单个映射、观察整个容器。
| 层次 | 常见方法 | 需要明确的边界 |
|---|---|---|
| 读取 | get、containsKey | 读到的是一次访问的结果 |
| 更新单个key | putIfAbsent、compute、merge | 适合把同一映射的判断与更新集中起来 |
| 遍历与统计 | forEach、size | 并发修改期间不代表整张Map的固定快照 |
它不接受null键或null值;并发遍历采用弱一致性语义。遍历可以与更新同时进行,不会因为并发修改直接抛出ConcurrentModificationException,但也不能把结果当成同一时刻的完整快照。具体契约见Java 21的ConcurrentHashMap文档。
本文聚焦公开API。老资料中常见的Segment分段锁讲法带有实现版本背景,阅读源码时应先确认JDK版本,避免把某一版实现结构当成接口保证。
三、把一次业务更新交给一个方法
图中的两张“+1”任务卡面向同一个计数器。每张卡都完成一次合并,计数从7走到8,再走到9。图示表达的是同一个key的更新语义,没有把Map画成一把锁住所有键的大锁。
几个常用方法可以这样选:
| 需求 | 方法 | 例子 |
|---|---|---|
| 没有映射时放入默认对象 | putIfAbsent | 注册一个已准备好的配置 |
| 需要时才构造对象 | computeIfAbsent | 为某个分组创建计数器 |
| 根据旧值重新计算 | compute | 依据当前数量决定新数量 |
| 将增量合并到旧值 | merge | 累加接口调用次数 |
| 值仍符合预期才替换 | replace(key, old, new) | 尝试推进单个状态值 |
例如用compute表达有限库存扣减时,可以在回调里统一检查旧值和计算新值。但“扣库存后写订单、再更新另一个key”已经超出一个映射的范围,应交给合适的事务或整体同步机制设计。
还要留意回调返回null的含义:在compute或已有映射的merge重映射中,null可以表示删除映射。对于计数示例,回调明确返回整数,避免无意触发删除语义。
四、完整示例:并发累计接口调用次数
下面启动4个工作线程,每个线程对同一路径累计1000次。主线程等待所有任务结束,再读取最终结果。
importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.ConcurrentHashMap;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.Future;publicclassConcurrentMapDemo{publicstaticvoidmain(String[]args)throwsException{ConcurrentHashMap<String,Integer>counts=newConcurrentHashMap<>();CountDownLatchstart=newCountDownLatch(1);List<Future<?>>tasks=newArrayList<>();try(ExecutorServicepool=Executors.newFixedThreadPool(4)){for(intworker=0;worker<4;worker++){tasks.add(pool.submit(()->{try{start.await();for(inti=0;i<1000;i++){counts.merge("/orders",1,Integer::sum);}}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewIllegalStateException(e);}}));}start.countDown();for(Future<?>task:tasks){task.get();}}System.out.println("/orders="+counts.get("/orders"));System.out.println("keys="+counts.size());}}保存为ConcurrentMapDemo.java,使用JDK 21执行:
javac-encodingUTF-8 ConcurrentMapDemo.javajavaConcurrentMapDemo输出:
/orders=4000 keys=1CountDownLatch只负责统一放行;Future.get负责等待任务结束并把任务异常交回主线程;准确累加来自merge所表达的原子更新。三个机制各有职责。
这里的size是在全部更新完成后读取的,因此可以验证key数量。若更新仍在持续,拿size作为是否允许下一次写入的严格判断条件,就会引入新的竞争窗口。
五、value里的对象也需要保护
考虑下面的代码:
groups.computeIfAbsent("java",key->newArrayList<>()).add("article");即使groups本身是ConcurrentHashMap,多个线程仍可能拿到同一个ArrayList并同时add。安全保存对象引用,不等于对象内部的所有操作都具备并发安全性。
可以根据访问模式使用并发集合、为对象提供同步方法,或者以不可变值替换旧值。不要只检查容器类型,还要沿着value继续检查共享状态。
对于高频统计,官方文档也给出了LongAdder配合computeIfAbsent的用法:
frequencies.computeIfAbsent(path,key->newLongAdder()).increment();这适合频繁累加、阶段性观察的统计业务。LongAdder.sum不提供并发更新期间的原子快照;在没有并发更新时,返回的合计值才是准确的。如果业务要求精确地基于当前值作出一次扣减决定,应重新选择同步方案,不能直接套用统计计数器。详见LongAdder.sum的官方说明。
六、几个值得提前避开的坑
回调保持短小。compute或merge回调适合局部计算,不宜塞入远程调用、长时间等待或复杂的嵌套更新。耗时操作会扩大竞争影响,异常处理也更难解释。
多key约束单独设计。“A减少一份、B增加一份”需要整体保持约束。两次单key更新即使分别安全,中间状态仍可能被观察到。
可变key谨慎使用。作为键参与equals和hashCode的内容应保持稳定。若放入Map后改变这些字段,后续查找可能无法按原预期定位。
先用契约写正确,再通过测量优化。本文没有比较不同实现的吞吐量。容量、key分布、读写比例和回调开销都会影响表现,实际压测要贴近业务负载。
七、🧠 思维导图
八、总结
总结要点
原子更新应围绕具体业务动作组织。一次计数尽量写成一次merge,避免把读取和写回分散成两个可被插入的步骤。
共享对象需要逐层检查。ConcurrentHashMap保护映射关系,value对象的修改、多key之间的约束以及跨系统操作仍需要各自的并发方案。
统计与判断有不同要求。运行中的遍历适合观察,严格决策需要明确一致性边界;示例在任务全部结束后验证最终值,结论也限定在这个场景内。
后续预告:下一篇继续Java进阶系列,聊聊ReentrantLock,看看显式锁如何配合tryLock、可中断等待和finally管理临界区。
👉如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊