【Java并发工具类】ReadWriteLock

前言

前面介绍过ReentrantLock,它实现的是一种标准的互斥锁:每次最多只有一个线程能持有ReentrantLock。这是一种强硬的加锁规则,在某些场景下会限制并发性致使没必要要的抑制性能。互斥是一种保守的加锁策略,虽然能够避免“写/写”冲突和“写/读”冲突,可是一样也避免了“读/读”冲突。java

在读多写少的状况下,若是可以放宽加锁需求,容许多个执行读操做的线程同时访问数据结构,那么将提高程序的性能。只要每一个线程都能确保读到最新的数据,而且在读取数据时不会有其余的线程修改数据,那么就不会发生问题。在这种状况下,就可使用读写锁一个资源能够被多个读操做访问,或者被一个写操做访问,但二者不能同时进行。数据库

Java中读写锁的实现是ReadWriteLock。下面咱们先介绍什么是读写锁,而后利用读写锁快速实现一个缓存,最后咱们再来介绍读写锁的升级与降级。编程

什么是读写锁

读写锁是一种性能优化措施,在读多写少场景下,能实现更高的并发性。读写锁的实现须要遵循如下三项基本原则:缓存

  1. 容许多个线程同时读共享变量;
  2. 只容许一个线程写共享变量;
  3. 若是一个线程正在执行写操做,此时禁止读线程读共享便利。

读写锁与互斥锁的一个重要区别就是:读写锁容许多个线程同时读共享变量,而互斥锁是不容许的。读写锁的写操做时互斥的。安全

下面是ReadWriteLock接口:性能优化

public interface ReadWriteLock{
    Lock readLock();
    Lock writeLock();
}

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

与Lock同样,ReadWriteLock能够采用多种不一样的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有些不一样。读取锁与写入锁之间的交互方式也能够采用多种方式实现。多线程

ReadWriteLock中有一些可选实现包括:并发

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

ReentrantReadWriteLock

ReentrantReadWriteLock是ReadWriteLock的一个实现,它为读取锁和写入锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造时也能够选择是一个非公平的锁(默认)仍是一个公平的锁。高并发

在公平的锁中,等待时间最长的线程将优先得到锁。若是这个线程是由读线程持有,而另外一个线程请求写入锁,那么其余读线程都不能得到读取锁,直到写线程使用完而且释放了写入锁。

在非公平的锁中,线程得到访问许可的顺序是不肯定的。写线程降级为读线程是能够的,但从读线程升级为写线程则是不能够的(容易致使死锁)。

实现一个快速缓存

下面使用ReentrantReadWriteLock来实现一个通用的缓存工具类。

实现一个Cache<K,V>类,类型参数K表明缓存中key类型,V表明缓存里的value类型。咱们将缓存数据存储在Cache类中的HashMap中,可是HashMap不是线程安全的,因此咱们使用读写锁来保证其线程安全
Cache工具类提供了两个方法,读缓存方法get()和写缓存方法put()。读缓存须要用到读取锁,读取锁的使用方法同Lock使用方式一致,都须要使用try{}finally{}编程范式。写缓存须要用到写入锁,写入锁和读取锁使用相似。

代码参考以下:(代码来自参考[1])

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 读取锁
    final Lock w = rwl.writeLock(); // 写入锁
    
    // 读缓存
    V get(K key) {
        r.lock(); // 获取读取锁
        try { 
            return m.get(key); 
        }finally { 
            r.unlock();  // 释放读取锁
        }
    }
    
    // 写缓存
    V put(K key, V value) {
        w.lock(); // 获取写入锁
        try { 
            return m.put(key, v); 
        }finally { 
            w.unlock(); // 释放写入锁 
        }
    }
}

缓存数据的初始化

使用缓存首先要解决缓存数据的初始化问题。缓存数据初始化,能够采用一次性加载的方式,也可使用按需加载的方式。

若是源头数据的数据量不大,就能够采用一次性加载的方式,这种方式也最简单。只须要在应用启动的时候把源头数据查询出来,依次调用相似上面代码的put()方式就能够了。可参考下图(图来自参考[1])

image-20200217192615172

若是源头数据量很是大,那么就须要按需加载,按需加载也叫作懒加载。指的是只有当应用查询缓存,而且数据不在缓存里的时候,才触发加载源头相关数据进行缓存的操做。可参考下图(图来自参考[1])

image-20200217192844702

实现缓存的按需加载

