1. 优享JAVA首页
  2. Java

浅析Java高并发下的ReadWriteLock读写锁

对于高频读/低频写的应用场景,使用Lock或者使用synchronized来做同步显然是不太合理的,那么有其他的方式来提高并发性能吗?

在Java的并发包中有许多功能不同的类,今天我们介绍其中的一个,读写锁ReadWriteLock。这种锁在工作中应用场景非常广泛,普遍的使用场景是:对于读多写少的场景。经常用到的例如存储元数据,缓存基础数据等等,这些都是典型的读多写少的应用场景。使用缓存可以极大提升应用程序的处理能力。

读写锁有下面这几个特征:

  • 两个或多个线程可以同时进行读操作
  • 线程正在进行读操作,另一个线程想要进行写操作,另一个线程将会被阻塞直到所有读操作结束
  • 线程正在进行写操作,另一个线程想要进行操作(读或写),另一个线程将会阻塞直到写入方完成操作

读写锁与互斥锁一个重要的区别就是读写锁允许多个线程共享临界段,而互斥锁是不允许的。这是在读多写少场景下读写锁性能优于互斥锁的原因。

但是读写锁在读写操作时是互斥的,当一个线程在进行写操作时,其他的读写线程都是是处于阻塞状态的

读写锁实现缓存

下面我们会来实践,用ReadWriteLock将非线程安全的Map包装为一个简单的缓存工具类

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

ICache这个工具类,我们提供了几种简单常用的方法,其中有读缓存方法 get(),写缓存方法 put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是 try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。

class ICache<K, V> {
    private final Map<K, V> m = new TreeMap<>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
​
    public V get(K k) {
        r.lock();
        try {
            return m.get(k);
        } finally {
            r.unlock();
        }
    }
    public Object[] allKeys() {
        r.lock();
        try {
            return m.keySet().toArray();
        } finally {
            r.unlock();
        }
    }
    public V put(K key, V value) {
        w.lock();
        try {
            return m.put(key, value);
        } finally {
            w.unlock();
        }
    }
    public void clear() {
        w.lock();
        try {
            m.clear();
        } finally {
            w.unlock();
        }
    }
}

如果你曾经使用过缓存的话,你应该知道使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。

浅析Java高并发下的ReadWriteLock读写锁

如果源头数据的数据量不大,就可以采用一次性加载的方式,这种方式最简单,只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put() 方法就可以了。

如果源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。下面你可以结合文中示意图看看如何利用 ReadWriteLock 来实现缓存的按需加载。

缓存按需加载

文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码注释中的5处,我们调用了 w.lock() 来获取写锁。

另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码注释6、7处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?

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 cache(K key) {
        V v = null;
        // 读缓存
        r.lock();         //1
        try {
            v = m.get(key); //2
        } finally {
            r.unlock();     //3
        }
        // 缓存中存在,返回
        if (v != null) {   //4
            return v;
        }
        // 缓存中不存在,查询数据库
        w.lock();         //5
        try {
            // 再次验证
            // 其他线程可能已经查询过数据库
            v = m.get(key); //6
            if (v == null) {  //7
                // 查询数据库
                v = null;//省略代码无数
                m.put(key, v);
            }
        } finally {
            w.unlock();
        }
        return v;
    }
}

在5处写缓存,需要写锁,在代码6和7处,为什么要重新判断是否存在?

原因是在高并发的场景下,有可能会有多线程竞争写锁。

假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码注释5处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。

读写锁的升级与降级

上面按需加载的示例代码中,在1处获取读锁,在3处释放读锁,那是否可以在2处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。

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

先是获取读锁,然后再升级为写锁,这叫锁的升级。问题:读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。

不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。

以下代码来源自 ReentrantReadWriteLock 的官方示例,略做了改动。你会发现在代码注释1处,获取读锁的时候线程还是持有写锁的,这种锁的降级是支持的。

class CachedData {
    private Object data;
    private volatile boolean cacheValid;  // 缓存无效   true:有效  false 无效
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
​
    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {// 在获取写锁之前必须释放读锁
            rwl.readLock().unlock();  //释放读锁
            rwl.writeLock().lock();      //获取写锁
            try {
                // 重新检查状态,因为另一个线程可能已经获得写锁并在我们之前更改了状态。
                if (!cacheValid) {
                    data = ...
                    cacheValid = true;
                }
                // 降级通过获取读锁之前释放写锁
                rwl.readLock().lock(); // 1
            } finally {
                rwl.writeLock().unlock(); // 释放写锁,仍然保持读
            }
        }
        try {
            use(data); // 此处仍然持有读锁
        } finally {
            rwl.readLock().unlock();
        }
    }
    private void use(Object data) {
        System.out.println(data.toString());
    }
}

总结

读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。

但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。另外,官方文档中还提到了,读写锁支持最多65535个递归写锁和65535个读锁。如果超过这个限制会导致锁定方法抛出错误。

原创文章,作者:Craig,如若转载,请注明出处:https://www.goodlymoon.com/archives/1833.html

发表评论

电子邮件地址不会被公开。 必填项已用*标注

QR code