13.显示锁
在Java5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。Java5.0增加了一种新的机制:ReentrantLock。与之前提到过的机制相反,RenntrantLock并不是一种替代内置加锁的方法,而是当内置锁机制不适用时,作为一种可选择的高级功能。
13.1 ReentrantLock
Lock接口提供了一组抽象的加锁操作,与内置加锁操作不同的是,Lock提供了一种无条件的、可轮训的、定时的以及可中断锁操作,所有加锁和解锁的方法都是显示的。
在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性与内存可见性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock时,有着与突出同步代码块相同的内存语义。此外,与synchronized一样,ReentrantLock还提供了可重入的加锁语义。
ReentrantLock支持在Lock接口中定义的所有获取锁模式,并且与synchronized相比,它还为处理锁的不可用性问题提供了更高的灵活性。
内置锁必须在获取该锁的代码块中释放,这简化编码,并且在异常处理操作实现了很好的交互。为什么要创建一种与内置锁如此相似的新加锁机制?原因有三:
- 在大多数情况下,内置锁都能很好的工作,但在一些功能上存在一些局限性。例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限的等待下去。
- 无法实现非阻塞结构的加锁规则。
- 在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性和性能。
代码清单 13-2 给出了Lock接口的标准使用形式。这种形式比使用内置锁复杂一些:
- 必须在finally块中释放锁。否则,如果在被保护的代码中抛出了异常那么,这个锁永远都无法释放。
- 当使用加锁时,还必须考虑在try块中抛出异常的情况,如果可能使对象处于某种不一致的状态,那么必须使用多个try-cathc或try-finally代码块。(使用内置锁也应该考虑出现异常的情况)。
代码清单 13-2 使用ReentrantLock来保护对象状态
Lock lock = new ReentrantLock(); // ...... lock.lock(); try { // 更新状态 //// 捕获异常,并在必要时恢复不变性条件 } finally { lock.unlock(); }
如果没有使用finlly来释放锁Lock,那么相当于启动了一个定时炸弹。当“炸弹爆炸”时,很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间。这就是ReentrantLock不能完全替代synchronized的原因:它更加“危险”,因为当程序的执行控制离开被控制的代码块时锁不会自动清除。虽然在finally块中释放锁并不困难,但可能忘记。
1.1 轮询锁于定时锁
可定时和可轮询的获锁模式是由tryLock 实现的,与无条件的锁模式相比较它具有跟完善的错误恢复机制。
在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的锁与可轮询的锁提供了一种选择:避免死锁的发生。
如果一定时间内,没有能获得所有需要的锁,那么可以使用可定时的或可轮询的锁获取方式,从而使你重新获取控制权,它会释放已经获得的锁,然后重新尝试获取所有锁(或者至少会将这个失败记录到日志并采取其他措施)。
代码清单 13-3给出了一个在不同账户间转账的例子:使用trylock来获取两个锁,如果不同时获得就退回并重试;如果在制定时间不能获的所需要的锁,那么transferMoney将返回一个失败状态,从而使该操作平缓的实效。
代码清单 13-3 用 tryLock 避免顺序死锁的发生
public boolean transferMoney(Account fromAcc, Account toAcc, DollarAmount amount, long timeout, TimeUnit unit) throws InsufficientFundsException, InterruptedException { long fixedDelay = getFixedDelayComponentNanos(timeout, unit); long randMod = getRandomDelayModuleNanos(timeout, unit); long stopTime = System.nanoTime() + unit.toNanos(timeout); while (true) { if (fromAcc.lock.tryLock()) { try { if (toAcc.lock.tryLock()) { try { if (fromAcc.getBalance().compareTo(amount) < 0) { throws new InsufficientFundsException(); } else { fromAcc.debit(amount); toAcc.credit(amount); return true; } } finally { toAcc.lock.unlock(); } } } finally { fromAcc.lock.unlock();
} } if (System.nanoTime() < stopTime) return false; NANOSECONDS.sleep(fixedDelay + randMod.nextLong() % randMod); } }
那些具有时间限制的活动,定时锁同样非常有用。当在带有时间限制的操作中调用了一个阻塞方法时,他能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。程序清单 13-4 实现了这样一个例子:在共享通信线路上发送一个消息,如果不能在指定时间内完成,代码就会失败。
程序清单 13-4 带有时间限制
public boolean trySendOnSharedLine(String message, long timeout, TimeUnit unit) throws InterruptedException { if (!lock.tryLock(nanosToLock, NANOSECONDS)) { return false; } try { return sendOnSharedLine(message); } finally { lock.unlock(); } }
1.2 可中断的锁获取操作
可中断的锁获取操作的标准结构比普通的锁获取操作稍微复杂一些,因为需要两个 try 块。如果可中断的锁获取操作抛出了 InterruptedException, 那么可以使用标准的 try-finally加锁模式。
程序清单 13-5 使用 lockInterruptibly来实现程序清单13-4的sendOnSharedLine,以便在一个课取消的任务中调用它。
定时的tryLock同样响应中断,因此当需要实现一个定时的和可中断的锁获取操作时,可以使用tryLock方法。
程序清单 13-5 可中断的锁获取操作
public boolean sendOnSharedLine(String messages) throws InterruptedException { lock.lockInterruptibly(); try {
return cancellableSendOnSharedLine(messages); } finally { lock.unlock(); } } private boolean cancellableSendOnSharedLine(String messages) throws InterruptedException {...}
13.2 性能考虑
当把ReentrantLock添加到Java 5.0时,他能比内置锁提供更好的竞争性能。对于同步原语来说,竞争性能是可伸缩性的关键因素:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序的到的资源就越少。锁的实现方式越好,将需要越少的系统调用和上下文切换,并且在共享内存总线上的内存同步通信量也越少,而一些耗时的操作将占用应用程序的资源。
Java 6.0使用了改进后的算法来管理内置锁,与在ReentrantLock中使用的算法类似,该算法有效的提高了可伸缩性。
图13-1给出了Java 5.0与Java 6.0版本中,内置锁与ReentrantLock之间的性能差异:在运行环境为4路的Opteron系统,操作系统是Solaris,测试并比较一个HashMap在由内置锁保护以及由ReentrantLock保护的情况下的吞吐量的例子。在Java 5.0中ReentrantLock能提供更好的吞吐量,但是在Java 6.0中二者的吞吐量非常接近。
13.3 公平性
ReentrantLock中提供了两种公平性选择:非公平的锁(默认)和公平的锁。在公平的锁上,线程将按照他们发出请求的顺序来获得锁,而非公平的锁则允许“插队”: 当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁(Semaphore与此相似)。在公平的锁中,如果一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。(注意:即使是公平锁,可轮询的tryLock仍然会“插队”)。
我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,对不对?当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低了性能。有些算法依赖于公平的排队算法以确保他们的正确性,但这些算法并不常见。在大多数情况下,非公平锁的性能要高于公平锁的性能。
图13-2给出了Map的性能测试,采用了对数缩放比例的方式,比较由公平的以及非公平的ReentrantLock包装的HashMap的性能,运行环境为4路的Opteron系统,操作系统是Solaris。从图可见公平性把性能降低了约两个数量级,不必要的话不要为了公平性而付出代价。
在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B在请求这个锁。由于这个锁已经被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此会再次尝试获取锁。与此同时,如果C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样的情况时一种“双赢”的局面:在B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也获得了提高。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,“插队”带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)则可能不会出现。
与默认的ReentrantLock一样,内置加锁并不会提供确定的公平性保证,Java语言规范对此也并没有要求。
13.4 在synchronized vs. ReentrantLock
ReentrantLock在加锁和内存上提供的语义与内置锁相同,此外还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性以及实现非块结构的加锁。ReentrantLock在性能上似乎优于内置锁,其中在Java 6.0中略有胜出,而在Java 5.0中则是远远胜出。那么为什么不放弃synchronized,在所有新的并发代码中都适用ReentrantLock呢?
原因有四:
- 与显式锁相比,内置锁仍然具有很大优势:内置锁为许多开发人员所熟知,并且简洁紧凑。
- 许多现有程序中都已经使用了内置锁,如果将两种机制混合使用,那么不仅容易令人困惑,也容易发生错误。
- ReentrantLock的危险性比较高,如果忘记在finally块中调用unlock,那么虽然代码表面上能正常运行,但实际上已经买下了一颗“定时炸弹”。
- 未来更可能会提升性能的时synchronized而不是ReentrantLock。因为synchronized时JVM的内置属性,他能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的颗粒度来消除内置锁的同步。
故,只有当内置锁不能满足需求的情况下,ReentrantLock可以作为一种高级的工具,否则还是应该优先使用synchronized。
13.5 读-写锁
ReentrantLock实现了一种标准的互斥锁,每次最多只有一个线程能持有ReentrantLock。但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要的限制了并发行。
互斥是一种保守的加锁策略,虽然可以避免“写/写”冲突和“写/读”冲突,但同样避免了“读/读”冲突。在许多情况下,数据结构上的访问操作都是“读操作”,此时如果能方块加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。
在这种情况下,可以使用读/写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但是两者不能同时进行。
在程序清单 13-6 的ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而一个用于写操作。要读取由ReadWriteLock保护的数据必须首先获得读取锁,当需要修改ReadWriteLock保护的数据时必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写入锁只是读-写锁对象的不同视图。
程序清单 13-6 ReadWriteLock接口
public interface ReadWriteLock { /** * Returns the lock used for reading. * * @return the lock used for reading */ Lock readLock(); /** * Returns the lock used for writing. * * @return the lock used for writing */ Lock writeLock(); }
读-写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况中,对于在多处理器系统上被频繁读取的数据结构,读-写锁能够提高性能。而在其他情况下,读-写锁的性能要比独占锁的性能要略差一些,这是因为他们的复杂性更高。如果要判断在某些情况下使用读-写锁是否会带来性能提升,最好对程序进行分析。
由于ReadWriteLock使用Lock来实现锁的读-写部分,因此在需要的时候,可以容易的将读-写锁换位独占锁。
ReentrantReadWriteLock为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock也只非公平锁(默认)和公平锁。在公平锁中,等待时间最长的线程将优先获得锁。如果这个锁由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读取锁,知道写线程使用完并且释放了写入锁。在非公平锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线程升级为写线程则是不可以的(可能两个读线程是图同时升级为写入锁,从而导致死锁)。
与ReentrantLock类似的是,ReentrantReadWriteLock中的写入锁智能有唯一的所有者,并且只能由获得该锁的线程来释放。ps:在Java 5中读取锁只维护活跃的读线程的数量,而在Java 6中则记录了哪些线程获得了读取锁。
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读-写锁能提高并发性,在程序清单13-7的ReadWriteMap中使用了ReentrantReadWriteLock来包装Map,从而使他能在多个读写成之间被安全的共享,并且仍然能避免“读-写”或“写-写”冲突。在现实中,ConcurrentHashMap的性能已经很好了,一次如果只需要一个并发的基于散列的映射时,直接使用ConcurrentHashMap就可以了,但如果需要对另一宗Map实现(如LinkedHashMap)提供并发性更高的访问,那么就可以使用该技术了。
程序清单 13-7 用读-写锁来包装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(); } } // 对remove, putAll,clear等方法执行相同的操作 public V get(Object key) { r.lock(); try { return map.get(key); } finally { r.unlock(); } } // 对其他只读的Map方执行相同的操作 }
图13-3给出了分别中ReentrantLock和ReadWriteLock来封装ArrayList的吞吐量比较。测试成为为4路Opteron系统,操作系统为Solaris。这里是使用的测试程序:每个操作随机的选择一个值并在容器中查找这个值,并且只有少量操作会修改这个容器中的内容。
小结
与内置锁相比,显示的Lock提供了一些扩展功能,在处理锁的不可用性方面有着更高灵活性,并且对队列行有着更好的控制。但ReentrantLocak不能完全替代snchronized,只有在snchronized无法满足需求时才应该使用它。
读-写锁允许多个读线程并发地访问被保护对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。
了解更多知识,关注我。