27 既有共享又有互斥的锁——读写锁ReentrantReadWriteLock
Diego38 38 1

1. 前言

前面介绍了可重入锁 ReentrantLock,适用于资源独占场景,但对于读多写少的场景,没必要在纯粹做读取时还进行独占加锁,这会造成吞吐量下降。

我们知道与独占锁相对的是共享锁,共享锁允许多个线程同时获取锁,这在读多写少的场景很适用,本节我们就学习实现这一特性的读写锁 ReentrantReadWriteLock,学习如何使用读写锁,以及读写锁的如何实现读写锁分离的。

2. 读写锁的特性

2.1 获取读写锁的条件

假设我们要操作一个 List 对象,是可以运行多个线程同时读取的,因为此时没有写线程,不用担心读到脏数据;在写的时候只允许一个线程写,否则多线程写带来非原子性操作,也会产生脏数据。要解决这一系列问题,当对同一份数据进行访问是需要满足如下条件:

  • 同时只能有一个写线程,不能出现其他读线程和写线程

  • 多个读线程可以共存,但不能出现读线程

  • 写线程在写时可以同时做读操作 通过图形描述如下: image

    2.2 公平性与非公平性

    ReentrantReadWriteLock 内有两把锁,两把锁共享同一个 State 和同步队列,读锁叫做 ReadLock,写锁叫做 WriteLock,读锁和写锁共享同一个内置锁 Sync,内置锁实现了公平和非公平特性。之前我们讲过独占锁的公平锁,是指在有等待状态线程时,所有试图获取锁的线程都需要进入同步队列,排队获取锁。非公平锁恰恰相反,队列之外的线程可以与队列内的线程一起参与锁竞争。 在读写锁里面,公平和非公平性的定义如下:

  • 公平:不管读写线程,只要有等待的线程,这些读写线程就必须进入同步队列

  • 非公平:写线程可以与队列内线程进行竞争锁;读线程在竞争锁时会判断是否存在等待的写线程,没有则进行抢占。

    2.3 锁降级与锁升级

    持有写锁后获取读锁,叫做锁降级 持有读锁获取写锁,叫做锁升级

在使用读写锁时尤其要注意锁降级与锁升级,使用不当即会引发死锁。

  • 锁降级:支持,写锁获取后可以再去获取读锁,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现锁降级。

以下代码演示了锁降级的过程:

public class degradeTest {

    private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    /**
     * 锁降级
     */
    static class LockDegrade implements Runnable {
        @Override
        public void run() {
            writeLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "获取写锁");
                readLock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + "获取读锁");
                } finally {
                    readLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "释放读锁");
                }
            } finally {
                writeLock.unlock();
                System.out.println(Thread.currentThread().getName() + "释放写锁");
            }
        }
    }
}
  • 锁升级:不支持 , 不要尝试获取读锁后获取写锁,会引起死锁。 关于锁升级,需要说明的是读锁是不能直接升为写入锁的,因为获取一个写入锁需要释放所有的读取锁。

那读线程去获取写锁会发生什么呢?答案是死锁

当读线程去获取写锁时,由于存在读线程,获取写锁发现独占线程为空,获写锁失败将进入等待并且永远等待下去,因为读锁未释放,即使被唤醒条件也不能获取写锁。正常写线程也不会获锁成功,从而引发死锁。

3. 读写锁的使用

通过一个对 List 对象修改和查看的例子,来演示下读写锁的使用:

public class ReentrantReadWriteLockTest {

    private static List<Long> list = new ArrayList<>();

    private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    private static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    private static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

    /**
     * 写线程
     * @param item
     */
    public void add(Long item) {
        writeLock.lock();
        try {
            System.out.println("获取写锁");
            list.add(item);
        } finally {
            writeLock.unlock();
        }
    }

    /**
     * 读线程
     * @param index
     * @return
     */
    public Long getByIndex(Integer index) {
        readLock.lock();
        try {
            System.out.println("获取读锁");
            if (index < 0 || index > list.size() - 1) {
                return null;
            }
            Long aLong = list.get(index);
            System.out.println("读线程获取值: " + aLong);
            return aLong;
        } finally {
            readLock.unlock();
        }
    }

    /**
     * 锁降级
     * @param item
     * @param index
     * @return
     */
    public Long getAndSet(Long item, Integer index) {
        writeLock.lock();
        try {
            System.out.println("获取写锁");
            readLock.lock();
            try {
                System.out.println("获取读锁");
                if (index < 0 || index > list.size() - 1) {
                    return null;
                }
                Long tmp =  list.get(index);
                list.set(index, item);
                System.out.println("锁降级得到旧值: " + tmp);
                return tmp;
            } finally {
                readLock.unlock();
            }
        } finally {
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantReadWriteLockTest listSync = new ReentrantReadWriteLockTest();
        for (int i = 0; i < 20; i++) {
            long item = (long) i;
            new Thread(() -> {
                while (true) {
                    listSync.add(item);
                    listSync.getByIndex(list.size() - 1);
                    listSync.getAndSet(ThreadLocalRandom.current().nextLong(100),
                            ThreadLocalRandom.current().nextInt(10));

                }
            }).start();

        }
    }

}

以上代码,add,getByIndex,getAndSet 分别代表写线程、读线程、写线程锁降级的过程。运行代码后,结果持续输出,也未出现死锁和并发冲突。

需要注意的是,在代码中涉及多个锁的获取也需要遵守 lock -> try -> finally 的书写规范,防止获取锁未释放未获取锁执行释放等带来异常的操作。

4. 读写锁的原理

在我们熟悉 AQS 原理后,再去了解读写锁原理几乎是手到擒来的,所有的同步工具类都是基于 AQS 的。即使我们不看源码,也可以通过 AQS 为我们提供的四个接口方法反推出读写锁的实现来。接下来我们就演示下反推过程

4.1 读写锁的前提条件

  • 读写锁有两把锁一个是读锁 ReadLock,一个是写锁 WriteLock
  • 读锁是共享锁,写锁是独占锁
  • 两把锁基于 AQS 实现,需要实现四个接口方法
    • tryAcquire (int) : 判断是否满足获取独占许可的条件;修改同步变量 State
    • tryAcquireShared (int):判断是否满足获取共享许可的条件;修改同步变量 State
    • tryRelease (int):判断是否满足释放独占许可的条件;修改同步变量 State
    • tryReleaseShared (int): 判断是否满足释放共享许可的条件;修改同步变量 State

      4.2 读写锁的实现分析

      释放写锁可以唤醒同步队列中的读线程,释放最后一个读许可可以唤醒同步队列中的写线程。 这意味着读锁和写锁共享同一个队列,共享同一个同步状态 State

State 需要存储读写的状态,写独占锁需要存放重入次数,而读共享锁需要存放当前获取读线程个数,读写锁用了一个很巧妙的方式,将 int 类型的 state 一分为 2,高十六位存放读状态,低十六位存放写状态。

这也为了提供了一种设计技巧,AQS 是通过一个整形 state 来存放同步状态的,期间需要使用 CAS 状态来修改状态,想要维护多个状态的数据,就需要按位进行切割。 image

获取读状态和写状态的方法也比较简单:

  • 低十六位:对 & 0000ffff 按位与获取低 16 位

  • 高十六位:通过右移 16 位 (state>>>16) 获取高 16 位 了解了读写状态,进而得到获取和释放锁的条件:

  • 写锁获取条件 读状态为 0 并且写状态为 0,或读状态为 0 并且独占线程是当前线程;成功获取写锁则在写状态中记录本次锁重入计数,将独占线程线程设置为当前线程。

  • 读锁获取条件 写状态为 0,或者独占线程是当前线程;有时候写线程会执行锁降级 -> 持有写锁时获取读锁,可以判断独占线程是否等于当前当前线程

  • 写锁释放条件 写状态为 0,即写状态存放的重入次数降为 0;每次释放减少一次重入次数

  • 读锁释放条件 读状态降为 0,即当前线程是最后一个释放锁的线程;每次释放减少一次读线程个数

5. 总结

本节学习了读写锁的理论、使用和原理,知识点之间关联紧密,能够加深对 AQS 的原理理解,读写锁和重入锁一样,都支持锁中断和条件变量;同时按位来存放同步状态,为我们今后编写更高级的同步器提供了设计案例。 image

预览图
评论区

索引目录