ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java线程通信机制详解:从synchronized到BlockingQueue,彻底理解可见性与原子性

Java线程通信机制详解:从synchronized到BlockingQueue,彻底理解可见性与原子性 直接聊这个话题吧。每次面试问到“Java线程之间是如何通信的”十个人里有七八个会先愣一下然后蹦出wait/notify、synchronized、volatile这几个词但真让他从头到尾讲清楚“通信”到底是什么意思、底层靠什么、选型时该用哪个又支支吾吾了。这篇文章就是把这些东西拆开揉碎了讲顺便把我在实际项目里踩过的坑、调过的性能问题一起放进来适合准备面试的朋友也适合写并发代码经常翻车的同学。拿我自己举例之前做一个实时数据上报系统多个线程同时往一个统计模型里写数据偶尔会出现“明明A线程刚写进去B线程读到的却是旧值”这种诡异现象。当时不理解线程通信以为是数据库缓存问题折腾了一整天。后来才明白问题就出在Java内存模型JMM上——线程间的通信不是靠“互相打电话”而是靠共享内存的状态变化加各种同步机制来完成的。这篇文章会从底层原理讲到代码实现再讲到问题排查尽量让看完的朋友能直接用在真实场景里。1. 线程通信的本质不是打电话而是共享与通知先明确一个点Java里的线程通信和现实中的“通信”不太一样。现实里两个人发微信、打电话是消息在物理上传递过去了。而线程之间大多数时候是读写同一个变量或对象靠“你改了我能看见”来通信。1.1 共享内存模型线程之间靠什么“看见”对方Java线程的内存模型基于“主内存”和“工作内存”来设计。主内存是所有线程共享的一块逻辑内存区域存着变量、对象等数据每个线程还有自己的一份工作内存里面缓存了从主内存读到的变量副本。普通情况下线程A改了变量值它只是把改后的值写在自己的工作内存里什么时候刷回主内存是不确定的。线程B读这个变量时也是先读自己的工作内存副本不一定能立刻看到A改的新值。这就好比两个同事在同一个项目里改一份文档但各自开了“本地草稿箱”一人改完没有主动同步另一个人打开文档看到的还是旧版本。所以线程通信的第一层含义就是建立一个规则让一个线程修改的数据能“及时可见”地传给另一个线程这就是内存可见性。而各类同步工具本质上都是在解决这个“什么时候刷上去、什么时候拿下来”的可见性问题。1.2 为什么需要通信从“并发修改”这个事故说起举个非常常见的场景一个计数变量count两个线程都要执行count。这个操作看起来是一句话其实是三步从工作内存读count的值在CPU执行加1把新值写回工作内存再刷到主内存。两个线程同时执行的话就可能出现“读到的都是0各自加1后写回1”最终结果只有1而不是2。这个就是“读-改-写”操作之间的竞争条件。线程通信机制就是用来防止这种事故的——要么保证同一时刻只有一个线程能操作这个变量要么把“读-改-写”变成一个原子操作要么用更轻量的方式让线程之间知道“这个数据正在被修改你别乱读”。理解了这个基础后面所有同步工具的设计逻辑你就能串起来了。JVM提供的synchronized、Lock、volatile、wait/notify还有CountDownLatch、BlockingQueue这些并发工具全部都是围绕“怎么让共享数据安全地在多个线程之间流动”来设计的。2. 第一梯队JVM内置的synchronized与volatile一提到线程通信大家最先接触的就是这两个关键字。它们不是二选一的关系侧重点完全不同用错了真的会出事。我在代码评审里见过好多次有人用volatile保护共享计数器然后理直气壮地说“它线程安全”。这个误区必须先说清楚。2.1 synchronized互斥内存可见性双管齐下synchronized是个重量级选手它的作用可以拆成两块互斥同一时间只有一个线程能进入同步代码块其他人必须等在锁外面这是通过Monitor监视器锁实现的。内存可见性当一个线程退出同步块时它会把它在工作内存里的所有修改强制刷回主内存当一个线程进入同步块时它会把自己的工作内存置为无效重新从主内存读取最新值。这两点是配套的。没有互斥可见性就没了意义没有可见性互斥只能保证“不会同时改”不能保证“修改能被看见”。public class Counter { private int count 0; public synchronized void increment() { count; } public synchronized int getCount() { return count; } }注意如果只给increment加锁不给getCount加锁那就完蛋了。因为getCount不加锁就不会主动重新读主内存线程B读到的可能还是旧值。所以读写都同步才叫同步只同步一个方向等于只锁了半边门。2.2 volatile轻量级可见性但别指望它做原子操作volatile的设计初衷是做一个轻量级的“即时广播”写volatile变量时JVM会强制把当前线程工作内存中的新值刷回主内存读volatile变量时JVM会强制当前线程从主内存重新读取而不是用工作内存的副本。所以volatile能保证可见性和一定程度的“排序约束”禁止指令重排但它不保证原子性。上面说的count用volatile修饰依然会丢数据因为它本质是三步操作volatile管不了“读-改-写”这三步之间的竞争。volatile更适用的场景是“一个线程写多个线程读”的标志位。比如用一个volatile boolean running来控制任务线程是否停止public class Task implements Runnable { private volatile boolean running true; public void stop() { running false; } Override public void run() { while (running) { // 执行逻辑 } } }这种场景如果不用volatile某个线程调了stop()工作线程可能迟迟看不到running变成false表现为“停不下来”。用了volatile写操作立刻可见任务能及时退出。这也是线程通信里最经典的一种——主线程通过共享变量“通知”工作线程结束。2.3 真实例子一个计数器的血泪史说一个我调过的具体问题。业务系统里有个库存扣减操作多个并发线程都要读剩余库存然后判断能不能扣。最早代码写成if (this.stock quantity) { this.stock this.stock - quantity; }看起来没啥毛病但一压测库存直接被扣成负数了。原因就是两个线程同时读到stock5都判断“够扣”都执行扣减结果扣了两次只少了一次的量。后来改成用synchronized包住整个判断和扣减逻辑问题才解决。如果当时图省事只给stock加上volatile结果一样是负数。踩过这个坑以后我养成了一个习惯看到“先查后改”就条件反射地想这段逻辑必须整体加锁而不是只指望字段修饰符。3. 等待与唤醒wait/notify 的正确打开方式synchronized和volatile解决的是“记得住、看得见”但很多业务场景还要解决“什么时候该做事、什么时候该等着”。比如消费者线程发现队列为空总不能一直空转吧应该让它停下来等生产者放入数据后再唤醒它。这就是wait/notify机制的用武之地。3.1 wait/notify 机制原理wait()和notify()/notifyAll()是Object类的方法它们的核心前提是必须持有同一把锁对象的监视器。具体表现是线程调用wait()后会释放掉当前持有的锁并进入该对象的等待集Wait Set进入WAITING状态另一个线程调用notify()后会从等待集中随机唤醒一个线程但被唤醒的线程并不会立刻执行它要重新去竞争锁拿到锁后才会继续执行wait()之后的代码notifyAll()唤醒等待集中的所有线程然后大家一起抢锁。这里最容易被忽视的一点是notify()唤醒后是“重新竞争锁”不是直接回到执行现场。很多第一次写多线程的人会误以为“唤醒就等于继续跑”结果在循环里写了一大堆后置逻辑出现各种奇怪的跳过行为。3.2 三步走的使用套路经过无数次踩坑我总结了一套稳妥的标准写法直接套用基本不会出大问题在synchronized代码块内调用wait()或者用synchronized修饰方法调用wait()前用while循环判断条件不要用if唤醒端尽量调用notifyAll()而不是notify()。第2点尤其重要。因为我曾经图省事用if判断结果出现一个经典问题两个消费者线程同时被唤醒第二个线程重新拿到锁之后队列里的数据已经被第一个消费者抢光了它又去消费一个“空数据”。用while可以保证线程醒过来后重新检查条件如果条件不满足就继续wait()。下面这个简化版是标准范例private final Object lock new Object(); private final QueueString queue new LinkedList(); public void consume() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } String data queue.poll(); System.out.println(消费: data); } } public void produce(String data) { synchronized (lock) { queue.offer(data); lock.notifyAll(); } }3.3 踩坑虚假唤醒与过早唤醒“虚假唤醒”是教科书里的经典概念指的是在没有主动notify的情况下线程也可能被莫名其妙唤醒。它不是Java的bug而是底层操作系统和多处理器环境下的行为。应对方式就是上文说的while循环而不是if。另外一个更容易被忽略的问题是“过早唤醒”。假如队列容量有限生产者需要等消费者腾出空间才能继续放。如果生产者线程在“队列非满”这个条件还没满足时被唤醒它又得继续wait()这会带来无效的锁竞争和CPU浪费。解决这个问题通常用Condition专门管理不同等待条件的队列这就是下一组工具要干的事。4. 更现代的通信方式Lock、Condition与并发工具老牌synchronized/wait/notify能满足基本需求但维护成本不低尤其是“多个不同唤醒条件”时要靠notifyAll把所有线程都叫起来再一个个检查效率上是浪费的。JDK 5 开始引入java.util.concurrent包把这套事讲得更精细了。4.1 Lock与Condition显式的等待通知ReentrantLock比synchronized灵活的地方在于可以显式lock()和unlock()还能用tryLock()避免无限等待配合Condition可以在同一个锁上建立多个等待队列比如“等待队列非空”和“等待队列非满”这两类条件互不干扰。Condition.await()对应wait()signal()/signalAll()对应notify()/notifyAll()。最大的好处是生产者只唤醒“等待队列非空”条件的消费者消费者只唤醒“等待队列非满”条件的生产者不需要鸡蛋碰石头地广播通知。private final ReentrantLock lock new ReentrantLock(); private final Condition notEmpty lock.newCondition(); private final Condition notFull lock.newCondition(); public void put(String data) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); } queue.offer(data); notEmpty.signal(); } finally { lock.unlock(); } }注意unlock()必须放在finally里。不然业务代码抛了异常锁没释放整个系统就死锁了。这是我见过最频繁的生产事故原因没有之一。4.2 CountDownLatch、CyclicBarrier、Semaphore一帮一协作这些工具严格来说不是“数据通信”但它们解决的是“线程之间的信号沟通”也算广义的线程通信。CountDownLatch主线程等若干个工作线程都完成后再继续往下走。比如批量导出报表10个线程各自导一批主线程等10个都完成后再合并文件。CyclicBarrierN个线程互相等待等所有人到达某个点后再同时放行。比如多线程分阶段处理数据每阶段都必须等所有人算完才进入下一阶段。Semaphore信号量限制同时访问某资源的线程数量经典场景是限流——只允许5个线程同时拿去数据库连接其他人排队。CountDownLatch是最常用的用法很简单CountDownLatch latch new CountDownLatch(5); for (int i 0; i 5; i) { new Thread(() - { try { // 执行业务 } finally { latch.countDown(); } }).start(); } latch.await(); // 等5个线程都执行完这里关键点是countDown()一定要放在finally里不然某个线程异常后不计数主线程会永远卡在await()。这跟unlock()放finally是同一道理。4.3 BlockingQueue生产消费场景的终极解法如果说Lock/Condition是必须严格手动的方案那BlockingQueue基本就是“开箱即食”。它是一个线程安全的队列当队列为空时取数据的线程会被自动阻塞当队列满时塞数据的线程会被自动阻塞。实现原理底层就是锁加条件队列但对使用者完全透明。常用的实现有ArrayBlockingQueue有界数组队列适合固定容量的生产消费LinkedBlockingQueue无界或可指定上限的链表队列SynchronousQueue没有容量的队列生产者放进去一个数据必须等消费者取走它才完成操作。用BlockingQueue的好处是不用自己写wait/notify那套细节也天然避免了虚假唤醒的问题因为底层实现已经帮你处理好了。我后来写的大部分生产消费系统只要对性能和特殊逻辑要求不高都会优先选BlockingQueue而不是自己手写锁。5. 线程通信的实操示例生产者-消费者为了直观地对比经典方式和现代方式我写一个最简单的“生产者-消费者”场景一个队列一个生产者不断放数据一个消费者不断取数据。分别用wait/notify和BlockingQueue实现你会发现后者代码量少得不是一点点。5.1 用wait/notify实现我直接贴一个完整可跑的版本注释里带上容易出错的地方import java.util.LinkedList; import java.util.Queue; public class WaitNotifyDemo { private static final int CAPACITY 5; private final QueueInteger queue new LinkedList(); public synchronized void produce(int value) throws InterruptedException { while (queue.size() CAPACITY) { // 必须用while wait(); // 队列满了生产线程等 } queue.offer(value); System.out.println(生产: value); notifyAll(); // 通知可能正在等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { // 必须用while wait(); // 队列空了消费线程等 } int value queue.poll(); System.out.println(消费: value); notifyAll(); // 通知可能正在等待的生产者 return value; } public static void main(String[] args) { WaitNotifyDemo demo new WaitNotifyDemo(); Thread producer new Thread(() - { int i 0; while (true) { try { demo.produce(i); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); Thread consumer new Thread(() - { while (true) { try { demo.consume(); Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); producer.start(); consumer.start(); } }这段代码我自己跑过两个线程能稳定工作没有死锁。需要注意的点是produce和consume都必须用synchronized修饰否则wait()直接报IllegalMonitorStateException。另外中断异常一定不要吞掉至少要把中断标志位重新设置回去Thread.currentThread().interrupt()否则线程的中断状态会被清掉外层代码无法感知。5.2 用BlockingQueue实现用BlockingQueue版本代码短到让人舒适import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class BlockingQueueDemo { public static void main(String[] args) { BlockingQueueInteger queue new LinkedBlockingQueue(5); Thread producer new Thread(() - { int i 0; while (true) { try { queue.put(i); System.out.println(生产: i); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); Thread consumer new Thread(() - { while (true) { try { Integer value queue.take(); System.out.println(消费: value); Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); producer.start(); consumer.start(); } }put和take分别是“队列满则阻塞”和“队列空则阻塞”的方法不需要自己处理wait/notify也不会出现虚假唤醒导致的问题。如果你没有特别强的定制需求我建议直接用这种方式。5.3 代码对比与选型建议从可读性和维护成本上说BlockingQueue完胜。它把锁、条件队列、唤醒逻辑全部封装进了JDK的并发库里那些代码是经过极度优化和严格测试的比自己写的wait/notify可靠得多。什么时候必须手写wait/notify或Condition呢我总结一下队列逻辑不是简单的 “FIFO”比如带优先级、带过期时间需要同时维护多个不同的等待条件且各个条件之间需要精细控制业务需要自定义阻塞时机而不是单纯地满或空。一般场景就优先用BlockingQueue或者结合CompletableFuture做异步编排。选型原则永远是优先用并发包里现成的工具不够用再自己造轮子。我自己早期就是吃了“什么都喜欢手写”的亏写出来的代码Bug多不说别人还看不懂。6. 常见问题与排查技巧实录以前团队里新同学经常遇到几个和线程通信相关的典型问题我把它们整理成一个速查表每条都是我亲手排查并修复过的照着这个思路定位问题通常不会走弯路。6.1 死锁通信没搞清线程抱在一起死锁的特征是程序卡死不报异常线程转储里能看到多个线程互相持有锁且都在等待对方释放。最常见的成因是“多个锁之间存在循环等待”。比如线程A持有锁1想拿锁2线程B持有锁2想拿锁1谁也不让谁。排查思路先jstack pid打印线程栈看到Found one Java-level deadlock基本就实锤了检查代码里加锁的顺序确保所有线程都按同一个全局顺序获取锁用tryLock(timeout)这种带超时的加锁方式避免无限等待。预防死锁的通用技巧是如果必须要上多把锁就统一约定“先锁A再锁B”比如所有操作都先锁用户锁再锁订单锁别一个线程先锁订单再锁用户另一线程又反过来。6.2 可见性问题两个线程打印出“不同”的值有个经典现象主线程修改了一个普通布尔变量子线程却始终读不到新值程序表现是“子线程死循环根本不退出”。这种问题多半就是没有用volatile或synchronized导致子线程的工作内存一直保留旧副本。排查思路确认共享变量是否有volatile修饰或者所有读写是否都在synchronized/Lock保护下检查代码里是否有编译优化带来的指令重排比如double check locking失效单例模式没有加volatile导致拿到半初始化对象。修法就是把共享标志位加volatile并且用while (flag)循环里加一点Thread.yield()或者sleep让线程有机会刷新工作内存。但根治还是得靠内存屏障。6.3 性能陷阱频繁加锁与锁粒度控制最后说个性能问题。加锁是线程通信的代价但锁用得太粗、太频繁会让并发变串行。举一个我优化过的例子一个统计服务每次请求进来都要更新10个统计项最初代码是给整个更新方法加synchronized结果压测时吞吐量只有几十。后来分析发现10个统计项其实是独立的互相之间没有数据关系。把它们的更新拆成10把细粒度锁比如用ConcurrentHashMap的原子方法分段更新吞吐量直接上了一个数量级。优化思路能用原子类AtomicInteger、AtomicLong就尽量用原子类避免用锁锁的粒度尽量小只锁住共享数据变更的那几行代码而不是锁整个方法读多写少的场景用ReadWriteLock或者StampedLock读锁之间不阻塞能显著提升并发度。但要记住性能优化的基础是“先量后调”没有压测数据作为依据别急着炫技。我见过不少把简单代码改成复杂锁结构结果反而更慢的案例。我个人在实际操作中的体会是调线程通信的问题顺序永远是——先确认共享数据和操作方式再选择可见性方案最后才考虑用哪个同步工具。很多看似复杂的并发Bug归根结底要么是没理解“可见性”和“原子性”的区别要么是忽略了“锁释放”的边界。如果你能把synchronized、volatile、wait/notify这三样东西的底层原理吃透再把BlockingQueue和Condition用熟日常开发里的绝大多数线程通信问题基本都能有清晰的解决路径。
返回列表