ReentrantReadWriteLock 源码debug详解

读写锁介绍

读写锁ReadWriteLock,顾名思义一把锁分为读与写两部分,读锁允许多个线程同时获得,因为读操作本身是线程安全的。而写锁是互斥锁,不允许多个线程同时获得写锁。并且读与写操作也是互斥的。读写锁适合多读少写的业务场景;

ReentrantReadWriteLock介绍

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

线程进入读锁的前提条件:
  • 没有其他线程的写锁 ;
  • 没有写请求或者有写请求,但调用线程和持有锁的线程是同一个;
线程进入写锁的前提条件:
  • 没有其他线程的读锁;
  • 没有其他线程的写锁;

而读写锁有以下三个重要的特性:

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

ReentrantReadWriteLock类结构

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

  • 读锁不支持条件变量 重入时升级不支持;
  • 持有读锁的情况下去获取写锁,会导致获取永久等待 重入时支持降级;
  • 持有写锁的情况下可以去获取读锁

 我们再来看看实现类ReentrantReadWriteLock的结构:

读写状态设计的精髓:

设计的精髓:用一个变量如何维护多种状态

在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁被一个线程重复获取的次数。但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写。

分割之后,读写锁是如何迅速确定读锁和写锁的状态呢?通过位运算。假如当前同步状态为S,那么:

  • 写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)。 当写状态加1,等于S+1.
  • 读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。当读状态加1,等于S+(1

根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。

  • exclusiveCount(int c) 静态方法,获得持有写状态的锁的次数。
  • sharedCount(int c) 静态方法,获得持有读状态的锁的数量。不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器

 我们来尝试debug走一遍源码:

先贴一下本次的例子:

package com.demo.project.demos.readwritelock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();//读锁
    private final Lock writeLock = lock.writeLock();//写锁
    private final String[] data = new String[10];

    public void write(int index, String value) {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取写锁");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            read(2);
            data[index] = value;
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放写锁");
            writeLock.unlock();
        }
    }

    public String read(int index) {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"获取读锁");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return data[index];
        } finally {
            System.out.println(Thread.currentThread().getName()+"释放读锁");
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReadWriteLockExample rwl = new ReadWriteLockExample();
        // 测试读读,读写,写写场景
        new Thread(()->{
            rwl.read(2);
           // rwl.write(2,"rwl");
          //  rwl.read(2);
        },"线程1").start();
        new Thread(()->rwl.read(2),"线程2").start();
//        new Thread(()->rwl.write(2,"rwl")).start();
//        new Thread(()->rwl.write(2,"rwl")).start();


    }
}
读读的情况:

这里启动了两个线程来获取读锁,上面讲到了读锁是共享的,即多个线程都可以同时持有这把锁;

好我们启动main方法。

我们先来看看线程1的情况。

 

 方法 acquireShared是一个尝试获取共享锁的函数,其中tryAcquireShared(arg)函数尝试获取共享锁,如果获取失败(返回值小于0),则调用doAcquireShared(arg)函数进行阻塞式获取共享锁。

说明tryAcquireShared方法只有返回大于0的值才不会被阻塞,我们点进去看看。

方面里的逻辑如下:

1.获取当前线程,获取当前锁的状态;

2.来到第一个if条件里,exclusiveCount(c) 是获取低十六位的状态,如果不等于0,说明写锁已经被持有,并且独占线程不是当前线程则会被阻塞,这个判断是什么意思呢?我们来假设下,如果exclusiveCount(c)==0,说明写锁没有被持有, 但是getExclusiveOwnerThread() != current,就会得到false,继续往下执行,这里就是共享锁的判断了。

3.,int r = sharedCount(c); 的目的是获取当前状态下共享锁的数量,

4.!readerShouldBlock():此条件检查当前线程是否应该因为锁的公平性策略或其它队列规则而被阻塞。如果返回true,意味着当前线程有资格尝试获取锁,无需等待;r < MAX_COUNT:这里的r是当前的共享锁计数,这个MAX_COUNT等于65535,实际上是在考虑递归无限获取锁而做的判断;compareAndSetState(c, c + SHARED_UNIT):这里的CAS尝试将锁的状态从c更新为c + SHARED_UNIT。c是当前锁的状态,SHARED_UNIT通常代表共享锁的单位增量。这个操作确保了在状态实际更新之前没有其他线程改变它,保证了操作的原子性和一致性。如果更新成功,说明当前线程成功获取了共享锁;

        4.1.第一次进来r肯定是等于0的,就把当前线程设置为第一个读线程,并将读锁持有计数初始化为1;如果第一个读线程就是当前线程,说明重入了,则做++操作;如果当前线程不是第一个读线程,缓存中获取当前线程的持有计数器对象HoldCounter,如果缓存中不存在或当前线程ID不匹配,则从readHolds中获取一个新的HoldCounter对象,并将其设置为缓存。如果缓存中的HoldCounter对象的计数为0,则将该对象重新设置回readHolds中,看到这这里的cachedHoldCounter,有没有想到这是个什么对象呢?没错,就是Threadlocal;

