问题

  1. ReentrantLock 是什么?为什么已经有 synchronized 了还需要它?
  2. ReentrantLock 和 synchronized 有什么区别?
  3. ReentrantLock 为什么叫“可重入锁”?可重入是怎么实现的?
  4. ReentrantLock 底层是怎么实现的?
    • AQS 是什么
    • state 表示什么
    • exclusiveOwnerThread 表示什么
  5. 一个线程调用 lock() 以后,完整的加锁流程是什么?
    • CAS 抢锁
    • 抢锁失败以后怎么办
    • 什么时候进入等待队列
    • 什么时候阻塞线程
  6. AQS 的等待队列是什么结构?
    • CLH 队列
    • head
    • tail
    • Node
  7. 等待线程是怎么被阻塞和唤醒的?
    • LockSupport.park()
    • LockSupport.unpark()
  8. unlock() 的完整流程是什么?
    • state 怎么减少
    • 什么时候真正释放锁
    • 怎么唤醒下一个线程
  9. ReentrantLock 是怎么实现可重入的?
  10. 公平锁和非公平锁有什么区别?
    • new ReentrantLock(true)
    • 为什么默认是非公平锁
    • 非公平锁为什么吞吐量通常更高
  11. tryLock() 和 lock() 有什么区别?
    • 立即失败
    • 超时获取
    • 适合什么场景
  12. lockInterruptibly() 是什么?和 lock() 有什么区别?
  13. Condition 是什么?为什么有了 wait/notify 还需要 Condition?
  14. 一个 ReentrantLock 为什么可以创建多个 Condition?
  15. Condition.await() 和 signal() 的底层流程是什么?
    • Condition Queue
    • AQS Queue
    • await 为什么会释放锁
    • signal 为什么不是直接让线程继续执行
  16. ReentrantLock 使用时为什么必须在 finally 里 unlock()?
  17. ReentrantLock 会发生死锁吗?常见死锁场景有哪些?
  18. ReentrantLock 和 synchronized 在性能上谁更好?
  19. 实际业务中什么时候更适合用 synchronized,什么时候更适合用 ReentrantLock?
  20. 如果让你手写一个简化版互斥锁,你会怎么基于 AQS 实现?

为什么已经有 synchronized 了,还需要 ReentrantLock?

ReentrantLock 本质上也是一种互斥锁,用来控制多个线程对共享资源的访问。

它和 synchronized 解决的是同一类问题,只不过 ReentrantLock 把锁做成了一个可以显式操作的 Java 对象。

例如:

1
2
3
4
5
6
7
8
ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

相比 synchronized,ReentrantLock 提供了更强的控制能力。

synchronized 的加锁和释放基本由 JVM 自动完成,而 ReentrantLock 可以让开发者主动控制锁的获取过程,比如尝试获取锁、超时获取、响应中断,还可以选择公平锁,并且支持多个 Condition 等待队列。

所以有了 synchronized 之后仍然需要 ReentrantLock,主要不是因为它“更高级”或者一定“性能更好”,而是因为它提供了更多锁控制能力。

如果只是普通的互斥同步,synchronized 往往已经够用;如果业务需要 tryLock()、超时、可中断、公平锁或者更灵活的线程等待通知机制,就更适合使用 ReentrantLock。

ReentrantLock 提供了哪些能力

ReentrantLock 和 synchronized 一样,本质上都是互斥锁,但它提供了更多显式控制能力。

首先,它支持可重入。同一个线程已经拿到锁以后,可以再次获取同一把锁,不会把自己阻塞住。

其次,它支持尝试获取锁:

1
lock.tryLock();

线程可以尝试拿锁,如果当前拿不到,可以直接返回,而不是一直等待。

还可以设置超时时间:

1
lock.tryLock(1, TimeUnit.SECONDS);

表示最多等待一段时间,超时以后就放弃。

ReentrantLock 还支持可中断地等待锁:

1
lock.lockInterruptibly();

线程在等待锁的过程中,可以响应中断,而不是只能一直等到拿到锁。

除此之外,它还可以选择公平锁和非公平锁:

1
2
new ReentrantLock(true);   // 公平锁
new ReentrantLock(false);  // 非公平锁

公平锁会尽量按照线程等待的先后顺序获取锁,非公平锁则允许新来的线程直接参与竞争。

