极客时间 ——《Java并发编程实战》 17 | ReadWriteLock:如何快速实现一个完备的缓存

2020-10-28   15 次阅读


Java 中已经实现了管程信号量这两种同步原语,理论上用这两个任何一个都可以解决所有的并发问题。 但是 JDK 并发包中还存在许多其他的工具类,原因就是:分场景优化性能,提升易用性。

有一种非常普遍的并发场景:读多写少。实际工作中,为了优化性能,我们经常使用缓存,例如缓存元数据,缓存基础数据等,这是一个典型的读多写少应用场景。

缓存提升性能的关键就是缓存数据一定是读多写少的,数据发生变化的概率很低,但是使用它们的地方很多。

针对这种读多写少的场景,JDK提供了工具类读写锁——ReadWriteLock,容易使用,性能很好。

image-20200808114558994

什么是读写锁

读写锁并不是 Java 特有的,而是一个通用技术,读写锁遵循以下三条基本原则:

  1. 允许多个线程同时读共享变量。
  2. 只允许一个线程写共享变量。
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁和互斥锁的重要区别:

  • 读写锁允许多个线程同时读共享变量。
  • 互斥锁只允许一个线程读/写 共享变量。

这是读写锁在读多写少场景下性能比互斥锁好的关键。但是读写锁的写行为还是互斥的,当一个线程在写共享变量的时候,不允许其他线程执行写操作和读操作。

快速实现一个缓存

下面是实践环节:用 ReadWriteLock 快速实现一个通用缓存工具类。

下面的代码中,声明了一个 Cache<K,V> 类,类型参数 K 代表缓存里的 key 类型,V 代表 value 类型。缓存数据保存在 Cache 类里的 HashMap 中,HashMap非线程安全的,这里使用读写锁 ReadWriteLock 保证线程安全。

ReadWriteLock 是接口,它的具体实现类是 ReentrantReadWriteLock,从名字就可以看出读写锁是支持重入的,下面通过 rwl 创建一把读锁一把写锁

Cache 这个工具类中提供两个方法:

  • 读缓存方法:get()
  • 写缓存方法 put()

读缓存时需要使用读锁,读锁的使用和之前介绍的 Lock 使用相同,都是 try{} finally{} 编程范式

写缓存需要用到写锁,其使用方法与读锁类似。

public 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, value);
        }finally {
            w.unlock();
        }
    }
}

缓存首先要解决的问题:缓存数据的初始化,解决方法可以是一次性加载全部缓存数据,也可以是按需加载缓存数据。

如果缓存数据的数据量不大,可以采用一次性加载的方式,这种方式最简单(参考下图),在应用启动时把源头有的数据查出来,一次调用 示例代码中的 put 方法放入缓存即可。

如果源头数据量非常大,就需要按需加载,按需加载也叫懒加载,指 「只有当应用查询缓存,并且数据不再缓存中时,才触发加载源头数据进缓存的操作。」

下面是**按需加载**示意图:

实现缓存的按需加载

下面这段代码实现了按需加载功能,这里假设缓存的源头是数据库。如果查询的数据在缓存中不存在,就需要从数据库中查出来,然后放入缓存。写入缓存的时候需要使用到写锁,在代码 处,调用了 w.lock() 获取写锁。

同时获取写锁后并没有立即查询数据库,而是重新验证了一次数据是否在缓存中存在,如果还是不存在才去查询数据库,这是因为高并发场景下,假设有线程T1T2T3 同时调用 get() 方法,并且查询的 key 相同。

那么此时它们会同时执行到代码 处,但是最终只有一个线程能获得锁,假设 T1 获得锁,并将数据写入缓存,此时T2第二个获得锁,如果不验证数据是否存在的话,就会多查一次数据库,造成了性能浪费,所以使用再次验证的方式可以避免高并发场景下重复查询数据的问题

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

读写锁的升级与降级

在上面的按需加载示例代码中,①处获取读锁,③处释放读锁,是否可以在②处增加验证缓存并更新缓存的逻辑呢?代码如下:


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

这样看上去好像没问题,先获取读锁,然后升级为写锁,对此有个专业名词 —— 锁升级。但是 ReadWriteLock 并不支持这种升级方式。

在上面的代码示例中,读锁没有释放, 此时获取写锁会导致写锁永久等待,导致相关线程被阻塞,永远没有机会唤醒。读写锁的升级不允许,这个是一定要注意的点。

虽然锁的升级不被允许,但是读写锁允许锁的降级。以下代码来自 ReentrantReadWriteLock 官方示例略做改动,可以看到在 代码 处时获取读锁的时候线程还持有写锁,这种锁的降级是支持的:

public 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();
            }
        }
    }
}

总结

读写锁类似 ReentrantLock ,也支持公平模式和非公平模式。读锁和谐锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()lockInterruptibly() 等方法也都支持。但是只有写锁支持条件变量,读锁不支持,在读锁上调用 newCondition() 会抛出 UnsupportedOperationException 异常。

这篇文章中使用 ReadWriteLock 实现了一个简单的缓存,这个缓存解决了数据初始化的问题,但是没有解决缓存数据数据源之间同步的问题。这里的同步指的是保证缓存数据和数据库中的数据的一致性

解决数据同步的一个最简单的方案是超时机制 —— 加载进缓存中的数据带有时效性,当数据过期后这条数据自动失效,再次查询时会从数据源更新数据。
也可以在数据源发生变化时从数据源反馈给缓存,这需要依赖具体场景:如果 MySQL 作为数据源头,可以通过近似实时地解析 binlog 来判断数据是否变化,如果变化就将最新的数据推送给缓存。还可以采取数据库和缓存双写的方案。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。