Concurrent Programming Series - ReadWriteLock

In actual work, in order to optimize performance, we often use caching, such as caching metadata, caching basic data, etc. This is a typical application scenario of more reading and less writing. An important condition for caching to improve performance is that the cached data must be read more and written less. For example, metadata and basic data will basically not change (less writes), but they are used in many places (more reads). ).

For concurrency scenarios involving more reading and less writing, the Java SDK concurrency package provides a read-write lock - ReadWriteLock, which is very easy to use and has good performance. In concurrent programming, sometimes we need to handle the situation where multiple threads read shared resources at the same time, and at the same time, we also need to ensure that access to resources is mutually exclusive when there is a write operation. This is the application scenario of read-write lock (ReadWriteLock).

What is a read-write lock?

Read-write lock is a lock mechanism that allows multiple threads to read shared resources at the same time, but requires mutual exclusion during write operations. Read-write locks separate read operations from write operations to improve concurrency and performance.

Features of ReadWriteLock

  • Multiple threads can read simultaneously : In the absence of write operations, multiple threads can read shared resources concurrently, thereby improving the performance of read operations.
  • Write operations are mutually exclusive : writes take exclusive locks, ensuring that no other thread can read or write to the shared resource while the write operation is in progress.
  • Mutual exclusion between read and write operations : While a write operation is in progress, other threads cannot read or write to ensure data consistency.

An important difference between read-write locks and mutex locks is that read-write locks allow multiple threads to read shared variables at the same time , while mutex locks do not allow this. This is because read-write locks perform better than mutex locks in scenarios where more reading is done and less writing is done. key. However, the write operations of the read-write lock are mutually exclusive . When a thread is writing a shared variable, other threads are not allowed to perform write operations and read operations.

How to use ReadWriteLock

Java provides classes java.util.concurrent.locksin packages ReentrantReadWriteLockto implement read-write locks. Here's a simple example:

javaimport java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private int sharedData = 0;

    public int readData() {
        lock.readLock().lock();
        try {
            return sharedData;
        } finally {
            lock.readLock().unlock();
        }
    }

    public void writeData(int newData) {
        lock.writeLock().lock();
        try {
            sharedData = newData;
        } finally {
            lock.writeLock().unlock();
        }
    }
}

在上面的例子中,我们创建了一个ReentrantReadWriteLock实例作为读写锁。使用readLock()方法获取读锁,writeLock()方法获取写锁。

在读取共享资源时,我们需要先获取读锁,然后执行读操作,最后释放读锁。在写入共享资源时,我们需要先获取写锁,然后执行写操作,最后释放写锁。

要注意的是,在使用读写锁时,应该根据实际需求合理地使用读锁和写锁,以便提升并发性和性能。

读写锁的优势与适用场景

  • 读多写少:当有大量读取操作,而写操作较少的情况下,读写锁可以提高系统的并发性和性能。
  • 数据一致性要求较低:如果对共享资源的一致性要求不高,即使在读写操作之间出现一定的延迟或不一致,也不会对系统产生严重影响。
  • 提升并发性和性能:读写锁通过允许多个线程同时读取共享资源,以及在写操作时互斥地访问资源,可以提高系统的并发性和性能。

快速实现一个缓存

下面我们就实践起来,用ReadWriteLock快速实现一个通用的缓存工具类。

在下面的代码中,我们声明了一个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(K key, V value) {
    w.lock();
    try { return m.put(key, v); }
    finally { w.unlock(); }
  }
}

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

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

alt

缓存一次性加载示意图

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

alt

缓存按需加载示意图

实现缓存的按需加载

文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 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;
  }
}

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

读写锁的升级与降级

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

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

这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫 锁的升级。可惜ReadWriteLock并不支持这种升级。在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的,这个你一定要注意。

不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。以下代码来源自ReentrantReadWriteLock的官方示例,略做了改动。你会发现在代码①处,获取读锁的时候线程还是持有写锁的,这种锁的降级是支持的。

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来检测数据是否发生变化,一旦变化就将最新数据推送给缓存。另外,还有一些方案采用了数据库和缓存双写的策略。

本文由 mdnice 多平台发布

Guess you like

Origin blog.csdn.net/qq_35030548/article/details/132353346