一、读写锁

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁(读多写少)。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源(读读可以并发);但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写操作了(读写,写读,写写互斥)。在读多于写的情况下, 读写锁能够提供比排它锁更好的并发性和吞吐量。
针对这种场景,JAVA的并发包提供了读写锁ReentrantReadWriteLock,它内部,维护了一对相关的锁,一个用于只读操作,称为读锁;一个用于写入操作,称为写锁。

1、线程进入读锁的前提条件

1)没有其他线程的写锁
2)没有写请求或者有写请求,但调用线程和持有锁的线程是同一个

2、线程进入写锁的前提条件

1)没有其他线程的读锁
2)没有其他线程的写锁

3、读写锁的特性

1)公平选择性
支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
2)可重入
读锁和写锁都支持线程重入。以读写线程为例:读线程获取读锁后,能够再次获取读锁。写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。
3)锁降级
遵循获取写锁、再获取读锁最后释放写锁的次序,写锁能够降级成为读锁。

二、ReentrantReadWriteLock的使用

ReentrantReadWriteLock实现接口ReadWriteLock的两个方法:
1.png
ReentrantReadWriteLock是可重入的读写锁实现类。在它内部,维护了一对相关的锁, 一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读锁可以由多个 Reader 线程同时持有。也就是说,写锁是独占的,读锁是共享的。
2.png ReentrantReadWriteLock适合读多写少的场景,例如缓存。

1、使用示例

重入时不支持升级,由于读和写是互斥的,写锁需要在释放读锁的情况下才能加锁成功,所以持有读锁的情况下去获取写锁,会导致获取永久等待。
重入时支持降级,持有写锁的情况下可以去获取读锁。

  1. public class Cache {
  2. static Map<String, Object> map = new HashMap<String, Object>();
  3. static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  4. static Lock r = rwl.readLock();
  5. static Lock w = rwl.writeLock();
  6. // 获取一个key对应的value
  7. public static final Object get(String key) {
  8. r.lock();
  9. try {
  10. return map.get(key);
  11. } finally {
  12. r.unlock();
  13. }
  14. }
  15. // 设置key对应的value,并返回旧的value
  16. public static final Object put(String key, Object value) {
  17. w.lock();
  18. try {
  19. return map.put(key, value);
  20. } finally {
  21. w.unlock();
  22. }
  23. }
  24. // 清空所有的内容
  25. public static final void clear() {
  26. w.lock();
  27. try {
  28. map.clear();
  29. } finally {
  30. w.unlock();
  31. }
  32. }
  33. }

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

2、锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。
因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作。

  1. private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  2. private final Lock r = rwl.readLock();
  3. private final Lock w = rwl.writeLock();
  4. private volatile boolean update = false;
  5. public void processData() {
  6. readLock.lock();
  7. if (!update) {
  8. // 必须先释放读锁
  9. readLock.unlock();
  10. // 锁降级从写锁获取到开始
  11. writeLock.lock();
  12. try {
  13. if (!update) {
  14. // TODO 准备数据的流程(略)
  15. update = true;
  16. }
  17. readLock.lock();
  18. } finally {
  19. writeLock.unlock();
  20. }
  21. // 锁降级完成,写锁降级为读锁
  22. }
  23. try {
  24. //TODO 使用数据的流程(略)
  25. } finally {
  26. readLock.unlock();
  27. }
  28. }

锁降级主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

三、ReentrantReadWriteLock源码分析

3.png

1、读写状态的设计

在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁被一个线程重复获取的次数。但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写。
HoldCounter 和 ThreadLocalHoldounter 是用来保存重入次数的,在读写锁中,使用一个int类型的属性来记录锁的状态,一个int类型占用4字节=32位,其中高16位用来标记读锁,低16位用来标记写锁,读锁和写锁都是可重入的,重入次数即在高位或低位+1。
假如当前同步状态为S:
写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)。当写状态加1,等于S+1。
读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。当读状态加1,等于 S+(1<<16),也就是S+0x00010000
根据状态的划分能得出一个推论:S不等于0时,当写状态(S & 0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。
4.png
在ReentrantLock 中的内部类Sync有如具体实现
5.png
exclusiveCount(int c) 静态方法,获得持有写状态的锁的次数。
sharedCount(int c) 静态方法,获得持有读状态的锁的线程数量。不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器。

2、HoldCounter 计数器

读锁的内在机制其实就是一个共享锁。一次共享锁的操作就相当于对HoldCounter 计数器的操作。获取共享锁,则该计数器 + 1,释放共享锁,该计数器 - 1。只有当线程获取共享锁后才能对共享锁进行释放、重入操作。 6.png
通过 ThreadLocalHoldCounter 类,HoldCounter 与线程进行绑定。HoldCounter 是绑定线程的一个计数器,而 ThreadLocalHoldCounter 则是线程绑定的 ThreadLocal。
HoldCounter是用来记录读锁重入数的对象。
ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读锁的线程的其他线程的读锁重入数对象。

3、写锁的获取

写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程, 则当前线程进入等待状态。
写锁的获取是通过重写AQS中的tryAcquire方法实现的。
在有线程读取的时候,是不允许读的,这是一个悲观锁的设计,会导致写锁的饥饿问题。
ReentrantReadWriteLock-writedLock.lock.png

4、写锁的释放

写锁释放通过重写AQS的tryRelease方法实现。
ReentrantReadWriteLock-writedLock.unlock.png

5、读锁的获取

实现共享式同步组件的同步语义需要通过重写AQS的tryAcquireShared方法和 tryReleaseShared方法。
ReentrantReadWriteLock-readLock.lock.png

6、读锁的释放

获取到读锁,执行完临界区后,要记得释放读锁(如果重入多次要释放对应的次数),不然会阻塞其他线程的写操作。读锁释放的实现主要通过方法tryReleaseShared。
ReentrantReadWriteLock-readLock.unlock.png