最后,ReentrantLock 还支持 Condition,可以为同一把锁创建多个等待队列,比 synchronized 的 wait/notify 更灵活。

所以 ReentrantLock 的核心能力可以归纳为:

1
2
3
4
5
6
可重入
tryLock
超时获取
可中断获取
公平 / 非公平
Condition

这些能力也是它相比 synchronized 更灵活的地方。

AQS

ReentrantLock 底层主要依赖 AQS,也就是 AbstractQueuedSynchronizer。

AQS 可以理解成 Java 并发包里的一套同步框架,ReentrantLock、Semaphore、CountDownLatch 等很多并发工具底层都会基于它实现。

对于 ReentrantLock 来说,AQS 主要依靠两个东西:

一个是 state,用来表示锁当前的状态;另一个是等待队列,用来管理没有获取到锁的线程。

当 state = 0 时,表示当前没有线程持有锁。线程获取锁时,会通过 CAS 尝试把:

1
state: 0 → 1

如果成功,就表示获取锁成功,同时记录当前持锁线程。

如果锁已经被当前线程持有,那么再次获取时只需要增加 state,这也是 ReentrantLock 实现可重入的基础。

如果其他线程获取锁失败,就会进入 AQS 的等待队列,并通过 LockSupport.park() 挂起。等持锁线程释放锁以后,再由 AQS 唤醒等待线程重新参与竞争。

AQS 的等待队列

当线程获取锁失败以后,AQS 会把这些线程组织到一个等待队列中。

这个队列采用的是 CLH 队列的变体。

CLH 本身是一种用于组织竞争线程的队列思想,核心是让线程按照顺序排队,后面的线程关注前面的节点,而不是所有线程一直同时竞争同一个锁变量。

AQS 在这个思路上做了改造,使用 Node 组成一个双向链表:

1
2
3
4
5
head
 ↓
Node A <-> Node B <-> Node C
                         ↑
                        tail

其中 head 和 tail 分别指向队头和队尾,每个 Node 会保存当前线程、前驱节点、后继节点以及等待状态等信息。

线程获取锁失败以后,会被封装成一个 Node,然后通过 CAS 加到队尾。

加入队列以后,线程不会一直无休止地抢锁,而是根据前驱节点的状态决定是否继续尝试,或者通过 LockSupport.park() 挂起。

当持锁线程释放锁以后,AQS 会唤醒队列中靠前的等待线程,让它重新尝试获取锁。

如果获取成功,这个节点会成为新的 head,后面的线程继续等待。

所以 AQS 的等待队列可以理解成:

基于 CLH 思想实现的双向链表,用来有序管理获取锁失败的线程。

unlock 的执行过程

ReentrantLock 释放锁时,会调用 unlock()。

底层首先会减少 AQS 中的 state。

因为 ReentrantLock 支持可重入,所以只有当 state 最终减到 0 时,才表示这把锁真正被释放,同时清空当前持锁线程。

如果等待队列中还有其他线程,AQS 会唤醒后继等待节点,让它重新尝试获取锁。

所以释放锁的大致过程就是:

1
2
3
4
5
6
unlock()
→ state 减少
→ state == 0,真正释放锁
→ 清空持锁线程
→ 唤醒等待队列中的后继线程
→ 后继线程重新竞争锁

公平锁和非公平锁

ReentrantLock 可以选择公平锁或者非公平锁:

1
2
new ReentrantLock(true);   // 公平锁
new ReentrantLock(false);  // 非公平锁

公平锁强调的是先来先得。

线程准备获取锁时,如果发现前面已经有其他线程在 AQS 队列中等待,就不会直接抢锁,而是先进入队列排队。

因此整体更接近:

1
2
3
4
5
6
A 等待
B 等待
C 后来

释放锁以后:
A → B → C

非公平锁则允许新来的线程直接尝试 CAS 抢锁。

即使等待队列里已经有线程排队,新线程到来时仍然可以先尝试:

1
state: 0 → 1

如果 CAS 恰好成功,它就可能直接拿到锁,相当于“插队”。

所以公平锁和非公平锁最核心的区别,并不是等待队列结构不同,而是:

获取锁之前,要不要先检查前面有没有等待线程。

公平锁会检查队列中的前驱节点,尽量按照等待顺序获取锁。

非公平锁则先直接抢一次,抢不到再进入 AQS 队列。

