1. 先把“线程通信”这件事理解对
1.1 两个线程之间到底怎么“说话”
Java线程通信——每次有同学拿着这个词来问我,我都会反问一句:“你觉得线程通信是什么?”大部分人的第一反应是:线程A发一条消息给线程B,像聊天软件那样。其实Java里根本没有那种“点对点发消息”的原语,线程之间能沟通的只有共享的内存状态,也就是堆上的对象、静态字段、数组元素。线程A把某个变量的值改了,线程B只要在读这个变量时能看到最新值,并且能按约定做出响应,这就是一种通信。所以线程通信的真正含义是:多个线程围绕同一份共享数据,完成“你写了我要读”“你干完我接着干”这类协作。
这里的通信实际上包含两层意思。第一层是数据传递:线程A产生一个结果,线程B需要消费它。第二层是动作协调:线程A需要等线程B完成某个前置动作,或者线程B需要被唤醒后再继续。单纯的数据传递可以用volatile、队列、Future来做;动作协调通常需要锁、等待/通知、闸门工具。而在Java并发编程里,这两层往往黏在一起,比如经典的生产者-消费者模型:既要传递数据,又要协调“队列满时等你消费、队列空时等你生产”。如果你只盯着某个API,却不知道它在解决哪一层问题,很容易用错。
做Java开发的人,几乎避不开这套东西。不管是写多线程爬虫、异步任务编排,还是处理接口层面的并发请求,底层都是线程之间在围绕共享状态通信。平时问Java面试题的,也特别喜欢从“线程通信”往外扩展,问到volatile、synchronized、wait/notify、AQS、线程池原理。所以这个话题不只是面试八股文,它决定了你写的并发代码到底稳不稳。
1.2 通信要解决的三类底层问题
为什么不能直接让两个线程读写同一个变量就完事?因为现代CPU为了速度引入了多层缓存,Java虚拟机为了优化还会对指令重排。线程A在工作内存里改了变量,主内存里可能还是旧值;线程B读的时候,可能读到的还是自己工作内存里的旧副本。这就是可见性问题,最常见的翻车现场就是“明明改了flag,另一个线程却看不到”。
然后是原子性问题,经典案例是i++,它其实是“读取-计算-写回”三步,两个线程同时执行就可能丢更新。有人误以为加个volatile就能解决,结果发现计数还是不对,就是因为他没意识到volatile不保证原子性。
还有一个是有序性问题。代码里写的顺序不一定按这个顺序执行,尤其在多线程场景下,指令重排可能改变代码逻辑的呈现顺序。最典型的就是DCL单例需要加volatile,否则可能拿到一个“半初始化”的对象。
这三个问题不是Java故意搞出来的,是为了在单线程下跑得更快所付出的代价。所以,线程通信不只是设计几个类做协调,本质上是在处理这三个底层问题。理解这一点后,很多API的“为什么”就可以自己推导了:volatile解决可见性和有序性,synchronized解决原子性、可见性、有序性,Lock又在其上加上了超时和中断能力,队列则是把“共享状态的读写”封装成了更安全的数据通道。
2. 最朴素的通信:共享变量加volatile
2.1 一个“停不下来”的启动标志
先看一个我见过无数次的场景。开发一个服务时,后台线程里跑着定时任务,业务方希望外部调用一个shutdown()方法后,后台线程能自己退出去。第一反应往往是这样写:
public class BackgroundService { private boolean stopped = false; public void shutdown() { stopped = true; } public void run() { while (!stopped) { // do something } } }理想情况是shutdown()把stopped改成true,run()里的循环立刻退出。但跑起来后你会发现,有时能退,有时一直退不出去。原因就在于stopped这个普通变量不保证可见性:工作线程可能一直在自己CPU缓存里读取旧值,主线程对它的修改没有同步回来。更隐蔽的是,JIT编译器甚至可能把while (!stopped)优化成一次判断,然后再也不重新读取。
把字段改成private volatile boolean stopped = false;之后,这个“幽灵线程”就消失了。volatile的含义可以这么理解:每次写volatile变量时,JVM会强制把工作内存的修改刷新到主内存;每次读volatile变量时,会强制从主内存重新拉取,并且会插入内存屏障,禁止相关指令重排。这也是volatile最典型的适用场景:一个线程写开关状态,其它线程读这个状态。
2.2 volatile的正确边界与三个使用原则
volatile不是万能钥匙,它有三个使用原则。
第一,volatile适合“单写多读”的状态标志。比如刚才的开关标志、心跳字段、发布不可变对象用的引用。只要满足“只有一个线程改,其他线程只读”,volatile足够用,不需要加锁。
第二,volatile不能保证原子性,复合操作仍然要用锁或原子类。比如多个线程同时执行count++,哪怕count是volatile,也会丢数据。如果你需要计数器,直接用AtomicInteger,或者用synchronized包住整个操作。
第三,volatile不能替代锁来做“先判断再操作”的逻辑。比如经典的if (map.get(key) == null) map.put(key, value),即使map里的字段都是volatile,两个线程也可能同时通过判断,导致逻辑被重复执行。这种“检查并执行”的复合动作,必须靠锁或并发容器来管。
我自己实际用volatile的习惯是:一旦发现某个字段被多个线程读,但只有一个线程写,先考虑volatile;如果写线程有多个,或者存在“没读到就想写”的场景,就果断换锁或换并发工具。这样代码简洁,性能也不会差太多。不过也要提醒一句:volatile加多了会干扰JIT优化,性能上不一定比一个细粒度锁更好,别一上来就到处加volatile。
3. 等待通知机制:wait/notify的完整玩法
3.1 wait/notify为什么必须在synchronized里
Java最原始的线程通信手段是Object.wait()、Object.notify()、Object.notifyAll()。它们不是什么高级魔法,而是基于每个对象内部都有的monitor管程模型实现的。你可以把每个Java对象想象成一个会议室:进入synchronized块就是拿到会议室的钥匙,wait()就是带着钥匙去等待室休息,notify()则是从等待室随机叫醒一个人。
这套机制规定得很死:调用wait()或notify()的线程必须先持有该对象的monitor,也就是必须处于synchronized块内。否则会直接抛IllegalMonitorStateException。为什么这么设计?因为wait/notify的语义里包含了“条件判断”和“状态修改”,如果不在锁内做,条件与唤醒之间就会出现竞态。比如消费者先检查队列为空,然后准备wait,这时生产者插入数据并notify,消费者才进入wait,那它就永远错过了这次唤醒——丢失唤醒问题。
另一个关键点是,wait()被调用后,线程会释放当前持有的锁,然后挂起等待。而sleep()不会释放锁。这也是面试必问题目之一。顺便说一句,wait()释放锁这件事极其重要,否则生产者永远拿不到锁去往队列里放东西,整个系统会直接死锁。理解这两点,你基本就懂wait/notify的核心了。
3.2 用wait/notify写一个标准的生产者-消费者
直接看一个手工实现的生产者-消费者代码。队列我用简单的LinkedList,生产者和消费者都持同一把锁:
public class WaitNotifyDemo { private final LinkedList<Integer> queue = new LinkedList<>(); private final int capacity = 10; public synchronized void produce(int value) throws InterruptedException { while (queue.size() == capacity) { wait(); // 队列满,释放锁并等待 } queue.add(value); notifyAll(); // 唤醒消费者可以取数据了 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空,释放锁并等待 } int value = queue.removeFirst(); notifyAll(); // 唤醒生产者可以放数据了 return value; } }这段代码里有三个坑,每个都是实际生产中踩出来的。
第一个坑是等待条件必须用while而不是if。用if的话,线程被唤醒后会直接往下执行,但如果它被“虚假唤醒”唤醒,或者被notifyAll唤醒了但条件其实还没满足,就会越界。比如两个消费者同时等待,队列里只有一个元素,一个消费者被唤醒取走元素后,另一个消费者也被唤醒了,这时队列已经空了,它一执行removeFirst()就抛异常。用while保证每次唤醒后都重新检查一遍条件,这才是安全的。
第二个坑是尽量用notifyAll()而不是notify()。notify()只会唤醒一个线程,而且唤醒谁由JVM决定,不一定是正确的那一方。比如生产者和消费者都在等待,生产者调notify()可能唤醒另一个生产者,而不是消费者,那就可能出现“全员都在等,却没人干活”的假死状态。后面我会专门讲这个坑。除非你能百分之百确认等待的线程只有一个,否则notifyAll()更稳。
第三个坑是条件判断和状态修改必须在同一个同步块里。你如果把while (queue.isEmpty())放在synchronized外面,两个消费者可能同时进入等待队列,然后又被同时唤醒,导致竞争条件暴露在代码里。这类问题在压测时才爆发,特别难查,所以一开始就要按规范写。
3.3 什么时候才值得手工实现
讲实话,日常业务代码里我不建议手写wait/notify。JDK已经提供了BlockingQueue、Condition这些封装好的东西,直接用它们更不容易出错。但理解wait/notify依然有价值:第一,面试Java基础几乎必考;第二,很多框架源码,比如线程池、AQS底层,核心就是这套等待通知思想换了个壳;第三,当你需要极致的精细化控制时,比如一个锁配多个条件变量,你手上得能写出正确的等待通知逻辑。
如果实在要在项目里手写,记住一个底线:别让生产环境的线程长时间裸等。尽量给等待加超时,比如用wait(long timeout),并且每次循环里重新检查超时时间和条件,否则一次通知丢失可能让线程挂到天荒地老。
4. 更精细的协调:Lock与Condition
4.1 显式锁带来的两个关键能力
synchronized能解决大部分互斥和可见性问题,但它有一个短板:一旦进入等待,你很难“限时等待”或“被外部打断”。比如一个线程持锁后卡住了,其它线程只能无限期阻塞;又比如调用wait()后,线程只有在被通知或中断时才能醒,而你没法设置最长的等待时间。ReentrantLock就是为了补这些短板出现的。
它带来了两个关键能力。第一个是tryLock(long timeout, TimeUnit unit),可以指定最多等多久,等不到就返回false,让代码有机会走“超时降级”的逻辑,而不是在锁上死等。第二个是lockInterruptibly(),允许线程在等待锁时响应中断,比如线程池在shutdownNow()时通过中断让阻塞线程退出。再加上ReentrantLock可以选择公平锁,虽然公平锁性能略差,但在有些场景下能避免线程饥饿。
显式锁要特别注意一点:必须手动解锁,而且最好在finally里解锁。很多人写完lock.lock()后,一忘写lock.unlock(),程序跑几次就莫名其妙卡死。这个坑比wait/notify那些更常见,因为编译器不会提醒你。
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }4.2 一个Lock配多个Condition的杀手锏
比ReentrantLock更值钱的,是它配套的Condition接口。你可以把它理解成更灵活的wait/notify:同一个锁上可以挂多个“等待队列”,每个队列有各自的唤醒语义。比如生产者-消费者最头疼的就是“唤醒哪一方”,用Object.wait/notify只有一个等待队列,notifyAll会把所有线程都喊起来抢锁,效率低还容易造成不必要的竞争。而用Condition可以把消费者和生产者分开管理。
来看改写的代码:
public class ConditionQueue { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private final LinkedList<Integer> queue = new LinkedList<>(); private final int capacity = 10; public void put(int value) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(value); notEmpty.signal(); } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value = queue.removeFirst(); notFull.signal(); return value; } finally { lock.unlock(); } } }这段代码里,生产者等待的是notFull,消费者等待的是notEmpty。生产者生产完只喊notEmpty.signal(),消费者消费完只喊notFull.signal(),不会误唤醒同类线程。相比notifyAll()一嗓子全喊起来,这种方式更精准,竞争更少,在高并发下吞吐量更稳。
使用Condition时有三条铁律:第一,await()和signal()都必须在lock()之后执行,否则会抛IllegalMonitorStateException;第二,await()被唤醒后线程会重新获得锁,所以依然要用while循环检查条件,防止虚假唤醒;第三,不要用signal()代替signalAll()去赌“只有一个等待者”,除非你完全确认这个条件队列里只有一条线程。宁可多唤醒几次,也不要漏唤醒。
5. 拿来就用的通信工具:闸门、屏障与信号量
5.1 CountDownLatch:让N个线程准备好再一起开跑
做并发测试时,我经常需要让多个线程“同时出发”。如果只是简单地创建线程池并发任务,大部分线程可能还没启动完,前面几个已经开始执行了,测试结果就会失真。CountDownLatch就是为了解决这一类“多线程等待汇合”问题。
它的用法很简单:构造时指定一个计数N,调用countDown()的线程每完成一个准备动作就把计数减一,调用await()的线程会一直阻塞到计数归零,然后所有等待线程被同时放行。我常用它来做压测的起跑闸门:
@Test public void stressTest() throws InterruptedException { int threadCount = 50; CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); for (int i = 0; i < threadCount; i++) { Thread t = new Thread(() -> { readyLatch.countDown(); // 告诉主线程我准备好了 try { startLatch.await(); // 等待主线程一声令下 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 执行真正要压测的逻辑 }); t.start(); } readyLatch.await(); // 主线程等所有人准备好 System.out.println("all ready, start."); startLatch.countDown(); // 放行所有线程 }使用CountDownLatch有一个容易被忽略的细节:countDown()最好放在finally或者正常路径的末尾。如果某个线程在执行准备动作时抛了异常,计数永远到不了0,其它线程就会无限期await()。这就是生产事故里常见的“程序假死”之一。排查时一眼望去所有线程都在park状态,但没人知道是Latch没数完。
5.2 CyclicBarrier:阶段式同步和复用
CyclicBarrier和CountDownLatch长得很像,但语义完全不同。CyclicBarrier是“屏障”,线程到达屏障后必须等其余线程都到了,才会一起放行,而且这个屏障可以循环使用。我举个例子:分阶段处理一批数据,每个阶段都要求所有工作线程到场汇合,然后再进入下一轮。
一个直观的区别是:CountDownLatch是一次性的——计数归零后不能再复用,除非你重新创建一个对象;而CyclicBarrier可以reset()后重用。另一个区别是等待方不同——CountDownLatch中是多个线程调用await()等待计数归零,而CyclicBarrier中是所有参与方互相等待,并且构造时可以传一个Runnable barrierAction,表示“这一轮凑齐后先执行一次这个动作”。
我给新人做选择建议时,习惯给一张对比表:
| 对比维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 核心概念 | 计数到0后放行 | 全员到齐后继续 |
| 一次性/可复用 | 一次性 | 可循环复用 |
| 谁在等待 | 多个线程await | 多个线程互相等 |
| 典型场景 | 线程池压测起跑、服务启动等待 | 多阶段并行计算、按轮次同步 |
5.3 Semaphore:用许可证控制并发数
Semaphore是我处理流量控制时用得比较多的工具。它维护一组许可证,线程执行前要acquire()拿一张,用完后release()归还。如果许可证已发完,新线程就得等。你可以把它理解成停车场的空位显示屏:有空位就放车进去,没有就排队等待。
一个常见误区是拿Semaphore和线程池做对比。线程池限制的是“线程数量”,Semaphore限制的是“能同时进入临界区的操作数量”,两者侧重点不同。比如数据库连接池本身已经限制了连接数,但业务层想在调用方再统一控一下并发,就可以用Semaphore。再比如某些外部API的QPS限制是100,你可以放一个Semaphore(100)兜底,超出后在内存里先排队。
使用Semaphore时,释放许可证一定要放进finally,否则异常会导致许可证漏掉,最终所有线程都卡在acquire()上。还要注意acquire()是响应中断的,如果你的业务接受中断取消,那就直接让它中断;如果不想被中断,可以用acquireUninterruptibly(),但使用场景要特别谨慎。
6. 数据通道与异步通知:队列和Future
6.1 用BlockingQueue替代手写等待通知
如果只是做生产者-消费者,我更推荐直接用BlockingQueue,没有比这更省心的了。LinkedBlockingQueue、ArrayBlockingQueue内部已经用锁和等待通知机制封装好了put/take,你不需要自己管理wait、notify,也不用担心丢失唤醒。
拿一个简单的异步日志收集器举例:业务线程把日志对象丢进队列,后台线程批量刷盘。业务线程和生产逻辑完全解耦,后台线程消费节奏自己掌握。关键API如下:
put(E e):队列满时阻塞等待,直到队列有空位。take():队列空时阻塞等待,直到有数据进来。offer(E e, long timeout, TimeUnit unit):限时入队,超时返回false,适合不想无限等待的场景。poll(long timeout, TimeUnit unit):限时出队,超时返回null。
使用队列最需要注意的是容量设计。无界队列看起来简单,但一旦生产速度持续大于消费速度,内存会被撑爆。线上用队列,我习惯一开始就指定一个合理的有界容量,配合offer超时做降级。如果业务上允许“丢旧数据保新数据”,还可以考虑SynchronousQueue,它不缓存元素,直接把生产者和消费者对接,但使用门槛高,非特殊场景不建议轻易上。
6.2 Future:阻塞等你结果
两个线程之间要传递“计算结果”,最常见的媒介是Future。向ExecutorService提交一个Callable任务后,会返回一个Future;当前线程调用future.get()时,如果任务还没完成,它就会阻塞等待,直到结果就绪。这其实就是一种通信——提交方通过Future对象获取到任务线程的产出。
用Future有个非常容易踩的坑:get()一定要带超时。如果任务线程因为某些原因一直阻塞,调用方会在get()上挂死。我见过一个真实案例,线程池里有一个任务调的是第三方接口,对方服务宕机后连接迟迟不释放,结果所有提交任务的线程都卡死在future.get()上。后来把代码改成future.get(3, TimeUnit.SECONDS),超时后走降级流程,系统才恢复可用。所以,任何阻塞调用,都应该先问一句:如果不返回怎么办?
6.3 CompletableFuture:从“等”到“回调”
如果说Future是“你做完这个事,我会一直等着拿结果”,那CompletableFuture就是“你做完之后,通知我去干下一件事”。它把线程通信从“阻塞式获取”变成了“回调式通知”,更贴合异步编程的思维方式。
CompletableFuture.supplyAsync(() -> queryOrderFromDb()) .thenApply(order -> fillDiscount(order)) .thenAccept(order -> sendMqNotification(order)) .exceptionally(ex -> { log.error("async failed", ex); return null; });这段代码里,有线程负责查数据库,有线程(也可能就内联线程)负责算折扣发消息,它们之间通过CompletableFuture的链式回调传递数据。你不需要自己去维护“等待”和“唤醒”,框架会帮你完成。它可以组合多个独立异步任务,比如thenCombine把两个任务的结果合并,allOf等所有任务完成,这在处理复杂编排时比手工管理线程协调省太多事。
不过也要诚实说一句:CompletableFuture的坑也不小,比如默认线程池是ForkJoinPool.commonPool(),被业务线程耗尽后会互相干扰;链路一长,异常传播容易看迷糊。我的建议是,简单的“一个任务干完通知一下”用它很爽;复杂到要分阶段、要重试、要补偿,倒不如先画清楚流程,再决定哪些环节真正需要异步。
7. 排查实录:我踩过的三类经典坑
7.1 假死:notify唤醒了一群“自己人”
很多年前我维护过一个纯手工的生产者-消费者服务,用的是wait/notify那一套。上线后收到告警:服务不再消费消息,但CPU占用不高,线程全部阻塞。我抓了线程栈,发现所有生产线程都在wait(),所有消费线程也在wait(),也就是说没有任何线程处于“可唤醒”状态。
原因就是notify只随机唤醒了一个线程,而这个线程可能属于“同类等待者”。比如队列满了,两个生产者都在等notFull信号,此时一个消费者取走数据后调用了notify(),JVM恰好唤醒了一个生产者。生产者醒来后一看队列还是满的——因为有另一个生产者可能也醒了先把位置占掉——于是它又继续wait()。更糟的是,因为生产者没有再次通知消费者,消费者那边一直没人唤醒,整个系统就假死了。
这个问题的教训很直接:如果无法保证等待队列里只有一个线程,就不要用notify。要么换notifyAll,要么用ReentrantLock配多个Condition,让唤醒精准落到目标队列。后来我重构这个服务,直接用BlockingQueue替换了手写逻辑,再也没出过这类假死。越是底层工具,越要谨慎使用。
7.2 幽灵线程:可见性引发的关不掉
另一个坑发生在一次发版后。应用关闭时,后台任务线程没有随主线程一起退出,导致重复消费消息。Java的Thread.stop()早已废弃,合理的退出方式是给线程一个退出标志。代码检查后发现,问题就出在退出标志没有加volatile。主线程在shutdown()里改了标志,后台线程读到的还是旧值。
这不是Java的bug,而是JMM的可见性规则决定的。普通变量跨线程读写必须通过锁或volatile建立Happens-Before关系。把标志字段改成volatile后,线程才真正“看见了”退出指令。现在我对所有“跨线程控制状态”的字段都坚持一个原则:要么加volatile,要么用AtomicBoolean,要么用中断机制,绝不裸用一个普通boolean去跨线程控制。
7.3 死锁:锁的获取顺序不一致
死锁排查比前两个就直观多了,但也很容易在代码评审时漏掉。典型场景:线程A持有锁L1,等待锁L2;线程B持有锁L2,等待锁L1。两边谁也不让谁,就卡死了。
排查时我最常用的命令是jstack <pid>。某次线上问题,CPU飙高但业务没响应,jstack里直接能看到:
"Thread-A" waiting to lock <0x000000...L2> "Thread-B" waiting to lock <0x000000...L1>两个线程互相持锁等待,马上就能判断是死锁。修复方式也简单:让所有线程按同一个顺序获取多把锁。比如统一先拿锁L1再拿锁L2,就不会出现环形等待。另外,tryLock加上超时也是很好的防线,虽然不能完全避免死锁,但至少能让线程在超时后主动退出来,而不是永久挂住。我在代码审查时会特别留意获取多把锁的路径,一旦发现A锁里有B锁、B锁里也有A锁,直接打回。
7.4 常见并发问题的速查表
| 现象 | 可能原因 | 快速排查方向 | 标准解决办法 |
|---|---|---|---|
| 线程停不下来 | 普通变量跨线程不可见 | 检查标志字段有没有volatile | 加volatile或AtomicBoolean |
| 计算计数丢失 | 复合操作未加锁 | 搜索i++、count+= 等 | 用AtomicInteger或synchronized |
| 全员等待无人唤醒 | notify只喊了“自己人” | 抓线程栈看等待位置 | notifyAll或使用Condition |
| 接口超时卡死 | 阻塞调用无超时 | 看future.get()/await()等 | 给所有阻塞加超时参数 |
| 死锁 | 多把锁获取顺序不一致 | jstack查看持锁状态 | 统一锁顺序或tryLock |
8. 几个让我少加班的小习惯
8.1 能用工具类就不要自己拼锁
写Java并发,我最深的体会是:优先使用JDK提供的现成工具,不要自己设计锁和等待唤醒逻辑。你需要的场景,大概率已经被别人实现好了:生产者-消费者用BlockingQueue,主从汇合用CountDownLatch,阶段同步用CyclicBarrier,限流用Semaphore,异步结果通知用CompletableFuture。自己手写wait/notify虽然能体现技术能力,但生产环境下出问题的概率高得多。
选型时我一般按这个顺序做决策:先想能不能用CAS加不可变数据结构,不行就看并发容器,再不行用锁和工具类,最后才回到最原始的裸锁。底层原语留给你去理解原理、拆解面试题就够了,别拿它直接当业务代码的常规武器。
8.2 给所有Lock和阻塞等待加超时
这是我踩坑踩出来的硬性习惯。凡是有可能在“等待”上长时间卡住的调用,我都要确认它是不是带超时参数。lock.tryLock(3, TimeUnit.SECONDS)、poll(5, TimeUnit.SECONDS)、future.get(2, TimeUnit.SECONDS)这些都是日常标配。遇到不允许超时的API,我会评估能否用线程中断来做兜底,比如lockInterruptibly()和响应中断的队列方法。提前想好“等不到就怎么办”,远比事后重启服务划算。
8.3 检查一下你是否吞掉了中断标志
最后一个容易忽略的细节,是InterruptedException的处理。很多人在catch到InterruptedException后只打一行日志,或者干脆return,却没有把线程的中断状态恢复回去。这会导致调用链上更高层的代码无法感知中断信号,线程池shutdownNow时就可能无法正确停止。正确做法通常是在catch里调用Thread.currentThread().interrupt(),把中断状态重新设置回去。
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException("interrupted", e); }这个点平时不起眼,但网上讨论线程池优雅停机时,十次里有八次最后都牵扯到它。如果你不想线上服务出“关不掉、卡残留线程”的问题,自查一下所有catch到InterruptedException的地方,看看有没有吞掉中断标志。
我自己写并发代码的最后一道工序,永远是静态审查线程栈和锁路径。代码提交前,把涉及多线程的类单独拎出来,画一遍“谁持有锁、谁等待谁、会不会循环等待”,比写一堆注释管用得多。并发问题不会因为你写得认真就消失,它只在运行到某个时间窗口时突然爆发。你唯一能做的,就是从一开始选择更简单、更安全的通信方式,然后给所有的“等待”留一条后路。