尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

wait为什么必须配synchronized?与sleep的底层区别一次讲透

wait为什么必须配synchronized?与sleep的底层区别一次讲透 先说个事儿平时带新人和面试别人的时候几乎每次问到并发这块都会冒出来这两个问题一个是“wait为什么非得放在同步块里”另一个是“wait和sleep到底差在哪儿”。很多人八股文背得滚瓜烂熟但一追问“底层到底怎么保证的”“换成sleep为什么不行”就开始含糊了。这篇文章就把这两个问题一次性掰开揉碎讲清楚从JVM锁机制到实际编码的坑尽量做到让新手能学会让老手也能有点收获。1. wait为什么必须和synchronized绑在一起1.1 先搞明白wait到底是干什么的很多人都知道wait是Object类的方法作用是让当前线程“等一会儿”。但这个“等”不是简单的时间暂停它的完整语义是当前线程释放该对象的监视器锁然后进入该对象的等待集合WaitSet直到其他线程调用notify或notifyAll把它唤醒或者等待时间超时。注意这几个关键词释放锁、进入等待集合、被唤醒。这里面最容易被忽略的是“释放锁”三个字。Java里一个线程如果想释放某个对象的锁前提是它得先持有这个锁这是Java语言规范写死的。这就好比你去酒店退房前提是你手里得有房卡。你没办入住跑去前台说要退房人家肯定不搭理你。wait释放锁也是同一个道理你没持有这个对象的锁凭什么能释放它所以JVM在实现上就做了一个强制校验。synchronized (lock) { // 这里是合法的因为当前线程持有了lock的锁 lock.wait(); } // 这里直接调用就不行会抛IllegalMonitorStateException lock.wait();如果你在非同步块里调用wait运行时会直接抛出IllegalMonitorStateException这是JVM层面的强制性检查不是编译器能提前发现的错误。1.2 丢失唤醒问题才是真正的原因表面上看“先持锁才能释放锁”已经能解释为什么必须在同步块中了。但从真实应用的角度看还有一个更隐蔽也更要命的问题丢失唤醒lost wake-up。假设没有同步块的限制wait和notify可以随意调用那代码很可能是这样写的// 伪代码展示一下错误逻辑 boolean flag false; // 线程A while (!flag) { // 先判断条件 lock.wait(); // 假设这里可以随便调用 } // 线程B另一段逻辑 flag true; lock.notify();这个代码有个巨大的时间窗口问题线程A执行完while (!flag)的条件判断之后还没执行到wait线程B抢到了CPU时间片把flag改成了true并且执行了notify。但此刻线程A还没进入等待状态这个notify信号就直接丢了。然后线程A终于执行到wait开始傻傻地等待一个永远不会再来的通知。用同步块锁住这段判断和等待的代码之后线程A在synchronized块里是独占锁的线程B要修改flag并调用notify必须先拿到同一把锁。也就是说线程B的notify要么在线程A进入wait之前就执行完要么就得等线程A真正wait并释放锁之后才能执行。无论哪种情况notify信号都不会丢失。所以wait和同步块绑定不只是为了满足JVM的语法检查更是为了保证“条件判断线程等待”这个操作是原子性的避免因为检查条件和挂起线程之间存在窗口期而丢失唤醒信号。1.3 从管程模型理解这个设计如果接触操作系统课程里面有个经典概念叫“管程Monitor”。Java的synchronized其实就是基于管程模型设计的。管程的核心思想是把共享变量和对它的操作封装起来同一时刻只允许一个线程进入管程内部操作。管程内部还维护了条件变量用来实现线程间的等待和唤醒。Java里每个对象都可以充当管程角色对象的监视器锁就是管程的互斥入口而对象的WaitSet就是条件变量对应的等待队列。条件变量的wait操作天然要求“进入管程之后才能执行”否则就没有办法保证条件变量和共享状态之间的一致性。这就是为什么Java把wait、notify、notifyAll这三个方法直接定义在Object类上而不是定义在Thread类上。因为这三个方法操作的不是线程本身而是“当前线程和某个对象锁之间的关系”。每个对象天生都有自己的锁和等待队列所以这三个方法就跟着Object走了。这也是很多面试官喜欢追问的衍生问题为什么wait在Object里而不在Thread里。2. wait和sleep的一字之差差出了天壤之别2.1 方法归属不同暴露了设计意图的不同先说最简单直白的一个区别wait是Object类的实例方法sleep是Thread类的静态方法。这个区别看起来只是个API归属问题但背后藏着设计意图的根本差异。sleep的作用是“当前线程让出CPU暂停执行一段时间”它的操作对象是“正在运行的线程”所以作为Thread的静态方法很合理直接Thread.sleep(1000)就完事了。wait的操作对象不是线程本身而是“线程和对象锁之间的协作关系”。它的核心动作是释放当前对象锁并进入等待队列这个动作必须依托于某个具体对象。所以它被设计为Object的实例方法必须通过对象.wait()来调用。说句大白话sleep是“我自己想歇会儿”wait是“我手里有个锁我想先把它交出去等别人通知我再来拿”。2.2 最核心的区别要不要释放锁这是面试里必须答出来的点也是实际编码中影响最大的区别。wait调用后当前线程会释放掉它持有的该对象的监视器锁然后进入等待状态。其他线程可以趁机获取这把锁来执行自己的逻辑。sleep调用后当前线程只是暂停执行它持有的任何锁都不会释放。别的线程如果想获取这把锁只能干瞪眼等着它睡醒。这个区别在设计上影响巨大。举个例子你在一个synchronized方法里调用Thread.sleep(5000)模拟耗时操作期间其他线程如果想进入同一个synchronized方法就必须阻塞等待这5秒结束。但如果在同步块里调用wait(5000)当前线程会先把锁交出去其他线程趁这个窗口期就能进入同步块干活了。所以有个经典的性能排查经验如果你的系统出现了线程大量堆积在某个锁上去看一下同步块内部是不是调用了sleep。用sleep模拟耗时逻辑等于把整条并发链路都堵住了。2.3 唤醒机制的差异sleep的唤醒条件是时间到期或者被其他线程interrupt打断。如果没有中断发生它一定会睡够指定的时间才会继续往下执行。wait的唤醒条件则复杂一些要么是其他线程调用了notify或notifyAll要么是设置了超时时间到期要么是被interrupt。注意wait还允许“无理由”地被唤醒这就是所谓的虚假唤醒spurious wakeup。JVM规范里明确提到生产者消费者模型里不允许假设notify一定会精确地唤醒目标线程。正是因为虚假唤醒的存在标准写法要求wait必须放在while循环里面而不能用if// 正确的写法用while重新检查条件 synchronized (lock) { while (!condition) { lock.wait(); } // 条件满足后的逻辑 }用if的话线程被唤醒后即使条件不满足也会直接继续往下执行这在高并发场景下很容易引发数据错乱。2.4 wait和sleep的完整对比表对比项waitsleep所属类ObjectThread是否释放锁释放对象的监视器锁不释放任何锁调用前提必须在synchronized块/方法中任意位置都可以唤醒方式notify/notifyAll/超时/中断时间到期/中断是否必须捕获异常必须处理InterruptedException必须处理InterruptedException主要用途线程间的协作与通信线程自身的暂停与节流设计意图释放锁等待条件变化暂停执行不涉及锁操作这张表基本就能覆盖面试里的标准答案了但如果你想要的是真正理解推荐把上面几节内容都消化透而不是只背表格。2.5 中断处理的相同点和不同点虽然两者都要求处理InterruptedException但处理方式略有差别。sleep被中断时它会直接抛出异常线程的中断标志位会被清除你可以根据业务需要决定是继续执行还是结束线程。wait被中断时同样抛出InterruptedException但注意wait抛出异常之前线程会先重新获取锁。换句话说如果一个线程在等待中被中断它必须先重新抢到锁才能从wait调用处抛出异常。这个细节在排查问题的时候可能会带来一点困惑因为中断响应不是“立即”的而是要在锁竞争成功后才真正表现为异常抛出。3. 经典场景实战生产者消费者的正确“打开方式”3.1 手写一个生产者消费者模型看代码是理解这两个方法差异最好的方式。下面用wait和notifyAll实现一个最基础的生产者消费者模型import java.util.LinkedList; import java.util.Queue; public class ProducerConsumerDemo { private final QueueInteger queue new LinkedList(); private final int capacity 5; // 生产者往队列里放数据 public synchronized void produce(int value) throws InterruptedException { while (queue.size() capacity) { // 队列满了等待消费者取走数据 wait(); } queue.offer(value); System.out.println(生产: value 当前队列大小: queue.size()); // 唤醒所有等待的消费者 notifyAll(); } // 消费者从队列里取数据 public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { // 队列空了等待生产者放入数据 wait(); } int value queue.poll(); System.out.println(消费: value 当前队列大小: queue.size()); // 唤醒所有等待的生产者 notifyAll(); return value; } }这里有几个关键点要展开说。方法用synchronized修饰所以方法内部可以直接写wait不需要额外的synchronized块因为方法本身已经持有了this对象的锁。while循环的作用是防止虚假唤醒同时也防止“条件被错误唤醒”的情况。比如队列刚有一个空位但有两个消费者同时被唤醒其中一个抢到锁消费了一个数据另一个再去消费时队列又空了这时候while会重新检查条件并继续等待。用if就做不到这一点它只会检查一次。生产者和消费者虽然用了同一个对象的锁但逻辑上是互补的。生产者发现队列满了就wait等待消费者腾位置消费者发现队列空了就wait等待生产者放数据。当生产者放入一个数据并notifyAll之后所有等待的消费者都有机会被唤醒但具体哪个消费者抢到锁取决于线程调度。3.2 如果用sleep替代wait会发生什么很多人刚开始学并发的时候会想等不到数据我就睡一会儿然后再看看不也能实现吗用sleep改一版可能长这样public synchronized void consumeWithSleep() throws InterruptedException { while (queue.isEmpty()) { // 错误做法不释放当前锁其他线程进不来 Thread.sleep(100); } int value queue.poll(); // ... }这个代码有两个问题。第一consumeWithSleep方法用的是synchronized线程在方法内部sleep不会释放锁所以生产者线程根本无法进入produce方法。队列永远是空的消费者睡醒了再看还是空的再睡死循环。有人看到这里可能会说那把sleep放在synchronized外面不就行了。但那样又引出一个新问题如果不在同步块里检查队列状态读到的可能是过期数据而且多个消费者同时发现队列为空各自去sleep醒过来后又同时去抢锁竞争反而更剧烈了。sleep的本质是“暂停执行”wait的本质是“释放锁并等待通知”。两者的设计目标完全不同强行交替使用要么死锁要么忙等都不是正确并发编程的方向。3.3 为什么推荐优先用并发包而不是手写wait讲到这里不得不提一件事虽然wait和sleep的区别是面试重点但实际工程里我建议优先用java.util.concurrent包里的工具而不是手写wait和notifyAll。就拿上面的生产者消费者模型来说完全可以用BlockingQueue来替代import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; public class BlockingQueueDemo { private final BlockingQueueInteger queue new ArrayBlockingQueue(5); public void produce(int value) throws InterruptedException { queue.put(value); // 队列满时自动阻塞 System.out.println(生产: value); } public int consume() throws InterruptedException { int value queue.take(); // 队列空时自动阻塞 System.out.println(消费: value); return value; } }BlockingQueue底层已经帮你处理好了锁、等待、唤醒的细节使用门槛低出错概率小。手写wait的典型场景更多是框架源码、面试手写题或者确实需要精细化控制并发逻辑的场合。不过这不代表你可以不懂wait的机制。恰恰相反只有理解了wait释放锁的原理你才能理解BlockingQueue的put和take为什么能自动阻塞才能理解ConcurrentHashMap的某些实现细节看源码才不会一头雾水。4. 高频面试追问和实际踩坑记录4.1 面试官追问notify会立即释放锁吗这是个很容易被误解的细节。调用notify或者notifyAll之后当前线程并不会立刻释放锁。notify的作用只是把等待队列里的线程“唤醒”到另一个队列EntrySet也就是阻塞队列让它们处于可运行状态可以参与锁竞争。真正释放锁的时机是当前线程退出synchronized块或者synchronized方法的时候。所以notify之后被唤醒的线程并不能立刻执行它还要和其他线程一起争抢锁。如果当前线程在notify之后还有一堆代码要执行那么被唤醒的线程就得一直等到当前线程彻底释放锁为止。这个点面试里经常被拿出来细问比如“notify之后我马上做了一堆耗时操作被唤醒的线程会立刻执行吗”答案是不会它还在锁池里排队。4.2 面试官追问wait(0)是什么意思很多初学者以为wait(0)是等待0毫秒立刻往下执行。实际上wait(0)的意思是“无限期等待直到被notify或notifyAll唤醒”。因为0这个特殊值在JDK源码里就是用来表示超时时间无穷大的。类似的细节还有一堆比如wait(5000)表示最多等5秒如果5秒没等到通知就自动醒过来继续抢锁但是要注意即使超时了它也得等其他线程释放锁之后才能抢到锁继续走。所以超时并不等于“准点醒来执行”实际执行时间取决于锁竞争情况。4.3 实际排查案例床上睡着的线程把整个服务拖垮我之前排查过一个线上问题服务里某个接口偶尔超时特别严重。看线程转储thread dump发现大量线程卡在一个同步方法内部再往下看方法内部有一个Thread.sleep(3000)的调用。问题就出在这这个同步方法内部有比较耗时的IO操作原本的意图是“每次处理间隔3秒避免把下游打挂”。但因为sleep不会释放锁一个线程睡3秒后面的线程全都得排队等着一旦入口流量稍微大一点线程池就被占满了接口响应时间飙升。修复方案也很简单把sleep改成了“本次处理完成前先去抢一把锁抢不到就说明有前面的线程在排队直接放弃本次处理”。整个思路调整过来之后问题就消失了。这就是sleep“霸占锁”的典型危害。后来我给自己定了一条经验synchronized块内部永远不要出现sleep如果有节流或延时的需求要么把锁的范围缩小要么用wait带超时参数来代替要么直接用并发包里的信号量工具。4.4 常见问题速查表场景问题处理建议非同步块里调用wait抛IllegalMonitorStateException把wait放进synchronized块里用sleep替代wait等待条件不释放锁其他线程无法进入改用wait或LockSupport.park用if判断条件虚假唤醒后条件不满足也继续执行改用while循环重新检查notify唤醒等待线程后还有耗时操作被唤醒线程迟迟得不到执行缩小锁范围或让notify尽量靠近同步块末尾多个线程等待不同条件notify可能唤醒错误的线程使用notifyAll或改用Condition接口wait超时返回后直接处理业务超时返回不一定代表条件满足超时后也要重新检查业务条件4.5 关于Object.wait/notify的一套“避坑心得”最后分享几个我实际用下来的体会都是踩过坑之后总结的。每次写wait之前先强迫自己回答三个问题当前线程持有了哪个对象的锁我要等待的是什么条件这个条件会不会被多个线程同时修改三个问题都答清楚了再动手写代码。能不自己管理wait和notify就别自己管理。JDK提供了太多现成的并发工具比如CountDownLatch、Semaphore、CyclicBarrier、Phaser还有Lock和Condition。它们把等待和唤醒的细节封装得更安全也更符合现代Java开发的习惯。手写wait已经属于“让你能看懂源码”的底子而不是“你应该天天在业务代码里这么写”的推荐用法。生产代码里如果必须使用自定义锁和条件等待优先考虑ReentrantLock和Condition。Condition可以创建多个等待队列分别管理不同条件的等待线程比wait和notifyAll在复杂的业务场景下少很多不必要的“惊群效应”。比如一个工作队列模型里“有空位”和“有数据”是两个不同的条件用两个Condition分开管理唤醒的精度更高整体效率也更好。5. 为什么这两个问题总是同时出现既然标题把wait和sleep放在一起对比这里再多说一句为什么这两个问题经常被绑在一起问。从接口设计上看wait和sleep都是让线程“停下”但一个是主动让出锁一个是被动霸占锁这正好是并发编程里最核心的两种线程状态变化方式。面试官问这两个的区别本质是想看候选人是否真正理解Java的锁模型和线程调度机制。你能讲清楚wait释放锁、sleep不释放锁就能推理出很多“为什么这样写会死锁”“为什么这个接口这么慢”这类实际问题。从学习方法上看把容易混淆的API放在一起对比研究是理解并发编程非常高效的方式。类似的组合还有start和run、interrupt和stop、notify和notifyAll、Lock和synchronized。每对都是“看起来相似、本质不同”逐个吃透之后你对整个并发体系的理解会扎实很多。我个人一直觉得面试题的意义不在于让你背答案而是通过一个问题逼着你去理解它背后那一整片知识网络。wait和sleep这对“双胞胎”背后牵出来的是Java对象头里的锁状态、管程模型、线程状态转换、等待唤醒机制、虚假唤醒甚至还能延伸到AQS和Condition的实现。把这根线捋顺了以后再遇到任何并发协作的问题你都有底气往下挖。
返回列表