ConcurrentHashMap与并发工具类实战:从CAS到锁降级 1. 并发编程里最绕不开的一张王牌做 Java 后端这几年凡是涉及到多线程、高并发、缓存共享这些东西ConcurrentHashMap基本是绕不开的那一个。面试会问线上排障会碰写业务代码更是一不留神就用错。因为它太常用了反而很少有人真正把它内部那套 CAS 加锁、协助扩容、树化降级的机制掰开揉碎讲清楚。这篇文章不是教科书式的源码逐行注释我更想从一个实际写并发代码、也踩过并发坑的开发者的角度把ConcurrentHashMap和 Java 并发工具类这组“标配组合”做一个系统性的梳理它们解决什么问题、内部怎么实现的、生产环境里怎么选型、常见的坑都在哪。适合谁看如果你刚接触 Java 并发这篇能帮你把ConcurrentHashMap和CountDownLatch、CyclicBarrier、Semaphore、Exchanger这些工具类串成一张完整的知识网如果你已经写了几年业务代码里面的踩坑记录和排查思路应该能让你少走点弯路。我尽量不端着也不整那些看着很高深、实际用不上的理论所有内容都以能落地、能复现、能解释得通为标准。先说一个很多人忽略的点ConcurrentHashMap不是一个“加了锁的 HashMap”它的设计目标是在保证线程安全的同时尽量别让锁成为瓶颈。JDK 8 之后的实现里锁粒度已经从“整张表一把锁”细化到“单个桶一把锁”甚至很多更新操作根本不加锁直接靠 CAS 搞定。这个设计思路恰恰是整个 Java 并发工具类体系的缩影——能用无锁就用无锁能用局部锁就不用全局锁能降低等待时间就绝不让人白白空转。2. 从 Hashtable 到分段锁再到 CASConcurrentHashMap 的前世今生2.1 为什么 HashMap 在并发下会直接翻车先回忆一个基础事实单线程下用HashMap又快又方便但多线程同时 put 的时候它根本不做任何同步会出现三种典型问题。第一是数据覆盖。两个线程同时命中同一个桶位都读到null然后各自 new 一个节点往里塞后写的把先写的盖掉了。这在业务上往往表现为“莫名其妙的丢数据”而且不是每次都能复现特别难查。第二是扩容时死循环。JDK 7 的HashMap扩容会把链表头插法迁移多线程同时触发 resize 时链表可能形成环。下次 get 这个桶位就会陷入无限循环CPU 直接飙到 100%。这个问题在 JDK 8 改成尾插法后基本解决了但“并发下丢数据”依然存在。第三是结构性修改互相干扰。一个线程在遍历另一个线程在 put 触发扩容就会抛出ConcurrentModificationException。注意这不是 bug是 fail-fast 机制的自我保护。但生产环境里遇到这个异常往往意味着你的并发设计本身就有问题——不该共享的 map 被共享了。所以Hashtable出现了。它把所有方法都加上synchronized简单粗暴地保证了线程安全但代价是“全局锁”任何线程想操作都得先拿到同一把锁并发越高竞争越激烈性能衰退得越明显。用一句话概括就是它是安全的但它的安全是用串行化换来的。2.2 JDK 7 的分段锁思路一次聪明的降粒度尝试JDK 7 的ConcurrentHashMap引入了分段锁Segment 锁。它的核心理念是把整张表分成 16 个 Segment每个 Segment 是一把独立的锁。线程去 put 的时候先根据 key 的 hash 定位到某个 Segment然后只需要锁住这个 Segment 就行其他 Segment 完全不受影响。这个设计比Hashtable高明在哪锁粒度从“全表”缩小到了“表的 1/16”。理论上16 个线程分别操作不同 Segment 时可以真正并行执行互不阻塞。这也是 JDK 7 时代并发性能远优于Hashtable的根本原因。但它也有明显的短板。首先是空间浪费每个 Segment 内部其实还维护着一个HashEntry[]数组结构上多了一层包装。其次是 get 操作虽然不用加锁但需要借助volatile保证可见性读多写少的场景还好一旦写竞争激烈Segment 内部的锁竞争依然会拉低吞吐。更关键的是JDK 7 的扩容机制是每个 Segment 独立扩容整体扩容时段的割裂会让容量控制变得有点微妙。2.3 JDK 8 的改造放弃分段锁改用“CAS synchronized”到了 JDK 8ConcurrentHashMap做了一次伤筋动骨的调整直接把 Segment 结构去掉了回归到与HashMap类似的“数组 链表 红黑树”结构同步控制改成了更精细的CAS 无锁操作 synchronized 锁桶头节点。为什么敢这么做因为锁粒度和锁竞争其实是一对矛盾。JDK 7 的 Segment 锁本质上还是“范围锁”一个 Segment 里所有桶共享一把锁。JDK 8 直接锁桶头节点桶与桶之间完全独立锁粒度缩到了最小。同时对于“桶位为空”的首次写入直接 CAS 写入连锁都不用只有桶位已有节点、需要处理哈希冲突时才对桶头节点加synchronized。顺带说一句synchronized在 JDK 8 之后经过锁升级偏向锁、轻量级锁、重量级锁的优化在锁竞争不激烈的场景下开销很低。官方这个选型本质上是在告诉开发者一种思路优先无锁其次轻量锁避免粗粒度重量锁。表格对比一下三个版本的核心差异版本数据结构同步方式锁粒度并发度上限HashMap非线程安全数组链表无无-Hashtable数组链表synchronized整表1JDK 7 ConcurrentHashMapSegmentHashEntrySegment锁1/16表16JDK 8 ConcurrentHashMap数组链表红黑树CASsynchronized单个桶理论无上限3. JDK 8 之后 ConcurrentHashMap 的核心机制拆解3.1 数据结构数组 链表 红黑树是怎么协同的JDK 8 的ConcurrentHashMap内部维护一个NodeK,V[] table数组每个数组元素是一个桶位。新元素写入时根据key的哈希值定位桶位如果桶位为空直接 CAS 放入如果桶位已有元素就把新节点挂到链表尾部尾插法。链表什么时候变成红黑树当某个桶位的链表长度超过8且整个数组容量大于等于64时链表会转为红黑树。为什么要做这个转换因为链表查找复杂度是 O(n)一旦某个桶位因为哈希碰撞堆了几百个元素get 操作会退化到接近线性扫描。红黑树的查找复杂度是 O(log n)数据量大了以后优势非常明显。这里有两个细节容易踩坑。第一个是“为什么阈值是 8”。源码注释里给过一个泊松分布的解释在随机哈希函数下桶位链表长度达到 8 的概率已经非常低约千万分之六。所以阈值定成 8是为了在“频繁转换的额外开销”和“链表过长导致的查询退化”之间取一个平衡点。简单理解如果链表长度都到 8 了说明哈希冲突已经足够严重必须用树来兜底了。第二个是“树化前要判断容量”。如果数组容量还没到 64即使链表长度到了 8也不会转树而是先做一次扩容把桶位重新分布通过扩大容量来降低冲突概率。这是一个很有意思的设计——优先用扩容解决冲突而不是一上来就上重武器。小贴士很多人在代码里做性能分析时只看链表/树的阈值设置忽略了“容量不足先扩容”这个前提导致对树化时机判断失误。实际排查性能问题时先看扩容日志再看链表长度顺序不要反。3.2 put 流程从扰动函数到 CAS 自旋每一步都在“防竞争”我按自己的理解把一次put操作的完整路径拆出来你会看到每一步都充满了“尽量减少锁竞争”的痕迹。第一步计算哈希。ConcurrentHashMap用的不是单纯的hashCode()而是对hashCode做了一次扰动处理(h ^ (h 16)) HASH_BITS。高 16 位和低 16 位异或目的是让高位的特征也能参与数组下标的计算减少碰撞。第二步判断空表。如果table还没初始化就调用initTable()。初始化过程用了 CAS 抢初始化权多个线程同时发现表为空只有一个线程能把sizeCtl从正数 CAS 成负数拿到初始化权限其他线程则Thread.yield()让出 CPU。你会看到连“初始化”这种低频操作都在避免多个线程一起干活。第三步定位桶位CAS 占坑。如果定位到的桶位是空的直接casTabAt把新节点放进去。这是整个 put 流程里最理想的情况没有锁、没有自旋一次原子操作就完成写入。这也是ConcurrentHashMap在写多读少的场景下依然能有不错吞吐的核心原因之一。第四步处理桶位非空的情况。如果桶位已经有节点了就需要看一下桶头节点的hash值。如果hash MOVED即 -1说明这个桶正在扩容迁移当前线程不会傻等而是调用helpTransfer()去协助迁移。这是 JDK 8 引入的“多线程协助扩容”机制——扩容不再是一个线程的体力活而是所有路过的线程都搭把手。第五步加锁写链表或树。如果桶位是普通链表节点就synchronized(桶头节点)然后遍历链表更新已存在 key 的值或者把新节点挂到尾部。如果桶位是TreeBin红黑树的包装节点同样加锁后执行树节点的插入。第六步判断是否需要树化或扩容。插入完成后如果链表长度达到TREEIFY_THRESHOLD8且容量够 64就转树否则检查是否需要扩容。整个流程看下来你会发现CAS 负责“无竞争时的快路径”synchronized 负责“有冲突时的兜底”二者结合既保住了热点路径的性能又没牺牲正确性。3.3 扩容机制不是一个人在战斗全民协助迁移ConcurrentHashMap的扩容是它区别于其他 Map 实现的一大亮点术语叫transfer。JDK 8 的扩容思路可以概括成三句话扩容标记用sizeCtl控制迁移任务按步长分成多个小任务路过线程可以帮忙搬砖。先说sizeCtl这个变量在ConcurrentHashMap里承担了多个角色表未初始化时sizeCtl表示初始化容量表初始化后sizeCtl表示扩容阈值容量 * 负载因子 0.75扩容进行中sizeCtl是一个负值其低 16 位表示参与扩容的线程数加 1。扩容开始时第一个触发线程会把sizeCtl设置成负值然后创建nextTable新数组容量翻倍。迁移不是一股脑从头搬到尾而是把整个数组分成若干段每段默认步长是 16 个桶。每个参与迁移的线程领到一段把自己负责的桶迁移到nextTable迁移完成的桶位会被标记为ForwardingNode就是前面说的MOVED节点。其他线程正常执行 put 或 get 时如果发现目标桶位是ForwardingNode说明这个桶已经迁移完了线程不会去操作旧桶而是要么协助迁移要么直接去新数组里找。这种“路过帮忙”的设计让扩容期间所有线程都在干活而不是干等着扩容线程一个人慢慢搬。这里有一个面试高频问题为什么 get 操作不需要加锁答案分两层。对未迁移的桶直接读旧数组数据是volatile修饰的可见性有保障对已迁移的桶遇到ForwardingNode会顺着引用找到nextTable继续读新数组。整个读路径不修改任何共享状态所以不需要锁。这也是ConcurrentHashMap读性能远优于写性能的根源。注意虽然 get 是线程安全的但“先 get 再 put”这种复合操作不是原子的。如果你需要“存在才更新不存在才插入”这种语义应该用computeIfAbsent、compute或merge这类原子方法而不是先 get 判断再 put。4. 并发工具类四件套闭锁、栅栏、信号量与交换器ConcurrentHashMap解决的是“共享容器”的并发安全问题。但真实业务里我们更多时候需要的是“多个线程之间怎么协同”。这时候就要用到java.util.concurrent里的工具类了。我把最常用的四个串成一讲每一个都给出实际场景和代码片段。4.1 CountDownLatch一场只放一次的倒计时门闩CountDownLatch的语义很直白创建时指定一个计数 NcountDown()让计数减一await()让调用线程阻塞直到计数归零。它通常用来做“等待 N 个任务全部完成后再继续”核心特点是一次性计数归零后就不能再用了。一个典型场景批量推送消息给 1000 个用户主线程要等所有推送线程结束后统一打印汇总日志。int taskCount 1000; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService pool Executors.newFixedThreadPool(20); for (int i 0; i taskCount; i) { final int id i; pool.submit(() - { try { pushMessage(id); } catch (Exception e) { log.error(push failed, id{}, id, e); } finally { // 无论成功失败都必须 countDown latch.countDown(); } }); } // 主线程等待所有推送结束 latch.await(30, TimeUnit.SECONDS); log.info(所有推送任务已结束开始汇总结果);这段代码里有三个经验点。第一个经验点countDown()必须放在finally里。如果某个任务抛异常提前返回计数减不到零主线程会一直阻塞。我见过线上事故就是这样一个批处理任务因为个别数据异常countDown()没执行整个主线程卡了半小时直到告警响了才定位到。第二个经验点await()一定要传超时参数。await()无参版本是永久阻塞一旦出问题就是灾难带超时版本至少能保证“即使没等到全部完成也不会无限等下去”。第三个经验点CountDownLatch的计数在创建后不能重置。如果你需要“每轮任务结束后重置等待”要用CyclicBarrier别硬用CountDownLatch。4.2 CyclicBarrier可循环使用的栅栏等人齐了再发车CyclicBarrier和CountDownLatch最大的区别是它支持循环使用而且它是“所有线程互相等待全部到达后同时放行”的模型。举个例子多线程分片处理一个大文件每处理完一批数据需要把各自的结果汇总一次然后再开启下一批。用CyclicBarrier可以让所有处理线程在每个“汇合点”对齐。int workerCount 4; CyclicBarrier barrier new CyclicBarrier(workerCount, () - { // 所有 worker 到达后由最后一个到达的线程执行这个动作 System.out.println(本批所有分片处理完成执行汇总); }); for (int i 0; i workerCount; i) { final int workerId i; new Thread(() - { for (int batch 0; batch 3; batch) { // 处理当前批次 processBatch(workerId, batch); try { barrier.await(); // 等待其他 worker } catch (InterruptedException | BrokenBarrierException e) { Thread.currentThread().interrupt(); return; } } }).start(); }这里有一个很容易忽略的坑CyclicBarrier.await()抛的BrokenBarrierException。当某个线程在等待时被中断或者等待超时栅栏会进入“broken”状态其他所有还等着的线程都会收到BrokenBarrierException。如果不处理这个异常线程可能直接退出整个任务的后续批次就再也凑不齐人了。实操建议使用CyclicBarrier时await()建议放在try-catch里捕获BrokenBarrierException后至少打印日志并记录是哪个 worker、哪一批次出了问题。栅栏损坏是并发任务里最难排查的隐性故障之一日志越详细越好。CountDownLatch和CyclicBarrier的选型记住一句口诀等待“外部事件完成”用闭锁等待“多线程彼此对齐”用栅栏。4.3 Semaphore信号量限流这件事的经典解法Semaphore维护一组许可证线程执行前要acquire()拿到许可证执行完release()归还。用它来做“同时最多 N 个线程访问共享资源”的限流控制非常直接。比如一个接口要调用外部服务外部服务最多同时接受 10 个连接超了就会拒绝。不加控制的话高并发下一定会有大量请求失败。private final Semaphore semaphore new Semaphore(10); public void callExternalService(Request req) { if (!semaphore.tryAcquire(2, TimeUnit.SECONDS)) { throw new BusinessException(系统繁忙请稍后重试); } try { doCall(req); } finally { semaphore.release(); } }用tryAcquire而不是acquire是为了避免线程无限期阻塞等着拿许可证。这种“拿不到就快速失败”的策略在保护外部依赖时非常有用——宁可拒绝请求也不能让线程堆积在阻塞队列里把整个应用拖垮。Semaphore还有公平和非公平两种模式。构造方法里传true表示公平模式即先等待的线程先拿到许可证适合对响应时间比较敏感的场景默认非公平模式吞吐更高但可能出现“后到线程先拿到许可证”的饿死现象。业务上默认用非公平就可以除非有明确的公平性要求。4.4 Exchanger两线程交换数据的“接头暗号”Exchanger是这几个工具类里存在感最低、但特定场景下非常巧妙的一个两个线程在某个汇合点交换数据。它的用法是两边各持一个对象调用exchange()两边都到达后变量互换。典型场景两个线程协作处理数据一个线程生产数据另一个线程消费数据而且双方都必须基于对方的最新结果继续下一步。用Exchanger就不需要额外的同步队列直接在汇合点互换。ExchangerListInteger exchanger new Exchanger(); new Thread(() - { ListInteger buffer new ArrayList(); for (int i 0; i 10; i) { buffer.add(i); if (buffer.size() 5) { try { buffer exchanger.exchange(buffer); // 交换后拿到对方的 buffer } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } }).start();需要注意Exchanger只支持两个线程超过两个就没法用了。而且如果两边到达时间严重不匹配早到的线程会一直阻塞等待实际使用中要评估好两边的耗时差距必要时给exchange加超时。5. 生产环境里的选型与实战心得5.1 什么场景用 ConcurrentHashMap什么场景别硬用ConcurrentHashMap很强但不是万能的。我把常见的场景做了一张决策表场景推荐方案原因多线程读写共享 Map读写比例高ConcurrentHashMap读无锁写轻量锁吞吐最高线程数很少或几乎无竞争HashMap 外部锁避免并发容器额外开销需要缓存时效、淘汰、过期Caffeine / Guava Cache内置淘汰策略比裸 Map 强太多需要连续原子操作先判断再插入ConcurrentHashMap 的 compute/computeIfAbsent保证复合操作原子性Value 是 List/Map 需要嵌套更新ConcurrentHashMap 嵌套的原子方法注意外层读-改-写仍是复合操作有一个真实案例我印象很深。某团队为了“省事”把一个全局配置缓存直接用了ConcurrentHashMapString, Objectvalue 是一个可变的业务对象。多个线程读出来之后直接修改对象内部字段结果出现线程之间互相覆盖配置的情况。这个问题的根源不是ConcurrentHashMap不够安全而是它只能保证 Map 自身的操作线程安全不能保证 value 对象内部的状态安全。如果你想“读出来改一改再放回去”要么把 value 设计成不可变对象要么在更新 value 时用compute方法保证整个读改写是原子的。5.2 与工具类组合一个批量任务的并发改造实例下面用一个完整的例子把ConcurrentHashMap和CountDownLatch、Semaphore组合起来模拟一个真实业务从外部接口拉取 1000 个用户的积分更新到本地”。任务要求有三个积分拉取接口限流同时最多 5 个请求所有拉取任务完成后统一打印成功率结果缓存到本地 Map供后续查询。public void refreshUserPoints(ListLong userIds) { // 限流外部接口最多同时处理 5 个请求 Semaphore limiter new Semaphore(5); // 结果容器userId - 最新积分 ConcurrentHashMapLong, Integer pointsMap new ConcurrentHashMap(1024); // 闭锁等待所有任务完成 CountDownLatch latch new CountDownLatch(userIds.size()); ExecutorService pool Executors.newFixedThreadPool(10); for (Long userId : userIds) { pool.submit(() - { boolean success false; try { if (limiter.tryAcquire(3, TimeUnit.SECONDS)) { try { int points fetchPoints(userId); pointsMap.put(userId, points); success true; } finally { limiter.release(); } } else { log.warn(获取信号量超时, userId{}, userId); } } catch (Exception e) { log.error(拉取积分失败, userId{}, userId, e); } finally { latch.countDown(); } }); } try { if (!latch.await(30, TimeUnit.SECONDS)) { log.warn(部分任务超时未完成); } log.info(刷新完成, 成功数量{}, 失败数量{}, pointsMap.size(), userIds.size() - pointsMap.size()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { pool.shutdown(); } }这个组合里有几个关键设计。第一ConcurrentHashMap初始容量给了 1024大约能装 750 个元素容量 * 0.75 是实际可用阈值比 1000 个用户略紧张但考虑到并发生长这个初始值不算离谱。如果你提前知道要装 1000 条数据更稳的写法是new ConcurrentHashMap(1000 / 0.75f 1)避免中途扩容影响性能。第二Semaphore控制的是“同时最多 5 个外部请求”而不是“线程池只有 5 个线程”。这两者语义完全不同。线程池大小影响本地并发处理能力信号量影响外部接口压力。我用 10 个线程去抢 5 个信号量许可证这样即使某个请求因为 IO 等待卡住也不至于把本地线程全部占满。第三CountDownLatch的计数是userIds.size()每个任务无论成功失败都会countDown()。主线程用带超时的await(30, TimeUnit.SECONDS)等待超时后也能继续打印部分结果不会无限阻塞。5.3 性能观测与参数调优经验很多人以为ConcurrentHashMap只要用了就万事大吉其实它在极端场景下也会变成性能瓶颈。我在实际调优中总结出几个观测点。第一个观测点是并发度。JDK 8 的ConcurrentHashMap虽然理论并发度很高但实际吞吐还是受限于两个因素哈希碰撞导致的链表变长以及写冲突时的锁竞争。如果某个 key 的访问频率极高热点 key所有线程都会去抢同一个桶头的锁这时性能跟Hashtable也没啥区别了。遇到这种情况可以考虑 key 维度的拆分比如把同一个 key 按业务维度分成多个子 key或者引入本地缓存降低热点访问。第二个观测点是扩容。在监控里如果看到频繁的扩容日志说明初始容量设置不合理。扩容期间虽然有协助迁移机制但整体性能还是会有一段时间的抖动。合理的做法是预估数据量级设置一个足够大的初始容量或者选择一个合适的负载因子。ConcurrentHashMap的负载因子固定是 0.75无法修改所以只能通过初始容量来间接控制触发扩容的时机。第三个观测点是size()方法的准确性。ConcurrentHashMap.size()并不是简单返回一个维护好的计数器而是会尝试无锁统计如果统计过程中发现容器被修改了它会加锁重试。在极端并发下size()的代价可能比一次 put 还高。如果你需要非常精确的实时大小建议自己维护一个LongAdder计数器在 put/remove 时更新。6. 常见问题与排查技巧实录6.1 高频翻车点 Top 5第一个翻车点把ConcurrentHashMap当成HashMap的“线程安全版”后忽略了 value 本身的可变性。这是最普遍的错误。Map 的操作是原子的但 value 对象内部的字段读写不是。解决方案要么 value 不可变要么 value 内部也做同步保护。第二个翻车点用containsKey后再put存在并发窗口。两个线程同时containsKey返回 false然后都执行 put后写的覆盖了先写的。正确姿势是用putIfAbsent或computeIfAbsent。第三个翻车点computeIfAbsent里做耗时操作。这个方法内部的映射函数执行时会持有桶锁。如果映射函数是访问数据库、调远程接口这种耗时操作其他线程访问同一桶位会被阻塞。所以映射函数里只应该放轻量计算外部先查缓存再走computeIfAbsent兜底。第四个翻车点遍历时修改 key。虽然ConcurrentHashMap不会抛ConcurrentModificationException但遍历结果可能是弱一致的弱一致遍历过程中可以看到部分修改但不保证看到全部修改。如果你的业务依赖“遍历期间数据不变”那必须自己加同步。第五个翻车点滥用LongAdder计数器与ConcurrentHashMap的组合时没考虑一致性。比如先increment计数器再 put 数据读的时候先看计数器再读 Map会读到“计数器已经加一但数据还没写入”的中间状态。这种跨组件的复合操作只能用更上一层的锁来保证。6.2 一段“伪线程安全”代码的诊断过程之前有同事给我看一段代码说“我用了ConcurrentHashMap为什么还会出现数据不一致”代码简化后是这样ConcurrentHashMapString, ListString cache new ConcurrentHashMap(); public void addTag(String key, String tag) { ListString tags cache.get(key); if (tags null) { tips new ArrayList(); cache.put(key, tags); } tags.add(tag); }问题很明显get和put之间的判断是非原子的。两个线程同时 get 到 null各自 new 一个 ArrayList后 put 的覆盖先 put 的就丢数据了。而且即使第一个 put 成功后来的线程 get 到的是一个共享的ArrayList多个线程同时addArrayList 内部又不是线程安全的会出现数组越界或者元素覆盖。排查过程其实挺有代表性的。第一步看现象线上偶发丢 tag而且不报错。第二步怀疑线程安全但代码里确实用了ConcurrentHashMap一度陷入“容器没用错”的误区。第三步仔细梳理“读-判断-写”的时序才发现问题出在复合操作上。修法也简单用computeIfAbsent一行搞定public void addTag(String key, String tag) { ListString tags cache.computeIfAbsent(key, k - new CopyOnWriteArrayList()); tags.add(tag); }这里computeIfAbsent保证了“没有才创建并放入”的原子性。value 用CopyOnWriteArrayList写时复制列表让add操作在并发环境下也安全。这个案例最大的价值是排查并发问题时不要被“用了并发容器”迷惑要往复合操作、value 内部状态这些更深一层的地方找。6.3 避坑清单与自查手册我把自己这些年积累的检查点整理成一份自查清单每次写完并发代码我都会对着过一遍共享容器是否真的线程安全value 本身是否可变且被多线程修改是否存在“先检查后执行”的复合操作是否应该用原子方法替代锁和容器是否覆盖了所有修改路径有没有漏网的普通 HashMapcountDown()是否在finally里await()是否设置了超时Semaphore的acquire是否可能无限阻塞是否需要tryAcquire线程池是否在任务完成后关闭不关闭会怎样遍历ConcurrentHashMap时是否依赖一致性快照初始容量是否预估过会不会频繁触发扩容热点 key 是否会导致单桶锁竞争有没有必要拆分 key最后再分享一个工具层面上的心得。排查并发 bug 时别光靠看代码。线上可以用jstack抓线程栈看哪些线程阻塞在synchronized上基本上能定位到锁竞争的现场。也可以用jmap导出堆看ConcurrentHashMap内部的链表长度分布如果某些桶位链表特别长就说明 key 分布有问题优先查 hashCode 的实现质量。我个人在实际操作中的体会是并发编程写的是思路不是语法。ConcurrentHashMap和这些工具类真正教会我的是如何从“全局锁”的思路切换到“局部控制”的思路——能不共享就不共享必须共享就尽量缩小共享范围实在躲不过就用原子操作兜底。这套思维方式比记住任何 API 都值钱。