Java并发17-读写锁实现缓存


Java并发17-读写锁实现缓存

读写锁原则:

  • 允许多个线程同时读共享变量
  • 同一时间只允许一个线程写共享变量
  • 写变量的时候不允许读

快速实现一个缓存

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()将缓存一次性加载好,再提供使用。

按需加载缓存

按需加载,先看缓存中是否有值,有就返回,没有就从数据库中查询。

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; 
  }
}

关注代码第25行,为什么拿到写锁不直接去数据库取数据,要再验证下v是否不存在?

假设线程 T1获取写锁后查询数据库并更新缓存,最终释放写锁。此时线程 T2T3 会再有一个线程能获取写锁。假设是 T2,如果不再次验证,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。

实际上线程 T1 已经把缓存的值设置好了,T2T3 完全没有必要再次查询数据库。

因此再次验证的方式能够避免高并发场景下重复查询数据的问题。

读写锁的升级与降级

  • 升级
//读缓存
r.lock();try {
  v = m.get(key);if (v == null) {
    w.lock();
    try {
      //再次验证并更新缓存
      //省略详细代码
    } finally{
      w.unlock();
    }
  }
} finally{
  r.unlock();}

这种读锁还没释放,在读锁作用范围内使用写锁是不可行的。读锁还未释放,会导致写锁永久等待。

  • 降级
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();
    }
  }
}

cacheValid标记了data是否可用,即是否有data缓存。

如果可用,读缓存直接执行到第34行使用数据,再释放锁。

如果不可用,先释放读锁,再获取写锁,再次检查data是否可用,因为获取到写锁的时候可能已经被其他线程写过data了,获取data后将cacheValid置为true

第26行允许释放锁前将写锁降级为读锁,即写锁还未释放时,获取读锁

特点

读写锁类似于ReentrantLock,也支持公平模式和非公平模式。

读锁写锁都实现了 java.util.concurrent.locks.Lock 接口,支持lock()tryLock()lockInterruptibly() 等方法。

但只有写锁支持条件变量,读锁不支持条件变量,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

思考

线上服务CPU占用率很小,但停止响应了,可能是读写锁升级导致的问题,如何确定?

考虑到是线上应用,可采用以下方法

  1. 源代码分析。查找ReentrantReadWriteLock在项目中的引用,看下写锁是否在读锁释放前尝试获取
  2. 如果线上是Web应用,应用服务器比如说是Tomcat,并且开启了JMX,则可以通过JConsole等工具远程查看下线上死锁的具体情况。

——Kǎfκã²⁰²⁰


文章作者: Wendell
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Wendell !
评论
  目录