十、显示锁

在Java 5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。 Java 5.0增加了一种新的机制:ReentrantLock。与之前提到过的机制相反,ReentrantLock并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选的改机功能。

一、Lock与ReentrantLock

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

public interface Lock {
    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();

    boolean tryLock(long var1, TimeUnit var3) throws InterruptedException;

    void unlock();

    Condition newCondition();
}

ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLock时同样有着与退出同步代码块相同的内存语更。此外,与synchronized一样, ReentrantLock还提供了重入的加锁语义。ReentfantLock支持在Lock接口中定义的所有获取锁模式,并且与synchronized相比,它还为处理锁的不可用性问题提供了更高的灵活性。
在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在 请求获取一个锁时无限地等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用synchronized的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
程序清单给出了Lock接口的标准使用形式。这种形式比使用内置锁复杂一些:必须在finally块中释放锁。否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。当使用加锁时,还必须考虑在try块中抛出异常的情况,如果可能使对象处于某种不一致 的状态,那么就需要更多的try-catch或try-fmally代码块。(当使用某种形式的加锁时,包括内置锁,都应该考虑在出现异常时的情况。)

Lock lock = new ReentrantLock();
...
lock.lock();
try {
	// 更新对象状态
	// 捕获异常,并在必要时恢复不变性条件 
} finally {
	lock.unlock()
}

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

  • 轮询锁与定时锁

可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的锁获取模式相比, 它具有更完善的错误恢复机制在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的锁获取方式,从而使f尔 重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁(或者至少会将这个失败 记录到日志,并采取其他措施)。
在实现具有时间限制的操作时,定时锁同样非常有用。当在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。

  • 可中断的锁获取操作

正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样 能在可取消的操作中使用加锁。有几种不能响应中断的机制,例如请求内置锁。这些不可中断的阻塞机制将使得实现可取消的任务变得复杂。locklntemiptibly方法能够在获得锁的同时保持对中断的响应,并且由于它包含在Lock中,因此无须创建其他类型扣不可中断阻塞机制。

  • 非块结构的加锁

在内置锁中,锁的获取和释放等操作都是基于代码块的–释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块。自动的锁释放操作简化了对程序的分析,避免了可能的编码错误,但有时侯需要更灵活的加锁规则。
通过降低锁的粒度可以提高代码的可伸缩性。锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过采用类似的原则来降低链表中锁的粒度,即为每个链表节点使用一个独立的锁,使不同的线程能独立地对链表的不同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能释放前一个节点上的锁。

二、公平性

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

三、在 synchronized 和 ReentrantLock 之间进行选择

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized。

四、读-写锁

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

ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而另一个用于写操作。要读取由ReadWriteLock保护的数据,必须首先获得读取锁,当需要修改 ReadWriteLock保护的数据时,必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写人锁只是读-写锁对象的不同视图。

public interface ReadWriteLock {
    Lock readLock();

    Lock writeLock();
}

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

读-写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况中,对于在多处理器系统上被频繁读取的数据结构,读-写锁能够提高性能。而在其他情况下,读-写锁的性能比独占锁的性能要略差一些。这是因为它们的复杂性更高。如果要判断在某种情况下使用读-写锁是否会带来性能提升,最好对程序进行分析。由于ReadWriteLock使用Lock来实现锁的读-写部分,因此如果分析结果表明读-写锁没有提高性能,那么可以很容易地将读-写锁换为独占锁。

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

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

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

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

当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读-写锁能提高并发性。在ReadWriteMap中使用了ReentrantReadWriteLock来包装Map,从而使它能在多个读线程之间被安全地共享,并且仍然能避免“读-写”或“写-写”冲突。在现实中,ConcurrentHashMap的性能已经很好了,因此如果只需要一个并发的基于散列的映射,那么就可以使用ConcurrentHashMap来代替这种方法,但如果需要对另一种Map实现(例如 LinkedHashMap)提供并发性更髙的访问,那么可以使用这项技术。

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) {
        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方法执行相同的操作

}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

书香水墨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值