☰
Java并发编程实战:ConcurrentHashMap选型、线程池调优与踩坑经验
2026/10/16 9:48:13 网站建设 项目流程

1. 一次线上事故让我重新认识并发工具

先讲一件真实发生过的事。早年间我在某电商平台做订单中心的后端开发,订单数据量上来之后,团队决定把原本单线程跑的数据统计任务改造成多线程并发执行。为了快速上线,第一版直接用了HashMap来汇总各线程计算结果,外部套了一层synchronized做保护。当时觉得没什么问题——都加锁了,还能出什么事?结果上线第二天,凌晨的统计任务出现了诡异的数据丢失:某几个分区的订单数时有时无,任务日志里偶尔还会出现 CPU 飙升、线程卡死的现象。

一开始怀疑是数据库连接不够,后来发现根本不是。真正的问题出在HashMap本身:在高并发写入、扩容触发时,JDK 7 及更早版本中的HashMap在多线程环境下可能形成环形链表,导致get操作陷入死循环,CPU 直接被打满。即使是在 JDK 8 之后解决了环形链表问题,HashMap在多线程并发 put 时依然会出现数据互相覆盖、丢失更新的情况。那次事故之后,我把团队里所有“手动加锁 + HashMap”的写法全部替换掉了,核心汇总容器直接换成了ConcurrentHashMap,配合线程池和并发工具类重新设计。也正是从那时候起,我开始系统地研究 Java 并发工具和ConcurrentHashMap的实现细节。

这篇文章就把我这几年在并发场景下积累的选型经验、源码理解、踩坑教训一次性讲清楚。文章面对的是有一定 Java 基础、想在真实项目中把并发工具用好用对的开发者,不讲理论堆砌,只讲怎么落地。

2. ConcurrentHashMap 内部机制详解

2.1 从 JDK 7 到 JDK 8:分段锁到 CAS + synchronized

想用好ConcurrentHashMap,第一步是理解它的设计演变,因为很多老资料、老博客讲的还是 JDK 7 的分段锁实现,而现在的线上环境基本都是 JDK 8+,机制已经完全不同。

JDK 8 之前的ConcurrentHashMap把整个 Map 切分成若干个 Segment(默认 16 个),每个 Segment 自带一把锁。写入时只需要锁住目标 Segment,不会影响其他 Segment 的读写,所以支持 16 个线程并发写入而不互相阻塞。这个设计的优点是锁粒度低、并发能力强,缺点也很明显:定位一个元素需要两次哈希,第一次定位 Segment,第二次定位桶;而且像size()这种跨 Segment 的聚合操作,必须先尝试无锁统计,失败后再加所有 Segment 的锁重试,逻辑比较繁琐。

JDK 8 版本放弃了 Segment,直接用 Node 数组 + CAS +synchronized实现。写入流程简化成:根据 key 的哈希定位桶位置,如果桶为空,就用CAS原子地把新节点放进去;如果桶不为空,则锁住桶的头节点,再执行链表插入或红黑树插入。这里的锁粒度从“一个 Segment”细化到“一个桶”,并发度进一步提升,而且代码结构更清晰。还有一点容易被忽略:JDK 8 的ConcurrentHashMap在链表长度达到 8 时会转换成红黑树,把最坏情况下的查询时间复杂度从 O(n) 降到 O(log n),这是为了防哈希碰撞攻击。

我自己的理解是:CAS 负责无竞争情况下的快速路径,synchronized 负责竞争情况下的阻塞控制,两者结合既避免了完全无锁带来的 ABA 问题,又避免了统一大锁带来的性能浪费。这个设计思路在面试中经常被问到,在实际项目里也指导着我怎么写并发代码——先尝试无锁操作,失败再降级到锁,而不是一上来就全部加锁。

2.2 核心方法的高并发行为分析

日常开发中用到ConcurrentHashMap最多的还是put、get、compute、merge、putIfAbsent这几个方法,但很多人对它们的并发语义理解得不够细腻。

先说get。它是完全无锁的,读取时通过volatile修饰的 Node 数组引用和节点内部的val字段保证可见性。这意味着只要一个线程完成了put,另一个线程立即调用get大概率能看到最新值,但这里的“大概率”不是绝对——如果对强一致性的可见性有极高要求,仅靠ConcurrentHashMap的get是不够的,还需要借助其他同步手段(比如后续要讲的显式锁或ConcurrentHashMap提供的原子性方法)。实际项目中绝大部分场景不需要这种绝对实时可见性,所以这个特性在性能上是巨大优势。

