浅谈Java里的ReentrantReadWriteLock

2023-10-19 11:01 雾和狼 786

一、基本概念

在开发并发相关的项目时,经常涉及到对共享资源读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,允许多个线程同时读取共享资源,并且不会造成线程安全问题。但是如果一个线程想去写这些共享资源,就必须阻塞其他线程对共享资源进行读和写的操作了。

针对以上场景,JDK源码包提供了读写锁工具类ReentrantReadWriteLock。

读锁可以多个线程同一时刻持有,写锁在同一时刻只能被一个线程持有。读锁和写锁共同使用才有意义,读锁可以使多线程访问数据时,如果仅仅是访问数据而不做修改,不用互相等待。但如果有线程要修改数据时,再加以限制,保证写数据的时候,别的线程不能再读取数据,以免读到的数据是污染数据。

在多线程操作共享资源时,读锁和写锁是如何互相影响的,下面以 A,B,C 3个线程做解释。

1、读读共享

A,B,C 三个线程可以同时持有读锁

2、读写互斥

当A,B同时持有读锁时,C想获取写锁,必须等A,B 的读锁全部释放。A,B 也必须等待读锁释放,才能再去获取写锁。锁重入不允许锁升级。

3、写读互斥

当A(有且仅有一个线程,写锁只能能同时被一个线程持有)持有写锁时,B,C想获取读锁,必须等A的写锁释放。但在A线程内,可以直接获取读锁,这是锁重入的概念。

4、写写互斥

当A(有且仅有一个线程,写锁只能能同时被一个线程持有)持有写锁时,B,C想获取写锁,必须等A的写锁释放。但在线程A内,可以直接获取写锁,这是锁重入的概念。

二、锁重入概念

NOTE : 锁重入是针对单线程内的一个概念

1、单线程内获取读锁后,读锁没释放之前,可以再次获取读锁

2、单线程内获取写锁后,写锁没释放之前,可以再次获取写锁

3、单线程内获取读锁后,读锁没释放之前,不能再获取写锁(锁升级)

4、单线程内获取写锁后,写锁没释放之前,可以再获取读锁(锁降级)

针对第1点和第2点分析:该特性基本不会显示的使用,在获取到读(写)锁后,再次去获取读(写)锁,本来就已经持有了,还去获取相同的锁,略显重复。

针对第3点分析:因为读锁是允许多个线程同时持有的,如果允许锁升级,那么这些持有读锁的线程就可以直接获取写锁,这样的结果是多个线程同时持有了写锁了,这与基本概念不符合,锁升级、锁降级是锁重入的概念。

针对第4点分析,在单个线程内(因为写锁同一时刻只可能被单个线程持有),持有了写锁,再去获取读锁,不违背同一时刻只有一个线程持有写锁这个基本概念。

三、使用场景

可以结合源码(ReentrantReadWriteLock.java)内的注释。

1、封装Collection和Map

原理:取数据是可以加读锁,修改数据时可以加写锁,这样可以保证多个线程可以同时读取集合内的 数据,但同时只允许一个线程修改集合数据。

源码注释为:

class RWDictionary {
   private final Map<String, Data> m = new TreeMap<String, Data>();
   private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
   private final Lock r = rwl.readLock();
   private final Lock w = rwl.writeLock();

   public Data get(String key) {
     r.lock();
     try { return m.get(key); }
     finally { r.unlock(); }
   }
   public String[] allKeys() {
     r.lock();
     try { return m.keySet().toArray(); }
     finally { r.unlock(); }
   }
   public Data put(String key, Data value) {
     w.lock();
     try { return m.put(key, value); }
     finally { w.unlock(); }
   }
   public void clear() {
     w.lock();
     try { m.clear(); }
     finally { w.unlock(); }
   }
 }}

从RWDictionary类可以看出,对于读取数据的get和allKeys()方法只需加读锁,对于put和clear方法才加写锁,这样写数据时,避免脏数据的产生。

2、实现本地缓存

源码注释为:

class CachedData {
   Object data;
   volatile boolean cacheValid;//use volatile get data from Main-memoryfinal ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

   void processCachedData() {
     rwl.readLock().lock();
     if (!cacheValid) {
       // Must release read lock before acquiring write lock
       rwl.readLock().unlock();
       rwl.writeLock().lock();
       try {
         // Recheck state because another thread might have// acquired write lock and changed state before we did.if (!cacheValid) {
           data = ...
           cacheValid = true;
         }
         // Downgrade by acquiring read lock before releasing write lock
         rwl.readLock().lock();
       } finally {
         rwl.writeLock().unlock(); // Unlock write, still hold read
       }
     }

     try {
       use(data);
     } finally {
       rwl.readLock().unlock();
     }
   }
 }}

从CachedData代码可以看出,读锁和写锁共同 争抢的资源为 cacheValid这个布尔值。当要修改这个值时,需要在写锁内修改。

  注意标红的注释,这几行注释描述得很详细。两个if(!cacheValid)跟单例模式里的DCL是一样的。假设线程A,第一次判断后,得到cacheValid=false,去获取写锁时,可能该写锁被别的线程争抢到了,并且将data和cacheValid值修改了。等别的线程将写锁释放后,线程A后续获得到了写锁,程序继续执行,需此时cacheValid的值已经变成true了,那么之前读到的cacheValid=false就是脏数据了,所以需要再次if(!cacheValid)。

CachedData 类的多个线程调用Demo:

CachedData cd = new CacheData();
new Thread(()->{cd.processCachedData()},"Thread-A").start();
new Thread(()->{cd.processCachedData()},"Thread-B").start();
new Thread(()->{cd.processCachedData()},"Thread-C").start();