☰
从三起并发事故到JUC实战:锁、原子类与线程协作全解析
2026/10/16 13:12:11 网站建设 项目流程

三起真实翻车事故还原:库存超卖、线程空转、顺序错乱

刚接触JUC(java.util.concurrent)的时候,我一度觉得并发编程是高不可攀的“高冷”话题,直到自己在项目里连续踩翻三个跟头,才意识到并发问题的根源其实非常朴素。所谓并发,本质就是多个线程同时读写共享数据时,如何保证结果依然正确——听起来简单,但做起来几乎处处是坑。

这篇文章不讲枯燥的理论定义,而是从三起真实的“翻车现场”出发,带你从大白话的角度拆穿并发背后那点事儿。内容覆盖JUC入门最核心的几个知识板块:synchronized、ReentrantLock、volatile、Atomic原子类、CountDownLatch、CyclicBarrier、Semaphore,以及并发容器和线程池的实战用法。每段代码都是可以复制跑起来的示例,适合刚入门 Java 并发、想快速建立整体认知的开发者。

先说第一个事故。

1.1 事故一:库存扣成负数,竞态条件的经典惨案

某个模拟订单系统上线后,运营同事在下单高峰期发现一个诡异现象:商品库存明明只剩 3 件,页面却有人下单成功,而且后台数据显示,库存瞬间变成了 -1。

这个问题的本质是竞态条件。两层解释:竞态是指多个线程同时操作同一个变量;条件是说结果取决于线程之间谁先谁后的竞争顺序。在低并发时,线程排队执行,问题不容易暴露;一旦请求量上来,线程交错执行,就会出现“读到同一个小库存数,然后各自扣减”的局面。

用代码还原一下当时的场景。假设我们有一个库存类:

public class Inventory { private int stock = 3; public void deduct() { if (stock > 0) { Thread.sleep(50); // 模拟耗时操作,放大竞态窗口 stock = stock - 1; } } }

三个线程同时调用deduct(),它们可能都先通过了stock > 0的判断,然后都在 sleep 之后执行扣减。结局是:3 件库存被扣了 3 次,库存变为 0,但实际执行的扣减成功次数是 3。等等,这还不算最惨。如果线程数更多,甚至会出现多个线程同时通过判断,最后把库存扣成负数。

最让人措手不及的其实不是这个结果,而是这种问题在测试环境很难复现。因为你的测试机器可能只有几个核心,线程交错概率低,而一旦上了生产环境,几十核 CPU 同时跑,问题就非常“随缘”。这也是并发问题为什么总被冠以“诡异”“玄学”的原因——它并不是真的不可捉摸,而是我们缺乏一套系统化的分析方法。

1.2 事故二:主线程永远等不到子线程的结果

第二个事故来自一次数据聚合的需求。一个数据报表接口需要同时调用三个上游服务的接口,然后再把所有数据组装起来返回。当时我的第一版代码是这样的:

public static void main(String[] args) { List<Integer> results = new ArrayList<>(); Thread t1 = new Thread(() -> results.add(queryA())); Thread t2 = new Thread(() -> results.add(queryB())); Thread t3 = new Thread(() -> results.add(queryC())); t1.start(); t2.start(); t3.start(); System.out.println(results.size()); // 输出:0 }

输出结果竟然是 0,而且每次跑还不太一样,偶尔是 1,偶尔是 2。原因是:主线程根本不会等这三个子线程执行完,就直接执行了results.size()。

这是一个典型的可见性和线程协作问题。它包含两层:第一层,主线程和子线程之间没有建立“等待/通知”的关系;第二层,即便瞎等一会儿能偶尔碰上结果,多线程之间共享变量的更新也不保证对另一个线程立即可见,因为 CPU 缓存在中间起到了缓冲作用。

我当时的解决办法非常原始,加了个Thread.sleep(1000)。本地测试一切正常,但上线后依然偶发数据缺失。原因很简单:sleep 只是让主线程“大概等了一下”,并不能保证子线程在 1000 毫秒内一定执行完;而且 sleep 期间什么都不干,纯属浪费资源。

后来才接触到 JUC 里专门的协作工具——CountDownLatch,这三个字翻译过来的意思就是“倒计时门闩”:你设定一个初始计数,每完成一个任务就减一,计数归零时门闩打开,主线程才会继续走。但那是后话,这个等讲到第四部分再展开。

1.3 事故三:诡异的重排序,让人摸不着头脑

第三个事故是我在阅读日志时发现的。一段代码的逻辑顺序是:先给变量 A 赋值,再给变量 B 赋值,然后一个线程 B 判断变量 B 是否就绪,如果就绪,就使用变量 A 的值做后续逻辑。

结果是什么呢?线程 B 看到了 B 已经赋值,但是读到的 A 却是默认值零,不是线程 A 刚刚赋给它的值。从直觉上看完全不可能,因为代码明明是先 A 后 B。

这里牵扯到两个底层机制:指令重排序和可见性。指令重排序是指 CPU 和编译器为了优化执行效率,可能在不改变单线程语义的前提下调整指令的执行顺序;可见性则是之前提到的 CPU 缓存问题——线程 A 的修改还留在 L1 缓存里,线程 B 在另一个核心上读的是它自己的缓存副本。

两个问题叠加起来,就出现了“B 看到最新值,A 却看到旧值”的诡异现象。这种 Bug 在测试阶段几乎不可能被发现,因为它的触发条件非常苛刻,只会在特定 CPU 架构、特定负载、特定时机下偶现。

这三个事故让我意识到:处理并发问题不能靠“运气好没碰上”,必须理解底层机制,再借助 JUC 提供的成熟工具,从结构上杜绝这些可能性。

synchronized 与 ReentrantLock:锁的进化史与实战选型

几乎所有的并发问题,归根结底都能浓缩成两句话:多个线程同时改同一个变量,怎么保证只有一个线程在改;一个线程改了变量,另一个线程能不能立刻看见。前一个问题靠锁解决,后一个问题靠内存可见性机制解决。Java 里最基础的两个解决方案,就是synchronized和ReentrantLock。

2.1 synchronized 的“进化史”:从重量级到轻量级

很多老资料把synchronized叫做“重量级锁”,但这已经严重过时了。JDK 1.6 之后,JVM 对 synchronized 做了一整套锁升级的优化,从无锁到偏向锁、轻量级锁、重量级锁,目的是让锁在大多数场景下成本极低,只在确有竞争时才“升级”。

用大白话理解锁升级:刚开始没有线程竞争,JVM 会给对象头做个标记,相当于“这个锁暂时没人用”,谁想用就用,成本几乎为零,这叫偏向锁。一旦有两个线程交替竞争,偏向锁会撤销,改为轻量级锁,通过自旋在用户态快速尝试获取锁,不用立刻陷入操作系统内核。如果竞争非常激烈,自旋都扛不住了,才升级为重量级锁,由操作系统来管理阻塞和唤醒。

这个设计对开发者最大的启示是:别一听到 synchronized 就担心性能。在大多数中小并发场景下,它完全是够用的,而且代码最简洁。

一个典型用法直接修复 1.1 的库存问题:

public class Inventory { private int stock = 3; public synchronized void deduct() { if (stock > 0) { Thread.sleep(50); stock = stock - 1; } } }

在方法上加了synchronized之后,三个线程同时进来,只有一个线程能进入方法体,其余线程必须等在门外。这从根本上杜绝了同时通过判断的问题。

2.2 ReentrantLock:锁的灵活性和公平性从哪来

ReentrantLock是 JUC 提供的替代方案,它的可玩性比 synchronized 高很多。先看一个常见的例子:

private final ReentrantLock lock = new ReentrantLock(true); public void deduct() { lock.lock(); try { if (stock > 0) { Thread.sleep(50); stock = stock - 1; } } finally { lock.unlock(); } }

注意几个关键点:必须把 unlock 放在 finally 里,否则一旦方法内抛出异常,锁会永远不释放,其他线程全部死锁。这是 ReentrantLock 和 synchronized 最大的区别——synchronized 的锁释放由 JVM 自动完成,就算抛异常也会释放;但 ReentrantLock 是手动锁,忘了释放就是灾难。

ReentrantLock(true)表示构造一个公平锁。公平锁的意思是:多个线程争抢锁时,按照请求的先后顺序排队获取,先来先得,不会出现“后来的线程插队”的情况。synchronized 默认是非公平的,它允许“新来的线程直接抢一次锁”,如果没抢到才进入队列。非公平锁的吞吐量通常更高,但有概率导致个别线程长时间拿不到锁(叫饥饿),所以到底选哪种,取决于你对“公平性”的要求有多高。

2.3 实战选型:到底用哪个更合适

我用一个对比表格把两者的差异梳理一遍,方便你快速判断:

维度synchronizedReentrantLock
使用方式关键字,修饰方法或代码块API,需手动 lock/unlock
自动释放是,JVM 自动管理否,必须 finally 中释放
公平性非公平支持公平/非公平构造
可中断等待不支持支持 lockInterruptibly
超时获取不支持支持 tryLock(timeout)
条件队列只能配合一个 wait/notify支持多个 Condition 队列
代码简洁度高略啰嗦,但功能更强

我的建议是:默认用 synchronized,除非你明确需要公平锁、超时获取锁、可中断获取或更细粒度的条件队列。

有一个非常典型的场景,就是用tryLock来避免死锁。比如两个线程各自持有一把锁,又都想获取对方的锁,就很容易死锁。如果改用带超时的tryLock,获取不到就主动放弃,能有效规避这种风险:

if (lockA.tryLock(3, TimeUnit.SECONDS)) { try { if (lockB.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lockB.unlock(); } } } finally { lockA.unlock(); } }

这种方式在写底层中间件、需要非常精细化控制锁行为的时候特别有价值。普通业务代码里,加个synchronized往往就够了,没必要为了“看起来高级”盲目上 ReentrantLock。

volatile 与 Atomic 原子类:轻量级方案能扛住多少压力

锁是最稳的,但也是相对重的。如果只是希望“一个线程对变量的修改,另一个线程能立刻看到”,或者只是做一个简单的计数增减,其实还有更轻的武器:volatile和AtomicInteger这一类原子类。

3.1 volatile 能做什么,不能做什么

volatile的作用用一个字概括:透。它保证一个线程对变量的修改,能立刻被其他线程看到。但它不保证操作本身的原子性。

什么意思呢?举个反例,很多人以为volatile能解决所有并发计数问题,于是写出这样的代码:

private volatile int counter = 0; public void increment() { counter++; // 这一行并不是原子的 }

counter++在字节码层面其实对应了三条指令:读旧值、加一、写回。即使变量加了volatile,这个“读-改-写”的过程依然不是原子的,线程 A 在读和写之间被打断时,线程 B 也可以进来把同一个旧值加一,最终丢更新。volatile只解决了“读到的数据是不是最新”的问题,没有解决“多个线程同时改一个数据”的问题。

那 volatile 的正确用途是什么?典型的场景是状态开关。比如一个线程在循环里干活,另一个线程想让它停下来,这时候用一个volatile boolean run = true标记就非常合适:

public class Worker { private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // 执行任务 } } }

这里不需要原子性——running的赋值本身是原子的,且写线程的修改必须对读线程可见。如果去掉 volatile,这个循环在理论上可能永远停不下来(实际中因为各种底层细节概率不高,但绝不安全)。

3.2 AtomicInteger 和它的 CAS 原理

如果你需要一个线程安全的计数器,正确姿势是使用原子类:

private final AtomicInteger stock = new AtomicInteger(3); public boolean deduct() { while (true) { int current = stock.get(); if (current <= 0) { return false; } if (stock.compareAndSet(current, current - 1)) { return true; } } }

compareAndSet是原子类的灵魂,它的语义是:如果当前值等于预期值,就更新为新值,并返回 true;否则什么也不做,返回 false。这个“比较-交换”的操作在硬件层面由一条 CAS 指令完成,不具备被打断的窗口。

如果 CAS 失败,说明有其他线程已经改掉了这个值,我们进入循环,重新读取新值再试一次。这种循环策略有个形象的名字叫自旋。自旋的好处是没用锁、没有线程阻塞和唤醒的上下文切换代价,在低竞争场景下性能极高;坏处是如果竞争非常激烈,线程会一直空转消耗 CPU,所以它更适合“临界区代码很短”的场景。

JUC 里还有LongAdder、AtomicLong、AtomicReference、AtomicBoolean等一堆原子类,应对不同需求。比如高并发的统计计数场景,LongAdder比AtomicLong更优秀,因为它把单一共享变量拆封成多个“累积单元”,分别计数最后汇总,大幅降低了 CAS 争抢的概率。

3.3 什么时候用原子类,什么时候该用锁

判断依据非常简单:如果这个操作是“先判断再行动”的多步操作,原子类不一定够用,锁才是首选。

库存扣减就是个好例子。表面上它只是一个“减一”的操作,但这个操作里其实包含了“先检查库存是否大于 0”的判断逻辑。CAS 循环其实也能实现这种复合操作,但代码明显要更精细。而如果用锁,直接一锁了之,根本不需要关心 CAS 失败怎么办。原子类的优势是细粒度、无阻塞,适合那种“单步操作”的场景;锁的优势是通用、简单、包容一切复合操作。

有一个实践原则可以参考:业务代码里,如果对并发安全没有极端要求,优先锁;在写轮询、计数、状态位这类底层机制时,优先原子类和 volatile。

3.4 ABA 问题与版本号

CAS 有一个经典副作用叫ABA 问题:线程 A 读到值 X,准备改成 Y;此时线程 B 先把它改成 Z,再改回 X;随后线程 A 去执行 CAS,发现当前值和预期值都是 X,于是 CAS 成功。但实际上这个值已经“变过心”了,A 的这次成功可能掩盖了 B 对数据的中间修改。

解决 ABA 问题最常见的方法是加版本号。JUC 提供了AtomicStampedReference,它为数据附加上一个 int 版本戳,判断是否一致时同时比较数据和版本。可以把它类比为“检查档案袋上有没有被人拆开过的痕迹”——不仅内容要一致,封条也得一致才算没动过。

我在实际项目里用到 ABA 的场景不算太多,因为它要求“读旧值、中间被别人改回”的情况同时发生,概率较低。但在实现无锁数据结构、内存队列时,它就是一个需要认真对待的理论盲区。

CountDownLatch、CyclicBarrier、Semaphore:三大线程协作工具拆解

平时我们说的 JUC 并发,除了锁和原子类,还有一组非常容易被忽略但极其实用的法宝:线程协作工具。它们解决的不是“数据安全”,而是“线程和线程之间的节奏配合”。如果用类比,一个班级的同学们都各干各的事,协作工具就是那一声哨响、一个集合点或者一张限行单。

4.1 CountDownLatch:主线程等所有任务完成

还记得 1.2 里那个主线程等不到子线程结果的事故吗?CountDownLatch就是专门干这个的。

public static void main(String[] args) throws InterruptedException { int taskCount = 3; CountDownLatch latch = new CountDownLatch(taskCount); List<Integer> results = new CopyOnWriteArrayList<>(); Runnable task = () -> { try { int result = query(); results.add(result); } finally { latch.countDown(); // 每次执行完计数减一 } }; new Thread(task).start(); new Thread(task).start(); new Thread(task).start(); latch.await(); // 计数归零前,主线程阻塞 System.out.println(results.size()); // 输出:3 }

核心机制特别朴素:await()让主线程等待,直到外部调用了足够次数的countDown(),计数归零,门闩打开。我那时第一次用这个工具的感受就四个字:“原来就这么简单。”

几个使用要特别注意:

  • countDown()最好放在finally里,保证任务即使抛异常也能递减计数,否则主线程永远等下去,直接卡死。这是CountDownLatch最有名的坑之一。
  • await(long, TimeUnit)支持带超时等待,业务上强烈建议使用,避免某个任务异常导致全局卡死。
  • CountDownLatch是一次性的,计数归零便无法重置。如果需要一个可以重复使用的门闩,得用下面的CyclicBarrier。
  • 存结果不要用普通的ArrayList,多个线程同时add会互相覆盖,应该用CopyOnWriteArrayList或者先各自存完再合并。

4.2 CyclicBarrier:任务之间的集合点

CyclicBarrier语意上更像个“集合出发”的关卡。比如一次并发启动测试,有 5 个测试线程,每个线程就绪后都等在关卡前,等 5 个线程全部到齐,再一起冲出去,从而制造一个“同时开始”的并发高峰:

int threadCount = 5; CyclicBarrier barrier = new CyclicBarrier(threadCount, () -> { System.out.println("全部就绪,同时出发"); }); for (int i = 0; i < threadCount; i++) { new Thread(() -> { // 模拟各自的准备工作 sleep(200); try { barrier.await(); // 等待其他线程 // 这是所有线程几乎同时到达的点 } catch (Exception e) { // handle } }).start(); }

CyclicBarrier的特点是循环可用:所有线程越过关卡后,计数器自动恢复,可以再来一轮。它还支持一个可选的barrierAction,在所有线程到达时、释放之前执行一个统一动作。

和 CountDownLatch 的区别,可以这样记:

  • CountDownLatch:领导人等待下属,一个人等一群人的结果;不可重复利用。
  • CyclicBarrier:同事互相等待,一群人等彼此到齐再一起干;可重复利用。
  • 还有一层区别:CountDownLatch 是别人数到零放行,CyclicBarrier 是伙伴们都到位了放行,前者在乎计算结果,后者在乎同时性。

4.3 Semaphore:给并发资源发许可证

Semaphore用来限制某个资源在同一时刻最多被多少个线程访问。可以举一个很贴近生活的例子:公共厕所只有 4 个坑位,最多同时进 4 个人,第 5 个人来了就得在门口等着,有人出来后才能进去。

在代码里,它常用来做限流,比如数据库连接池只有 10 个连接,为了防止高并发挤爆连接池:

private final Semaphore semaphore = new Semaphore(10); public void execute(Runnable task) { try { semaphore.acquire(); task.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }

同样要注意:release()必须放在finally中,否则任何一个意外情况都会导致“许可证只借不还”,池子最终耗尽。

Semaphore也分公平和非公平,构造时传入boolean。一个我自己的实战体会:限流场景下优先使用公平模式。非公平模式会让后到的线程有机会插队,虽然吞吐更高,但如果你在限流,公平性反而更重要,避免某个请求无限被饿着。

4.4 三个工具的选型速查表

工具核心语义典型场景是否可复用
CountDownLatch倒计数门闩主线程等待多个子任务完成否
CyclicBarrier线程互相等待并发比赛同时起跑是
Semaphore资源许可证限流、池化资源控制是

我在真实项目中用得最多的其实是 CountDownLatch,因为“主线程等接口结果”的场景太普及了。但如果你要做批量数据加工,每个批次都要求多个线程同时开始,那么 CyclicBarrier 才是顺手的工具。至于 Semaphore,它更像一个“手动挡的限流器”,比单纯用锁保护连接池更优雅,因为它控制的是并发线程数量,而非互斥访问。

ConcurrentHashMap 与线程池:高并发实战的入场券

讲完了锁、原子类、协作工具,JUC 入门还差两块拼图:并发容器和线程池。这两个东西在平时的项目里几乎无处不在地使用,但大部分人只是“会用接口”,并没有真正理解它们设计的巧妙之处。

5.1 ConcurrentHashMap 的正确打开方式

我先说一个反直觉的结论:HashMap 在多线程环境下不只是数据丢失,还可能让 CPU 飙到 100%。JDK 1.7 的 HashMap 在扩容时会形成循环链表,某个线程遍历链表时会陷入死循环。所以并发场景下,绝不能让共享的 HashMap 裸奔。

ConcurrentHashMap是 JUC 提供的线程安全 Map,但它不是简单地给每个方法加锁,而是用了更精细的分段思想。JDK 1.7 的实现是分段锁:把整个 map 分成多个段,每个段一把锁,不同段之间互不干扰,这样并发度就是段的数量。JDK 1.8 之后进一步优化,放弃了分段锁,改用 CAS + synchronized,锁的粒度精准到单个桶节点,并发度更高。

但有一个使用陷阱必须警惕:ConcurrentHashMap 的方法安全并不等于复合操作安全。经典的例子是:

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); if (!map.containsKey("key")) { map.put("key", 1); // 线程不安全!两步之间可能被别的线程插入 }

两个线程同时进入containsKey和put的间隙,就可能互相覆盖,期待的效果完全丢失。正确做法是用 JUC 专门提供的复合方法,比如putIfAbsent或computeIfAbsent:

map.computeIfAbsent("key", k -> 1);

computeIfAbsent的意思是:如果 key 不存在,就计算新值并放入;如果已经存在,直接返回现有值。这个动作在内部被保证原子性。说白了,你要么用容器自带的复合方法,要么外部加锁,不要想当然地组合多个单步方法。

5.2 线程池:为什么并发开发都需要它

并发编程如果全靠手动new Thread(...),不只是浪费资源,还会让系统崩溃。原因很直接:频繁创建和销毁线程的代价很高;而且无限制地创建线程,会把 CPU 和内存吃光。线程池的意义就相当于“预先把一批线程养好,任务来了直接复用”。

JUC 中最核心的用法是ThreadPoolExecutor,但很多初学者容易被它的一堆参数吓到。先看一个最基本的自定义线程池:

ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, // corePoolSize:核心线程数 8, // maximumPoolSize:最大线程数 60L, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue<>(100), // 工作队列 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );

把这几个参数串成一个故事:任务像客人,线程像服务员。平时有 4 个服务员(核心线程)随时待命。客人太多,4 个服务员忙不过来,排队等位的座位有 100 个(工作队列)。等位区也满了,就临时增加服务员撑到 8 个(最大线程数)。如果 8 个服务员都爆满,等位区也满了,新来的客人只能按拒绝策略处理——默认是直接拒绝并抛异常。

我强烈不建议直接用Executors.newFixedThreadPool()或Executors.newCachedThreadPool()这类现成方法,原因是它们隐藏了队列长度和最大线程数的上限。比如newFixedThreadPool的等待队列是无界的,一旦任务堆积,内存可能会被打爆。很多生产环境事故的根源就在这。即使麻烦一点,也建议显式参数创建线程池。

5.3 线程池参数配置:别迷信公式,先看任务类型

线程池参数怎么配,不同资料给出的公式各不相同,因为“最佳值”取决于任务到底干了什么,如果任务是 IO 密集型的,大部分时间线程都在等待外部响应,理论上可以把线程数调得多一些;如果任务是 CPU 密集型的,线程数设置成 CPU 核心数的附近通常收益最高。

不过理论归理论,我更推荐的做法是:结合压测来定参数。先设置一组保守参数,用模拟请求打满,观察 CPU 使用率、队列积压情况和响应时间,再逐步调整核心线程数和最大线程数。一个常见的经验值是核心线程数设为核心数的 1 到 2 倍,最大线程数根据峰值流量预留一定余量。如果任务中有大量 IO 等待,可以对翻到 3 到 5 倍,但最终还是要靠压测数据说话。

另外提一个很多线上故障的元凶:把任务提交到线程池之后,以为它会自动排队,结果没有设置饱和策略导致的丢任务。我在项目中一般推荐的组合是CallerRunsPolicy,它的语义是“线程池满了,任务退回给提交方线程自己执行”。这样不会丢任务,但资源不够时,提交方线程反而被占住,自然形成的一种背压机制,反而比直接抛异常更安全。

5.4 线程池使用中的三个“隐形坑”

第一个坑是没有优雅关闭线程池。很多人写完pool.execute(...)就不再管了。如果是 Web 应用,线程池通常是常驻的,问题不大;但如果是临时任务场景,用完不关,线程池会一直占着资源,甚至导致 JVM 无法退出。关闭时可以用shutdown()发一个停止信号,再用awaitTermination(...)等在途任务全部执行完。

第二个坑是线程池中的异常被吞掉。execute()提交的任务如果抛出运行时异常,线程池会直接吞掉异常,你的日志里什么都看不到。排查这类问题时,推荐在任务内部自行try-catch并记录日志,或者提交Future用get()主动获取异常:

Future<?> future = pool.submit(task); try { future.get(); // 阻塞等待结果,抛出执行中的异常 } catch (ExecutionException e) { log.error("任务执行失败", e.getCause()); }

第三个坑是线程池中的变量可见性问题。多线程处理完一批数据后,想要在主线程读取状态,常会遇到“明明处理完了,但集合大小不对”的现象。这时候需要结合前面讲的CountDownLatch或者Future.get()来建立主线程和线程池之间的完成信号,千万不要在主线程里无脑 sleep 等待。

线程池用完后,CompletableFuture 还能这样串联

如果你已经掌握了锁、原子类和线程池,还有一个从 Java 8 开始提供的 JUC 新面孔值得专门提一嘴:CompletableFuture。它不是用来替代线程池的,而是把线程池提交任务、等待结果、处理回调这一整套流程,封装成了更自然、更接近业务思维的写法。

过去我们要用线程池并发查询三个接口,再聚合结果,会写大量Future和阻塞get()的代码,错综复杂。用CompletableFuture可以这么写:

CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> queryA(), pool); CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> queryB(), pool); CompletableFuture<Integer> f3 = CompletableFuture.supplyAsync(() -> queryC(), pool); CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3); all.join(); // 等待所有异步任务完成 int total = f1.join() + f2.join() + f3.join();

这里的supplyAsync把一个“有返回值的任务”提交到线程池里执行,allOf合并多个异步任务,join()阻塞等待结果。看上去是不是比CountDownLatch+ 手写集合的方式清晰很多?

更进一步的玩法是互相依赖的任务编排。比如先查用户信息,拿到用户 ID 后再查订单列表,再按订单查物流,这一层层依赖如果用Future来写,每一步都要嵌套等待,而CompletableFuture.thenApply可以直接串起来:

CompletableFuture.supplyAsync(() -> queryUserId(), pool) .thenApplyAsync(userId -> queryOrders(userId), pool) .thenApplyAsync(orderList -> queryLogistics(orderList), pool) .thenAccept(logistics -> log.info("查询结果: {}", logistics));

我个人的体会是:依赖式串行、聚合式并行,这类任务编排逻辑用 CompletableFuture 非常顺手。它把 JUC 原本偏底层的“线程协调”提升到了偏业务层的“异步流水线”水平,但不是所有地方都适合用它;如果任务简单、没有复杂的依赖关系,老老实实使用线程池加 CountDownLatch 反而更好排查,因为没有那么多看不见的回调。

并发编程入门的一些个人体会

文章到这里,JUC 的几块核心拼图算是都触碰到了:可以从synchronized和ReentrantLock理解锁的使命;用volatile和AtomicInteger解决轻量级共享变量问题;再用CountDownLatch、CyclicBarrier、Semaphore协调多线程节奏;最后借助ConcurrentHashMap和线程池搭建高并发场景的基础骨架。每一块都没有太抽象的东西,落到底层,就是“你改数据,我不希望被你打断”和“我等你改完,你再告诉我”这两件事。

如果让我给刚开始入门并发编程的读者一条最实在的建议:不要先急着背 API,先拿一段代码制造一个明显的 Bug,然后观察它是怎么发生的。比如故意去掉synchronized,跑一批高并发扣减,亲眼看库存变成负数;故意不关线程池,亲眼看日志里任务被吞。当这些现象真实出现在你面前,你对“并发问题为什么可怕”的理解,会超过看十篇教程。

我当年反复踩坑之后养成了一个习惯:写任何涉及到共享数据的代码,先问自己两个问题——这个操作是否需要原子性?这个变量的修改是否需要立刻被其他线程看到?第一个问题决定我要不要用锁或原子类,第二个问题决定我要不要考虑内存可见性。把这两个问题想明白,并发编程的“面具”基本上就被撕得差不多了。

如果你也想练手,可以从拿一个模拟的下单接口开始,用到了线程池处理多笔订单,再用CountDownLatch等待扣减完成,最后用ConcurrentHashMap记录每个商品的扣减结果。这样一个完整的 JUC 入门练习,会比你零散地看 API 文档高效得多。

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

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

立即咨询