5.自此,方法结果,返回1,结果不小于0,不会被阻塞;

我们来看看线程2的执行情况:

其实可以看到,线程2和线程1执行情况差不多,也就是在计算重入次数那儿有些许差别,最终返回1,获取锁成功;

切到线程1来看,执行完业务代码后释放锁:

控制台也能看到两个都获取到了读锁;

读写的情况:

先来看结果,带着结果去验证我们的猜想

说明读写互斥,我们也还是一步一步来看看执行流程:

切到线程1,肯定是能获取到锁的,具体流程上面已经看过了,这就不再展开,我们来看看线程2获取写锁的情况。

最终会来到这里:

我们来阅读下代码。

同样的先获取当前线程,与锁状态,这里的state肯定不等于0了,因为读锁已经被获取到了,自然而然进入if的逻辑里,int w = exclusiveCount(c);这个代码我们刚刚讲过了,是获取低十六位的值也就是写锁状态,因为写锁还没获取到肯定为0,大家想想,既然能进入if里说明当前有一把锁已经被获取到了,但是不是写锁,因为低十六位等于0,那就只能说明读锁已经被获取到了,所以得到true,返回false,获取锁失败;

反过来我们想想,如果w != 0 &&current == getExclusiveOwnerThread(),这里又是什么场景呢?对,写锁的重入;

获取锁失败取反,得到true,向后执行,先执行走到addWaiter方法,这里方法里就完成了当前线程的入队与阻塞,这里就不展开,感兴趣的可以去看看我这篇文章,后面解锁唤醒写线程的逻辑需要有基础这篇文章的基础

AQS获取锁失败线程入队阻塞与唤醒流程解析

线程2获取锁失败 被挂起;

来看看线程1释放锁并唤醒线程2的流程:

线程1释放锁会走到这里:

 

 首先,它获取当前线程,并检查当前线程是否是第一个获取读锁的线程。如果是,它将检查读锁的持有次数,如果持有次数为1,则将第一个读者置为null,否则将持有次数减1。
如果当前线程不是第一个获取读锁的线程,则会尝试从缓存中获取HoldCounter对象,该对象记录了线程持有的读锁次数。如果缓存中的HoldCounter对象与当前线程不匹配,则从readHolds中获取与当前线程对应的HoldCounter对象。接着,它将该对象的持有次数减1,并在线程持有次数不大于1时,从readHolds中移除该对象。如果持有次数小于等于0,会抛出一个异常。
最后,方法进入一个循环,不断尝试将锁的状态减去SHARED_UNIT(表示读锁的单位),并使用compareAndSetState方法原子地更新锁的状态。如果更新成功,它会检查更新后的状态是否为0,如果是,则返回true,表示成功释放了读锁。

得到true后进入doReleaseShared方法:

大家注意这几个值:

首先被阻塞的线程入队后,不会放在头节点,而是放在第二个节点上,但是这个头节点和尾节点有相互指向的引用,h的next指向被挂起的线程2对应的node节点Node @720对象,而Node @720对象对的pred指向了这个h节点;在唤醒的时候,会唤醒节点的waitStatus=-1之后的那个节点(这点很重要);

大家看这里这个h节点的waitStatus的状态等于-1,进入if条件

因为需要唤醒后面的节点,所以这里不断尝试把当前h节点的waitStatus修改为0,成功后走到unparkSuccessor方法中,准备唤醒后继线程写线程(线程2);

 进来后拿到线程节点的waitStatus, 因为已经修改成0了,所以if不成立,随后拿到当前节点的后继节点,也就是线程2对应的节点,唤醒线程2

唤醒成功:

我们切到线程2去看看

返回false后继续循环会再次尝试获取锁,同样的获取锁的逻辑我也不展开了,详情看我上面提到的另外一篇帖子,总之,这个if会得到true,把当前节点设置为头节点,随之线程1里doReleaseShared的死循环也会结束;然后线程1解锁完成方法执行完成,线程2也获取到锁,返回true;

好了,还有写写的情况我就不演示了,篇幅原因,写写的情况也很简单,大家可以拿着我的演示代码自行debug;

还有关于写降级读的情况,我贴一段jdk 源码注释里的代码,并用补全工具解释下代码,大家一读就能明白:

好,本次分享就到这里; 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值