使用ReentrantLock,实现更灵活的锁管理策略

本文是对OO第二单元总结的一部分。

前言

众所周知,我们可以通过synchronized方法,以及synchronized代码块获取对象的锁;但是,你是否发现,这一做法有时并不灵活?

就以电梯为例,我们现在使用这么一个设计:电梯属性为一个类(Elevator),电梯的运行由一个排班器线程(ElevatorScheduler) 管理,乘梯需求的分配由一个调度器线程(Dispatcher),根据电梯当前属性,决定是否分配给当前电梯:

Pasted image 20250331095642.png

两个线程自然会对Elevator产生竞争,我们想到的最简单解决办法,便是用synchronized块/方法解决,对吧?这里以排班器为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ElevatorScheduler:

@Override
public void run() {
synchronized (elevator) {
switch (elevator.getStatus()) {
case IDLE:
idleSchedule();
case RUNNING:
runSchedule();
case WAIT:
waitSchedule();
}
}
}

Dispatcher:
@Override
public void run() {
...
synchronized (elevator) {
if (elevator.canDispatch()) {
elevator.addRequest(request);
}
}
}

在我们的电梯进入WAIT状态,执行waitSchedule()开门上下客的时候,我们自然希望:此时Dispatcher也可将能够加入的需求,放入电梯的请求表内。

但是在上面使用synchronized块的实现下,我们的希望会落空:此时Elevator锁被ElevatorScheduler持有,Dispatcher此时无法加入任何请求;为了保证两线程在读取Elevator状态的关键节点不出错,synchronized块似乎也不能去掉,减小synchronized块的颗粒度也不好实现。

那么,有没有什么办法,能在进了WAIT状态后,让ElevatorScheduler暂时释放Elevator的锁,在ElevatorScheduler即将加入待乘需求的时候,又重新取回电梯的锁呢?

是有的,这就需要用到我们今天的主角ReentrantLock。与synchronized类似,它也是一种互斥锁。在他的帮助下,我们可以突破synchronized关键字加锁的不少限制,为实现更灵活的锁管理提供可能。

基本使用

构建与使用对象锁

我们在这里只介绍如何使用ReentrantLock对一个对象加锁这一常见情况,其他情况欢迎各位自行查阅资料探索。

首先,我们需要将ReentrantLock作为对象的一个属性:

1
2
3
4
import java.util.concurrent.locks.ReentrantLock;
public class Elevator {
private ReentrantLock lock = new ReentrantLock();
}

synchronized块自动进行锁的获取与释放不同,我们需要显式通过lock.lock()lock.unlock()两个方法,获取/释放锁。

显然,我们此时的lock属性的权限是private你猜这限制哪来的?)。在线程中每一次对其加锁时,都要先ReentrantLock lock = elevator.getLock();lock.lock();并不美观,我们选择在Elevator类中实现setLock()setUnlock()两个public方法。这样一来,对象外线程即可由这两个方法,获取该对象的锁。

ReentrantLock还支持通过isHeldByCurrentThread()方法,查询当前线程是否持有锁;这为我们后续进行锁的管理提供了方便,建议用上。至于有什么用,请看后面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Elevator {
private ReentrantLock lock = new ReentrantLock();

public void setLock() {
lock.lock();
}

public void setUnlock() {
lock.unlock();
}

public boolean hasReentrantLock() {
return lock.isHeldByCurrentThread();
}
}

oolens介绍的ReentrantReadWriteLock类似,由于我们此时锁的获取/释放完全手动,我们需要使用try..catch..finally..块,避免出现异常时,锁泄露的问题:

1
2
3
4
5
6
7
8
9
10
11
public class Dispatcher {
...
public void example() {
elevator.setLock();
try {
// do something
} finally {
elevator.setUnlock();
}
}
}

以防各位产生思维定式,我们不一定要在finally{}里释放锁,在catch{}里也是可以的,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void example() {
try {
Elevator target = null;
for (Elevator elevator : elevators) {
elevator.setLock();
if (elevator.isOurTarget()) {
target = elevator;
}
}
for (Elevator elevator : elevators) {
if (elevator != target) {
elevator.setUnlock();
}
}
if (target != null) {
// do something to it
target.setUnlock();
}
}
} catch (Exception e) {
// 全部解锁
for (Elevator elevator1 : elevators) {
if (elevator1.hasReentrantLock()) {
elevator1.setUnlock();
}
}
e.printStackTrace();
}

我们实现hasReentrantLock()方法,用处就在这里;我们可以如上述例子灵活地管理锁的获取与释放,并避免了没获取到锁便释放,会引起的IllegalMonitorStateException

可重入机制

正如其名,ReentrantLock是一个可重入锁,即一个线程可以多次获取对象的锁,这与synchronized关键字对应的锁是一致的。

ReentrantLock实现可重入的方法,可以理解成:

  • 有一个计数器,初始值为0;
  • 线程调用一次lock(),计数器+1
  • 线程调用一次unlock(),计数器-1
  • 仅在计数器为0时,才认为线程释放了这一把锁