ReentrantLock 默认使用非公平锁,因为非公平锁通常吞吐量更高。刚释放锁的线程和新到来的线程可以直接竞争,减少线程频繁挂起和唤醒带来的切换成本。

公平锁的顺序更可控,但代价通常是更多的线程调度和上下文切换,因此吞吐量往往低一些。

Condition 的等待队列

Condition 用来处理这样一种场景:线程已经拿到了锁,但发现当前业务条件还不满足,于是主动释放锁,等待条件发生变化。

例如生产者拿到锁以后发现队列已经满了,这时候继续持有锁没有意义,因为消费者也需要拿到这把锁才能取走数据。

所以线程可以调用:

1
condition.await();

await() 会把当前线程加入这个 Condition 自己的等待队列,同时释放当前持有的 ReentrantLock,然后线程进入等待状态。

这里需要区分两种队列。

AQS 的同步队列是用来等待锁的,属于 CLH 队列的变体,本质上是双向链表。

而 Condition 有自己独立的等待队列,它不是 CLH 队列,主要通过节点的 nextWaiter 串起来,可以理解成一个单向链表。

所以两者分别表示:

1
2
3
4
5
AQS 同步队列
→ 等待获取锁

Condition 等待队列
→ 已经拿到过锁,但在等待某个业务条件

当其他线程改变了业务条件以后,可以调用:

1
condition.signal();

signal() 不会让等待线程立刻继续执行,而是把 Condition 等待队列中的第一个有效等待节点转移到 AQS 的同步队列中。

它接下来还需要重新参与锁竞争:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Condition 等待队列
        ↓ signal()
AQS 同步队列
        ↓
重新抢锁
        ↓
抢到锁
        ↓
await() 返回
        ↓
继续向后执行

线程并不是从方法开头重新执行,而是从 await() 返回以后继续执行。

不过 await() 外面通常会使用 while:

1
2
3
while (!conditionOk) {
    condition.await();
}

因为 signal() 只代表“条件可能发生了变化”,并不能保证线程重新拿到锁时条件一定还满足,所以线程恢复以后需要再次检查条件。

如果调用的是:

1
condition.signalAll();

那么会把当前 Condition 等待队列中的所有有效等待节点都转移到 AQS 同步队列中,之后它们再分别重新竞争锁。

因此,Condition 的完整流程可以理解成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
先抢锁
→ 检查业务条件
→ 条件不满足
→ await()
→ 释放锁
→ 进入 Condition 等待队列

其他线程改变条件
→ signal / signalAll
→ 转移到 AQS 同步队列
→ 重新抢锁
→ 抢到锁
→ await() 返回
→ 再次检查条件
→ 条件满足后继续执行

Condition 真正的价值在于,一把 ReentrantLock 可以创建多个独立的 Condition,从而把不同业务条件下等待的线程分开管理。

例如生产者消费者场景中,可以分别创建:

1
2
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();

消费者在队列为空时等待 notEmpty,生产者在队列满时等待 notFull。

这样唤醒线程时,就可以只通知真正需要被唤醒的那一类线程。

ReentrantLock 和 synchronized 的区别

ReentrantLock 和 synchronized 都能实现互斥,并且都支持可重入,但它们的使用方式和能力不一样。

synchronized 是 Java 语言级的关键字,加锁和释放锁由 JVM 自动完成,写法更简单,也不容易因为忘记释放锁而出问题。

ReentrantLock 是一个显式的锁对象,需要手动调用:

1
2
3
4
5
6
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

所以使用时必须注意在 finally 中释放锁。

相比 synchronized,ReentrantLock 提供了更多控制能力,比如 tryLock()、超时获取、可中断获取、公平锁,以及多个 Condition 等待队列。

可以简单理解成:

1
2
3
4
5
synchronized
→ 简单、自动、够用

ReentrantLock
→ 更灵活、可控能力更多

如果只是普通的互斥同步,没有特殊需求,通常优先使用 synchronized,因为代码更简单,维护成本也更低。

如果业务需要下面这些能力,就更适合使用 ReentrantLock:

1
2
3
4
5
尝试获取锁
超时放弃
等待锁时响应中断
公平锁
多个 Condition

所以实际选择时,重点不是比较谁“更高级”,而是看业务是否需要额外的锁控制能力。

synchronized 适合简单、常规的同步场景;ReentrantLock 更适合需要精细控制锁行为的场景。