使用ReentrantLock,实现更灵活的锁管理策略
本文是对OO第二单元总结的一部分。
前言
众所周知,我们可以通过synchronized方法,以及synchronized代码块获取对象的锁;但是,你是否发现,这一做法有时并不灵活?
就以电梯为例,我们现在使用这么一个设计:电梯属性为一个类(Elevator),电梯的运行由一个排班器线程(ElevatorScheduler) 管理,乘梯需求的分配由一个调度器线程(Dispatcher),根据电梯当前属性,决定是否分配给当前电梯:

两个线程自然会对Elevator产生竞争,我们想到的最简单解决办法,便是用synchronized块/方法解决,对吧?这里以排班器为例:
1 | ElevatorScheduler: |
在我们的电梯进入WAIT状态,执行waitSchedule()开门上下客的时候,我们自然希望:此时Dispatcher也可将能够加入的需求,放入电梯的请求表内。
但是在上面使用synchronized块的实现下,我们的希望会落空:此时Elevator锁被ElevatorScheduler持有,Dispatcher此时无法加入任何请求;为了保证两线程在读取Elevator状态的关键节点不出错,synchronized块似乎也不能去掉,减小synchronized块的颗粒度也不好实现。
那么,有没有什么办法,能在进了WAIT状态后,让ElevatorScheduler暂时释放Elevator的锁,在ElevatorScheduler即将加入待乘需求的时候,又重新取回电梯的锁呢?
是有的,这就需要用到我们今天的主角ReentrantLock。与synchronized类似,它也是一种互斥锁。在他的帮助下,我们可以突破synchronized关键字加锁的不少限制,为实现更灵活的锁管理提供可能。
基本使用
构建与使用对象锁
我们在这里只介绍如何使用ReentrantLock对一个对象加锁这一常见情况,其他情况欢迎各位自行查阅资料探索。
首先,我们需要将ReentrantLock作为对象的一个属性:
1 | import java.util.concurrent.locks.ReentrantLock; |
与synchronized块自动进行锁的获取与释放不同,我们需要显式通过lock.lock()与lock.unlock()两个方法,获取/释放锁。
显然,我们此时的lock属性的权限是private(你猜这限制哪来的?)。在线程中每一次对其加锁时,都要先ReentrantLock lock = elevator.getLock();再lock.lock();并不美观,我们选择在Elevator类中实现setLock()与setUnlock()两个public方法。这样一来,对象外线程即可由这两个方法,获取该对象的锁。
ReentrantLock还支持通过isHeldByCurrentThread()方法,查询当前线程是否持有锁;这为我们后续进行锁的管理提供了方便,建议用上。至于有什么用,请看后面。
1 | public class Elevator { |
与oolens介绍的ReentrantReadWriteLock类似,由于我们此时锁的获取/释放完全手动,我们需要使用try..catch..finally..块,避免出现异常时,锁泄露的问题:
1 | public class Dispatcher { |
以防各位产生思维定式,我们不一定要在finally{}里释放锁,在catch{}里也是可以的,比如这样:
1 | public void example() { |
我们实现hasReentrantLock()方法,用处就在这里;我们可以如上述例子灵活地管理锁的获取与释放,并避免了没获取到锁便释放,会引起的IllegalMonitorStateException。
可重入机制
正如其名,ReentrantLock是一个可重入锁,即一个线程可以多次获取对象的锁,这与synchronized关键字对应的锁是一致的。
ReentrantLock实现可重入的方法,可以理解成:
- 有一个计数器,初始值为0;
- 线程调用一次
lock(),计数器+1 - 线程调用一次
unlock(),计数器-1 - 仅在计数器为0时,才认为线程释放了这一把锁
因此,在使用lock()/unlock()时,请务必保证这二者是成对使用的,否则锁不会正常释放。
修饰实例方法
我们如何实现像synchronized关键字修饰实例方法一样,在执行该方法时,就获取该实例对象的锁的效果呢?
只需在执行方法时获取锁,并在完成后释放锁即可。
1 | public void example1() { |
那么,这种情况怎么办?
1 | public synchronized int example2() { |
修改后的结构仍然与上文一致;Java此时会暂存return的值,先执行finally{}中的代码,再执行return,也就是说,我们上述的锁仍然可以保证会被释放。这方面的原理不是本文的重点,欢迎各位自行多加探索。
1 | public int example2() { |
线程的等待/通知
基本用法
与synchronized关键字使用wait()和notify()/notifyAll()进行按对象的线程等待/通知不同,ReentrantLock使用另一Condition属性的await()与signal()/signalAll()方法,进行按条件的线程等待/通知。
我们需要将Condition也作为对象的属性;这里先实现按对象的等待/通知,在这一需求下,我们的对象中只有一个Condition属性:
1 | import java.util.concurrent.locks.ReentrantLock; |
还是因为private属性的原因,我们需要给await()/signal()/signalAll()套个壳:
1 | public void condHangUp() throws InterruptedException { |
不难注意到,给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 | Thread1 { |
在非公平锁的情况下,“有可能” 出现Thread1一直获得obj1锁,而Thread2一直被阻塞的情况,而公平锁的排队机制,可以保证这种事情不会发生。
不过使用公平锁,可能会对整体性能产生影响,这就见仁见智了。
警示
谨慎与synchronized/多把锁混用
本人在HW5中,我在已经实现了synchronized块的锁机制后,才决定换成ReentrantLock;但在修改代码的过程中,我出于偷懒的目的尝试synchronized与ReentrantLock两把锁混用,以期实现与原有代码的兼容性。
然后,Elevator类中出现了这样的方法:
1 | public synchronized int example2() { |
随后就不会出意外地出意外了,程序中出现了多个死锁点,直到作业提交截止前我都没有找完,然后…
最后我还是决定,去掉电梯有关所有的synchronized。这样改以后,既方便维护,也顺便解决了所有的死锁问题。
最后,再次告诫诸位:
!!谨慎对一个对象加多把锁!!,这是血的教训……
加/放锁要谨慎
使用ReentrantLock后,加/放锁操作全部手动,这就要求我们处理加/放锁格外谨慎。
建议回顾上面的“可重入机制”。
总结
ReentrantLock用好的话,不仅能实现synchronized块做不到的灵活锁管理,更能因此做到程序效率的提升。
欢迎大家对此多加尝试,也预祝大家顺利通过U2的所有作业…
使用ReentrantLock,实现更灵活的锁管理策略