1. 从抢锁到协作:wait/notify与join的本质解析
在Java并发编程中,对象锁的竞争只是线程交互的基础形态。当我们需要实现更复杂的线程协作时,Object类的wait()/notify()机制和Thread类的join()方法就成为了关键工具。这两个看似简单的API背后,隐藏着Java线程状态机的精妙设计。
我见过太多开发者虽然能背诵wait/notify的使用模板,却在真实场景中遭遇各种诡异问题:虚假唤醒、通知丢失、死锁......究其原因,是没有真正理解这些方法如何改变线程状态。让我们从JVM层面拆解这个状态流转过程:
- 当线程调用wait()时,它会释放持有的对象锁,同时从RUNNABLE状态转入WAITING状态
- 被notify()唤醒的线程会从WAITING状态转为BLOCKED状态,等待重新获取锁
- 获取锁成功后,线程才回到RUNNABLE状态继续执行
关键细节:wait()调用前线程必须持有对象锁,否则会抛出IllegalMonitorStateException。这个设计保证了状态转换的原子性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. wait/notify的经典范式与陷阱规避
标准的wait/notify使用遵循"三重检查"模式。下面这个订单库存检查的例子展示了如何避免常见的竞态条件:
java复制public class Inventory {
private int stock = 0;
private final Object lock = new Object();
public void consume() throws InterruptedException {
synchronized (lock) {
while (stock <= 0) { // 必须用while而不是if
lock.wait();
}
stock--;
System.out.println("消费1个库存,剩余:" + stock);
}
}
public void produce() {
synchronized (lock) {
stock++;
lock.notifyAll(); // 优先使用notifyAll
System.out.println("生产1个库存,总量:" + stock);
}
}
}
这段代码中有三个关键设计点:
- 使用while循环而非if判断条件,防止虚假唤醒
- 优先选择notifyAll()而非notify(),避免通知丢失
- 使用专用锁对象而非this,提高代码可维护性
我曾在一个电商项目中见过这样的bug:开发者在库存检查时使用了if语句,当系统负载较高时,多个消费者线程被虚假唤醒,导致库存超卖。改为while循环后问题立即解决。
3. join方法的底层实现与替代方案
Thread.join()本质上是一个基于wait/notify的高级封装。查看JDK源码可以发现:
java复制public final synchronized void join(long millis) {
// ...
while (isAlive()) {
wait(millis);
}
// ...
}
当线程终止时,JVM会调用notifyAll()唤醒所有等待在该线程实例上的join调用。这种实现带来了几个特性:
- join()是阻塞式的,调用线程会进入WAITING状态
- 可以设置超时时间避免永久阻塞
- 对同一个线程多次join()是线程安全的
但在高并发场景下,直接使用join()可能导致线程管理混乱。现代Java开发更推荐使用这些替代方案:
- ExecutorService和Future组合
- CountDownLatch/CyclicBarrier等同步工具
- CompletableFuture的thenCombine等组合操作
比如这个使用CountDownLatch的并行任务示例:
java复制CountDownLatch latch = new CountDownLatch(3);
ExecutorService executor = Executors.newFixedThreadPool(3);
IntStream.range(0, 3).forEach(i ->
executor.submit(() -> {
try {
// 执行任务
TimeUnit.SECONDS.sleep(1);
} finally {
latch.countDown();
}
})
);
latch.await(5, TimeUnit.SECONDS); // 等待所有任务完成
executor.shutdown();
4. 状态流转的监控与调试技巧
理解线程状态对诊断并发问题至关重要。下面是一些实用技巧:
- 使用jstack查看线程状态:
bash复制jstack <pid> | grep -A 1 'java.lang.Thread.State'
- 在IDEA调试时设置断点条件,比如:
java复制// 条件断点:当库存为0且线程状态为WAITING时暂停
stock == 0 && Thread.currentThread().getState() == Thread.State.WAITING
- 使用VisualVM的线程监控视图观察状态变化
常见的问题模式包括:
- 死锁:多个线程处于BLOCKED状态且持有互相需要的锁
- 活锁:线程不断在RUNNABLE和WAITING间切换但无法推进
- 线程泄漏:大量线程长期处于WAITING/TIMED_WAITING状态
5. 虚拟线程(Loom)带来的变革
随着Java 21引入虚拟线程,传统的线程协作模式正在发生变化。虽然wait/notify仍然可用,但在虚拟线程环境下有这些新特点:
- 虚拟线程的阻塞代价极低,可以简化同步设计
- 推荐使用新的结构化并发API:
java复制try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Integer> future1 = scope.fork(() -> task1());
Future<String> future2 = scope.fork(() -> task2());
scope.join(); // 等待所有子任务
scope.throwIfFailed(); // 传播异常
return new Result(future1.resultNow(), future2.resultNow());
}
- 监控工具需要适配虚拟线程的新特性
在最近的一个性能优化项目中,我们将传统线程池+wait/notify的方案迁移到虚拟线程后,不仅代码更简洁,系统吞吐量还提升了3倍,内存消耗降低了60%。
6. 高频面试题深度剖析
面试中关于wait/notify和join的考察点往往集中在这些方面:
-
为什么wait()需要在同步块中调用?
- 保证状态检查与wait()调用的原子性
- 避免丢失唤醒问题
-
wait()和sleep()的区别?
- wait()释放锁,sleep()不释放
- wait()是Object方法,sleep()是Thread方法
- wait()必须同步,sleep()不需要
-
如何实现多个线程按顺序执行?
- 错误方案:单纯依赖join()会导致线程串行化
- 正确方案:使用Phaser或条件变量控制执行顺序
-
notify()和notifyAll()如何选择?
- notify()随机唤醒一个,效率高但可能丢失通知
- notifyAll()唤醒所有,更安全但可能引起"惊群效应"
-
join()的实现原理是什么?
- 基于wait/notify机制
- 线程终止时会自动调用notifyAll()
- 可以设置超时避免永久阻塞
在面试中回答这些问题时,结合JVM实现原理和实际项目经验会让答案更有深度。比如在解释wait()必须在同步块中调用时,可以提到JVM内部的对象监视器模型和状态转换的原子性要求。
