当前位置 : 主页 > 编程语言 > java >

Thread专题(12) - 显式锁

来源:互联网 收集:自由互联 发布时间:2022-10-15
此文被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中 在java5.0之前,对象共享访问的机制只有synchronized和volatile。内部锁不能中断那些正在等待获取锁的线程,并且在请求锁失

此文被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中

在java5.0之前,对象共享访问的机制只有synchronized和volatile。内部锁不能中断那些正在等待获取锁的线程,并且在请求锁失败的情况下,必须无限等待。在5.0之后提供了ReentrantLock,ReentrantLock并不是为了替代内部锁,提供可选择的高级特性,比如活跃度和性能。

一、Lock和ReentrantLock

Lock接口,定义了一些抽象的锁操作,与内部加锁机制不同,Lock提供了无条件的、可轮询的、定时的、可中断的锁操获取操作,所有锁的方法也是显式的。Lock的实现必须提供具有与内部加锁相同的内存可见性的语义。

Lock接口的规范形式,锁必须在finally块中释放。原因是如果锁守护的代码在try块之外抛出了异常,它将永远都不会被释放,如果对象能够被置于不一致的状态,可能需要额外的try-catch或try-finally块。而且显式锁并不会主动释放,如果忘记写fianlly块,则程序很难追踪的到。

Lock lock = new ReentrantLock();
lock.lock();
try{

}catch (Exception e){

}
finally{
lock.unlock();
}

可轮询和可定时的锁请求

可定时和可轮询的锁获取模式是由tryLock方式实现,与无条件的锁获取相比,它具有更完善的错误恢复机制。在内部锁中,死锁-唯一的恢复方法是重新启动程序,唯一的预防方法是在构建程序时不要出错,可定时与可轮询的锁提供了另一个规避死锁发生的方法。

如果不能获得所有需要的锁,可以用定时与可轮询的获取方式重新拿到控制权,它会释放你已经获得的这些锁,然后再重新尝试(至少可以记录这个失败)。

class Account {
public Lock lock;
void debit(DollarAmount d) {
}
void credit(DollarAmount d) {
}
DollarAmount getBalance() {
return null;
}
}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);

while (true) {
if (fromAcct.lock.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();
}
}
if (System.nanoTime() < stopTime)
return false;
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}

对于实现有时间限制的活动,定时锁能够在时间预算内设定相应的超时,如果活动在期待的时间内没能获得结果,这个机制使程序能提前返回。

串行化访问资源,一种方法是单线程、另一个方法是使用独占锁来守护对资源的访问。定时的tryLock与独占锁相互配合,可以很好的解决有时间限制的活动的这样的程序。

//具有预定时间的锁
private Lock lock = new ReentrantLock();

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();
}
}

可中断的锁获取

当你正在响应中断的时候,lockInterruptibly方法能够使你重新获得锁。

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;
}
}

非块结构的锁

在内部锁中,获取和释放这样成对的行为是块结构的---总是在其获得的相同的基本程序块中释放锁,而不考虑控制权是如何退出阻塞的。自动释放锁简化了程序的分析,并避免了潜在的代码造成的麻烦,但是有时需要更灵活的加锁规则。

分离锁时不同的哈希链在哈希容器中使用不同的锁,在链表中,我们可以通过为每个链表节点应用相似的原则来减小锁的粒度,从而允许不同的线程独立地操作链表的不同部分。给定节点的锁守护链接的指针,数据就存储在该节点中,所以如果要遍历或修改链表,我们必须得到这个锁,并持有它直到我们获得了下一个节点的锁;只有在这之后我们才能释放前一个节点的锁。这项技术的例子被称为连锁式加锁或锁联接。

二、公平性

ReentrantLock构造函数提供了两种公平性的选择,创建非公平锁(默认实现)或者公平锁。线程按顺序请求获得公平锁,然而一个非公平锁允许“闯入”,当请求这样的锁时,如果锁的状态变为可用,线程的请求可以在等待线程的队列中向前跳跃,获得该锁(Semaphore同样提供了公平和非公平的获取顺序)。即使对于公平锁而言,可轮询(while循环)的tryLock总会闯入。

