Java5.0增加了一种新的机制:ReentrantLock
,ReentrantLock并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。
Lock和ReentrantLock
与内置的加锁机制不同,Lcok提供了一种无条件的、可轮训的、定时的以及可中断的锁获取操作,所有的加锁和解锁的方法都是显式的。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。
public interface Lock{
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tyrLock(long timeout, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
- ReentrantLock实现了Lock接口,并提供了与synchronized相同的
互斥性
和内存可见性
: - 在获取ReentrantLock时,有着与进入同步代码块相同的内存语义
- 在释放ReentrantLock时,有着与退出同步代码块相同的内存语义。
- 与synchronized一样,ReentrantLock还提供了
可重入的加锁语义
- ReetrantLock支持在Lock接口中定义的所有获取锁模式
- 并且与synchronized相比,它还为处理锁的不可用性提供了更好的灵活性
为什么要创建一种与内置锁如此相似的新加锁机制?
- 在大多数情况下,内置锁都能很好的工作,但是在功能上存在一些局限性,例如无法中断一个正在等待获取锁的线程,无法在请求一个锁时无限的等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
Lock接口的标准使用形式
Lock lock = new ReentrantLock();
...
lock.lock();
try{
// 更新对象状态
// 捕获异常,并在必要时恢复不变性条件
} finally {
lock.unlock();
}
Lock的使用比内置锁复杂点,必须在finally中释放锁。否则如果在被保护的代码中抛出了异常,那么这个锁将永远不能被释放。
轮训锁与定时锁
可定时的
与可轮训的
锁获取模式是由tryLock
方法实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。
在内置锁中,死锁是个相当严重的问题,恢复程序的唯一方法是重新启动,而防止死锁的唯一方法是在构造和编写程序的时候避免出现不一致的锁获取顺序。
而可定时的与可轮训的锁提供了另外一种选择,避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用定时的或可轮训的锁获取方式,从而使你重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁。
通过tryLock来避免顺序死锁
public boolean transferMoney(Account fromAcct, Account toAcct, DollarAmount, long timeout, TimeUnit unit) throws InsufficientFundsException, InterruptedException{
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModuluNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
while(true){
if(fromAcct.lock.tryLock()){
try{
if(toAcct.lock.tryLock()){
try{
doSomething();
return true;
} finally{
toAcct.lock.unlock();
}
}
} finally{
fromAcct.lock.unlock();
}
}
if(System.nanoTime() < stopTime){
return false;
}
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
使用tryLock来获取两个锁,如果不能同时获得,那么就回退并重新尝试。在休眠时间里包括固定部分和随机部分,从而降低发生活锁的可能性。如果在指定时间内不能获得所有需要的锁,那么将会返回一个false,从而使该操作平缓的失败。
带有时间限制的加锁
在实现一个具有时间限制的操作时,定时锁同样非常有用。当在带有时间限制的操作中调用了一个阻塞方法,它能根据剩余时间来提供一个时限,如果操作不能在指定的时间给出结果,那么就会使程序提前结束。使用内置锁时,在开始请求锁后,这个操作就无法取消,因此内置锁很难实现带有时间限制的操作。
if(!lock.tryLock(nanosToLocks,NANOSECONDS)){
return false;
}
try{
doSomething();
} finally{
lock.unlock();
}
可中断的锁获取操作
正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。lockInterruptibly
方法能够在获得锁的同时保持对中断的响应,并且由于它包含在Lock中,因此无需创建其他类型的不可中断阻塞机制。
public boolean sendOnSharedLine(String message) throws InterruptedException{
lock.lockInterruptibly();
try{
return doSomething(message);
} finally{
lock.unlock();
}
private boolean doSomething(String message) throws InterruptedException{}
}
可中断的锁获取操作的标准结构比普通的锁获取操作略微复杂一些,因为需要两个try块(如果在可中断锁操作中抛出InterruptedException,那么只需要常规的try-finally即可)。
性能考虑因素
对于同步原语来说,竞争性能是可伸缩性的关键要素之一:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。
锁的实现越好,将需要越少的系统调用和上下文切换,并且在共享内存总线上的内存同步通信量也越少,而一些耗时的操作将占用应用程序的计算资源。
jdk 6使用了改进后的算法来管理内置锁,与在ReentrantLock中使用的算法类似,该算法有效地提高了可伸缩性。内置锁的性能不会由于竞争而急剧下降,并且两者的可伸缩性也基本相当。
公平性
在ReentrantLock的构造函数中提供了两种公平性选择:
-
非公平的锁(默认)
:允许插队,当一个线程请求非公平的锁时,如果在发出请求的同时,该锁状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁(Semaphonre中同样可以选择采取公平或者非公平的获取顺序)。 -
公平的锁
:线程按照它们发出请求的顺序来获得锁
非公平的ReentrantLock并不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。(如果使用tryLock()方法,则获得一次插队机会)
- 在公平锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。
- 在非公平的锁中,只有当锁被某个线程持有的时候,新发出的请求才被放入队列中。
我们为什么不希望所有的锁都是公平的
当执行加锁操作时,公平性将由于在挂起线程和恢复线程时候存在开销而极大的降低性能。在实际情况下,统计上的公平性保证(确保被阻塞的线程能最终获得锁)通常已经够用了,并且实际上开销也能小很多。在大多数情况下,非公平锁的性能要高于公平锁的性能。
在激烈竞争的情况下,非公平锁的性能高于公平锁性能的一个原因是:在恢复一个被挂起的线程于该线程真正开始运行之间存在着严重的延迟。
假设:线程A持有一个锁,并且线程B请求这个锁,由于这个锁已经被A持有了,因此B将被挂起,当A释放锁的时候,B将被唤醒,因此会再次尝试获取锁。于此同时,如果C也请求锁,那么C有可能会在B被完全唤醒之前,获得、使用以及释放这个锁。这样是一个双赢局面:B获得锁的时刻并没有推迟,C更早地获得了锁,并且吞吐量也获得了提高。
当持有锁的时间比较长,或者请求锁的平均时间间隔比较长,那么应该使用公平锁。在这种情况下,通过插队来提升吞吐量的情况可能不会出现。
与默认的ReentrantLock一样,内置加锁并不会提供确定的公平性保证,但是在大多数情况下,在锁实现上实现统计的上的公平性保证已经足够了。java语言规范并没有要求jvm以公平的方式来实现内置锁,jvm也没有这样做。
读写锁
ReentrantLock实现了一种标准的互斥锁:每次最多只有一个线程能持有ReentrantLock。但对于维护数据的完整性来说,互斥锁通常是一种过于强硬的加锁规则,所以也不太必要地限制了并发性。但是在许多情况下,数据结构上的操作都是读操作。此时,如果能放宽加锁需求,允许多个读操作同时访问数据结构,并且读取数据时候不会有其他线程修改数据,那么就不会有问题。
在以下情况下可以使用读写锁:
- 一个资源可以被多个读操作访问,或者被一个写操作访问,但是两者不能同时进行。
ReadWriteLock接口
public interface ReadWriteLock{
Lock readLock();
Lock writeLock();
}
在读写锁的加锁策略中,允许多个读同时进行,但是每次只允许一个写操作。与Lock一样,ReadWriteLock可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有所不同。
读写锁是一种性能优化措施,在一些特定的情况下可以实现更好的并发性,在实际情况下,对于在多处理器上被频繁读取的数据结构,读写锁能够提高性能。而在其他情况下,读写锁的性能会比互斥锁更差一点,因为它们的复杂性更高。
由于ReadWriteLock使用Lock来实现锁的读写部分,因此如果分析结果表明读写锁没有提高性能,那么可以很容易的将读写锁换成独占锁。
在读取锁和写入锁之间的交互可以采用多种可选的实现方式:
-
释放优先
:当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程? -
读线程插队
:如果锁是由读线程持有,但是写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么能提高并发性,但却可能造成写线程发生饥饿问题? -
重入性
:读取锁和写入锁是否可以重入 -
降级
:如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这样可能会使得写入锁被降级为读取锁,同时不允许其他写线程修改被保护的资源。 -
升级
:读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读写锁实现中并不支持升级,因为如果没有显式的升级操作,很容易造成死锁(如果两个读线程都试图升级锁,那么二者都不会释放读取锁)。
ReentrantReadWriteLock
为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造的时候也可以选择一个非公平的锁(默认)或者一个公平锁。在公平的锁中,等待最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读取锁,知道写线程使用完成后并释放写入锁。在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程可以降级为读线程,但是不能从读线程升级到写线程。
与ReentrantLock类似的是,ReentrantReadWriteLock中的写入锁只能有唯一的所有者,并且只能由获得该锁的线程来释放。而读取锁通过记录那些线程已经获取了读取锁。
小结
与内置锁相比,显式的Lock提供了一些拓展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列行有着更好的控制。但ReentrantLock不能完全替代synchronized,只有在synchronized无法满足需求时,才应该使用它。
读写锁允许多个读线程并发地访问被保护的对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。