Java synchronized底层原理:为什么生产者消费者必须用notifyAll而非notify?
1. 先说结论synchronized到底是什么很多Java开发者用了好几年synchronized但被问到“它的原理是什么”时只能答出“它是JVM层面的锁”“可以保证原子性和可见性”这种面试标准答案。真要往深处问下去——为什么它能保证可见性为什么一个lock对象能锁住代码块为什么wait之后必须用notifyAll而不是notify——很多人就开始含糊了。这篇文章我想把这块内容彻底讲透。我会从JVM的底层实现出发把synchronized的加锁过程、锁升级路径、wait/notify协作机制一条线串起来然后重点拆解标题里那个经典问题为什么生产者消费者模型里大家几乎清一色用notifyAll()而不是notify()是习惯还是背后有不可逾越的坑先说结论notifyAll比notify安全这是设计层面上的问题不是性能层面上的吹毛求疵。你以为notify省事能唤醒一个线程就够了但在多条件等待的场景下notify会直接导致信号丢失、线程永久挂起。具体怎么发生的后面一步步拆。适合谁来读学Java并发学了半吊子、被synchronized源码和wait/notify搞晕过的开发者正在准备面试、想把这部分讲出深度的人以及在生产环境里踩过线程“莫名其妙不跑了”这种坑、想搞清楚根因的人。2. 从字节码到对象头synchronized的底层身份2.1 两个字节码指令monitorenter与monitorexit编写Java代码时synchronized看起来就是语言层面的一个关键字但编译成字节码之后它对应的是两条指令monitorenter和monitorexit。先看一段简单的代码public class Demo { private final Object lock new Object(); public void test() { synchronized (lock) { System.out.println(hello); } } }用javap -c反编译后你能看到test方法的字节码大致长这样public void test(); Code: 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 10: ldc #4 // String hello 12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return注意看字节码里有两个monitorexit一个是正常执行完代码块之后的退出另一个是异常路径上的退出藏在异常表中保证任何时候锁都能被释放。JVM规范规定synchronized代码块无论是正常跑完还是抛异常都必须执行monitorexit释放锁。这一点是JVM层面强行保障的不是靠程序员自觉。如果你synchronized修饰的是方法实例方法或静态方法则不需要monitorenter/monitorexit这两个指令因为JVM规范规定方法级别的ACC_SYNCHRONIZED标志就足够表明这是一个同步方法调用时由JVM隐式完成monitor的获取与释放不需要额外的计算指令位置。2.2 对象头里的Mark Word锁信息的存放位置不过光知道monitorenter和monitorexit还不够。你要继续追问JVM执行monitorenter的瞬间怎么知道这个对象“已经被锁住”了呢锁状态信息到底记录在哪里答案在对象头Object Header里。HotSpot虚拟机中Java对象在堆内存中的布局分三块对象头、实例数据、对齐填充。对象头里有一个至关重要的部分叫Mark Word它用64位32位虚拟机是32位的比特位来记录对象的哈希码、GC分代年龄、锁状态标志等信息。不同锁状态下Mark Word的含义完全不同锁状态Mark Word中存储的内容无锁对象哈希码、分代年龄、偏向锁标志位0、锁标志位01偏向锁持有偏向锁的线程ID、偏向时间戳、分代年龄、偏向锁标志位1、锁标志位01轻量级锁指向栈中锁记录的指针锁标志位00重量级锁指向Monitor监视器锁的指针锁标志位10GC标记空锁标志位11锁标志位只有两位却能和偏向锁标志位配合区分出五种状态。JVM拿到锁标志位之后就知道该走哪条加锁路径。这就是为什么synchronized被称为“对象锁”——它锁的本质上不是一段代码而是一个Java对象在内存里那几十个比特位的状态。2.3 Monitor重量级锁那一层的核心结构synchronized最终在重量级锁阶段依赖的是一个叫ObjectMonitor的数据结构。这个结构是C写的在HotSpot源码的objectMonitor.hpp文件里你可以把它理解成一个“房间管理员的登记本”。ObjectMonitor的主要字段包括_owner当前持有锁的线程_WaitSet调用了wait()方法的线程队列_EntryList等待进入锁的线程队列_recursions可重入计数同一个线程能反复加同一把锁_count锁的计数我习惯用一个饭馆类比来理解它_owner就是“正在包间吃饭的客人”_EntryList是“门口排队拿号的客人”_WaitSet是“已经进了饭馆但需要等某道菜上桌才能开吃、先坐在休息区等的客人”。将来讲到wait/notify你只要把_WaitSet和_EntryList这两个队列分清楚一切都会豁然开朗。3. 锁升级的完整路径偏向锁、轻量级锁、重量级锁3.1 为什么要设计锁升级很多老教程讲synchronized开口就说“它是重量级锁性能差”。这句话放在JDK 1.5之前基本成立因为那时monitorenter确实直接依赖操作系统底层的互斥量Mutex Lock实现线程被挂起、唤醒都要切换到内核态代价巨大。但JDK 1.6做了重大优化设计了锁升级机制。核心思路是大多数并发场景下锁竞争根本不激烈。要么一个线程反复进入同一把锁要么两个线程偶尔错峰访问真正多线程同时争抢同一把锁的情况其实是少数。如果所有情况都直接上重量级锁等于杀鸡用牛刀白白把线程送到内核态去睡了一觉。于是JVM设计出四级锁状态无锁 → 偏向锁 → 轻量级锁 → 重量级锁。锁只能升级、不能降级。为什么不能降级因为降级意味着重新分析线程竞争情况带来额外的计算成本而且没有必要——级别升上来之后说明竞争确实存在过保留在高等级状态避免反复折腾。这个设计取舍背后的权衡值得体会一下。3.2 偏向锁同一个线程反复进出偏向锁针对的场景是代码由同一个线程反复进入同步块完全没有竞争。这种情况下每次加锁都走CAS操作都是浪费。JVM会在Mark Word里直接记录持有锁的线程ID之后这个线程再来时只需要检查线程ID是否匹配匹配就说明锁还归自己不需要再做任何原子操作。偏向锁的核心逻辑是延迟竞争处理如果只有一个线程访问它基本不付出额外开销只有当另一个线程真的来抢锁时偏向锁才会被撤销。撤销偏向锁需要在安全点暂停所有线程判断原持有线程是否还在执行然后决定是升级为轻量级锁还是恢复到无锁状态。这个过程叫“批量重偏向”和“批量撤销”在大量线程交替访问同一锁的场景下会有明显性能影响。JDK 15之后JEP 374默认禁用了偏向锁。原因是偏向锁的撤销逻辑复杂度高而现代应用里线程池普遍、线程复用频繁偏向锁带来的收益越来越不明显。3.3 轻量级锁CAS忙等抢锁如果第二个线程也来抢这把锁偏向锁撤销后升级为轻量级锁。轻量级锁本质上就是自旋锁的变体线程在自己的栈帧里创建一个Lock Record锁记录通过CAS操作尝试把Mark Word更新为指向这份Lock Record的指针。CAS成功了说明抢到锁CAS失败了说明锁被别人持有当前线程就开始自旋也就是空转CPU循环反复CAS等待持锁线程释放。自旋为什么比挂起线程好因为线程挂起和唤醒需要操作系统介入成本在几百纳秒到微秒级别而锁持有时间往往只有几十纳秒。如果自旋一小会儿就能等到锁比切换线程状态划算得多。但自旋本身就是“用CPU换时间”如果锁竞争非常激烈自旋会让CPU飙高。所以JVM有自适应自旋让JVM根据上一次自旋等待的成功率动态调整自旋时间不再用固定值。3.4 重量级锁进入ObjectMonitor队列当自旋也搞不定锁竞争白热化时轻量级锁膨胀为重量级锁。此时Mark Word里存放的是指向ObjectMonitor的指针线程进入_EntryList队列等待由操作系统管调度。重量级锁的最大特点是没有抢到锁的线程会被挂起进入内核态阻塞状态等锁释放时再被唤醒。锁释放时JVM会从_EntryList里挑一个线程唤醒让它重新参与锁竞争。这块内容很容易和wait/notify混淆。注意区分重量级锁的_EntryList等待线程在synchronized入口等锁它没拿到锁老老实实排队wait队列_WaitSet等待线程已经在synchronized里面了条件不满足主动让出锁去休息区等待信号很多人在面试时说“notify唤醒的是正在等待锁的线程”这是错误的。notify唤醒的是在_WaitSet里调用过wait()的线程。3.5 可重入锁为什么同一线程能反复进入synchronized的可重入性用_recursions字段实现。同一个线程的monitorenter和monitorexit是一对一嵌套的每进入一次锁_recursions加1每退出一次减1。当_recursions减到0时锁才真正释放。可重入的价值在于一个线程持锁后调用另一个synchronized方法——如果锁不可重入自己就和自己死锁了。举个例子public synchronized void outer() { inner(); // 同一个线程再次进入 synchronized } public synchronized void inner() { // ... }如果synchronized不可重入outer获取锁后调用innerinner发现锁被占用但占用者就是自己直接死锁。但有了可重入机制inner进入时_recursions变成2退出时变回1outer退出时变回0整个过程完全没问题。4. 从wait/notify到ObjectMonitor管程模型的应用4.1 wait和notify为什么必须在synchronized里代码里如果直接在普通方法中调用wait()会直接抛IllegalMonitorStateException。为什么设计得这么严格先说原理。wait()执行时做了三件事把当前线程放入_WaitSet队列释放当前持有的锁线程状态变为WAITING挂起等待如果允许线程在不持有锁的情况下调用wait()那么第一件事就没法做——JVM不知道该把线程放到哪个ObjectMonitor的_WaitSet里因为线程跟锁根本没有绑定关系。再看语义层面。wait/notify是线程间协作的通信机制它的前提是共享某个状态变量。比如生产者往队列里放数据消费者发现队列为空就等待生产者放入数据后通知消费者可以取了。这种“检查条件→等待→条件满足→被唤醒”的流程中间对于条件变量的检查必须保证原子性否则就会出“竞态条件”消费者检查到队列为空正要wait这时候生产者放入数据并执行notify但此时消费者还没进入_WaitSetnotify找不到任何线程可以唤醒然后消费者才开始wait结果就是队列里有数据但消费者在傻等没人再通知它了。这个场景就是经典的“信号丢失问题”Lost Wakeup。把wait/notify放进synchronized代码块里强制要求线程先获取锁再去检查条件、再决定是否wait整个“检查-等待”过程被锁保护就不会出现上述中断窗口。4.2 notifyAll内部到底做了什么notifyAll()方法内部最终会调用ObjectMonitor的notifyAll逻辑把_WaitSet队列里的所有线程都移入_EntryList队列让它们重新去竞争锁。这个过程的关键点在于被notifyAll唤醒的线程不会立即从wait()返回继续执行。它们要先参与锁竞争抢到锁之后才能从wait()调用处返回。而且返回之后不会自动重新验证条件是否满足。所以被唤醒的线程必须用while循环重新检查条件而不是用if。这就是为什么所有教科书都强调下面这个范式synchronized (lock) { while (!condition) { lock.wait(); } // 条件满足继续执行 }用while而非if的意义在于线程被唤醒后condition可能已经被其他线程消费掉了。比如两个消费者同时被notifyAll唤醒但队列里只剩一个元素先抢到锁的消费者把元素取走第二个消费者抢到锁后如果直接往下走就会从空队列里取数据。用while重新检查能规避这种“虚假唤醒”和“唤醒后条件已失效”的情况。4.3 synchronized对应哪个管程模型很多并发书籍讲到管程Monitor的Mesa与Hoare两种模型此时你可以把synchronized对号入座。Hoare模型条件满足时被唤醒的线程立即执行且拥有高优先级通知者被挂起等待Mesa模型被唤醒的线程不保证立即执行也不保证条件一定满足需要重新检查条件synchronized实现的是Mesa模型。也就是说notify/notifyAll只是给了等待线程一个“你有可能可以继续了”的通知而不是“你一定可以继续了”。这个模型天然就要求等待方用while循环重新检测条件变量。理解了这一点就理解为什么JVM不提供synchronized的“自动验证条件”功能——因为在Mesa模型里条件验证必须是线程自己的职责。5. 核心问题为什么是notifyAll而不是notify5.1 从信号丢失的角度看前面讲wait/notify必须放在synchronized里的信号丢失问题值得继续深挖一层。现在我们把场景扩大一个锁对象上可能存在多种不相关的等待条件。比如一个阻塞队列实现既有“队列空”等待条件又有“队列满”等待条件。消费者线程在队列空时wait等待生产者往队列里放数据生产者线程在队列满时wait等待消费者从队列里取数据。此时如果某个生产者向队列中放入一个元素然后调用notify()notify只能从_WaitSet中唤醒一个线程。问题是JVM并不知道哪个线程对应哪个等待条件。它是随意挑选一个线程唤醒的有可能唤醒的还是另一个等待“队列满”的生产者。这个被唤醒的生产者拿到锁之后重新检查条件发现队列依然是满的只能再次进入wait。结果呢那个真正在等待“队列空”的消费者白白错过了这次唤醒机会连被执行的资格都没有拿到。如果后续没有其他生产者再往队列里放数据那么这个消费者就永远等下去。这是典型的“信号丢失”导致的线程永久挂起。用notifyAll就不存在这个问题所有等待线程都会被唤醒虽然唤醒后大部分线程会发现自己条件不满足、再次回到wait看起来浪费了调度开销但至少那个条件真正满足的线程不会被漏掉。5.2 一个能把notify用对的典型案例单个条件极简队列有没有能用notify()的安全场景理论上有单生产者、单消费者、阻塞队列中只有一个等待条件这种情况下notify和notifyAll效果几乎等价。但问题来了你怎么保证这个队列永远只有一个等待条件如果未来需求方给队列加一个“关闭后等待线程需要退出”的新功能再引入一个“关闭状态”条件变量单一notify的选择马上变成炸弹。我不建议在代码里使用notify()除非以下条件全部满足锁对象上只有一个条件变量只有一个生产者线程一个消费者线程代码永远不会被后续需求变更影响团队所有人都理解notify的语义并且没人会改坏第4条基本是空谈。工程开发中代码是持续演进的依赖“所有人都不会改坏”比依赖“notifyAll一定安全”脆弱得多。用notifyAll代码的可演进性更强任何新来的成员加一个等待条件都不会触发那个隐蔽的坑。5.3 从面试答题角度拆解标准答案如果你正在准备面试问到这道题应该分几个层次回答第一层语义差异。notify随机唤醒一个等待线程notifyAll唤醒所有等待线程。第二层条件变量问题。一个锁上可能存在多个不同的等待条件notify可能唤醒一个条件不满足的线程导致真正需要被唤醒的线程得不到信号。第三层信号丢失风险。如果某个线程终身错过一次唤醒信号且后续不再有同类信号该线程可能永久WAITING程序卡死。第四层Mesa模型要求。synchronized实现的是Mesa管程模型被唤醒线程需要重新获取锁并重新检查条件所以唤醒全部线程让它们自检比“唤醒一个赌正确”更合适。第五层性能权衡。notify比notifyAll少了线程调度开销但在正常业务场景中这个开销往往可忽略。真正需要极致锁竞争的场合通常会用显式Lock Condition而不是在synchronized里抠notify和notifyAll的差异。能说出来第五层面试官就知道你对并发工具有整体认知不只是背了一篇面经。5.4 什么时候用Lock/Condition替换synchronized说到极致竞争场景顺带聊一下什么时候该抛弃synchronized。如果一个锁上确实有多个不同的等待条件而且你对唤醒的精确性有很高要求推荐使用显式锁ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { while (isFull()) { notFull.await(); } enqueue(data); notEmpty.signal(); // 精确唤醒消费者而不是随机唤醒一个线程 } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (isEmpty()) { notEmpty.await(); } Object data dequeue(); notFull.signal(); // 精确唤醒生产者 } finally { lock.unlock(); }这段代码用两个Condition避免了notify的盲目性。但如果你的项目只是简单的生产者消费者、边界条件不多完全可以用synchronized wait/notifyAll代码更简洁也不用担心忘记unlock。工程上选择工具的原则是在满足需求的前提下尽量简单而不是一味追新追复杂。6. 实操过程从零侧写一个安全的生产者消费者6.1 实战代码基于synchronized的阻塞队列接下来给一套完整可直接运行的生产者消费者实现用synchronized wait/notifyAll同时给出测试入口。import java.util.LinkedList; import java.util.Queue; public class SyncBlockingQueueT { private final QueueT queue new LinkedList(); private final int capacity; public SyncBlockingQueue(int capacity) { this.capacity capacity; } public synchronized void put(T item) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满了生产者等待 } queue.offer(item); System.out.println(Thread.currentThread().getName() 生产: item); notifyAll(); // 通知所有等待线程 } public synchronized T take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了消费者等待 } T item queue.poll(); System.out.println(Thread.currentThread().getName() 消费: item); notifyAll(); // 通知所有等待线程 return item; } public static void main(String[] args) { SyncBlockingQueueInteger queue new SyncBlockingQueue(2); // 一个生产者 Thread producer new Thread(() - { for (int i 0; i 10; i) { try { queue.put(i); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, 生产者); // 三个消费者 for (int i 0; i 3; i) { new Thread(() - { try { while (true) { queue.take(); Thread.sleep(200); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, 消费者- i).start(); } producer.start(); } }你可以把这段代码粘到自己的环境里跑一下观察输出。注意每个方法上加了synchronized锁对象就是this。等待条件用的是while (queue.size() capacity)和while (queue.isEmpty())没有用if。6.2 把notifyAll换成notify试试看做个对比实验把上面代码中的notifyAll替换成notify()然后用一个生产者、三个消费者跑。你会发现程序偶尔会卡住所有线程都WAITING控制台不再输出任何东西。这就是notify的信号丢失问题在实际环境中的表现。为什么一定会有这个问题因为当队列满时可能有两个生产者都在wait消费者取走一个数据后调用notify有可能唤醒的是另一个生产者而不是正在等待空位的消费者那个生产者唤醒后发现队列依然满再次wait消费者已经被白白浪费了一次唤醒机会。多跑几次迟早出现全员等待。在单生产者、单消费者、单一条件时不会卡这也正是notify的诱惑所在——小规模场景下它表现正常导致很多人误以为它可以用。这种“小规模正常规模化崩溃”的bug是最难排查的那种。生产环境里线程数量一大偶发卡死又复现不出来排查成本极高。6.3 从jstack日志里看线程卡死现场如果线上真出现了这种问题怎么确认第一步执行jstack PID抓取线程快照。第二步在堆栈里搜WAITING状态的线程会看到类似下面的输出消费者-1 #13 prio5 os_prio0 cpu12.00ms elapsed325.23s tid0x00007f8a3400a800 nid0x2f02 in Object.wait() [0x00007f8a2c3f9000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) at SyncBlockingQueue.take(SyncBlockingQueue.java:24) - waiting on 0x0000000713d6f8b0 (a SyncBlockingQueue) at SyncBlockingQueue$$Lambda$2.run(...) at java.lang.Thread.run(...)如果所有生产者和消费者全部停在Object.wait()而且没有任何锁竞争信息没有BLOCKED线程说明大家都在等待但没有谁去唤醒别人。这时候优先怀疑notify/notifyAll使用不当。第三步再用jstack多抓几次确认线程状态始终一致排除临时抖动。第四步回顾代码检查是否存在不同wait条件共用一把锁的情况。如果是把notify改成notifyAll或者拆成Lock 多Condition。6.4 一套快速判断是否改对了的自测方法代码修改完之后不只要看“跑一遍没问题”还需要做几轮压力测试并发度调高消费者从3个调到8个生产者从1个调到3个反复跑观察是否还有所有线程WAITING的时刻条件触发频率调高把生产间隔从100ms改成1ms让队列频繁在满和空之间切换运行时间拉长至少跑5到10分钟而不是几秒钟加入线程状态监控用jstack定时抓线程状态统计WAITING线程数如果偶发出现全员WAITING立刻修复如果不做这种压力测试只靠肉眼“程序没死”来判断很可能漏掉低频的偶发问题。7. 常见误区和面试追问7.1 面试高频追问一wait会不会被中断如果线程在wait状态被调用interrupt()线程会从wait中醒来但不会立即抛异常而是在重新获取锁之后才抛出InterruptedException。而且wait有一个带超时参数的版本wait(long timeout)超时后线程自动回到等待队列这和notify唤醒在行为上是类似的——先竞争锁抢到后从wait返回。7.2 面试高频追问二notifyAll唤醒的线程要去抢锁那刚唤醒时线程是什么状态被notifyAll唤醒的线程不是RUNNABLE而是BLOCKED状态。它先从_WaitSet移入_EntryList等待获取monitor锁。只有抢到锁的瞬间才变为RUNNABLE。这个细节很多开发者都不清楚其实这一步能看出你对ObjectMonitor队列模型的理解。7.3 面试高频追问三synchronized锁对象如何选锁对象的选取是一个经常被低估的问题。有人喜欢 同步字符串常量池里的内容很危险因为JVM里同一个字符串字面量是全局共享的可能导致毫不相关的类互相锁住。正确做法是使用私有的、不会被外部修改的对象作为锁最好是final修饰避免锁引用被改变后线程对不上号。7.4 面试高频追问四synchronized和ReentrantLock怎么选Lock的优势是支持超时、可中断、多Condition精确唤醒但使用成本更高容易忘记unlock代码可读性差。synchronized的优势是简洁出现异常时JVM自动解锁。选择原则很简单能用synchronized就用synchronized只有需要高级功能超时/中断/多条件时再换Lock。很多团队规定禁止用synchronized换成Lock的“炫技式重构”因为收益远小于引入的风险。7.5 业务代码中的真实踩坑案例我遇到过一个线上事故任务调度系统里多个工作线程共享一个任务队列用synchronized wait/notify实现任务分发。某天上线一个需求往队列里加了一个“特殊类型任务优先处理”的逻辑为了优雅某开发者在同一个锁对象上加了一个新的wait条件结果就是notify随机唤醒偶发出现任务队列里有任务但所有工作线程都在WAITING的严重故障。最后排查出来原因就是标题里问的这个问题。把notify改成notifyAll之后故障彻底消失半天压力测试零复发。这类问题在代码评审里很难发现因为bug触发需要特定时序概率极低。这也正是为什么我坚持在团队规范里写死一条synchronized wait场景禁用notify强制notifyAll。没有人反对——因为没人能证明自己的锁对象上永远不会增加第二个条件变量。8. 最后分享一点实用的排查习惯如果你在维护一个老项目代码里已经有大量notify()调用不要急着全部替换。先做一个轻量级审计对每个使用notify的地方确认锁对象上有几个条件变量。只有一个等待条件、并且并发模型简单单生产者单消费者的代码可以暂时保留但要在注释里写清楚为什么这里是安全的。如果锁对象上存在多个条件变量或者线程数量较多别犹豫统一改成notifyAll。改完以后用我上面说的压力测试方法跑一轮然后交给团队review。我个人在实际排查线程卡死问题时还有个土办法在测试环境把所有线程的名字打印到日志里出现异常时从日志中一眼看出哪个线程在做什么、在哪一步暂停。这个方法比对着jstack猜要快得多。每次遇到并发相关的诡异问题先别急着怀疑JDK有bug——99%的情况是自己对wait/notify的语义理解不到位。把ObjectMonitor的原理吃透把Mesa模型这件事记在心里很多问题其实根本不会发生。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →