深入理解读写锁ReentrantReadWriteLock

632 阅读7分钟

深入理解读写锁ReentrantReadWriteLock

前言

业务开发中我们可能涉及到读写操作。 面对写和读,对于数据同步,在使用Lock锁和 synchronized关键字同步数据时候,对于读读而言,两个线程也需要争抢锁,此时额外争抢锁是没有意义的,造成性能损耗,写的时候,不能读,没有写的时候,读线程不能互斥。

对于 Lock锁和 synchronized 来说。都是互斥锁,读读也存在互斥。

针对这种场景,JAVA的并发包提供了读写锁ReentrantReadWriteLock,它内部,维护了 一对相关的锁,一个用于只读操作,称为读锁;一个用于写入操作,称为写锁

如实例

生产者和消费者而言

当一个线程负责生产,2个线程负责消费,生产者没有进行生产时,两个消费线程都可以去消费数据(这里我们不考虑 重复数据问题)

两个线程彼此还要争抢资源

   private static final int LINED_SIZE = 1000;
    private static  int num = 0;
    private static  final Object lock = new Object();
    private static final LinkedList<Integer> linkedList = new LinkedList<>();

    public static void main(String[] args) throws InterruptedException {




        t1.start();
        t2.start();
        t3.start();

        t1.setPriority(5);
        t2.setPriority(5);
        t3.setPriority(5);

        t1.join();
        t2.join();
        t3.join();

        TimeUnit.SECONDS.sleep(2);
        System.out.println(" main  end ");

    }


    static class ConsumerObje implements Runnable {


        @Override
        public void run() {

           while (true){
               synchronized (lock) {
                   while (linkedList.size() == 0) {
                       try {
                           lock.wait();
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                   }

                   try {
                       Thread.sleep(5_00);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   System.out.println(Thread.currentThread().getName() + " : " + linkedList.removeFirst());
                   lock.notifyAll();
               }
           }
        }
    }

    static class ProductObje implements Runnable {


        @Override
        public void run() {

            while (true) {

                synchronized (lock) {
                    while (linkedList.size() >= LINED_SIZE) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }

                    }
                    int n = num++;
                    System.out.println(" 正在生产: " + n);
                    linkedList.addLast(n);
                    lock.notifyAll();
                }

            }

        }
    }

思考

  • 为什么需要使用读写锁ReentrantReadWriteLock

  • 这个锁有什么好处?缺点?

好处: 读读不能互斥,提升锁性能,减少线程竞争。 缺点是:当读锁过多时候,写锁少,存在锁饥饿现象。

读写锁ReentrantReadWriteLock用法详解

ReentrantReadWriteLock 也提供了公平和非公平锁

基于构造默认非公平锁, ReentrantReadWriteLock 读写锁内部也是基于AQS队列实现的。

    public ReentrantReadWriteLock() {
        this(false);
    }


    //读写锁
    private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);

    //写锁
    private final static Lock writeLock = readWriteLock.writeLock();

    //读锁
    private final static Lock readLock = readWriteLock.readLock();

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

    public final static void main(String[] args) throws InterruptedException {


//        new Thread(ReentrantReadWriteLockTest::write).start();
//        TimeUnit.SECONDS.sleep(1);
//        new Thread(ReentrantReadWriteLockTest::write).start();


        new Thread(ReentrantReadWriteLockTest::write).start();
        TimeUnit.SECONDS.sleep(1);
        new Thread(ReentrantReadWriteLockTest::read).start();
        new Thread(ReentrantReadWriteLockTest::read).start();

    }

    static void write() {

        try {
            writeLock.lock();
            TimeUnit.SECONDS.sleep(1);
            System.out.println(Thread.currentThread().getName() + " write ");
            longs.add(System.currentTimeMillis());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
        }
    }

    static void read() {
        try {
            readLock.lock();
            TimeUnit.SECONDS.sleep(1);
            longs.forEach(x -> System.out.println(x));
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.lock();
        }
    }

可以看到我们写了一条数据,两条数据同时打印出来,读读是不互斥的。

Thread-0 write 1648997092090 1648997092090

读写锁 存在一个问题: 当读锁比例很多,写锁很少,锁竞争情况下,写锁抢到锁的机会就回少,读锁数量太大的情况下,写锁不一定能抢到锁.

我们使用非公平锁,来测试,启动5个读锁,一个写锁。


    //读写锁
    private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(false);

    //写锁
    private final static Lock writeLock = readWriteLock.writeLock();

    //读锁
    private final static Lock readLock = readWriteLock.readLock();

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

    public final static void main(String[] args) throws InterruptedException {
        
        
        new Thread(ReentrantReadWriteLockTest2::write).start();
        TimeUnit.SECONDS.sleep(1);
        //new Thread(ReentrantReadWriteLockTest2::read).start();
        //new Thread(ReentrantReadWriteLockTest2::read).start();

        for (int i = 0; i <5; i++) {
            new Thread(ReentrantReadWriteLockTest2::read).start();
        }



    }

    static void write() {
        for (;;){
           try {
               writeLock.lock();
               TimeUnit.SECONDS.sleep(1);
               System.out.println(Thread.currentThread().getName() + " write ");
               longs.add(System.currentTimeMillis());
           } catch (InterruptedException e) {
               e.printStackTrace();
           } finally {
               writeLock.unlock();
           }
           }

    }

    static void read() {
        for (;;){
              try {
                  readLock.lock();
                  TimeUnit.SECONDS.sleep(1);
                  longs.forEach(x -> System.out.println(x));
              } catch (InterruptedException e) {
                  e.printStackTrace();
              } finally {
                  readLock.lock();
              }
          }
       }

测试结果这里就不写了,刚开始一直写,后来一直读,写锁机会很少,当读线程比例再大时,写的机会就更少了。

ReentrantReadWriteLock 原理剖析

ReentrantReadWriteLock 支持读锁和写锁

ReentrantReadWriteLock是可重入的读写锁实现类。在它内部,维护了一对相关的锁, 一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读锁可以由多个 Reader 线程同时持有。也就是说,写锁是独占的,读锁是共享的。 在这里插入图片描述

实现了读写锁的接口 在这里插入图片描述

既然支持读写锁,那读锁和写锁都需要一个状态去保存锁的状态,在 aqs 中是使用变量state变量进行保存的。

ReentrantReadWriteLock 中是如何保存的呢?

在源码里可以看到

在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁 被一个线程重复获取的次数。

但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写 锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将 其切分为两部分:高16为表示读,低16为表示写。( 类似 线程池的状态和工作线程数量)

在这里插入图片描述

获取锁方法可以看到,获取锁然后再根据这个计算线程数量 ,这个方法是是写锁释放锁

在这里插入图片描述

状态计算

通过位运算。假如当前同步状态为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,即读锁已被获取。

在这里插入图片描述

exclusiveCount(int c) 表示获得持有写状态的锁的次数。 sharedCount(int c) 表示获得持有读状态的锁的线程数量。不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个 线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器,实际上是 ThreadLocal 保存每一个读线程锁重入的次数。

在这里插入图片描述

HoldCounter 计数器

读锁的内在机制其实就是一个共享锁。一次共享锁的操作就相当于对HoldCounter 计数器 的操作。获取共享锁,则该计数器 + 1,释放共享锁,该计数器 - 1。只有当线程获取共享锁后 才能对共享锁进行释放、重入操作。

HoldCounter是用来记录读锁重入数的对象 ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读锁的线 程的其他线程的读锁重入数对象

第一个获取读锁的重入次数 可以看到 用一个变量保存 ,以及保存线程。

在这里插入图片描述

写锁

加锁

在这里插入图片描述

acquire(int arg) 方法 ,可以看到这里获取锁失败,还是加入到同步队列里,还是aqs里的方法 ,排它锁的node节点 很熟悉。 在这里插入图片描述

acquireQueued 方法里就不用看了,也是aqs里的设置唤醒节点为-1状态,然后unpark 阻塞。 在这里插入图片描述

可以看到写锁是一个支持重进入的排它锁。

在这里插入图片描述

如果当前线程已经获取了写锁,则增加写状态。

如果当 前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程, 则当前线程进入等待状态。如上面的aqs 同步队列以及unpark阻塞那段代码。

解锁

解锁很简单,和之前解锁几乎一样,唯一不一样的地方就是从高位获取锁。

在这里插入图片描述

读锁

加锁

通过重写AQS的tryAcquireShared方法和 tryReleaseShared方法。

在这里插入图片描述

看代码意思大致是

首先这里是获取写锁 然后判断是否是当前线程,这里是读写锁降级。获取读锁失败返回 exclusiveCount(c)

下面获取读锁,这里就简单了一看就能看到,先判断第一个读锁线程以及数量, 是则设置第一个,没有就设置 1 有就+1。

不是则从 ThreadLocal 里获取设置,没有就设置 1 有就+1。

在这里插入图片描述

这里是读锁是否阻塞,公平锁和非公平锁的实现。可以不用管。

在这里插入图片描述

compareAndSetState(c, c + SHARED_UNIT) cas这行失败则表示有竞争,则执行下面代码,进行自旋重试。在这里插入图片描述

解锁 这里没啥可说的,如果是第一个读锁则设置线程是null,重入次数-1 不是则从 ThreadLocal 里拿,然后进行设置以及-1。

最后cas更新读锁状态数量。 在这里插入图片描述

释放锁

doReleaseShared

也是共享锁的释放逻辑,还是aqs里的逻辑,很熟悉。

在这里插入图片描述

最后

读写锁的实现继承图

在这里插入图片描述

ReentrantReadWriteLock 读写锁既有有点也有缺点

好处: 读读不能互斥,提升锁性能,减少线程竞争。 缺点是:当读锁过多时候,写锁少,存在锁饥饿现象。

使用时候需要控制读写比例,防止出现锁饥饿现象。

当出现读比例特别大时候,ReentrantReadWriteLock锁就不适合了,此时JDK8之后提供的StampedLock锁更适合读写比例大的场景

设计的精髓的地方

  • 1 一个变量保存 2 个状态 和 线程池里类似
  • 2 读锁的可重入使用 ThreadLocal 进行存储
  • 3 写锁可以重入
  • 4 写锁降级(没释放锁时候获取读锁,保证数据的一致性)