下面代码实现了按需加载的功能(代码来自参考[1])。
这里假设缓存的源头时数据库。若是缓存中没有缓存目标对象,那么就须要从数据库中加载,而后写入缓存,写缓存是须要获取写入锁。

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 读取锁
    final Lock w = rwl.writeLock(); // 写入锁

    V get(K key) {
        V v = null;
        //读缓存
        r.lock();    // 获取读取锁     
        try {
             v = m.get(key);
         } finally{
             r.unlock();     // 释放读取锁
         }
        //缓存中存在目标对象,返回
        if(v != null) {   
            return v;
        }  
        //缓存中不存在目标对象,查询数据库并写入缓存
        w.lock();         // 获取写入锁 ①
        try {
            //再次验证 其余线程可能已经查询过数据库
            v = m.get(key); 
            if(v == null){  
            //查询数据库
                v=省略代码无数
                m.put(key, v);
            }
         } finally{
             w.unlock(); //释放写入锁
         }
        return v; 
    }
}

当缓存中不存在目标对象时,须要查询数据库,在上述代码中,咱们在执行真正的查库以前,又查看了缓存中是否已经存在目标对象,这样作的好处是能够避免重复查询提高效率。咱们举例说明这样作的益处。

在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,若是此时有三个线程 T一、T2 和 T3 同时调用get()方法,而且参数 key也是相同的。那么它们会同时执行到代码①处,但此时只有一个线程可以得到写锁。
假设是线程 T1,线程 T1 获取写锁以后查询数据库并更新缓存,最终释放写锁。
此时线程 T2 和 T3 会再有一个线程可以获取写锁,假设是 T2,若是不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁以后,T3 也会再次查询一次数据库。
而实际上线程 T1 已经把缓存的值设置好了,T二、T3 彻底没有必要再次查询数据库。

读写锁的升级与降级

上面读取锁的获取释放与写入锁的读取和释放是没有嵌套的。若是咱们改一改代码,将再次验证并更新缓存的逻辑换个位置放置:

//读缓存
r.lock(); // 获取读取锁      
try {
    v = m.get(key); 
    if (v == null) {
        w.lock(); // 获取写入锁
        try {
            //再次验证并更新缓存
            //省略详细代码
         } finally{
            w.unlock(); // 释放写入锁
         }
    }
} finally{
  r.unlock(); // 释放读取锁
}

上述代码,在获取读取锁后,又试图获取写入锁,即咱们前面介绍的锁的升级。可是,ReadWriteLock是不支持这种升级,在代码中,读取锁尚未释放,又尝试获取写入锁,将致使相关线程被阻塞(读取锁和写入锁只是读写锁对象的不一样视图),永远没有机会被唤醒。

虽然锁的升级不被容许,可是锁的降级倒是被容许的。(下例代码来自参考[1])

class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 读取锁
    final Lock w = rwl.writeLock(); //写入锁
  
    void processCachedData() {
        // 获取读取锁
        r.lock();
        if (!cacheValid) {
            r.unlock(); // 释放读取锁,由于不容许读取锁的升级
            w.lock(); // 获取写入锁
            try {
                // 再次检查状态  
                if (!cacheValid) {
                    data = ...
                    cacheValid = true;
                }
                // 释放写入锁前,降级为读取锁 降级是能够的
                r.lock();
            } finally {
                w.unlock(); // 释放写入锁
            }
        }
        // 此处仍然持有读取锁,要记得释放读取锁
        try {
            use(data);
        } finally {
            r.unlock();
        }
    }
}

小结

读写锁的读取锁和写入锁都实现了java.util.concurrent.locks.Lock接口,因此除了支持lock()方法外,tryLock()lockInterruptibly()等方法也都是支持的。可是须要注意,只有写入锁支持条件变量,读取是不支持条件变量的,读取锁调用newCondition()会泡池UnsupporteOperationException异常。

咱们实现的简单缓存是没有解决缓存数据与源头数据同步的,即保持与源头数据的一致性。解决这个问题的一个简单方案是超时机制:当缓存的数据超过期效后,这条数据在缓存中就失效了;访问缓存中失效的数据,会触发缓存从新从源头把数据加载进缓存。也能够在源头数据发生变化时,快速反馈给缓存。

虽然说读写锁在读多写少场景下性能优于互斥锁(独占锁),可是在其余状况下,性能可能要略差于互斥锁,由于读写锁的复杂性更高。因此,咱们要根据场景来具体考虑使用哪种同步方案。

参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

相关文章
相关标签/搜索