因此,在使用lock()/unlock()时,请务必保证这二者是成对使用的,否则锁不会正常释放。

修饰实例方法

我们如何实现像synchronized关键字修饰实例方法一样,在执行该方法时,就获取该实例对象的锁的效果呢?

只需在执行方法时获取锁,并在完成后释放锁即可。

1
2
3
4
5
6
7
8
public void example1() {
this.setLock();
try {
// do sth
} finally {
this.setUnlock();
}
}

那么,这种情况怎么办?

1
2
3
public synchronized int example2() {
return 0;
}

修改后的结构仍然与上文一致;Java此时会暂存return的值,先执行finally{}中的代码,再执行return,也就是说,我们上述的锁仍然可以保证会被释放。这方面的原理不是本文的重点,欢迎各位自行多加探索。

1
2
3
4
5
6
7
8
public int example2() {
this.setLock();
try {
return 0;
} finally {
this.setUnlock();
}
}

线程的等待/通知

基本用法

synchronized关键字使用wait()notify()/notifyAll()进行按对象的线程等待/通知不同,ReentrantLock使用另一Condition属性的await()signal()/signalAll()方法,进行按条件的线程等待/通知。

我们需要将Condition也作为对象的属性;这里先实现按对象的等待/通知,在这一需求下,我们的对象中只有一个Condition属性:

1
2
3
4
5
6
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
public class Elevator {
private ReentrantLock lock = new ReentrantLock();
private Condition cond = lock.newCondition();
}

还是因为private属性的原因,我们需要给await()/signal()/signalAll()套个壳:

1
2
3
4
5
6
7
public void condHangUp() throws InterruptedException {  
idleCond.await();
}

public void condCall() {
idleCond.signalAll();
}

不难注意到,给await()套壳的时候,我们要throws InterruptedException;这是因为,与synchronized不支持等待时发生中断不同,ReentrantLock支持等待时发生中断。这一部分就不展开了,各位大可自行探索。

随后,就可以按照仿照synchronized下的wait/notify逻辑,进行线程的等待与通知了。

同样,没有获取到锁就await/signal,一样是会引起IllegalMonitorState的,注意。(笑)

进阶用法

上面提到,ReentrantLock进行的是按条件的线程等待/通知;我们可不可以绑定多个条件呢?

是可以的,这也是其区分于synchronized的一大方面。 而且,我们可以借用这一机制,指定唤醒一个正在等待的程序,解决掉notifyAll()选取等待线程的随机性的问题!

具体例子可以观摩这篇文章 中“ReentrantLock和Synchronized比较”部分,受限于篇幅,这里就不搬过来了。

公平锁支持

synchronized关键字对应的锁是非公平的;也就是说,线程调度会随机选取一个被阻塞的线程执行

在我们上面的样例中,我们实例化的ReentrantLock也是非公平的。但,ReentrantLock是支持实例化为公平锁的,我们只需调用实例化方法时,传入一个true布尔变量:

1
ReentrantLock lock = new ReentrantLock(true);

公平锁会将被阻塞的线程进行排队;选取要执行的线程时,会选取队列中第一个被阻塞的线程。

这有什么影响呢?考虑这样一种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Thread1 {
@Override
public void run {
synchronized (obj1) {
foo();
}
}
}

Thread2 {
@Override
public void run {
synchronized (obj1) {
bar();
}
}
}

在非公平锁的情况下,“有可能” 出现Thread1一直获得obj1锁,而Thread2一直被阻塞的情况,而公平锁的排队机制,可以保证这种事情不会发生。

不过使用公平锁,可能会对整体性能产生影响,这就见仁见智了。

警示

谨慎与synchronized/多把锁混用

本人在HW5中,我在已经实现了synchronized块的锁机制后,才决定换成ReentrantLock;但在修改代码的过程中,我出于偷懒的目的尝试synchronizedReentrantLock两把锁混用,以期实现与原有代码的兼容性。

然后,Elevator类中出现了这样的方法:

1
2
3
4
5
6
7
8
public synchronized int example2() {
this.setLock();
try {
return 0;
} finally {
this.setUnlock();
}
}

随后就不会出意外地出意外了,程序中出现了多个死锁点,直到作业提交截止前我都没有找完,然后…
Pasted image 20250331120759.png

最后我还是决定,去掉电梯有关所有的synchronized。这样改以后,既方便维护,也顺便解决了所有的死锁问题。

最后,再次告诫诸位:
!!谨慎对一个对象加多把锁!!,这是血的教训……

加/放锁要谨慎

使用ReentrantLock后,加/放锁操作全部手动,这就要求我们处理加/放锁格外谨慎。

建议回顾上面的“可重入机制”。

总结

ReentrantLock用好的话,不仅能实现synchronized块做不到的灵活锁管理,更能因此做到程序效率的提升。

欢迎大家对此多加尝试,也预祝大家顺利通过U2的所有作业…

作者

LajiPZ

发布于

2025-04-17

更新于

2025-04-21

许可协议

评论