Java 多线程:ReentrantLock 深入学习
一、ReentrantLock 的基本原理
1. 什么是 ReentrantLock?
ReentrantLock 是 JDK 提供的一种可重入互斥锁,基于 AQS 实现。
主要特点:
支持可重入。
支持公平锁和非公平锁。
支持尝试获取锁。
支持可中断地获取锁。
支持超时获取锁。
支持多个 Condition 条件队列。
2. 什么是可重入?
可重入指的是:同一个线程可以重复获取自己已经持有的同一把锁。
例如:
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
lock.lock();
try {
System.out.println("第二次获取锁");
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}底层通过 AQS 的 state 记录锁的持有次数。
第一次获取锁:
state = 1
第二次获取锁:
state = 2
第一次释放锁:
state = 1
第二次释放锁:
state = 0只有 state 完全归零,锁才真正释放。
注意:
同一个线程可以重复获取自己持有的锁。
其他线程不能因为可重入而进入临界区。
每次 lock() 都需要对应一次 unlock()。
3. ReentrantLock 的底层结构
ReentrantLock
|
v
Sync(继承 AQS)
|
├── NonfairSync(非公平锁)
|
└── FairSync(公平锁)ReentrantLock 通过 Sync 继承 AQS,实现公平锁和非公平锁的获取策略。
二、公平锁与非公平锁
1. 公平锁
公平锁的核心思想:
尽量按照线程请求锁的先后顺序,让等待时间更长的线程先获得锁。
例如线程 A 持有锁,线程 B、C、D 依次请求锁:
线程 A:持有锁
线程 B:先请求,进入等待队列
线程 C:后请求,进入等待队列
线程 D:最后请求,进入等待队列
A 释放锁
|
v
B 获得锁
|
v
C 获得锁
|
v
D 获得锁创建公平锁:
ReentrantLock lock = new ReentrantLock(true);2. 非公平锁
非公平锁允许新来的线程直接尝试获取锁,即使等待队列中已经有其他线程,也可能抢先获得锁。
例如:
线程 A:持有锁
线程 B:进入等待队列
线程 C:进入等待队列
线程 A:释放锁
线程 D:刚好到来,直接尝试 CAS 获取锁
如果 D 抢先成功:
D 获得锁
B、C 继续等待这就是插队。
创建非公平锁:
// 默认就是非公平锁
ReentrantLock lock = new ReentrantLock();
// 明确指定非公平锁
ReentrantLock lock2 = new ReentrantLock(false);3. 公平锁与非公平锁的底层区别
非公平锁:
新线程获取锁时,优先尝试 CAS。
简化逻辑:
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(
Thread.currentThread()
);
return;
}
// 获取失败,再进入 AQS 队列等待
acquire(1);即使队列中已经存在等待线程,新线程仍然可能直接抢到锁。
公平锁:
获取锁时,会先检查是否存在排在前面的等待线程。
if (getState() == 0
&& !hasQueuedPredecessors()
&& compareAndSetState(0, 1)) {
setExclusiveOwnerThread(
Thread.currentThread()
);
return true;
}其中:
hasQueuedPredecessors()用于判断当前线程前面是否存在等待获取锁的线程。
如果存在,就不会直接抢锁。
4. 公平锁和非公平锁的区别
为什么非公平锁通常性能更好?
因为新线程可能直接获取锁,不需要等待前面的线程被唤醒和调度,可以减少部分上下文切换和排队成本。
注意:
公平锁不代表操作系统严格按照请求时间调度线程。
公平锁不代表绝对不会发生线程长时间等待。
默认情况下,ReentrantLock 使用非公平锁。
生产环境通常优先使用非公平锁,除非业务确实需要公平性。
5. 公平锁的一个陷阱:tryLock()
即使创建的是公平锁:
ReentrantLock lock = new ReentrantLock(true);调用:
lock.tryLock();仍然可能插队。
因为无参 tryLock() 不遵循公平性检查。
如果希望尝试获取锁时也遵循公平策略,可以使用:
lock.tryLock(0, TimeUnit.SECONDS);注意:这个重载方法支持中断,调用时需要处理 InterruptedException。
三、ReentrantLock 的三种获取锁方式
ReentrantLock 常见的三种获取锁方式:
lock();
tryLock();
lockInterruptibly();1. lock():一直等待获取锁
lock.lock();如果锁已经被其他线程持有,当前线程会进入等待队列,等待其他线程释放锁。
特点:
获取成功后继续执行。
获取失败会等待。
等待期间不会因为中断而放弃获取锁。
如果等待期间发生中断,获取成功后会保留中断标记。
示例:
ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
System.out.println("执行业务逻辑");
} finally {
lock.unlock();
}
}2. tryLock():尝试获取锁,不等待
boolean result = lock.tryLock();它的意思是:
尝试获取锁,如果获取失败,立即返回 false,不会一直等待。
示例:
ReentrantLock lock = new ReentrantLock();
public void doSomething() {
if (lock.tryLock()) {
try {
System.out.println("获取锁成功,执行业务");
} finally {
lock.unlock();
}
} else {
System.out.println("获取锁失败,暂不执行");
}
}执行过程:
调用 tryLock()
|
v
尝试获取锁
|
├── 成功
| |
| v
| 执行业务
|
└── 失败
|
v
立即返回 false适用场景:
例如后台任务正在执行,不希望其他线程重复执行同一任务。
if (!lock.tryLock()) {
return;
}
try {
// 执行任务
} finally {
lock.unlock();
}tryLock 还有一个定时版本:
boolean result = lock.tryLock(3, TimeUnit.SECONDS);表示最多等待 3 秒,如果仍然没有获得锁,就返回 false。
完整示例:
try {
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
// 执行业务
} finally {
lock.unlock();
}
} else {
System.out.println("等待 3 秒,仍未获取到锁");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}3. lockInterruptibly():可中断地等待获取锁
lock.lockInterruptibly();它与 lock() 最大的区别:
如果当前线程在等待获取锁的过程中被其他线程中断,可以放弃等待,并抛出 InterruptedException。
示例:
import java.util.concurrent.locks.ReentrantLock;
public class InterruptLockDemo {
private static final ReentrantLock LOCK =
new ReentrantLock();
public static void main(String[] args)
throws InterruptedException {
LOCK.lock();
Thread worker = new Thread(() -> {
try {
System.out.println("准备获取锁");
LOCK.lockInterruptibly();
try {
System.out.println("获取锁成功");
} finally {
LOCK.unlock();
}
} catch (InterruptedException e) {
System.out.println("等待获取锁时被中断");
Thread.currentThread().interrupt();
}
});
worker.start();
Thread.sleep(1000);
// 中断 worker 的等待
worker.interrupt();
LOCK.unlock();
worker.join();
}
}执行过程:
主线程先获得锁
|
v
worker 调用 lockInterruptibly()
|
v
worker 进入等待
|
v
主线程调用 worker.interrupt()
|
v
worker 响应中断
|
v
抛出 InterruptedException
|
v
放弃等待获取锁注意:
InterruptedException 被抛出时,中断标记通常已经被清除。
因此:
Thread.currentThread().interrupt();可以在捕获异常后恢复中断标记,让上层调用者感知中断状态。
4. 三种方式对比
四、Condition 条件队列
1. Condition 是什么?
Condition 是 ReentrantLock 提供的条件等待机制。
它的作用类似于 Object 的:
wait();
notify();
notifyAll();但是 Condition 更灵活。
最重要的特点:
一把 ReentrantLock 可以创建多个 Condition,每个 Condition 都有自己独立的条件等待队列。
创建方式:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();2. Condition 与 Object.wait() 的区别
注意:
wait()、notify()、notifyAll() 必须持有对应对象的监视器锁。
await()、signal()、signalAll() 必须持有对应 ReentrantLock。
不满足条件会抛出 IllegalMonitorStateException。
3. await():进入条件等待队列
await() 的作用:
让当前线程进入 Condition 等待队列,并完全释放当前线程持有的锁。
例如:
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}await() 内部主要执行:
创建 Condition 等待节点。
加入 Condition 条件队列。
完全释放当前线程持有的重入锁。
阻塞当前线程。
被唤醒后重新竞争锁。
成功获得锁后,await() 才返回。
流程:
线程 A 获取锁
|
v
调用 await()
|
├── 进入 Condition 等待队列
|
├── 完全释放当前锁
|
v
线程 A 阻塞等待4. signal():唤醒一个等待线程
signal() 用于将 Condition 等待队列中的一个线程转移到 AQS 同步队列。
例如:
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}执行过程:
线程 A 调用 await()
|
v
进入 Condition 等待队列
|
v
线程 B 调用 signal()
|
v
A 从 Condition 队列
转移到 AQS 同步队列
|
v
B 释放锁
|
v
A 重新竞争锁
|
v
A 获得锁后,await() 返回signal() 不是直接让等待线程获得锁,而是让它进入 AQS 同步队列,重新竞争锁。
即使 A 已经被 signal() 通知,只要 B 还没有释放锁,A 就不能继续执行。
5. signalAll():唤醒所有等待线程
区别:
signal():将一个等待节点转移到同步队列。
signalAll():将当前 Condition 等待队列中的所有等待节点转移到同步队列。
注意:
signalAll() 不代表所有线程同时获得锁,它们仍然需要依次竞争锁。
6. Condition 的两个队列
Condition 与 AQS 同步队列是两个不同的队列。
完整流程:
调用 await()
|
v
进入 Condition 条件队列
|
v
完全释放锁
|
v
阻塞等待
|
v
其他线程调用 signal()
|
v
转移到 AQS 同步队列
|
v
重新竞争锁
|
v
获取锁成功
|
v
await() 返回补充一个重要细节:
如果线程调用 await() 前已经重入同一把锁两次:
await 前:state = 2
await 释放锁:state = 0
被 signal 后重新竞争锁
await 返回:state = 2await() 会完全释放锁,并在返回前恢复原来的重入次数。
五、Condition 实战:生产者与消费者
假设有一个容量为 3 的消息队列。
要求:
队列满了,生产者等待。
队列为空,消费者等待。
有数据后,通知消费者。
有空位后,通知生产者。
使用两个 Condition:
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();分别负责:
notFull:队列未满。
notEmpty:队列非空。
完整代码:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final ReentrantLock lock =
new ReentrantLock();
// 队列未满时,生产者可以继续生产
private final Condition notFull =
lock.newCondition();
// 队列非空时,消费者可以继续消费
private final Condition notEmpty =
lock.newCondition();
public BoundedQueue(int capacity) {
this.capacity = capacity;
}
// 生产数据
public void put(T item) throws InterruptedException {
lock.lockInterruptibly();
try {
// 队列满了,生产者等待
while (queue.size() == capacity) {
notFull.await();
}
queue.offer(item);
System.out.println("生产:" + item);
// 通知一个消费者
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 消费数据
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
// 队列为空,消费者等待
while (queue.isEmpty()) {
notEmpty.await();
}
T item = queue.poll();
System.out.println("消费:" + item);
// 通知一个生产者
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}使用示例:
public class ProducerConsumerDemo {
public static void main(String[] args) {
BoundedQueue<Integer> queue =
new BoundedQueue<>(3);
// 生产者
Thread producer = new Thread(() -> {
int i = 0;
while (!Thread.currentThread().isInterrupted()) {
try {
queue.put(i++);
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// 消费者
Thread consumer = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
queue.take();
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
producer.start();
consumer.start();
}
}为什么必须使用 while,而不是 if?
错误写法:
if (queue.isEmpty()) {
notEmpty.await();
}正确写法:
while (queue.isEmpty()) {
notEmpty.await();
}原因:
Java 允许线程发生伪唤醒。
即使线程被正常唤醒,也不代表业务条件依然成立。
可能有其他线程先获得锁并消费了数据。
因此:
线程被唤醒后,必须重新检查业务条件,不能直接认为条件已经满足。
这是 Condition 和 Object.wait() 都必须遵守的原则。
六、ReentrantLock 与 synchronized 的区别
什么时候使用 synchronized?
简单的共享变量保护:
public synchronized void update() {
// 修改共享变量
}如果没有特殊需求,synchronized 通常是很好的选择。
什么时候使用 ReentrantLock?
以下情况更适合 ReentrantLock:
需要公平锁。
需要尝试获取锁,失败就放弃。
需要设置锁等待超时时间。
需要响应中断,取消锁等待。
需要多个 Condition 条件队列。
ReentrantLock 的功能更丰富,但不代表所有场景都应该使用它。
七、生产环境注意事项
1. 一定要在 finally 中释放锁
不推荐:
lock.lock();
doSomething();
lock.unlock();如果 doSomething() 抛出异常,unlock() 就不会执行。
推荐:
lock.lock();
try {
doSomething();
} finally {
lock.unlock();
}2. 不要把 ReentrantLock 当作分布式锁
ReentrantLock 只在当前 JVM 内有效。
例如 Docker 部署三个 Spring Boot 实例:
容器 A:ReentrantLock A
容器 B:ReentrantLock B
容器 C:ReentrantLock C三个容器中的锁互相独立。
如果需要跨实例互斥,需要考虑 Redis 分布式锁、数据库锁等方案。
3. 不要在持有锁时执行耗时操作
例如:
lock.lock();
try {
// 调用外部 HTTP 接口
// 执行耗时操作
// 长时间等待
} finally {
lock.unlock();
}这样会导致其他线程长时间无法获得锁。
推荐尽可能缩小加锁范围,只保护真正需要线程安全的共享资源。
但如果业务要求整个操作期间保持互斥,就不能为了缩短锁时间随意释放锁。
4. 多把锁注意死锁
例如:
线程 A:
先获取锁 1
再获取锁 2
线程 B:
先获取锁 2
再获取锁 1可能出现:
线程 A 持有锁 1,等待锁 2
线程 B 持有锁 2,等待锁 1双方互相等待,形成死锁。
解决方案之一:
统一规定多把锁的获取顺序。
另外,可以使用 tryLock(timeout, unit) 设计有限等待和失败回退机制,但必须保证失败时正确释放已经获取的锁。
八、重点总结
需要特别记住:
ReentrantLock 支持可重入,state 记录重入次数。
默认创建的是非公平锁。
非公平锁允许新线程直接 CAS 抢锁。
无参 tryLock() 即使在公平锁中也可能插队。
lock() 不响应中断来取消获取锁,但等待期间发生中断,成功获取后会保留中断标记。
lockInterruptibly() 可以响应中断并放弃等待。
await() 会完全释放当前线程持有的锁。
signal() 只是将等待节点转移到同步队列,不代表立即获得锁。
await() 返回前必须重新获得原来的锁。
Condition 等待必须使用 while 检查条件。
ReentrantLock 必须在 finally 中释放。
ReentrantLock 只在当前 JVM 内有效。
九、常见问题与答案
1. 公平锁和非公平锁最大的区别是什么?
公平锁获取锁时会考虑是否有排在前面的等待线程;非公平锁允许新线程直接尝试 CAS 获取锁。
2. 为什么非公平锁通常性能更好?
因为新线程可能直接获得锁,减少等待线程唤醒和上下文切换带来的开销。
3. lock() 和 lockInterruptibly() 有什么区别?
lock() 等待期间不会因为中断而放弃获取锁;lockInterruptibly() 等待期间被中断,可以抛出 InterruptedException 并放弃等待。
4. tryLock() 失败后会怎样?
立即返回 false,不会一直等待。
5. await() 为什么必须释放锁?
因为其他线程需要获得同一把锁,才能改变条件并调用 signal() 唤醒等待线程。
6. signal() 后等待线程是否立即执行?
不一定。它只是从 Condition 队列转移到 AQS 同步队列,还需要等待释放锁并重新竞争。
7. await() 为什么要使用 while,而不是 if?
因为可能发生伪唤醒,而且被唤醒后业务条件可能再次不成立,所以必须重新检查。
8. 一个 ReentrantLock 为什么可以创建多个 Condition?
因为每个 Condition 都拥有自己的条件等待队列,可以将不同条件的线程分别管理。
9. Condition 和 Object.wait() 最大的区别是什么?
Condition 可以创建多个独立等待队列,而一个对象监视器对应一个等待集。
10. ReentrantLock 和 synchronized 应该如何选择?
简单的同步场景优先 synchronized;需要公平性、超时获取锁、可中断获取锁或多个 Condition 时,选择 ReentrantLock。
十、复习测试
ReentrantLock 为什么支持可重入?state 是如何变化的?
公平锁和非公平锁的底层获取锁策略有什么区别?
为什么非公平锁通常比公平锁吞吐量高?
为什么公平锁调用无参 tryLock() 仍然可能插队?
lock()、tryLock()、lockInterruptibly() 有什么区别?
lock() 等待期间被中断,会发生什么?
lockInterruptibly() 等待期间被中断,会发生什么?
Condition 是什么?解决了什么问题?
await() 为什么要完全释放锁?
signal() 为什么不代表等待线程立即获得锁?
Condition 队列与 AQS 同步队列有什么区别?
为什么 await() 必须使用 while,而不能使用 if?
一个 ReentrantLock 创建多个 Condition 有什么好处?
ReentrantLock 和 synchronized 分别适合什么场景?
为什么 ReentrantLock 必须在 finally 中 unlock()?
为什么 ReentrantLock 不能作为 Docker 多实例之间的分布式锁?
