1. 为什么需要等待-通知机制
在Java并发编程的世界里,线程间的协作是一个永恒的话题。想象这样一个场景:你去银行办理业务,发现前面有20个人在排队,而柜员只有一个。此时你有三个选择:
- 不断询问柜员"轮到我了没?"(忙等待)
- 取个号后找个座位休息,等叫号(等待-通知)
- 直接放弃办理业务(线程终止)
显然第二种方式最高效。这就是等待-通知机制在现实生活中的映射——它避免了无意义的资源消耗,让线程在条件不满足时优雅地等待,条件满足时及时被唤醒。
关键理解:等待-通知机制本质上是一种线程间通信方式,它解决了"条件不满足时线程该怎么办"的问题。相比忙等待(不断检查条件),它能显著降低CPU占用。
2. synchronized与内置锁的深度解析
2.1 对象监视器模型
每个Java对象都有一个内置锁(intrinsic lock)和一个关联的等待集(wait set)。当线程调用对象的wait()方法时:
- 释放该对象的锁
- 线程状态变为WAITING
- 线程进入该对象的等待集
public class MonitorExample { private final Object lock = new Object(); public void doWait() throws InterruptedException { synchronized (lock) { // 1. 获取锁 while (!condition) { lock.wait(); // 2. 释放锁并等待 } // 条件满足后继续执行 } } public void doNotify() { synchronized (lock) { // 必须持有相同锁 lock.notifyAll(); // 唤醒所有等待线程 } } }2.2 锁的获取与释放机制
synchronized关键字实现了互斥访问,但开发者常犯的错误是:
- 在非同步块中调用wait()/notify()(抛出IllegalMonitorStateException)
- 忽略wait()的三大特性:
- 自动释放锁
- 需要循环检查条件(避免虚假唤醒)
- 被唤醒后需要重新获取锁
实测陷阱:在Android开发中,如果在主线程调用wait()会导致ANR,因为主线程被阻塞无法处理用户输入。
3. wait()/notify()的正确打开方式
3.1 经典的生产者-消费者实现
public class BlockingQueue<T> { private Queue<T> queue = new LinkedList<>(); private int capacity; public BlockingQueue(int capacity) { this.capacity = capacity; } public synchronized void put(T item) throws InterruptedException { while (queue.size() == capacity) { wait(); // 队列满时等待 } queue.add(item); notifyAll(); // 唤醒可能等待的消费者 } public synchronized T take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空时等待 } T item = queue.remove(); notifyAll(); // 唤醒可能等待的生产者 return item; } }3.2 为什么必须用while而不是if
虚假唤醒(spurious wakeup)是操作系统层面的特性,可能导致线程在没有收到通知的情况下被唤醒。JDK官方文档明确要求:
线程也可能在没有被通知、中断或超时的情况下唤醒,即所谓的虚假唤醒。虽然这种情况在实践中很少发生,但应用程序必须通过测试应该导致线程被唤醒的条件来防范这种情况,并在条件不满足时继续等待。
4. 现代并发工具的对标方案
4.1 Lock与Condition接口
public class ConditionExample { private final Lock lock = new ReentrantLock(); private final Condition condition = lock.newCondition(); public void await() throws InterruptedException { lock.lock(); try { condition.await(); } finally { lock.unlock(); } } public void signal() { lock.lock(); try { condition.signal(); } finally { lock.unlock(); } } }与synchronized相比的优势:
- 一个Lock可以创建多个Condition
- 支持公平锁
- 可中断的锁获取
- 尝试获取锁
4.2 阻塞队列的实现选择
对于高并发场景,Java提供的线程安全队列:
| 队列类型 | 特性 | 适用场景 |
|---|---|---|
| ArrayBlockingQueue | 有界、数组实现 | 固定大小的缓冲池 |
| LinkedBlockingQueue | 可选有界、链表实现 | 无界或大容量队列 |
| SynchronousQueue | 不存储元素 | 直接传递任务的场景 |
| PriorityBlockingQueue | 优先级排序 | 任务优先级调度 |
| DelayQueue | 延时获取元素 | 定时任务调度 |
5. 性能优化与避坑指南
5.1 锁粒度的权衡
在电商秒杀系统开发中,我曾遇到过一个典型案例:最初设计将所有商品库存放在一个HashMap中,用单个锁保护,导致QPS只有200左右。通过以下优化提升到5000+:
- 按商品ID分片,不同商品使用不同锁
- 用ReadWriteLock实现读写分离
- 热点商品采用CAS操作
5.2 线程池与等待通知的交互
常见的死锁场景:
ExecutorService executor = Executors.newFixedThreadPool(1); Future<?> future = executor.submit(() -> { synchronized (lock) { while (!condition) { lock.wait(); // 死锁风险! } } }); // 如果通知任务也在同一个线程池... executor.submit(() -> { synchronized (lock) { lock.notifyAll(); } });解决方案:
- 使用不同线程池
- 改用await()/signal()并设置超时
- 使用CompletableFuture等高级API
6. JVM层面的实现原理
当线程调用wait()时,JVM会执行以下操作:
- 将线程状态从RUNNABLE改为WAITING
- 记录线程的锁重入计数
- 将线程加入对象的等待集
- 调用park()挂起线程
对应的HotSpot源码片段(简化):
void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS) { // 检查中断状态 if (interruptible && Thread::is_interrupted(Self, true)) { THROW(vmSymbols::java_lang_InterruptedException()); } // 创建WaitObject节点并加入等待集 WaitObject node(Self, millis); add_to_wait_set(&node); // 释放锁 exit(Self); // 挂起线程 if (millis == 0) { Self->_ParkEvent->park(); } else { Self->_ParkEvent->park(millis); } // 被唤醒后重新获取锁 enter(Self); }7. 真实案例:数据库连接池实现
以HikariCP为例,其等待获取连接的逻辑:
public Connection getConnection(long hardTimeout) throws SQLException { // 尝试从队列获取空闲连接 PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS); if (poolEntry == null) { // 创建新连接的逻辑 if (pendingConnectionCount < maxPoolSize) { createNewConnection(); } // 再次尝试获取 poolEntry = connectionBag.borrow(remainingTime, MILLISECONDS); } return poolEntry.createProxyConnection(); }关键优化点:
- 使用LockSupport而非Object.wait()
- 无锁设计的connectionBag
- 区分软超时和硬超时
8. 面试高频问题解析
8.1 wait()和sleep()的区别
| 特性 | wait() | sleep() |
|---|---|---|
| 所属类 | Object | Thread |
| 锁行为 | 释放锁 | 不释放锁 |
| 唤醒条件 | notify()/notifyAll()或超时 | 仅超时 |
| 使用要求 | 必须在同步块中 | 任何地方 |
| 异常 | 抛出InterruptedException | 抛出InterruptedException |
8.2 为什么wait()要在循环中调用
在一次线上事故排查中,我们发现某个订单状态更新偶尔会丢失。根本原因是:
// 错误写法 if (!order.isCompleted()) { wait(); // 可能虚假唤醒后直接继续执行 } // 正确写法 while (!order.isCompleted()) { wait(); // 每次唤醒都重新检查条件 }9. 多线程调试技巧
9.1 线程转储分析
当出现死锁时,可以通过jstack获取线程转储:
- 查找BLOCKED状态的线程
- 查看"waiting to lock <0x000000076bf62200>"信息
- 追踪锁的持有者
示例死锁报告:
"Thread-1" #12 prio=5 os_prio=0 tid=0x00007f48740f7000 nid=0x4a1e waiting for monitor entry [0x00007f486b7f6000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadLock$2.run(DeadLock.java:42) - waiting to lock <0x000000076bf62200> (a java.lang.Object) - locked <0x000000076bf62210> (a java.lang.Object) "Thread-0" #11 prio=5 os_prio=0 tid=0x00007f48740f5000 nid=0x4a1d waiting for monitor entry [0x00007f486b8f7000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadLock$1.run(DeadLock.java:28) - waiting to lock <0x000000076bf62210> (a java.lang.Object) - locked <0x000000076bf62200> (a java.lang.Object)9.2 VisualVM监控
使用VisualVM的线程监控功能可以:
- 实时查看线程状态
- 检测死锁
- 分析线程CPU占用
- 跟踪线程生命周期
10. 最佳实践总结
经过多个高并发项目的锤炼,我总结了以下黄金法则:
- 锁范围最小化:同步块只包含必要的操作
- 避免嵌套锁:按固定顺序获取多个锁
- 超时机制:任何等待都应设置合理超时
- 线程安全文档化:明确标注类的线程安全级别
- 优先使用并发容器:如ConcurrentHashMap、CopyOnWriteArrayList
- 考虑无锁算法:CAS、原子变量等
- 线程池隔离:不同类型任务使用不同线程池
在最近的一个金融交易系统中,我们通过以下优化将并发性能提升3倍:
- 用StampedLock替代ReadWriteLock
- 使用ThreadLocal保存线程本地变量
- 将notifyAll()改为notify()(在确定只唤醒一个线程时)
- 采用分层锁设计