再看put。JDK 8 的put在发现目标桶为空时会走casTabAt快速插入,不需要加锁。这个设计让多线程同时写入不同空桶时几乎没有任何锁竞争,性能非常漂亮。如果桶非空,会锁住桶头节点,再遍历链表或者操作红黑树。整个过程里,扩容是一个隐藏的成本点:当元素数量超过阈值时会触发transfer操作,JDK 8 的扩容支持多线程协助,每个线程负责迁移一部分桶,迁移期间老数组和新数组同时存在,读操作通过volatile引用动态感知。

putIfAbsent和compute是容易被误用的两个方法。putIfAbsent(key, value)只在 key 不存在时放入,存在时返回已有值,适合做缓存初始化。但要注意,它做不到“按条件更新”。比如你想“只有当旧值是 null 时才放入新值”,用putIfAbsent是没问题的;但如果你想“当旧值大于 100 时才覆盖”,就必须用compute。compute(key, (k, v) -> ...)接收一个 BiFunction,在锁内执行,所以复合逻辑是原子的。这是我强烈推荐的一个方法,很多并发 Bug 都源于“先查后写”两步操作之间被其他线程插入了,而compute把这两步合并成一个原子动作,从根上消灭了竞态条件。

2.3 初始化容量与负载因子的正确姿势

ConcurrentHashMap的构造参数比HashMap复杂一点,理解不对会直接影响性能和内存。它有三个参数:初始容量initialCapacity、负载因子loadFactor、并发级别concurrencyLevel。其中负载因子默认是 0.75,一般情况下不建议改;并发级别在 JDK 8 里已经语义弱化,主要影响初始容量的推导。

关键点在于:ConcurrentHashMap初始化时并不会真正创建长度为initialCapacity的数组,它内部会计算size = initialCapacity / loadFactor + 1,然后向上取整到 2 的幂,作为数组的初始大小。比如你写new ConcurrentHashMap<>(16),实际数组长度可能是 32,因为它要留出扩容余量,避免一插入就频繁扩容。这个逻辑和HashMap不同,HashMap是直接用传入容量,只有超过阈值才扩容。如果不清楚这一点,容易把初始容量设得过大,浪费内存。

我的经验是:如果预估 Map 会存 100 个元素,直接new ConcurrentHashMap<>(100)就好,不用过度设计;如果元素量级达到几十万、上百万,建议预估容量并适当乘以 1.5 倍余量,减少扩容带来的性能抖动。扩容在并发场景下成本更高,因为涉及多线程协助迁移,虽然设计得很精巧,但毕竟不是零开销。另外要提醒一句:concurrencyLevel这个参数在 JDK 8 里最好不要依赖,它已经不能真正控制并发粒度了,新项目里可以直接忽略,只传initialCapacity即可。

2.4 compute 与 merge:原子复合操作是胜负手

我单独把compute、merge提出来讲,因为这是实践中最容易写错、但写对之后收益最大的部分。

举个例子:用ConcurrentHashMap做单词计数,常见的错误写法是:

// 错误写法:非原子,可能丢计数 Integer count = map.get(word); if (count == null) { map.put(word, 1); } else { map.put(word, count + 1); }

这段代码在两个线程同时读到count == null时,会同时执行put(word, 1),最终结果可能只有一个 1,另一个被覆盖。正确写法有几种:

// 方式一:compute 原子完成 map.compute(word, (k, v) -> v == null ? 1 : v + 1); // 方式二:merge 语义清晰 map.merge(word, 1, Integer::sum); // 方式三:putIfAbsent + computeIfPresent 组合 map.putIfAbsent(word, 0); map.computeIfPresent(word, (k, v) -> v + 1);

三种方式都能保证原子性,但语义和性能不同。merge最适合计数累加,语义直白;compute最灵活,可以对 key 的旧值做任意变换;putIfAbsent + computeIfPresent因为两次访问,性能略差,不推荐。另外需要注意的是compute的 BiFunction 执行在锁内,如果函数里有耗时操作(比如远程调用、磁盘 IO),会长时间持有桶锁,导致其他线程阻塞。这条经验对我很重要,后来凡是compute内只写简单计算,绝不放 IO。

3. 并发工具类的选型与实战

3.1 synchronized 与 ReentrantLock 怎么选

并发编程绕不开锁,synchronized和ReentrantLock是最常用的两种。很多初学者以为ReentrantLock一定比synchronized好,其实到了 JDK 8 之后,这个结论早就反过来了。JDK 6 开始synchronized引入了偏向锁、轻量级锁、重量级锁的升级路径,在无竞争时开销极低;而ReentrantLock因为需要显式 lock/unlock,代码更啰嗦,且本身有一定的创建成本。真正让我坚定支持“优先synchronized”的,是它不用手动释放锁,try-finally 里忘掉 unlock 的bug在团队里不止出现过一次。

那什么时候必须用ReentrantLock?我的判断标准有三个:第一,需要可中断的锁获取,即线程在等待锁时可以响应中断,synchronized做不到;第二,需要尝试非阻塞获取锁,即tryLock()带超时时间,拿不到就干别的,避免死锁;第三,需要公平锁或多个 Condition 条件队列。这些场景里ReentrantLock是唯一选择。比如某些资源池分配逻辑,线程拿不到锁时希望等待 500 毫秒就放弃,这时候synchronized只能干等着,ReentrantLock.tryLock(500, TimeUnit.MILLISECONDS)却可以优雅退出。

还有一点:ReentrantLock的Condition可以把锁内等待的线程按不同条件分组唤醒,这比wait/notify的单一等待队列精细得多。我在做生产者-消费者队列时用过多个Condition:一个是队列非满条件,一个是队列非空条件,生产者和消费者各等各的,避免无效唤醒,体验很好。

3.2 CountDownLatch、CyclicBarrier、Semaphore 的应用场景对照

这几个工具类常常被混着考、混着用,但它们的语义差别其实非常清晰。

CountDownLatch是“倒数门闩”,一个或多个线程等待其他线程完成操作。我用得最多的场景是:主线程拆一个大任务给线程池并行处理,然后await等待所有子任务完成再汇总结果。它是一次性的,计数归零后不可重置。注意await(long timeout, TimeUnit unit)一定要设置超时,否则子任务中任何一个线程异常退出,主线程可能永远等下去,线上会直接卡死。这个坑我踩过一次:某个子任务因为数据库连接断开抛了异常,但我没在catch里调用countDown(),结果整个统计任务阻塞了一个多小时,直到监控报警才发现。

CyclicBarrier是“循环栅栏”,所有线程互相等待,全部到达后再同时继续,并且可以重置后重复使用。它适合分阶段任务:比如并行计算中,每个线程完成第一阶段后等别人,全部完成第一阶段再一起进入第二阶段。和CountDownLatch的关键区别是,CyclicBarrier是线程彼此等待,CountDownLatch是等待计数归零;CyclBarrier还支持所有线程到齐后执行一个barrierAction回调,在这个回调里做阶段汇总非常自然。

Semaphore是信号量,控制同时访问某个资源的线程数量。我用它来做接口限流、数据库连接池并发控制、批量任务并发度限制。比如一个上游接口只允许 5 个并发调用,就new Semaphore(5),每次调用前acquire(),调用完release()。它和线程池最大线程数不是一个概念:线程池控制线程存在数量,Semaphore控制可同时执行的逻辑数量,两者可以配合使用。

3.3 线程池参数计算与坑点

并发工具类使用起来绕不开线程池,因为CountDownLatch、FutureTask往往要配合ExecutorService使用。线程池参数的核心是corePoolSize、maximumPoolSize、workQueue这三者之间的关系,理解不透很容易出线上问题。

先给一个我常用的参考公式:CPU 密集型任务,核心线程数设置CPU 核数 + 1;IO 密集型任务,核心线程数设置CPU 核数 * 2,可以根据 IO 阻塞比例适当增加。如果把 ThreadPoolExecutor 的队列设成无界队列(比如new LinkedBlockingQueue<>()),那么maximumPoolSize其实永远不会生效,因为任务永远先入队;只有队列满时才会继续创建非核心线程。这个机制导致一个经典事故:核心线程忙不过来,任务在无界队列里越积越多,内存持续上涨,最终 OOM。所以生产环境尽量不要用无界队列,可以从ArrayBlockingQueue或SynchronousQueue里选。

