显式锁知识梳理
1.Lock与ReentrantLock
1.1Lock接口
Lock与synchronized的区别:Lock接口中增加了一些synchronized关键字不具备的特性,并且加锁和解锁都是显式的。
如下表为,Lock接口提供的synchronized关键字不具备的主要特性:
特性 | 描述 |
---|---|
尝试非阻塞地获取锁 | 当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁 |
能被中断地获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放 |
超时获取锁 | 在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回 |
下面介绍一下Lock接口都含有哪些API以及API的功能
如下为Lock接口:
//Lock接口
public interface Lock {
//获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lock();
//可中断地获取锁,该方法可以响应中断,即在锁获取中可以中断当前线程
void lockInterruptibly() throws InterruptedException;
//尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false
boolean tryLock();
//超时的获取锁(有时间限制的获取锁的方法并且允许被中断)
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
//释放锁
void unlock();
//获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁
Condition newCondition();
}
1.2ReentrantLock重入锁
ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock时,有着与退出同步代码块相同的内存语义(Java内存模型JMM后面我会有一篇专门的文章记录)。
下面代码展示了Lock的标准使用方式:
//使用ReentrantLock来保护对象状态
Lock lock = new ReentrantLock();
...
lock.lock();
try {
...
}
finally {
lock.unlock();
}
注意:必须在finally块中释放锁,否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。
ReentrantLock重入锁,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平的选择。
这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁时公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的,但是公平锁机制往往没有非公平的效率高。
1.3显式锁的优点1——轮询锁
可轮询的锁的获取模式可以使用tryLock方法来实现。
如果不能获得所有需要的锁,那么可以使用可轮询或可定时的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁(或者至少会将这个失败记录到日志,并采取其他措施)。
下面的程序中使用tryLock来获取两个锁,如果不能同时获得,那么就回退并重新尝试。
//通过tryLock来避免锁顺序死锁
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime=System.nanoTime()+unit.toNanos(timeout);
//使用tryLock来获取两个锁,如果不能同时获得,那么就回退并重新尝试
while(true){
if(fromAcct.lock.tryLock()){ //使用tryLock来获取锁
try{
if(toAcct.lock.tryLock()){
try{
if(fromAcct.getBalance().compareTo(amount)<0)
throw new InsufficientFundsException();
else{
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
}finally{
toAcct.lock.unlock();
}
}
}finally{
fromAcct.lock.unlock(); //无论成功与否都会释放所有锁
}
}
//如果在指定时间内不能获得所有需要的锁,那么transferMoney将返回一个失败状态,从而使该操作平缓地失败。
if(System.nanoTime()<stopTime)
return false;
//在休眠时间中包含固定部分和随机部分,从而降低发生活锁的可能性。
NANOSECONDS.sleep(fixedDelay+rnd.nextLong()%randMod);
}
}
1.4显式锁的优点2——定时锁
在实现具有时间限制的操作时,定时锁非常有用。 当在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定时间内给出结果,那么程序就会提前结束。 当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
下面代码中,定时的tryLock能够在这个带有时间限制的操作中实现独占加锁行为:
//带有时间限制的加锁
public class TimedLocking {
private Lock lock = new ReentrantLock();
//定时的tryLock能够在这个带有时间限制的操作中实现独占加锁行为。
public boolean trySendOnSharedLine(String message,
long timeout,TimeUnit unit)
throws InterruptedException{
long nanosToLock=unit.toNanos(timeout)
-estimatedNanosToSend(message);
if(!lock.tryLock(nanosToLock,NANOSECONDS)) //如果不能再指定时间内获得锁,就失败
return false;
try{
return sendOnSharedLine(message);
}finally {
lock.unlock();
}
}
private boolean sendOnSharedLine(String message) {
//传送信息
return true;
}
long estimatedNanosToSend(String message) {
return message.length();
}
}
1.5显式锁的优点3——锁获取操作可中断
可中断的锁获取操作能在可取消的操作中使用加锁。
如下代码中,InterruptibleLocking 使用了lockInterruptibly来实现sendOnSharedLine,以便在一个可取消的任务中调用它。 定时的tryLock同样能响应中断,因此当需要一个定时的和可中断的锁获取操作时,可以使用tryLock方法。
//可中断的锁获取操作
public class InterruptibleLocking {
private Lock lock = new ReentrantLock();
public boolean sendOnSharedLine(String message)
throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException {
/* send something */
return true;
}
}
1.6显式锁的优点4——非块结构加锁
在内置锁中,锁的获取和释放等操作都是基于代码块的——释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块,可以避免可能的编码错误,但有时候需要更加灵活的加锁规则。
2.性能考虑
竞争性能是可伸缩性的关键要素:如果有越多的资源被消耗在锁的管理和调度上,那么应用程序可以得到的资源就越少。锁的实现方式越好,系统调用和上下文切换消耗的资源越少,在共享的内存总线的内存同步通信量也越少。
java5.0刚出显式锁时,ReentrantLock确实极大的与内置锁体现出吞吐率的差距,ReentrantLock能提供更高的吞吐量。但到了java6中,内置锁的性能得到极大改善,性能并不会由于竞争而急剧下降,并且与ReentrantLock可伸缩性基本相当。
3.在synchronized和ReentrantLock之间如何选用
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized。
内置锁相比ReentrantLock优点在于:
- 内置锁在线程转储中能给出哪些帧中获得了哪些锁,并能识别和检测发生死锁的线程。而ReentrantLock在java5.0时还不知道哪些线程持有ReentrantLock,但在java6.0中提供了一个接口,通过对接口注册可以访问ReentrantLock的加锁信息。
- 内置锁自动加锁与释放锁,ReentrantLock需要在finally中手动释放锁。
ReentrantLock相比内置锁优点在于:
- ReentrantLock可以非阻塞的获取锁,但synchronized不行
- ReentrantLock可以超时地获取锁,但synchronized不行
- ReentrantLock可以被中断地获取锁,但synchronized不行
4.读-写锁
4.1什么是读写锁?
读写锁:读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。(读操作允许多线程同时访问共享变量,但是写操作线程独占共享变量)读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
在读多于写的情况下,读写锁能够提供比排他锁更好的并发性和吞吐量。Java并发包提供读写锁实现的是ReenrantReadWriteLock,它提供的特性如下表:
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平 |
重进入 | 该锁支持重进入,以读写线程为例:读线程在获取读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁 |
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 |
下面为ReadWriteLock接口的API:
//ReadWriteLock接口
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
4.2锁降级的具体介绍
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的写锁),再获取到读锁,随后释放(先前拥有的)写锁的过程。
下面为锁降级的示例:
public void processData() {
readLock.lock();
if (!update) {
//必须先释放读锁
readLock.unlock();
//锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
...//准备数据的流程
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
//锁降级完成,写锁降级为读锁
try {
...//使用数据的流程
} finally {
readLock.unlock();
}
}
}
上面示例中,当数据发生变更后,update变量(boolean类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。
锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。
Condition接口
任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。
Condition接口也提供了类似Object的监视器方法与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。
下面为Objecy的监视器方法和Condition接口的对比表:
对比项 | Object Monitor Methods | Condition |
---|---|---|
前置条件 | 获取对象的锁 | 调用Lock.lock()获取锁 调用Lock.newCondition()获取Condition对象 |
调用方式 | 直接调用 如:object.wait() |
直接调用 如:condition.await() |
等待队列个数 | 一个 | 多个 |
当前线程释放锁并进入等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态,在等待状态中不响应中断 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态到将来的某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition是依赖Lock对象的。
下面为Condition对象的使用示例:
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void conditionSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock();
}
}
如上所示,一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。