1. 先搞清楚你问的复合操作到底是哪一类先说结论免得你等不及ConcurrentHashMap 对单个方法是原子性的但对先读后写、先查后改这一类复合操作默认不保证原子性。但这话又不绝对因为 ConcurrentHashMap 里有一部分方法本身就是复合操作比如putIfAbsent、remove(key, value)、replace这些方法在内部实现上又是原子性的。所以问题的关键不在ConcurrentHashMap 是不是线程安全而在于你到底把哪几步操作当成一个整体。我在实际项目里见过太多人踩这个坑看到 ConcurrentHashMap 就说线程安全然后直接写if (map.containsKey(key)) { map.put(key, value); }这代码放在多线程环境里线上就会偶发出问题。查日志查半天发现每个线程单独看逻辑都对但合在一起就是会出现重复覆盖、数据丢失。原因很简单containsKey 是一步put 是另一步两步之间其他线程完全可以插进来。ConcurrentHashMap 只能保证它提供的每个方法内部是原子的但它没有办法替你把这跨方法的两步操作焊死成一个原子操作。那怎么办不是让你放弃 ConcurrentHashMap而是你得搞清楚它的原子性边界到底在哪里。这篇我打算从源码实现角度拆一遍再结合缓存、计数器、分布式锁这些真实场景把复合操作原子性这件事彻底聊透。2. 从 JDK 8 的源码设计看并发模型为什么单步操作是安全的2.1 锁粒度进化从分段锁到 CAS synchronized聊原子性之前得先明白 ConcurrentHashMap 凭什么能保证单步操作原子。JDK 7 时代它用的是分段锁Segment把整个 Map 分成 16 段每段一把锁不同的 key 落在不同的段上就能并行写。JDK 8 之后改成了CAS synchronized 锁桶锁粒度进一步缩小到单个哈希桶bin并发能力比 JDK 7 强不少。所谓 CAS就是compareAndSwap翻译成人话就是如果内存里的值现在还是我预想的那个旧值就把它替换成新值如果不是就什么都不做返回失败。这是个 CPU 级别的原子操作不需要加锁。ConcurrentHashMap 在写入链表头节点、更新size计数这些场景里大量用了 CAS所以很多单步操作根本不需要锁就能保证原子性。而 synchronized 锁桶发生在什么时候当多个线程同时往同一个哈希桶里写CAS 竞争失败的时候。比如两个线程同时往同一个链表里追加节点光靠 CAS 不够因为链表结构变更涉及多个指针的联动所以 Java 8 的实现是先用 CAS 往空桶里放头节点成功就完事如果桶里已经有节点了那就对桶的头节点加 synchronized 锁锁住之后再操作链表。这设计最巧妙的地方是并发高的场景同一个桶竞争才加锁并发低的场景不同桶或空桶CAS 搞定锁的粒度控制在最小。很多人问JDK 8 的 ConcurrentHashMap 还分段吗答案是不再分段了但原理上比分段更精细。2.2 为什么 ConcurrentHashMap 的读操作不需要加锁ConcurrentHashMap 的get方法是完全不加锁的这是它读性能高的根本原因。它靠的是 volatile 修饰的Node数组和Node节点的val、next字段。volatile 的含义是一个线程对 volatile 变量的修改对其他线程是立即可见的happens-before 关系。也就是说当你往 map 里 put 一个 key 时即使没加锁其他线程的 get 也能立刻看到这次修改前提是没有正在扩容之类的中断状态。这里有个关键点值得展开volatile 保证的是可见性不保证复合操作的原子性。get方法本身只有一步读取某个 key 对应的 value这自然没问题但如果你在 get 之后紧接着做别的判断、再做别的写操作volatile 就管不了这么远了。另外要提醒你一个细节ConcurrentHashMap 的 get 不允许返回 null 的原因是它内部用 null 来标记节点正在移动等特殊状态。所以当你调用map.get(key)返回 null 时有可能是 key 确实不存在也有可能是节点正处于 resize 的中间状态。设计者干脆规定不允许存入 null 键和 null 值这样 get 返回 null 就统一表示没找到避免了歧义。很多新手在同时读写报 null的问题上栽跟头就是因为往 ConcurrentHashMap 里放了 null 值。2.3 size() 为什么是个估算值size()方法的原子性也经常让人误解。它返回的是当前 map 的元素个数吗严格说是某一瞬间的近似值。JDK 8 的实现里size()会先尝试不加锁地统计每个桶的节点数加上一个baseCount的 CAS 计数如果竞争激烈导致统计结果不稳定它还会用CounterCell数组来分散计数压力。也就是说ConcurrentHashMap 的 size() 不是一个快照更不是一种强一致的数量。官方注释里直接说了may be inaccurate。这跟HashMap的size()语义差别很大不过在某些场景下这个差异无所谓比如统计缓存总量、展示个大概数量但在需要精确计数的场景就必须小心你不能靠if (map.size() 0)来判断容器空了该做初始化了因为这个判断在多线程环境下可能看到的是一个漂移的中间值。3. 复合操作原子性的分水岭到底哪些操作是安全的现在进入正题。我把 ConcurrentHashMap 的常用操作分成四类分别说明原子性边界。建议你收藏这张表写完代码之后对着自查。操作类型典型代码是否原子原因单步读map.get(key)是内部无锁 volatile 读独立完成单步写map.put(key, val)是CAS 锁桶确保单个节点操作原子条件写原子实现putIfAbsent、remove(k,v)、replace(k,v)是底层走 CAS多步判断合并为一步条件写手写复合if (containsKey) put、if (getnull) put否两步操作之间存在时间窗口可能被其他线程打断计算型复合computeIfAbsent、compute、merge是在锁内完成读改写全过程但要注意递归、重入问题跨键复合先读 A 再写 B基于多个 key 的统计否ConcurrentHashMap 不提供跨键事务能力表格里最值得展开的是第三行和第五行。3.1 putIfAbsent/remove/replace 为什么是原子的putIfAbsent最简单语义是如果 key 不存在就放进 value 并返回 null如果 key 已存在就返回已有 value不修改。它内部调用的是putVal方法传入参数onlyIfAbsent true。在putVal的实现里当发现 key 对应的桶已有时会遍历链表/红黑树查找节点找到节点后在锁内判断onlyIfAbsent为 true 就不覆盖。整个过程是在锁桶之后完成的所以从检查 key 是否存在到决定是否写入之间没有其他线程插足的空间。这就是原子性。remove(key, value)就更典型了它要求只有当 key 对应的 value 等于你传入的那个 value 时才删掉这本质上是一个读取旧值 比较 删除的复合操作。如果是你自己先 get 再判断再 remove中间绝对会被其他线程打断但 ConcurrentHashMap 把这个三合一封装成了单个方法内部实现是进入锁桶后遍历找到节点比对值相等才删除。于是这个读改写链条被锁保护了成为原子操作。replace(key, oldValue, newValue)跟remove(key, value)原理一样也是CAS 语义的封装。这里才是真正的坑点别人提供的原子方法你不用偏要自己先 get 再判断。我见过不少代码是这么写的if (map.get(key) null) { map.put(key, value); }这个逻辑等价于不存在才放但它是非原子的因为 get 和 put 之间可能有另一个线程先 put 了相同 key然后你的 put 把它覆盖掉了虽然 value 可能一样但对象引用、时间戳等细节会有差异。正确姿势应该直接写map.putIfAbsent(key, value);又简洁又安全。3.2 computeIfAbsent 的读改写闭环computeIfAbsent是 JDK 8 加入的语义是如果 key 不存在就用传入的 Function 计算出一个值放进去并返回如果 key 已存在直接返回已有值。它内部实现是在锁桶之后先查链表/树没有找到节点时才调用 Function然后创建新节点插入链表。也就是说整个判断不存在 计算 写入的过程是在同一把锁内完成的不会被其他线程打断。这方法特别适合做缓存初始化的场景。比如我之前做一个本地缓存模块每秒有几百个线程同时请求同一个 key 的初始化数据如果用先 get 再 put会出现大量重复计算用computeIfAbsent就能保证同一个 key 只有第一个线程执行加载逻辑其余线程直接拿到结果。不过要注意两点第一Function内部不能再操作同一个 ConcurrentHashMap否则可能死锁或者产生难以理解的递归行为。官网没有明确说这是禁忌但实际测试中computeIfAbsent里回调函数再次调用compute或put同一个 map 时在 JDK 8 某些版本会抛出IllegalStateException: Recursive update从 JDK 9 开始也依然有递归边界限制。稳妥的做法是回调函数里只管加载外部数据绝不碰当前 map。第二它的原子性不等于同步执行锁外逻辑。如果 Function 里做网络请求、数据库查询这种耗时操作那这把锁会一直持有同一个桶的其他写操作都会被阻塞。所以千万别在computeIfAbsent里做重量级 IO否则就是拿并发容器的名义干串行化的活。4. 安全性完全依赖方法选型不还要看你的使用边界有些人看完上一节可能觉得那我全用 computeIfAbsent、merge 不就完事了没那么简单。复合操作的范围远不止读改写单个 key。下面我拆几个真实场景每个场景都有对应的安全和不安全写法。4.1 场景一高频计数器需求多线程累加某个 key 的计数例如统计每个用户 ID 的请求次数。新手很容易写成Integer count map.get(userId); if (count null) { map.put(userId, 1); } else { map.put(userId, count 1); }这个写法的非原子性很明显两个线程同时读到count 10同时 put 11结果丢失一次累加。正确解法有几种用computemap.compute(userId, (k, v) - v null ? 1 : v 1);用merge更简洁map.merge(userId, 1, Integer::sum);或者用LongAdder作为 valuemap.computeIfAbsent(userId, k - new LongAdder()).increment();第三种方案我比较推荐在高并发计数的场景用因为Integer是不可变对象每次累加都要替换整个对象引用CAS 竞争激烈时 CPU 开销不小LongAdder内部做了分片累加性能更稳。4.2 场景二缓存击穿防护做本地缓存时经常要处理key 不存在则加载的逻辑有个经典错误是双重检查锁DCL写法if (map.get(key) null) { synchronized (lock) { if (map.get(key) null) { Value v loadFromDB(key); map.put(key, v); } } }这个 DCL 在 HashMap 上是对的配合锁但在 ConcurrentHashMap 上就属于多余的锁。直接computeIfAbsent搞定锁粒度还更细只锁对应桶不锁整个 lock 对象Value v map.computeIfAbsent(key, k - loadFromDB(k));这里有个关于computeIfAbsent的隐藏细节如果loadFromDB返回 null那么computeIfAbsent会认为没有值可放并且不会为这个 key 创建映射。也就是说当你的数据源确实可能返回 null 时要特别小心——它会变成每次都执行加载逻辑也就是缓存永远填不上。处理办法是包装一个Optional或者约定加载不到就返回一个空对象占位。4.3 场景三先检查后执行的权限/状态判断有这样一个业务场景只有 key 不存在时才允许执行某个操作比如幂等控制、订单防重。业务代码可能是if (!orderMap.containsKey(orderId)) { orderMap.put(orderId, processing); // 执行业务逻辑 }这个代码在多线程下会出大问题两个线程同时执行containsKey都返回 false然后都进入业务逻辑导致订单被处理两次。正确做法是Object prev orderMap.putIfAbsent(orderId, processing); if (prev null) { // 只有当前线程抢到了初始化权才执行业务逻辑 }putIfAbsent返回 null 表示之前没有值我放成功了返回非 null 表示之前已经有值其他线程抢了先。利用返回值来判断谁抢到了锁是高并发场景里很常用的套路。4.4 场景四批量读取后统计还有一种更隐蔽的复合操作多个 key 之间需要保持一致。比如转账场景A 账户扣款B 账户加款。这在 ConcurrentHashMap 的场景里属于跨键事务任何单键原子方法都解决不了。即使分别用compute对 A 和 B 做原子更新也没办法保证A 扣款成功的同时 B 加款一定成功中间一旦有异常A 扣了 B 没加。这种情况下要么用数据库事务要么用分布式事务框架要么自己实现补偿机制。ConcurrentHashMap 的原子性是单键级别的不是事务级别的。这个边界必须想清楚否则你会在架构选型时犯大错。5. 怎么正确实现复合操作四套实用方案如果你遇到的场景确实是复合操作且需要强一致我给你几套方案按复杂度从低到高排列。5.1 方案一优先找现成的原子型复合方法这是性价比最高的方案。把需求翻译成putIfAbsent、remove(key, value)、replace(key, old, new)、compute、merge、computeIfAbsent、computeIfPresent这类方法尽量不自己拼凑。这里的关键能力是识别你的需求等价于哪个标准方法业务需求推荐方法不存在才写入putIfAbsent存在才写入computeIfPresent读旧值改新值无论是否存在compute旧值等于指定值才删除remove(key, value)旧值等于指定值才替换replace(key, oldValue, newValue)统计累加、字符串拼接merge(key, value, remappingFunction)不存在就初始化为默认对象computeIfAbsent(key, k - new DefaultValue())我在实际项目中总结的经验是90% 的单 key 复合操作都能在这张表里找到对应方法。如果你发现自己要写好几行 if-else 才能实现大概率是方法选型没到位。5.2 方案二自旋重试 CAS 思想有些复杂操作无法用现成方法表达但可以借鉴 CAS 的自旋思路。原理是循环里读取当前值计算新值然后尝试用replace(k, old, new)原子地更新如果更新失败说明期间有其他线程修改了这个 key重新读取再来一次。示例while (true) { Integer oldVal map.get(key); Integer newVal oldVal null ? 1 : oldVal 1; if (oldVal null) { if (map.putIfAbsent(key, newVal) null) { break; } } else if (map.replace(key, oldVal, newVal)) { break; } // 失败则继续循环重新读取 }这个写法把读改写变成了乐观锁重试先乐观地假设没人改失败再重来。优点是并发粒度极细没有长时间持锁缺点是写竞争激烈时循环次数会增加CPU 会有点忙。一般单 key 冲突不严重时完全没问题。5.3 方案三外部锁隔离如果复合操作涉及多个步骤且中间步骤不能被其他线程打断例如先校验再写入还要做日志记录可以用外部锁把整个步骤包起来synchronized (lockObject) { if (map.containsKey(key)) { // ... } map.put(key, newValue); // 其他步骤 }但要注意这里锁的保护范围必须和所有操作该 key 的代码路径保持一致。如果有的地方用synchronized(lockObject)有的地方又直接用map.put那就等于没锁。锁不完整 没有锁这是并发编程最常见的人也最容易犯的错。5.4 方案四用 Striped Lock 做细粒度锁如果你对性能有要求不想用一把大锁锁所有 key可以按 key 的哈希值分散锁对象。比如用 Guava 的StripedStripedLock locks Striped.lazyWeakLock(1024); Lock lock locks.get(key); lock.lock(); try { // 复合操作 } finally { lock.unlock(); }原理和 ConcurrentHashMap JDK 7 的 Segment 类似1024 把锁按 key 哈希映射到其中一把不同 key 大概率落在不同锁上互不干扰同一 key 永远落在同一把锁上所以对同一个 key 的复合操作是串行的。这种方式比较适合复合操作很复杂、用现成方法表达不了、但又不想全局串行的场景。5.5 方案五不要用 ConcurrentHashMap 去硬扛强一致事务最后必须强调如果你的复合操作跨越多个 key、多个容器甚至多个服务ConcurrentHashMap 本身无法给你事务保证。这时候选型应该是数据库、分布式事务中间件或者提前设计好状态机、对账补偿机制。把并发容器当数据库用是很多分布式事故的根源。6. 常见并发陷阱排查实录看上去安全实际上翻车这一节我整理几个实战中高频踩坑的案例每个都有人付过学费。6.1 陷阱一containsKey put 的老套路症状偶发数据覆盖线下测不出来线上压测必现。排查方法在代码里加日志打印线程名和 put 前后的旧值会发现两个线程同时进入 put 分支。根治方案替换为putIfAbsent或computeIfAbsent。6.2 陷阱二get 之后做非原子判断忘记 null 的可能比如Object obj map.get(key); if (obj ! null) { // 执行业务逻辑 }这个逻辑本身没问题但如果后面紧跟的是map.remove(key)或者map.replace(key, obj, newObj)就埋雷了。因为obj可能是别的线程修改前的旧引用等你在旧引用上做判断、再想更新时map 里的值已经变了。正确做法是用replace(key, obj, newObj)的返回值来判断是否替换成功而不是先 get 再替换。6.3 陷阱三往 ConcurrentHashMap 里放 null 导致异常报错信息一般是NullPointerException或看起来像没有找到 value的奇怪现象。ConcurrentHashMap不允许 null 键和 null 值这是设计使然。尤其在同时读写报 null的热搜词里就藏这这个坑。我记得有一次排查线上问题发现某条数据写入时 value 是 nullput直接抛 NPE但外层 catch 把异常吞了导致数据一直写不进去。凡是用 ConcurrentHashMap 的地方都要对数据源做 null 过滤。6.4 陷阱四迭代 ConcurrentHashMap 时误以为弱一致性是强一致ConcurrentHashMap的迭代器是弱一致的迭代过程中其他线程对 map 的修改迭代器不保证能看到也不保证会抛ConcurrentModificationException。这跟HashMap的fail-fast迭代器完全不同。很多人据此写全量扫描 删除的功能for (Map.EntryString, Integer entry : map.entrySet()) { if (entry.getValue() threshold) { map.remove(entry.getKey()); } }这代码不会抛异常但删除操作是并发进行的可能漏删。如果要求快照式遍历 精确删除正确做法是先把要删的 key 收集到列表里遍历结束后再批量 remove或者直接用map.entrySet().removeIf(...)JDK 8 的removeIf内部是按桶加锁遍历删除的安全性好很多。6.5 陷阱五size() 判断触发错误需求缓存容量达到上限时清理。代码if (map.size() MAX_SIZE) { map.clear(); }size()是估算值clear()之后其他线程可能正好又放入了新的元素这逻辑本身就是非原子的。如果真要限量建议用CacheBuilder之类的带容量控制的缓存组件或者自己做 Striped Lock 的复合控制别指望 map.size() 能当信号量用。6.6 陷阱六compute 回调里抛异常导致映射被删除computeIfPresent的回调如果返回 nullkey 会被移除如果回调抛异常映射关系会保持原样不更新也不删除。很多人的预期是抛异常就回滚到初始状态这在单步操作下确实是回滚了但在复合操作里容易产生不一致。比如回调里先更新了外部数据再抛异常map 里虽然是旧值外部数据库已经是新值了。不要在 compute 回调函数里做有副作用的操作这是铁律。7. 从实际项目出发给复合操作场景设计的经验清单聊完了原理和误区最后给一套我在项目里常用的设计决策清单看到具体需求时可以直接按这个顺序做判断。第一层判断这个复合操作只涉及一个 key 吗是继续看能不能用现成的原子方法表达不能用自旋 replace。否直接考虑外部锁、Striped Lock、或者干脆改数据库/分布式锁。第二层判断这个操作对强一致要求高吗高幂等、防重、扣款、订单状态流转必须用原子方法或完整锁。低统计、缓存、日志聚合可以用 ConcurrentHashMap 自带方法容忍弱一致。第三层判断这个操作内部有没有 IO 操作有网络/DB IO别在 compute 里做也别在锁内做否则并发能力瞬间归零。正确做法是先加载数据到局部变量再用 putIfAbsent 或 compute 做最终写入。没有 IO随便用 compute 系列。第四层判断这个操作会被高频调用吗高频且单 key 竞争激烈考虑 LongAdder 值对象、Striped Lock、或者降低锁粒度。低频直接用最简单可靠的方案别过度设计。这套清单基本覆盖了我遇到过的九十多个并发业务场景。你可以把它贴到项目文档里作为代码审查时的自查项。8. 踩坑总结与个人体会最后说点实在的。我在写高并发代码的早期也犯过用 ConcurrentHashMap 就以为万事大吉的错误直到线上出现了一次幂等控制失效的 P0 事故。那次事故的根因就是代码里先containsKey再put两个线程同时进来导致同一笔订单被处理了两次。从那以后我给自己定了一条铁律只要看到一个复合操作需要多行代码才能完成就必须停下来问自己——有没有现成的并发原语或者原子方法能表达这个逻辑大多数时候答案是有只是你没去查文档。再分享一个小技巧排查并发代码时可以在关键复合操作前后加版本号或时间戳打印到日志里线上对照日志看时间窗口。你会发现很多偶发问题其实都发生在两个操作之间那几毫秒的时间缝隙里——这就是并发问题的本质不是某一行的错而是行与行之间的缝隙出了错。ConcurrentHashMap 是一把好用的钥匙但你要清楚它开的锁是哪一把。复合操作原子性问题没有一刀切的答案需要按方案选型、按场景决策。把上面的分类逻辑和实践清单吃透再多线程场景都不会再被它到底原子吗这个问题绊住。