在激烈竞争的情况下,闯入锁比公平锁性能更好,原因之一:挂起的线程重新开始,与它真正开始运行,两者之间会产生严重的延迟;当线程持有锁的时间相对较长,或者请求锁的平均时间间隔比较长,那么使用公平锁是比较好的。

正如默认的ReentrantLock一样,内部锁没有提供确定的公平性保证,但是大多数锁实现统计上的公平性保证,在大多数条件下已经足够好了。java语言规范并没有要求JVM公平地实现内部锁,JVM也的确没有这样做,ReentrantLock并没有减少锁的公平性--它只不过使一些存在的部分更显性化了。

三、在synchronized和ReentrantLock之间决择

他们在语义、定时锁的等待、可中断锁的等待、公平性、以及实现非块结构的锁基本都是相同的。ReentrantLock的性能看起来胜过内部锁,虽然这样,但是一般来说不应该混合使用这两种方式。

只有在synchronized不能满足需求的时间,才需要使用ReentrantLock。这些需求包括:可定时的、可轮询的与可中断的锁获取操作、公平队列、非块结构的锁,否则请使用synchronized。

内部锁与ReentrantLock相比,还具有另一个优点:线程转储能够显示哪些个调用框架获得了哪些锁,并能够识别发生了死锁的那些线程,JVM并不知道哪个线程持有ReentrantLock,因此在调试使用ReentrantLock的线程间存在的问题,可以调用管理和调试接口,但只局限于JAVA 6。

四、读-写锁

ReentrantLock是一个标准的互斥锁,一次最多只有一个线程能够持有相同的ReentrantLock。但是互斥通常作为保护数据一致性的很强的加锁方式,因此过分地限制了并发性。互斥是保守的加锁策略,避免了“写/写”、“写/读”、“读/读”的重叠,只要一个资源能够被多个读者访问,或者被一个写者访问,两者不能同时进行。读取ReadWriteLock锁守护的数据,必须首先获得读取的锁,当需要修改ReadWriteLock守护的数据时,必须首先获得写入的锁。

interface ReadWriteLock{
Lock read();
Lock write();
}

ReadWriteLock这个接口是一个简单的读-写锁实现,ReadWriteLock允许多种实现,这些实现可以在性能、调度保证、获取优先级、公平性、加锁主义等方面不尽相同。读-与锁的设计是用来进行性能改进的,使得特定情况下能够有更好的并发性。读取和写入锁之间的互动可以有很多种实现,比如:

1、释放优先:当写者释放写入锁,并且读者和写者都排在队列中,应该选择哪个--读者,写者还是其它的。

2、读者闯入:如果锁由读者获得,但是有写者正在等待,那么新到达的写者应该被授予读取的权力、还是等待。允许读者闯入到写者之前提高了并发性,但是却带来了写饥饿的风险。

  • 重进入:读和写允许重入吗?
  • 降级:如果线程持有写入的锁,它能够在不释放该锁的情况下获得读取锁么?这可能会造成写者“降级”为一个读取锁。读的级别比写要高。
  • 升级:同降级。 这可能会造成死锁。

ReentrantReadWriteLock可以构建成公平和非公平的(默认实现),在公平的锁中,选择权交给等待时间最长的线程。如果锁由读者获取,而一个线程请求写入锁,那么不再允许读者获得读取锁,直到写者获得锁并正确释放。在非公平锁中,写者可以降级为读者,但反过来程序会造成死锁。

通俗的讲这种加锁机制提高了读的并发性。下面的例子一般由ConcurrentHashMap来代替应该可以满足大部分的需求。除了包装Map也可以包装其它容器。

  • 用读写锁包装的Map
public class ReadWriteMap <K,V> {
private final Map<K, V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();

public ReadWriteMap(Map<K, V> map) {
this.map = map;
}

public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}

public V remove(Object key) {
w.lock();
try {
return map.remove(key);
} finally {
w.unlock();
}
}
}

小结

显示的Lock与内部锁相比提供了一些扩展的特性,包括处理不可用的锁时更好灵活性,以及对队列行为更好的控制。但是ReentrantLock不能完全替代synchronized,只有当你需要synchronized没能提供的特性时才应该使用。

读-写锁允许多个读者并发访问被守护的对象,当访问多为读取数据结构的时候,它具有改进可伸缩性的潜力。

网友评论