
Java多线程那点事绕不开线程之间到底怎么通信。我在做并发下载工具和消息推送系统的时候被这个问题狠狠教育过几次。后来翻源码、查资料、做压测才把这几条路子摸透。今天不聊虚的就把Java线程通信的几种典型手段、背后原理、适用场景和实际踩坑一次讲清楚。先给不熟悉并发的朋友定个调线程通信的本质是让多个线程之间能够传递状态、同步动作。Java里最常接触的通信方式大致分为四类——基于synchronized和wait/notify的管程模型、基于volatile和原子类的共享变量模型、基于CountDownLatch/CyclicBarrier/Semaphore/Exchanger的并发工具模型以及基于BlockingQueue的队列模型。这四种不是互相替代的关系而是各自解决不同层面的问题实际项目中经常会叠加使用。1. 管程模型synchronized wait/notify最底层的线程协作1.1 为什么必须先持有锁才能调用wait和notify刚开始学多线程的人99%都会在wait和notify上报错——IllegalMonitorStateException。这个异常的意思是你没有持有对象的监视器锁就去调用了它的wait或notify方法。JVM这么设计不是故意刁难而是为了保证通信的安全性。试想一个场景两个线程都要修改一个共享队列线程A判断队列满了准备wait可就在它判断完、还没来得及wait的瞬间线程B取走了一个元素队列空了B开始notify唤醒别的线程。如果A没能及时进入等待状态那B的这次notify就白发了——这就是经典的丢失通知问题。让线程必须先持有锁再检查条件、再决定是否wait就是为了把检查条件和进入等待从逻辑上绑定成一个原子操作。虽然JVM没有把这两步真正合并成一条机器指令但synchronized的互斥性保证了这个临界区内不会有其他线程同时改状态。我当时在一个订单推送服务里就靠这个模型控制采集线程和推送线程的步伐。代码结构大致是这样public class TaskQueue { private final ListString tasks new LinkedList(); public synchronized void put(String task) throws InterruptedException { while (tasks.size() 10) { wait(); // 队列满等待消费者取走任务 } tasks.add(task); notifyAll(); // 通知所有等待的消费者 } public synchronized String take() throws InterruptedException { while (tasks.isEmpty()) { wait(); // 队列空等待生产者放入任务 } String task tasks.remove(0); notifyAll(); // 通知所有等待的生产者 return task; } }这里有一个非常重要的细节条件判断用的是while而不是if。这是我在生产环境踩过坑之后才真正理解的。notifyAll会唤醒所有在wait上挂起的线程它们被唤醒后会去重新竞争锁。假设有三个消费者线程同时被唤醒其中两个抢到了锁先抢到锁的线程消费了唯一的一条任务那后抢到锁的线程如果用的是if它就不会重新检查队列是否为空直接remove(0)——结果就是NoSuchElementException或者取到null。用while被唤醒的线程会重新检查条件发现队列空了就继续wait。这就是虚假唤醒防护。即使JVM规范里说虚假唤醒在现实中很少发生但代码必须按最坏情况来写。1.2 为什么通信用的是同一个对象锁synchronized用在实例方法上锁的是this对象用在静态方法上锁的是Class对象用在代码块上锁的是括号里指定的对象。wait和notify必须作用在同一个对象上因为线程通信的本质就是围绕对象监视器进行的——线程A在对象X上wait实际上是把自己挂到了X的等待集wait set里线程B在同一个对象X上notify才能从X的等待集里唤醒线程A。如果你用两个不同的对象做锁和做wait那B唤醒的就不是A等待的那个对象上的线程通信就断了。我见过一个比较隐蔽的坑有人在ReentrantLock的临界区里调用Object.wait()。ReentrantLock的锁和Object的监视器锁是两套完全独立的机制你在持有了ReentrantLock的情况下调用waitJVM检查Object监视器锁的时候发现自己并没有持有它直接抛IllegalMonitorStateException。正确的做法是在ReentrantLock的临界区里用Condition接口的await和signal方法。这个区别一定要记清楚。1.3 Producer-Consumer的完整落地配置基于synchronized的管程模型适合任务队列长度有限、生产消费速率波动不大的场景。我在做采集任务分发的中间件时用的就是这类实现但有几个参数是务必要调好的。第一个是队列容量。我一开始拍脑袋定了100压测的时候发现生产者经常阻塞在put上消费者却一直空闲。后来加了监控才发现任务生产速率峰值能达到每秒200个而消费端单线程处理一个任务平均耗时50毫秒也就是每秒最多处理20个。100的容量远远不够生产者大部分时间都在wait。把容量调到500之后生产者阻塞的比例从47%降到了3%系统吞吐量明显改善。容量不是越大越好——太大会导致消费者永远追不上生产者堆积在内存里的任务越来越多一旦节点宕机这500个任务就全丢了。第二个是notify还是notifyAll。网上很多文章说两者区别是唤醒一个还是唤醒所有但什么时候用哪个往往没说透。只有一个生产者和一个消费者的时候用notify够用因为一个生产者只会唤醒一个消费者反之亦然。多个生产者和多个消费者的时候必须用notifyAll否则可能出现生产者A通知消费者但消费者C没有在等待而消费者B在等待却没人通知的尴尬局面。实际项目里除非你能严格证明生产者消费者都是单数否则老老实实用notifyAll性能损耗微乎其微。2. 共享变量模型volatile和原子类从内存层面通信2.1 可见性问题的本质工作内存与主内存的差异要理解volatile是怎么实现线程通信的得先明白Java内存模型JMM里一个核心概念每个线程都有自己的工作内存可以理解为CPU寄存器加高速缓存的抽象线程对变量的读写操作先发生在工作内存再同步到主内存。如果线程A改了一个普通变量的值还没来得及同步到主内存线程B读到的就是旧值——这就是可见性问题。volatile做的事情通俗点讲就是两点写操作强制立刻刷回主内存读操作强制从主内存重新读取并让其他CPU核心里缓存的该变量失效。这样一个线程对volatile变量的修改对另一个线程来说就是立刻可见的。这就构成了一种最轻量的线程通信——不需要锁不需要wait/notify只要一个线程写、另一个线程读消息就到了。我在一个实时指标采集系统里就用volatile控制着监控线程的启停。监控开关和最近一次心跳时间戳这两个数据用普通变量做会有问题主线程把开关置为false但采集线程读到的还是旧的true它就会一直跑下去。用volatile之后开关一改采集线程的循环条件立刻就能感知到。2.2 读改写操作的坑以及AtomicInteger的补位volatile有一个天然的限制它只保证可见性和有序性不保证原子性。count这一行代码在高并发下用volatile修饰变量结果仍是丢了更新。因为count在字节码层面是三步读取旧值、加一、写回新值。两个线程同时读取到旧值100各自加一然后分别写回101——最终结果是101而不是102。这中间丢失了一次加法的结果。这就是读-改-写不原子问题。要解决这个问题就得用到java.util.concurrent.atomic包下的原子类。AtomicInteger内部维护了一个volatile int value配合CASCompare And Swap比较并交换操作实现原子自增。CAS的流程是读取当前值计算新值然后执行如果内存里的值还是我读到的那个值就把新值写进去这个原子操作。如果在这期间有其他线程改了内存里的值CAS就失败重新读取再试。我在实现一个多线程的请求计数器时最开始用synchronized包着一整个count压测发现这个锁成了瓶颈——所有的计数请求都在抢同一把锁。换成AtomicInteger之后TPS从每秒8万提升到了35万而且代码更简洁。AtomicInteger的incrementAndGet底层用的是Unsafe类的compareAndSwapInt在支持CAS指令的CPU上这个操作是硬件级别的原子操作性能远好于重量级锁。2.3 单例模式里的双重检查锁为什么要volatile除了原子类volatile最典型的使用场景是单例模式的双重检查锁DCL。一段经典的代码长这样public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }new Singleton()不是一步完成的它大体上分三步分配内存空间、调用构造方法初始化对象、将引用指向这块内存。JVM和CPU为了性能会做指令重排序可能把第2步和第3步调换顺序。如果线程A执行完引用指向内存但还没执行构造方法线程B恰好在这时进来读到的instance就不是null直接返回了一个构造了一半的对象后面一用属性就是NullPointerException。volatile在这里的作用就是禁止对这个对象引用写入的指令重排序保证线程B要么看到完整的对象要么看到null继续走加锁路径。我还遇到过把volatile加在Long类型上的情况。在32位JVM上long和double的写入如果不对齐会被拆成两次32位写入这时候一个线程可能读到前32位是新的、后32位是旧的这种撕裂值。加volatile可以保证这段读写的原子性和可见性。虽然在64位JVM和现代处理器上这个问题基本不存在但写通用代码的时候跨平台考虑一下总没错。3. 并发工具模型CountDownLatch、CyclicBarrier、Semaphore与Exchanger3.1 数线程完事通知CountDownLatch我做数据处理管线的时候有一个典型的场景主任务要等所有子线程把数据捞完才能进入下一阶段。最开始我用Thread.join()——每个子线程执行完主线程就join等它。问题在于join是一个线程对应一个等待如果我有50个子线程就要写50次join调用而且没法做等够3个就算完成这种部分等待。CountDownLatch解决的就是这种等待N个信号的问题。它的用法非常直白CountDownLatch latch new CountDownLatch(3); // 3个数据源分别启动一个线程去拉数据 for (int i 0; i 3; i) { new Thread(() - { fetchData(); latch.countDown(); // 拉完了计数减一 }).start(); } latch.await(30, TimeUnit.SECONDS); // 主线程等待最多30秒 // 这里开始合并三个源的数据这里要注意的是countDown()和await()的配对关系。countDown()可以在任意线程里调用每调用一次计数减一减到零的时候所有阻塞在await上的线程会被一次性唤醒。CountDownLatch是一次性的计数归零之后就废了不能复用。我在实际使用中踩过一个坑忘记在finally里调用countDown()一旦子线程抛异常计数永远到不了零主线程就一直阻塞在await上整个任务卡死。所以正确写法要么是把countDown()放进finally要么用await(超时时间)兜底。现在我的代码里只要有await()就一定带一个超时参数这算是血泪经验。3.2 线程到齐一起跑CyclicBarrier以及和Latch的取舍CyclicBarrier和CountDownLatch经常被放在一起比较但它俩的逻辑完全不一样。CyclicBarrier是等待N个线程都到达屏障点之后一起放行CountDownLatch是等待计数归零之后放行。一个是人到齐了再出发一个是等完事儿了就走。我在做多机数据对齐的模拟测试时用过CyclicBarrier。场景是这样的4个线程分别模拟4台机器必须等到4台机器都完成上一轮的数据发送才能统一进入下一轮。用CyclicBarrier实现CyclicBarrier barrier new CyclicBarrier(4, () - { System.out.println(四台机器完成同步开始下一轮); }); for (int i 0; i 4; i) { new Thread(() - { for (int round 0; round 10; round) { sendData(round); barrier.await(); // 等待其他机器到齐 } }).start(); }CyclicBarrier有一个构造参数Runnable barrierAction这个动作会在所有线程到达屏障后被自动执行一次很适合做回合切换的逻辑。它还支持循环使用——所有线程被放行之后屏障自动重置下一轮还能继续用。这正是它名字里Cyclic的含义。选型的时候如果是一次性的事件通知比如多个子任务完成后主任务做汇总选CountDownLatch如果是多线程分阶段推进、需要重复同步选CyclicBarrier。另外注意await可以设置超时超时的时候会抛TimeoutException此时需要处理屏障破坏的情况——其他还在await的线程会收到BrokenBarrierException。我当时没处理这个异常导致一个线程超时后其他线程也全部崩了排查了好一阵才定位到。3.3 限流与换手Semaphore和Exchanger的实际玩法Semaphore在某种程度上算是信号的通信方式。它维护一个许可数量线程获取许可acquire之后才能执行用完释放release。这种机制天然适合做限流控制比如控制同时访问某个数据库连接池的连接数。我做过一个接口防刷模块限制某个上游接口同时最多只能有5个请求在飞。用Semaphore实现Semaphore semaphore new Semaphore(5); for (Request req : requests) { new Thread(() - { try { semaphore.acquire(); callUpstream(req); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }).start(); }release放在finally里是铁律不然一旦业务异常没捞到许可后续请求全被挡在外面。用Semaphore而不是线程池的固定线程数来做限流的区别在于线程池限制的是同时运行的线程数其实也间接限制了并发请求数但如果你有100个线程都试图调上游接口而只想限制5个并发Semaphore更灵活不需要额外维护线程池。Exchanger是个相对冷门的工具它是两个线程之间交换数据的换手点。我在两个流水线阶段之间传递数据批次时用过它——线程A产出批次数据线程B负责处理两者的速度不匹配用一个Exchanger让他们在交接点碰头各自把自己手里的数据交给对方。exchange方法是阻塞的只有双方都到达交换点才能完成交换。用它的时候有一个注意点如果一方处理失败没有调用exchange另一方就会一直阻塞所以exchange也要带超时。4. 队列模型BlockingQueue生产者和消费者之间最优雅的桥梁4.1 从手写wait/notify到直接用队列如果你回看第1节手写的TaskQueue会发现它本质上就是一个有界阻塞队列——满了就阻塞生产者空了就阻塞消费者。Java的java.util.concurrent.BlockingQueue直接提供了这些语义我们完全不需要自己维护wait/notify。BlockingQueue接口下有几个重要实现选型很关键。ArrayBlockingQueue是有界数组队列容量固定底层结构简单性能稳定LinkedBlockingQueue是链表队列默认容量是Integer.MAX_VALUE基本等于无界SynchronousQueue比较特殊它不存储任何元素生产者直接把数据递给消费者没有中间缓冲PriorityBlockingQueue是无界优先队列元素按优先级出队。我在重构数据采集系统时把原来手写的TaskQueue换成了LinkedBlockingQueue配合put和take两个阻塞方法代码量直接砍掉一半而且边界处理比手写的稳得多BlockingQueueString queue new LinkedBlockingQueue(500); // 生产者线程 new Thread(() - { while (running) { String task buildTask(); queue.put(task); // 队列满时自动阻塞 } }).start(); // 消费者线程 new Thread(() - { while (true) { String task queue.take(); // 队列空时自动阻塞 process(task); } }).start();4.2 线程池里的阻塞队列选择这一条面试也常问热搜词里有一条是线程池的阻塞队列选择这确实是所有用线程池的人都要面对的问题。ThreadPoolExecutor构造时有一个BlockingQueueRunnable参数它决定了任务排队的方式直接影响系统在突发流量下的表现。用无界队列比如LinkedBlockingQueue默认容量无限或者设得非常大的时候所有提交的任务都会先排队线程池永远不会触发拒绝策略——因为队列不会满。看上去很美好但风险是如果任务生产速度长期大于消费速度队列会无限膨胀内存迟早撑不住。我之前在一个日志上报服务里吃过这个亏高峰期任务队列膨胀到几百万最终内存溢出把节点打挂。用有界队列比如ArrayBlockingQueue的时候队列满了之后会尝试创建新线程在没到maximumPoolSize的情况下都满了就触发拒绝策略。这种组合适合对延迟比较敏感、又不能无脑排队丢失任务的服务。实际项目中我更喜欢有界队列自定义拒绝策略的组合——拒绝策略里做降级处理比如把来不及执行的任务写入本地临时存储等低峰期再补跑。4.3 队列通信的变种延迟队列DelayQueue还有一个排障过程中觉得特别有用的实现是DelayQueue它是无界延迟队列元素只有到达设定的延迟时间才能被取出。这个语义天然适合做延迟重试和定时任务。我在做外部接口调用的失败重试时就把失败的请求封装成延迟元素放进DelayQueue单独一个消费者线程从队列里take到期的请求自动取出重试。这样重试时间到了这个事件通过队列本身完成了线程间的信号传递不需要额外的定时器或Thread.sleep代码逻辑干净很多。5. 面试官眼里线程通信的深层考点5.1 从你怎么实现追问到底层原理面试场景里很多候选人能说出synchronized和wait/notify的用法但一旦被问到底层原理就把线程通信这个词理解得太窄了。面试官常追问的包括synchronized加锁之后线程是怎么在对象头上做标记的。Java对象头里有Mark Word记录了锁状态和持有锁的线程信息。锁有三种状态无锁、偏向锁、轻量级锁、重量级锁其实是从无锁升级到偏向锁再到轻量级锁再到重量级锁。偏向锁是优化单线程反复进入临界区的场景轻量级锁是优化竞争不激烈时的CAS自旋重量级锁才会让没抢到锁的线程进入内核态的阻塞队列。这个锁升级过程就是线程通信状态切换的过程。wait之后线程去了哪里。线程调用wait后会被放到对象的等待集里状态是WAITING或TIMED_WAITING。当其他线程notify时它从等待集移出重新进入锁池blocked pool参与竞争。唤醒不是立刻拿到锁而是重新参与竞争。为什么要有sleep和wait的区别。面试官特别喜欢问这个其实是考察你是否明白sleep不释放锁、wait释放锁以及sleep是Thread的静态方法、wait是Object的方法。5.2 常见的死锁与活锁问题以及通信顺序的坑线程通信的过程中最容易出事的是死锁和活锁。死锁是A等B释放资源B等A释放资源两者互相等待谁也无法推进。想复现死锁很容易但要在高并发生产环境里定位死锁就得靠jstack了。我遇到过的一个死锁场景是线程A持有锁1想要锁2线程B持有锁2想要锁1。排查时我用jps找到进程IDjstack抓线程栈看到两个线程都卡在synchronized上互相持有对方需要的锁一眼就定位到了。预防手段有三个一是尽量少嵌套锁二是固定获取锁的顺序统一先锁1再锁2三是用ReentrantLock的tryLock(long timeout)代替直接lock超时了就不等了避免无限期阻塞。活锁比死锁隐蔽一些。活锁是线程虽然没有阻塞但互相谦让不断重复某个动作始终没法推进。我写过一段处理任务重试的代码任务A失败后交给B重试B失败后礼貌地又把任务还给A重试两者就这样来回传递谁也没能完成任务。这本质上也是通信语义设计出了问题——我给失败重试设计的条件太宽松没有设置重试次数上限。解决方式是给每个任务加一个重试计数字段超过阈值就进入死信队列不再互相传递。5.3 从线程通信到线程安全这两者是什么关系聊到最后必须把这些概念串起来。很多初学者把线程通信和线程安全当成两码事其实通信手段的选择直接决定了安全性的保障方式。wait/notify和synchronized是一套的它通过互斥保证了临界区中的共享状态在检查、修改、通知的全过程中不会被其他线程干扰。volatile和原子类是另一套思路它不提供互斥而是通过可见性和CAS来保证无锁情况下的安全更新。BlockingQueue则是把并发控制封装在队列内部让使用方跳出了锁的细节天然线程安全。实际项目里的推荐路径是能用BlockingQueue就用队列能用并发工具类CountDownLatch等就别手写wait/notify必须手写底层协作时再上synchronized。因为越是上层的封装边界处理得越完善你踩的坑就越少。但这不代表底层的原理可以不懂——我见过一个团队用BlockingQueue却对offer和put这两个方法的区别一无所知在队列满的时候offer返回false他们以为提交成功了导致大量任务被静默丢弃。这就是只知用法、不懂语义的后果。关于线程通信最后想掏心窝子说一句并发编程里90%的诡异问题都不是语法错误而是你没能看清状态是如何在线程之间传递的。下次遇到莫名其妙的NPE、数据错乱、任务丢失别急着加synchronized先画一张图——哪个线程写了什么状态哪个线程读了这个状态中间有没有可靠的同步边界。把这张图理清楚问题往往就已经解决一半了。我在Review别人代码的时候第一眼就是看共享变量上有没有volatile、任务交接处用的是不是阻塞队列、等待有没有超时——这三处不出问题并发代码基本就跑得稳。