Java 5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronizedvolatile。Java5.0增加了一种新的机制:ReentrantLockReentrantLock并不是一种替代内置锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。

ReentrantLock

13.1 Lock与ReentrantLock

Lock接口中定义了一组抽象的加锁操作。与内置加锁机制不同的是,Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。

ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock时,同样有着与退出同步代码块相同的内存语义。此外,与synchronized一样,ReentrantLock还提供了可重入的加锁语义。ReentrantLock支持在Lock接口中定义的所有获取锁模式,并且与synchronized相比,它还为处理锁的不可用性问题提供了更高的灵活性。

为什么要创建一种与内置锁如此相似的新加锁机制?在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限地等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用synchronized的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的灵活性或性能。

Lock接口的标准使用形式比使用内置锁复杂一些:必须在finally块中释放锁。否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。当使用加锁时,还必须考虑在try块中抛出异常的情况,如果可能使对象处于某种不一致的状态,那么就需要更多的try-catch或try-finally代码块。

如果没有使用finally来释放Lock,那么相当于启动了一个定时炸弹。当“炸弹爆炸”时,将很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间。这就是ReentrantLock不能完全替代synchronized的原因:它更加“危险”,因为当程序的执行控制离开被保护的代码块时,不会自动清除锁。虽然在finally块中释放锁并不困难,但也可能忘记。

13.1.1 轮询锁与定时锁

13.1.2 可中断的锁获取操作

13.1.3 非块结构的加锁

13.2 性能考虑因素

13.3 公平性

ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁或者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果再发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。非公平的ReentrantLock并不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。在公平锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平锁的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。

我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,而不公平则是一种不好的行为。当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大地降低性能。在大多数情况下,非公平锁的性能要高于公平锁的性能。

13.4 在synchronized和ReentrantLock之间进行选择

ReentrantLock在加锁和内存上提供的语义与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。ReentrantLock在性能上似乎优于内置锁,其中在Java 6中略有胜出,而在Java 5.0中则是远远胜出。

与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且简洁紧凑。ReentrantLock的危险性比同步机制更高,如果忘记在finally块中调用unlock,那么虽然代码表面上能正常运行,但实际上已经埋下了一颗定时炸弹,并很有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock

在Java 5.0中,内置锁与ReentrantLock相比还有另一个优点:在线程转储中能给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。JVM并不知道哪些线程持有ReentrantLock,因此在调试使用ReentrantLock的线程的问题时,将起不到帮助作用。Java6解决了这个问题,它提供了一个管理和调试接口,锁可以通过该接口进行注册,从而与ReentrantLock相关的加锁信息就能出现在线程转储中,并通过其他的管理接口和调试接口来访问。与synchronized相比,这些调试消息是一种重要的优势,即便它们大部分都是临时性消息,线程转储中的加锁能给很多程序员带来帮助。ReentrantLock的非块结构特性仍然意味着,获取锁的操作不能与特定的栈帧关联起来,而内置锁可以。

未来更可能会提升synchronized而不是ReentrantLock的性能。因为synchronized是JVM的内置属性,它能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能性不大。除非将来需要在Java 5.0上部署应用程序,并且在该平台上确实需要ReentrantLock包含的可伸缩性,否则就性能方面来说,应该选择synchronized而不是ReentrantLock

13.5 读写锁

ReentrantLock实现了一种标准的互斥锁:每次最多只有一个线程持有ReentrantLock。但对于维护数据的完成性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要地限制了并发性。互斥是一种保守的加锁策略,虽然可以避免“写/写”冲突和“写/读”冲突,但同样也避免了“读/读”冲突。在许多情况下,数据结构上的操作都是“读操作”,虽然它们也是可变的并且在某些情况下被修改,但其中大多数访问操作都是读操作。此时,如果能够放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。只要每个线程都能确保读取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会发生问题。在这种情况下就可以使用读/写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而另一个用于写操作。要读取由ReadWriteLock保护的数据,必须首先获得读取锁,当需要修改ReadWriteLock保护的数据时,必须首先获得写入锁。

1
2
3
4
public interface ReadWriteLock{
  Lock readLock();
  Lock writeLock();
}

在读写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。与Lock一样,ReadWriteLock可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有所不同。

在读取锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock中的一些可选操作包括:

  • 释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程。
  • 读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该再写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发生饥饿问题。
  • 重入性。读取锁和写入所是否可重入?
  • 降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
  • 升级。读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。

ReentrantReadWriteLock为这两种锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造时也可以是一个非公平的锁(默认)还是一个公平的锁。在公平的锁中,等待时间最长的线程将优先获取锁。如果这个锁由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程时可以的,但从读线程升级为写线程则是不可以的。

ReentrantLock类似的是,ReentrantReadWriteLock中的写入锁只能有唯一的所有者,并且只能由获得该锁的线程来释放。在Java5.0中,读取锁的行为更类似于Semaphore而不是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在Java6中修改了这个行为:记录哪些线程已经获得了读者锁。

当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读-写锁能提高并发性。