在 Java 5.0 之前,在协调对共享对象的访问时可以使用的机制只有 synchronized
和 volatile
。Java 5.0 增加了一种新的机制:ReentrantLock
。ReentrantLock
并不是一种代替内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。
13.1 Lock 与 ReentrantLock
Lock
接口定义了一组抽象的加锁操作。在 Lock
的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。
// 程序清单 13-1
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock
实现了 Lock
接口,并提供了与 synchronized
相同的互斥性和内存可见性。在获取 ReentrantLock
时,有着与进入同步代码块相同的内存语义,在释放 ReentrantLock
时,同样有着与退出同步代码块相同的内存语义。此外,与 synchronized
一样,ReentrantLock
还提供了可重入的加锁语义。
那么问题来了,为什么要创建一种与内置锁如此相似的新加锁机制?这是因为,在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性。例如,阻塞在内置锁上的线程无法被中断,或者无法在请求获取一个锁时无限地等待下去。内置锁是以代码块为范围来进行 “加锁/释放锁” 的,这就简化了编码工作,但却无法实现非阻塞结构的加锁规则(连锁式加锁:Hand-Over-Hand Locking,13.1.3 节)。
程序清单 13-2 给出了 Lock
接口的标准使用形式。
// 程序清单 13-2
Lock lock = new ReentrantLock();
...
lock.lock();
try {
// 更行对象状态
// 捕获异常,并在必要时恢复不变性条件
} finally {
lock.unlock();
}
13.1.1 轮询锁与定时锁
可定时的与可轮询的锁获取模式是由 tryLock
方法实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
程序清单 13-3 给出了另一种方法来解决 10.1.2 节中动态顺序死锁的问题:使用 tryLock
来获取两个锁,如果不能同时获得,那么就回退并重新尝试。在休眠事件中包括固定部分和随机部分,从而降低发生活锁的可能性。如果在指定时间内不能获得所有需要的锁,那么 transferMoney
将返回一个失败状态,从而使该操作平缓地失败。
// 程序清单 13-3
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.debit(amount);
return false;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
if (System.nanoTime() < stopTime) {
return false;
}
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
在实现具有时间限制的操作时,定时锁同样非常有用。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
9.5节介绍了确保对资源进行串行访问的方法:一个单线程的 Executor
。另一种方法是使用一个独占锁来保护对资源的访问。
// 程序清单 13-4
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();
}
}
上面代码试图在 Lock
保护的共享通信线路上发送一条消息,如果不能在指定时间内完成,代码就会失败(第一条消息如果不能在指定时间内完成,则第二条消息会失败)。定时的 tryLock
能够在这种带有时间限制的操作中实现独占加锁行为。
13.1.2 可中断的锁获取操作
正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。7.1.6 节给出了几种不能响应中断的机制,例如请求内置锁(synchronized
关键字修饰的代码块)。lockInterruptibly
方法能够在获得锁的同时保持对中断的响应,并且由于它包含在 Lock
中,因此无须创建其他类型的不可中断阻塞机制。下面程序清单 13-5 中使用了 lockInterruptibly
来实现 程序清单 13-4 中的 sendOnSharedLine
。
// 程序清单 13-5
public boolean sendOnSharedLine(String message) throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
13.1.3 非块结构的加锁
这个略过,例子如 连锁式加锁(Hand-Over-Hand Locking)。
13.3 公平性
在 ReentrantLock
的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。非公平的 ReentrantLock
兵不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,而不公平则是一种不好的行为。在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于这个锁已被线程 A 持有,因此 B 将被挂起。当 A 释放锁时,B 将被唤醒,因此会再次尝试获取锁。与此同时,如果 C 也请求这个锁,那么 C 很可能会在 B 被完全唤醒之前 获得、使用以及释放这个锁。这样的情况是一种 “双赢” 的局面:B 获得锁的时刻并没有推迟,C 更早的获得了锁,并且吞吐量也获得了提高。
我们使用的锁,默认都是非公平锁。
13.4 在 synchronized 和 ReentrantLock 之间进行选择
在一些内置锁无法满足需求的情况下,
ReentrantLock
可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock
,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized
。
13.5 读 - 写锁
ReentrantLock
实现了一种标准的互斥锁:每次最多只有一个线程能持有 ReentrantLock
。但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要地限制了并发性。互斥是一种保守的加锁策略,虽然可以避免 “写/写” 冲突和 “写/读” 冲突,但同样也避免了 “读/读” 冲突。在许多情况下,数据结构上的操作都是 “读操作” —— 虽然它们也是可变的并且在某些情况下被修改,但其中大多数访问操作都是读操作。在这种情况下就可以使用读/写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。
// 程序清单 13-6
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
程序清单 13-6 的 ReadWriteLock
中暴露了两个 Lock
对象,其中一个用于读操作,而另一个用于写操作。要读取由 ReadWriteLock
保护的数据,必须首先获取读取锁,当需要修改 ReadWriteLock
保护的数据时,必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写入锁只是 读 - 写 锁对象的不同视图。
在读取锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock
中的一些可选实现包括:
释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发生饥饿问题。
重入性。读取锁和写入锁是否是可重入的?
降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被 “降级” 为读取锁,同时不允许其他写线程修改被保护的资源。
升级。读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读 - 写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。
下面 程序清单 13-7 的 ReadWriteMap
中使用了 ReentrantReadWriteLock
来包装 Map
,从而使它能在多个线程之间被安全的共享,并且仍然能避免 “读 - 写” 或 “写 - 写” 冲突。
// 程序清单 13-7
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();
}
}
// 对 remove(), putAll(), clear() 等方法执行相同的操作
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 对其他只读的 Map 方法执行相同的操作
}
但是在现实中,ConcurrentHashMap
的性能已经很好了,因此如果只需要一个并发的基于散列的映射,那么就可以使用 ConcurrentHashMap
来代替这种方法,但如果需要对另一种 Map
实现提供并发性更高的访问,那么可以使用这项技术。
网友评论