多线程之读写锁

背景和意义

java.util.concurrent中有很多的同步工具类,比如ReentrantLock、Semaphore、CountLatch、CyclicBarrier、BlockingQueue、ConcurrentLinkedQueue等等,其中,很多使用的是排他锁的实现,即,同一时间只有一个线程能够访问共享的变量或临界区。因此,在某些场景下,大部分的同步工具类的性能都不尽人意。想想一下这种场景,比如缓存,缓存存在的意义就是减轻数据库的压力,在一段时间内可能只有很少次数的更新缓存,并且会有大量的读取缓存的次数。为了保证缓存与数据库中的一致性,避免脏数据,我们可以在缓存更新时进行加锁,但是,如果该锁是排它锁的话,等于把所有对该缓存的查询和更新操作都串行化了,效率太低,这个时候,ReentrantReadWriteLock就派上用场了。ReentrantReadWriteLock的锁在同一时刻只会有一个线程进行写入操作,而在同一时刻有多个读线程进行读操作,相对于ReentrantLock来说,大大提高了读取的效率。其实只要保证在更新的同时,没有其他线程正在读取就可以了,至于如果没有线程正在执行更新操作,那么不管有多少个线程在读取,都不会造成数据变化,那么读的时候就让那些线程一起读好了。

ReentrantReadWriteLock中读锁和写锁,其中,如果当前有一个线程获取到了写锁,那么后续过来的读写锁获取操作都会阻塞住,直到之前的那个写锁释放掉了;如果当前有一个线程获取到了读锁,那么,后续的读锁获取操作都能获取到读锁。那么,考虑一下这个问题,如果当前有一个进程获取到了读锁,后续一直有进程来请求读锁,也有进程请求写锁,如果后续的读进程一直都能获取到读锁,那么获取写锁的进程不就饥饿了吗,永远获取不到写锁了吗?

这个问题,ReentrantReadWriteLock中对锁提供了公平性和非公平性的选择。默认是非公平的锁。即,非公平的锁不会保证锁被获取的顺序与锁被请求的顺序一致(因为可能会由于操作系统调度的原因尽可能帮程序优化获取锁的顺序),这就可能会导致刚刚说的写进程饥饿的问题,而公平锁则表示先请求锁的进程先获取到锁,那么就不存在饥饿的问题。但是,由于公平锁还要维护锁获取的顺序,一般来说,非公平锁的吞吐量更高。另外,从名字就能知道,ReentrantReadWriteLock的锁是可重入锁。ReentrantReadWriteLock还提供了锁降级的功能,当前有进程获取到写锁后,该进程也能够获取到读锁,然后再释放写锁就可以了,这其中就有写锁降级到读锁的过程。

ReadWriteLock

ReadWriteLock是一个接口,提供了readLock()和writeLock()方法实现读锁和写锁的获取,ReentrantReadWriteLock实现了ReadWriteLock接口,还提供了一些方法能够获取到当前读锁和写锁的状态,比如:

  1. getReadLockCount()返回当前读锁被获取了多少次,
  2. getReadHoldCount()方法返回当前线程获取了读锁多少次,
  3. getWriteHoldCount()返回当前写锁被获取了多少次等等,
  4. isWriteLocked()方法可以查看当前写锁是否被获取了,
  5. getQueuedReaderThreads()返回当前想要获取读锁的线程列表,
  6. getQueuedWriterThreads()返回当前想要获取写锁的线程列表。
public class Cache {
  static Map map = new HashMap();
  static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  static Lock r = rwl.readLock();
  static Lock w = rwl.writeLock();
  // 获取一个key对应的value
  public static final Object get(String key) {
    r.lock();
    try {
      return map.get(key);
    } finally {
      r.unlock();
    }
  }
  // 设置key对应的value,并返回旧有的value
  public static final Object put(String key, Object value) {
    w.lock();
    try {
      return map.put(key, value);
    } finally {
      w.unlock();
    }
  }
  // 清空所有的内容
  public static final void clear() {
    w.lock();
    try {
      map.clear();
    } finally {
      w.unlock();
    }
  }
}

上述示例中,Cache组合了一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作 get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key, Object value)和clear()方法,在更新HashMap时必须提前获取写锁,当写锁被获取后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放 之后,其他读写操作才能继续。Cache使用读写锁提升读操作并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。

ReentrantReadWriteLock实现

如前面一片文章提到,其实ReentrantReadWriteLock是依赖于AbstractQueuedSynchronizer来实现同步功能的,内部的Sync类扩展了AbstractQueuedSynchronizer。如内部的读锁和写锁的实现为:

多线程之读写锁_第1张图片

多线程之读写锁_第2张图片

该Sync内部使用state来表示锁被一个线程重复获取的次数。它将变量分为高16位和低16位,高位表示写锁,地位表示读锁,如:

多线程之读写锁_第3张图片

图中状态表示当前线程获取到了写锁,重入写锁了两次,同时该进程也获取了两次读锁。读写锁是如何迅速的确定读和写各自的状态呢? 答案是通过位运算。假设当前同步状态值为S,写状态等于 S & 0x0000FFFF(将高16位全部抹去),读状态等于 S >>> 16(无符号补0右移16位)。当写状态增加1时,等于S + 1,当读状态增加1时,等于S + (1 << 16),也就是S + 0×00010000。根据状态的划分能得出一个推论:S不等于0时,当写状态(S & 0x0000FFFF)等于0时,则读状态(S >>> 16)大于0,即读锁已被获取。

ReadLock

如前面所述,ReadLoc读锁是共享可重入锁,如果当前写锁没有被任何进程获取,那么当前进程肯定能够获取到读锁。如:

多线程之读写锁_第4张图片

WriteLock

WriteLock写锁是排它可重入锁。如果当前线程已经获取了写锁,则增加state中的写状态。如果读锁或者写锁被其他线程已经获取了,那么当前线程获取写锁的操作就被阻塞住了,如:

多线程之读写锁_第5张图片

锁降级

如前面所述,如果当前进程拥有写锁,那么可以降级为读锁。而如果只持有读锁,是不能升级为写锁的。道理很简单,因为一个线程拥有写锁之后,对数据变更了,该线程需要感知到这种数据变更,那么就可能尝试获取读锁,看看数据是不是真的变化了。而如果当前线程持有写锁,释放写锁之后,再获取读锁,这过程是不能成为锁降级的。

示例,

多线程之读写锁_第6张图片

喜欢的可以关注微信公众号:
多线程之读写锁_第7张图片

参考

  1. 我自己的头条号:开发技术专注者

你可能感兴趣的:(Java)