另一个容易被忽略的参数是keepAliveTime。默认情况下,allowCoreThreadTimeOut(false)时核心线程即使空闲也不会回收,只有非核心线程会在空闲超过keepAliveTime后被回收。如果业务有明显的波峰波谷,可以考虑设置allowCoreThreadTimeOut(true),让核心线程在空闲时也回收,节省资源。

线程池还有一个坑:异常吞噬。直接用execute()提交任务,任务抛出异常后,在默认的ThreadPoolExecutor行为里,这个异常只是让该线程终止并重新创建一个线程,调用方根本不知道任务失败了。所以业务代码里,任务内部必须有 try-catch;或者用submit()拿Future,然后future.get()感知异常。这两条路我建议都用:任务内部 catch 是保底,Future.get是显式感知失败。

4. 一个完整的并发场景:订单统计服务改造实录

4.1 需求背景与性能瓶颈

理论讲完了,用一个完整案例把前面的工具串起来。场景是这样的:某电商系统需要统计一段时间内每个商品类目的订单量、订单金额、用户数。原始数据存储在订单表里,单表数据量约 2000 万行,统计维度是类目(约 500 个),时间范围是一天。

第一版实现很简单:一条 SQLGROUP BY category直接跑,执行时间约 40 秒。这个时间对夜间批处理来说勉强可以接受,但后来业务要求半小时内出结果,并且要实时预览部分数据,单条 SQL 就撑不住了。我们决定改成“分片并行统计 + 内存聚合”的方案:把订单表按主键 ID 范围水平切分成 32 片,每片由一个线程负责统计,最后把 32 份结果汇总进一个并发安全的 Map 里。这个方案的核心就是ConcurrentHashMap加并发工具类的应用。

4.2 第一版实现:线程池 + ConcurrentHashMap 分片统计

我按下面的思路实现了第一版。先定义统计结果实体:

public class CategoryStat { private String categoryId; private long orderCount; private BigDecimal amountSum; private long userCount; // getter/setter 省略 }

核心汇总容器用ConcurrentHashMap<String, CategoryStat>,key 是类目 ID,value 是统计结果。因为每个线程只会统计自己分片内的数据,不同线程之间可能存在同一个类目,所以汇总时必须用原子性的compute:

final ConcurrentHashMap<String, CategoryStat> resultMap = new ConcurrentHashMap<>(); CountDownLatch latch = new CountDownLatch(shardCount); for (int i = 0; i < shardCount; i++) { final int shardId = i; executor.submit(() -> { try { // 1. 查询本分片的订单数据 List<Order> orders = orderMapper.selectByRange(startId, endId); Map<String, CategoryStat> localStat = doLocalStat(orders); // 2. 合并到全局 resultMap localStat.forEach((categoryId, stat) -> { resultMap.compute(categoryId, (k, oldStat) -> { if (oldStat == null) { return stat; } oldStat.setOrderCount(oldStat.getOrderCount() + stat.getOrderCount()); oldStat.setAmountSum(oldStat.getAmountSum().add(stat.getAmountSum())); oldStat.setUserCount(oldStat.getUserCount() + stat.getUserCount()); return oldStat; }); }); } catch (Exception e) { log.error("shard {} failed", shardId, e); } finally { latch.countDown(); } }); } latch.await(60, TimeUnit.SECONDS);

这里有两个细节特别说明。第一,compute内部对oldStat做修改时,其他线程的compute是互斥执行的,所以不会出现两个线程同时累加导致丢失的问题。第二,latch.await加上超时是必须的,我设了 60 秒,如果某个分片查询超时,主线程也能及时退出并告警,而不是无限等待。

这个版本跑下来,32 个分片并行统计,总耗时约 5.5 秒,比原来的 40 秒提升了 7 倍多。但这个版本还不是终点,因为资源的消耗明显增大:32 个线程同时查询数据库,数据库连接池一度被占满,影响其他在线业务。

4.3 优化细节:减少锁竞争与原子性保证

针对数据库连接池过载的问题,我把分片数从 32 降到了 8,同时给每个分片加了限流,也就是Semaphore的应用:

Semaphore dbSemaphore = new Semaphore(4); // 每个分片查询前: dbSemaphore.acquire(); try { List<Order> orders = orderMapper.selectByRange(startId, endId); } finally { dbSemaphore.release(); }

这样无论分片多少,同时访问数据库的查询最多只有 4 个,连接池的压力一下子降下来了。代价是总耗时从 5.5 秒升至 8 秒左右,但换来的是在线业务的稳定性,非常划算。

第二个优化点是减少ConcurrentHashMap的锁竞争。前面提到,如果多个线程频繁更新同一个类目的 key,它们会在同一个桶的锁上竞争,虽然粒度已经很小,但仍然存在。我把每个分片先在自己的HashMap里做本地汇总,再把简化后的结果合并到全局ConcurrentHashMap,这样全局compute的调用次数从订单行数降到类目数量级别,锁竞争少了两个数量级。这个优化思路很通用:能在线程内部解决的问题,不丢给全局容器做。

第三个优化点是精细化的并发度控制,用ReentrantLock做分片任务的状态管理。因为某些分片可能因为数据倾斜导致执行时间远大于其他分片,我们引入了动态调整:分片内部把大范围 ID 再切成几个小分片,任务执行完当前小分片后,可以主动“认领”其他未执行的小分片。这个“认领”过程就用到tryLock()避免多个线程重复认领。

4.4 压测结果与结论

压测环境是 8 核 CPU、16G 内存的机器,数据库为 MySQL,数据量 2000 万行。三个版本的结果对比:

方案并发模型耗时数据库连接占用线程数
原始单 SQL无40 秒11
第一版并发32 分片 + ConcurrentHashMap5.5 秒峰值 3232
第二版优化8 分片 + Semaphore + 本地预聚合8 秒峰值 412

最终选择第二版方案上线。虽然耗时比第一版多,但在资源受限的系统中,降低数据库压力和线程数量比单纯追求快更重要。这也是我做并发设计的一个核心心得:并发不是线程越多越好,而是以最小的资源代价完成任务。

5. 并发场景常见问题与排查技巧

5.1 统计结果总是差几条,怎么定位

最让人头疼的问题就是结果不正确,差几条、偶尔对、偶尔不对。这类问题十有八九是竞态条件。

排查时第一步不是看代码,而是先确认哪里用了非原子的“先查后写”。统计类任务的通病就是:get之后put,中间被插队。我之前用过一段错误代码,就是先map.get(key)判断是否为 null,再map.put(key, value),多线程下必然丢数据。改成compute或merge后问题立即消失。

第二步是检查局部变量和共享变量是否混淆。比如统计结果实体CategoryStat如果被多个线程同时引用,且修改没有加锁,即使容器是ConcurrentHashMap也没用,因为容器只保证“容器操作”的原子性,不保证“容器中对象内部状态”的原子性。我见过有人把AtomicLong放进普通HashMap,以为容器不安全就完蛋了,其实只要AtomicLong本身是原子的,普通HashMap也能用,只不过HashMap自身并发写入不安全,逻辑上要另做保证。这个点很绕,但搞清楚了就理解了“容器安全不等于对象安全”。

5.2 锁顺序导致死锁,如何提前发现

死锁问题在多线程开发中很经典,尤其在使用多个锁时。场景复现:线程 A 持有锁 L1 等待锁 L2,线程 B 持有锁 L2 等待锁 L1,双方互不相让,程序卡死。

排除死锁的一个笨办法是看线程 dump。用jstack抓线程堆栈,死锁的线程会显示Found one Java-level deadlock,并且能直接看到线程各自持有和等待的锁。如果用的ReentrantLock,可以通过dumpAllThreads(true, true)在代码里打印,不过生产环境一般还是靠jstack。

更好的办法是预防。我在代码规范里定了一条铁律:多个锁时,所有线程必须按相同顺序加锁。如果锁有编号,那就从小到大;如果锁没有编号,就先对全局锁排序,再以排序后的顺序获取。这条规则能消灭绝大多数死锁。另外一个补充手段是tryLock加超时,拿不到锁就回滚当前操作并重试,把死锁变成可恢复错误。

5.3 线程池任务堆积,监控是关键

线程池问题通常藏得比较深。常见的表象是接口响应变慢、内存上涨、GC 频繁,原因可能是队列积压了海量任务,而 worker 线程都卡在某个慢操作上。

我的做法是在线程池外层包一个可监控的实现,定期打印核心指标:活跃线程数、队列剩余容量、完成任务数、拒绝任务数。用ThreadPoolExecutor自带的方法即可:

ThreadPoolExecutor pool = (ThreadPoolExecutor) executorService; int activeCount = pool.getActiveCount(); int queueSize = pool.getQueue().size(); long completedTaskCount = pool.getCompletedTaskCount();

每天定时任务打印这些值,或者接入到监控系统打点。当发现queueSize持续增长时,不要急着调大线程数,先看看任务卡在哪。最常见的卡点是任务里做了远程调用,没有设置超时,导致线程一直等响应,队列越积越多。解决方向有两个:给所有远程调用加超时;或者拒绝策略不选AbortPolicy,换成CallerRunsPolicy,让提交任务的线程自己执行,反而能让上游慢下来,实现背压。

另外要提醒CompletableFuture的使用者,默认的ForkJoinPool.commonPool()线程数是 CPU 核数减 1,对 IO 密集型任务很容易成为瓶颈。我建议CompletableFuture也显式传入自定义线程池,不要依赖默认池。

6. 写在最后的经验之谈

6.1 并发编程的核心心法

做并发编程这些年,我总结出的核心心法只有一句话:先找到共享可变状态,再决定怎么保护它。所有的锁、所有的并发容器、所有的原子变量,本质上都是在为“共享可变状态”服务。如果一个变量不共享,那就是线程局部变量,不需要任何保护;如果一个变量不可变,那即使共享也不怕;只有既共享又可变的变量,才需要认真设计同步策略。

这句话说起来简单,但很多并发 Bug 都源于没想清楚哪些状态是共享可变的。比如局部变量被无意中通过 lambda 捕获后跨越了线程边界,比如 static 集合被多个线程直接写入,这些都属于“共享状态识别失败”。每次写完并发代码,我都习惯性问自己:这个容器是线程安全的吗?这个实体类会被多个线程修改吗?这个计数器需要原子累加吗?三个问题问完,大部分隐患都能提前暴露。

6.2 一些值得读的源码片段

如果你想在并发领域更进一步,我建议直接读java.util.concurrent包下的源码,重点看三处:ConcurrentHashMap.putVal和transfer方法,理解 CAS 与 synchronized 的协作;ThreadPoolExecutor.execute方法,理解线程池的状态流转;AbstractQueuedSynchronizer的acquireQueued方法,理解ReentrantLock、Semaphore、CountDownLatch的共同基座。

我个人读源码的经验是:不需要逐行读懂,而是抓核心数据结构和方法调用链。比如ConcurrentHashMap.putVal就是三个分支:桶空走 CAS、桶非空走 synchronized、扩容时协助 transfer。抓住这个主干,其他细节都可以按需再查。读多了会发现,并发工具的设计处处在寻找“无竞争时的快速路径”和“竞争时的正确性”之间的平衡,理解了这种权衡,以后自己写并发代码也会更有感觉。

6.3 优先使用并发工具,少造轮子

最后分享一个经验教训。刚入行那两年,我最爱自己封装并发组件:自己写双重检查锁的懒加载单例、自己实现缓存、自己写读写锁。结果怎么样?线上出过死锁、出过缓存穿透、出过对象逸出,每一次都是重写才知道标准库的实现有多精妙。能用ConcurrentHashMap就不要自己加锁 HashMap,能用Executors工厂方法(但要注意线程池参数)就不要手写线程管理,能用AtomicLong就不要自己 synchronized 计数。

标准库里的每一件工具都是经过无数人验证过的,它们的设计一定考虑了我在前文讲到的性能、安全性、可用性之间的平衡。优先使用它们,把精力放在业务逻辑的设计上,而不是重新发明轮子。这是我做并发开发几年下来最有价值的一条建议。

提示:文章中的代码示例基于 JDK 8+ 环境验证,不同 JDK 版本的并发类实现可能略有差异,但核心语义保持一致。生产环境使用前,建议先在小流量灰度场景充分压测。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询