[Java并发-10] ReadWriteLock:快速实现一个完备的缓存

你们知道了Java中使用管程同步原语,理论上能够解决全部的并发问题。那 Java SDK 并发包里为何还有不少其余的工具类呢?缘由很简单:分场景优化性能,提高易用性java

今天咱们就介绍一种很是广泛的并发场景:读多写少场景。实际工做中,为了优化性能,咱们常常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之因此能提高性能,一个重要的条件就是缓存的数据必定是读多写少的.数据库

针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,很是容易使用,而且性能很好。编程

什么是读写锁

读写锁,并非 Java 语言特有的,而是一个广为使用的通用技术,全部的读写锁都遵照如下三条基本原则:缓存

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

读写锁与互斥锁的一个重要区别就是读写锁容许多个线程同时读共享变量,而互斥锁是不容许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操做是互斥的,当一个线程在写共享变量的时候,是不容许其余线程执行读操做和写操做的。安全

快速实现一个缓存

在下面的代码中,咱们声明了一个 Cache<K, V> 类,其中类型参数 K 表明缓存里 key 的类型,V 表明缓存里 value 的类型。缓存的数据保存在 Cache 类内部的 HashMap 里面,HashMap 不是线程安全的,这里咱们使用读写锁 ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock,经过名字你应该就能判断出来,它是支持可重入的。下面咱们经过 rwl 建立了一把读锁和一把写锁。多线程

Cache 这个工具类,咱们提供了两个方法,一个是读缓存方法 get(),另外一个是写缓存方法 put()。读缓存须要用到读锁,读锁的使用和前面咱们介绍的 Lock 的使用是相同的,都是 try{}finally{}这个编程范式。写缓存则须要用到写锁,写锁的使用和读锁是相似的。并发

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(String key, Data v) {
    w.lock();
    try { return m.put(key, v); }
    finally { w.unlock(); }
  }
}

实现缓存的按需加载

设计封装缓存类时,咱们须要在当应用查询缓存,而且数据不在缓存里的时候,触发加载源头相关数据进缓存的操做,这也是咱们须要实现的最基本的功能。下面看下利用 ReadWriteLock 来实现缓存的按需加载。高并发

这里咱们假设缓存的源头是数据库。须要注意的是,若是缓存中没有缓存目标对象,那么就须要从数据库中加载,而后写入缓存,写缓存须要用到写锁,因此在代码中的⑤处,咱们调用了w.lock() 来获取写锁。工具

另外,还须要注意的是,在获取写锁以后,咱们并无直接去查询数据库,而是在代码⑥⑦处,从新验证了一次缓存中是否存在,再次验证若是仍是不存在,咱们才去查询数据库并更新本地缓存。为何咱们要再次验证呢?性能

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 并不支持这种升级。在上面的代码示例中,读锁尚未释放,此时获取写锁,会致使写锁永久等待,最终致使相关线程都被阻塞,永远也没有机会被唤醒。

小结

读写锁相似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,因此除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。可是有一点须要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

相关文章
相关标签/搜索