ReentrantLock与Condition对象:实现比wait/notify更精细的线程等待与唤醒
各位同学,大家好!今天我们来深入探讨Java并发编程中一个非常重要的工具:ReentrantLock及其配套的Condition对象。在传统的并发编程中,我们常常使用synchronized关键字配合wait()和notify()/notifyAll()方法来实现线程的等待和唤醒。然而,这种方式在某些复杂的场景下显得不够灵活和精细。ReentrantLock和Condition的出现,为我们提供了更强大、更精细的线程同步和通信机制。
一、synchronized和wait/notify的局限性
在使用synchronized关键字时,每个Java对象都有一个与之关联的内部锁(也称为监视器锁)。当一个线程进入synchronized代码块时,它会尝试获取该对象的锁。如果锁已经被其他线程持有,则该线程会被阻塞,直到获取到锁为止。
wait()、notify()和notifyAll()方法必须在synchronized代码块中使用,它们是Object类的方法,用于线程之间的通信:
wait():使当前线程释放锁,并进入等待状态,直到被其他线程唤醒。notify():唤醒一个等待在该对象锁上的线程。如果有多个线程在等待,则随机唤醒一个。notifyAll():唤醒所有等待在该对象锁上的线程。
虽然synchronized和wait/notify机制在简单场景下足够使用,但存在一些局限性:
- 单一等待队列: 所有调用
wait()方法的线程都会进入同一个等待队列。当调用notify()或notifyAll()时,无法精确地控制唤醒哪个或哪些线程。这在需要区分不同条件的等待线程时,会导致不必要的线程唤醒和竞争,降低程序效率。例如,生产者-消费者模型中,当队列为空时,所有消费者线程都在等待;当生产者生产了一个产品后,调用notifyAll()会唤醒所有消费者,但只有一个消费者能够成功消费,其他消费者会再次进入等待状态。 - 强制性锁定:
synchronized是隐式锁定,一旦进入synchronized代码块,就必须持有锁,直到代码块执行完毕或调用wait()方法释放锁。这使得锁定和释放锁的时机不够灵活。 - 非公平性:
synchronized默认是非公平锁,等待时间最长的线程不一定能优先获得锁,可能导致某些线程饥饿。
二、ReentrantLock:可重入锁
ReentrantLock是Java并发包java.util.concurrent.locks中提供的一个可重入的互斥锁。它提供了比synchronized更强大的功能和更灵活的控制。
ReentrantLock的特点:
- 可重入性: 一个线程可以多次获取同一个
ReentrantLock锁,每次获取锁计数器都会加1,释放锁时计数器减1,只有当计数器为0时,锁才真正被释放。这避免了死锁的发生,特别是在递归调用的情况下。 - 公平性/非公平性: 可以选择公平锁或非公平锁。公平锁会按照线程请求锁的顺序授予锁,而非公平锁则允许插队,可能会导致某些线程饥饿。
- 中断响应: 可以响应中断,允许线程在等待锁的过程中被中断,从而避免无限期等待。
- 超时获取: 可以设置获取锁的超时时间,避免线程无限期等待。
- 提供Condition对象: 可以创建多个
Condition对象,每个Condition对象都有自己的等待队列,可以实现更精细的线程等待和唤醒。
ReentrantLock的基本用法:
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void accessResource() {
lock.lock(); // 获取锁
try {
// 访问共享资源的代码
System.out.println(Thread.currentThread().getName() + " acquired the lock.");
} finally {
lock.unlock(); // 释放锁
System.out.println(Thread.currentThread().getName() + " released the lock.");
}
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
for (int i = 0; i < 3; i++) {
new Thread(() -> {
example.accessResource();
}, "Thread-" + i).start();
}
}
}
注意事项:
- 必须在
try-finally块中释放锁,确保即使在发生异常的情况下,锁也能被正确释放,避免死锁。
三、Condition对象:更精细的线程等待和唤醒
Condition对象是ReentrantLock的内部类,用于管理等待特定条件的线程。每个Condition对象都有自己的等待队列,线程可以通过Condition对象来等待特定的条件满足,并通过Condition对象的signal()或signalAll()方法来唤醒等待的线程。
Condition对象的方法:
| 方法 | 描述 |
|---|---|
await() |
使当前线程释放锁,并进入该Condition对象的等待队列,直到被其他线程通过signal()或signalAll()唤醒。类似于Object.wait()。 |
signal() |
唤醒该Condition对象等待队列中的一个线程。如果有多个线程在等待,则随机唤醒一个。类似于Object.notify()。 |
signalAll() |
唤醒该Condition对象等待队列中的所有线程。类似于Object.notifyAll()。 |
awaitUninterruptibly() |
使当前线程释放锁,并进入该Condition对象的等待队列,直到被其他线程通过signal()或signalAll()唤醒。与await()不同的是,该方法不会响应中断。 |
awaitNanos(long nanosTimeout) |
使当前线程释放锁,并进入该Condition对象的等待队列,等待指定的时间(纳秒)。如果在超时时间内没有被唤醒,则线程会自动被唤醒。 |
await(long time, TimeUnit unit) |
使当前线程释放锁,并进入该Condition对象的等待队列,等待指定的时间。如果在超时时间内没有被唤醒,则线程会自动被唤醒。 |
awaitUntil(Date deadline) |
使当前线程释放锁,并进入该Condition对象的等待队列,等待直到指定的截止时间。如果在截止时间之前没有被唤醒,则线程会自动被唤醒。 |
Condition对象的使用示例:生产者-消费者模型
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumer {
private final Queue<Integer> buffer = new LinkedList<>();
private final int maxSize = 10;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 缓冲区未满的条件
private final Condition notEmpty = lock.newCondition(); // 缓冲区非空的条件
public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == maxSize) {
System.out.println("Buffer is full, producer waiting...");
notFull.await(); // 缓冲区已满,生产者等待
}
buffer.offer(value);
System.out.println("Produced: " + value);
notEmpty.signal(); // 唤醒等待的消费者
} finally {
lock.unlock();
}
}
public int consume() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) {
System.out.println("Buffer is empty, consumer waiting...");
notEmpty.await(); // 缓冲区为空,消费者等待
}
int value = buffer.poll();
System.out.println("Consumed: " + value);
notFull.signal(); // 唤醒等待的生产者
return value;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ProducerConsumer pc = new ProducerConsumer();
new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
pc.produce(i);
Thread.sleep((long) (Math.random() * 100));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer").start();
new Thread(() -> {
try {
for (int i = 0; i < 20; i++) {
pc.consume();
Thread.sleep((long) (Math.random() * 100));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer").start();
}
}
在这个例子中,我们使用ReentrantLock来保证对共享缓冲区buffer的互斥访问。同时,我们创建了两个Condition对象:notFull和notEmpty。notFull用于管理等待缓冲区未满的生产者线程,notEmpty用于管理等待缓冲区非空的消费者线程。
- 当缓冲区已满时,生产者线程调用
notFull.await()方法释放锁,并进入notFull的等待队列。 - 当消费者线程消费了一个产品后,调用
notFull.signal()方法唤醒notFull等待队列中的一个生产者线程。 - 当缓冲区为空时,消费者线程调用
notEmpty.await()方法释放锁,并进入notEmpty的等待队列。 - 当生产者线程生产了一个产品后,调用
notEmpty.signal()方法唤醒notEmpty等待队列中的一个消费者线程。
通过使用Condition对象,我们可以实现更精细的线程等待和唤醒,避免了不必要的线程唤醒和竞争,提高了程序的效率。
四、ReentrantLock vs synchronized
| 特性 | synchronized |
ReentrantLock |
|---|---|---|
| 实现方式 | JVM内置关键字 | Java类库提供的类 |
| 锁定机制 | 隐式锁定,自动加锁和释放锁 | 显式锁定,需要手动加锁和释放锁,必须在try-finally块中释放锁 |
| 灵活性 | 较低,锁定和释放锁的时机不够灵活 | 较高,可以灵活地控制锁定和释放锁的时机 |
| 公平性 | 默认非公平锁,可以通过JVM参数设置为公平锁 | 可以选择公平锁或非公平锁 |
| 中断响应 | 无法响应中断 | 可以响应中断,允许线程在等待锁的过程中被中断 |
| 超时获取 | 不支持超时获取 | 支持超时获取,可以设置获取锁的超时时间 |
| Condition对象 | 不支持 | 支持创建多个Condition对象,每个Condition对象都有自己的等待队列,可以实现更精细的线程等待和唤醒 |
| 性能 | 在JDK 1.6之后,synchronized经过优化,在某些情况下性能可能与ReentrantLock相近甚至更好 (特别是竞争不激烈的情况下) |
在高并发、竞争激烈的情况下,ReentrantLock的性能通常优于synchronized (特别是需要公平锁、中断响应、超时获取等特性时) |
五、使用场景选择
- 简单场景: 如果并发控制逻辑比较简单,且不需要公平锁、中断响应、超时获取等特性,可以使用
synchronized。 - 复杂场景: 如果需要更灵活的锁定和释放锁的时机,需要公平锁、中断响应、超时获取等特性,或者需要实现更精细的线程等待和唤醒,应该使用
ReentrantLock和Condition。 - 性能敏感场景: 在高并发、竞争激烈的场景下,如果需要公平锁、中断响应、超时获取等特性,应该优先选择
ReentrantLock。
六、最佳实践
- 始终在
try-finally块中释放锁: 确保即使在发生异常的情况下,锁也能被正确释放,避免死锁。 - 选择合适的锁类型: 根据实际需求选择公平锁或非公平锁。公平锁可以避免线程饥饿,但性能可能较低;非公平锁性能较高,但可能导致某些线程饥饿。
- 合理使用
Condition对象: 根据不同的等待条件创建不同的Condition对象,避免不必要的线程唤醒和竞争。 - 避免长时间持有锁: 尽量缩短持有锁的时间,减少线程阻塞的可能性。
- 注意死锁问题: 避免多个线程互相等待对方释放锁,导致死锁。
总结一下所讲内容
ReentrantLock和Condition对象为我们提供了比synchronized和wait/notify更强大、更灵活的线程同步和通信机制。通过使用ReentrantLock和Condition,我们可以实现更精细的线程等待和唤醒,提高程序的效率和可靠性。在实际开发中,我们应该根据具体的场景选择合适的并发控制工具,并遵循最佳实践,避免出现并发问题。
今天的讲解就到这里,谢谢大家!