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. 最朴素的通信共享变量加volatile2.1 一个“停不下来”的启动标志先看一个我见过无数次的场景。开发一个服务时后台线程里跑着定时任务业务方希望外部调用一个shutdown()方法后后台线程能自己退出去。第一反应往往是这样写public class BackgroundService { private boolean stopped false; public void shutdown() { stopped true; } public void run() { while (!stopped) { // do something } } }理想情况是shutdown()把stopped改成truerun()里的循环立刻退出。但跑起来后你会发现有时能退有时一直退不出去。原因就在于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 LinkedListInteger 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与Condition4.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 LinkedListInteger 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表示“这一轮凑齐后先执行一次这个动作”。我给新人做选择建议时习惯给一张对比表对比维度CountDownLatchCyclicBarrier核心概念计数到0后放行全员到齐后继续一次性/可复用一次性可循环复用谁在等待多个线程await多个线程互相等典型场景线程池压测起跑、服务启动等待多阶段并行计算、按轮次同步5.3 Semaphore用许可证控制并发数Semaphore是我处理流量控制时用得比较多的工具。它维护一组许可证线程执行前要acquire()拿一张用完后release()归还。如果许可证已发完新线程就得等。你可以把它理解成停车场的空位显示屏有空位就放车进去没有就排队等待。一个常见误区是拿Semaphore和线程池做对比。线程池限制的是“线程数量”Semaphore限制的是“能同时进入临界区的操作数量”两者侧重点不同。比如数据库连接池本身已经限制了连接数但业务层想在调用方再统一控一下并发就可以用Semaphore。再比如某些外部API的QPS限制是100你可以放一个Semaphore(100)兜底超出后在内存里先排队。使用Semaphore时释放许可证一定要放进finally否则异常会导致许可证漏掉最终所有线程都卡在acquire()上。还要注意acquire()是响应中断的如果你的业务接受中断取消那就直接让它中断如果不想被中断可以用acquireUninterruptibly()但使用场景要特别谨慎。6. 数据通道与异步通知队列和Future6.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查看持锁状态统一锁顺序或tryLock8. 几个让我少加班的小习惯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的地方看看有没有吞掉中断标志。我自己写并发代码的最后一道工序永远是静态审查线程栈和锁路径。代码提交前把涉及多线程的类单独拎出来画一遍“谁持有锁、谁等待谁、会不会循环等待”比写一堆注释管用得多。并发问题不会因为你写得认真就消失它只在运行到某个时间窗口时突然爆发。你唯一能做的就是从一开始选择更简单、更安全的通信方式然后给所有的“等待”留一条后路。