首页 > 编程语言 >JUC源码学习笔记6——ReentrantReadWriteLock

JUC源码学习笔记6——ReentrantReadWriteLock

时间:2022-11-30 23:11:17浏览次数:71  
标签:重入 JUC ReentrantReadWriteLock 写锁 获取 读锁 源码 线程 公平

系列文章目录和关于我
阅读此文需要有AQS独占和AQS共享的源码功底,推荐阅读:

1.JUC源码学习笔记1——AQS独占模式和ReentrantLock

2.JUC源码学习笔记2——AQS共享和Semaphore,CountDownLatch

一丶类结构和源码注释解读

image-20221115185042587

1.ReadWriteLock

维护了一对关联锁,一个用于只读操作,另一个用于写入。读读可以共享资源,因为不会造成数据的变更,读写,写写互斥,因为读写可能造成脏读,幻读,不可重复读等错误,写写可能造成脏写等错误(ps:有点mysql innodb隔离级别的味道,只是mysql innodb 使用mvcc多版本并发控制让并发能力更高,当然innodb也有S锁,和X锁,类似于读写锁一样对并发事务进行控制)。读写锁在访问共享数据时允许比互斥锁更高级别的并发性。与使用互斥锁相比,读写锁是否会提高性能取决于数据被读取的频率,读写锁适用于读多写少的情况,如果写操作变得频繁,那么数据的大部分时间都被独占锁定,并发性几乎没有增加。

读写锁面临的问题:

  • 写锁被释放时,存在多个线程拿读锁,和多个线程拿写锁,是将锁给读线程还是写线程呢,通常情况下都是给写锁的,因为我们认为写操作没用读操作频繁。如果读操作非常频繁,且持有读锁的时间很长,写锁需要等待所有读锁释放才能获取,这样将造成写锁长时间无法获取锁。当然这种情况下可以使用”公平“的策略,先来后到获取锁
  • 锁是否可重入,读锁是否可重入,写锁是否可重入
  • 读锁是否可以升级为写锁,写锁是否可以降级为读锁

2.ReentrantReadWriteLock

ReadWriteLock的一个实现,它具备以下特性。

2.1支持公平和非公平

  • 非公平

    非公平情况下,会导致没拿到锁的线程处于”饥饿“状态,但是拥有更高的吞吐率。为什么?我认为是公平情况下需要排队,排队的线程会被LockSupport.park挂起,意味着释放锁的时候需要使用LockSupport.unpark唤醒排队的线程,这时候并不允许其他线程抢占先机,唤醒是需要时间的,公平情况下这一段时间锁是没用被任何线程获取锁的,所以说吞吐率不如非公平锁。

  • 公平

    当构造为公平时,线程已近似到达顺序策略来竞争进入(CAS入队的顺序,为什么说是近似顺序,入队的瞬间存在消耗完时间片,让老六线程抢先一步,这个顺序是cpu决定的,并不是绝对时间上的先后顺序)。当前持有的锁被释放时,等待时间最长的单个写入线程将被分配写入锁,或者如果有一组读取线程等待时间超过所有等待的写入线程,则该组将被分配读取锁(这里的意思是说,如果写锁排在队列头部,那么写锁被持有,如果队列头部是一堆读锁,那么读锁被持有)。

    公平情况下,如果写锁被持有,或者存在等待写锁的线程,那么在此之后获取读锁的线程会被阻塞。在最早等待获取写锁的线程没有释放锁的情况下,其他线程是无法获取读锁的,除非等待获取写锁的线程,放弃获取写锁并在AQS队列中不存在其他等待写锁的线程位于读锁之前。

    注意,非阻塞ReentrantReadWriteLock.ReadLock.tryLock()和ReentrandReadWriterLock.WriteLock.tryLock()方法不支持这种公平设置,如果可能的话,不管等待的线程如何,都会立即获取锁

2.2 可重入,写锁降级为读锁,读锁无法升级为写锁

ReentrantReadWriteLock允许读线程和写线程以ReentrantLock类似的方式重新获取读或写锁。``此外,获取写锁的线程可以获取读读锁,仅持有读锁的线程试图获取写锁,它将死锁。注释里面还提了一嘴这个重入,以及写锁可以拿到读锁有啥用——A方法获取写锁,调用B方法,B方法是读取数据进行校验,这时候B需要获取读锁,B调用C方法,C也需要获取读锁,这些方法可以正常进行,可以看出重入和写锁可降级的用处了吧

可以先持有写锁,然后获取读锁(这时候肯定直接可以拿到)然后释放掉写锁,将写锁降级为读锁,这种使用方式可以保证,持有写锁的线程一定可以成功拿到读锁。

2.3读写锁都支持等待获取锁的途中响应中断

这是synchronized所不支持的,在ReentrantLock源码解读中,解读过源码,重点是LockSupport.park方法挂起线程A,线程A有两种方式可以从LockSupport.park中返回:1.被unpark,2.被中断。源码的实现是如果不支持等待锁的途中中断,会记录下当前线程被中断过,然后Thread.interrupted()重置中断标注(因为中断的线程无法再次被park)然后继续park当前线程,让其等待锁,获取到锁之后,发现之前被中断过会自我中断补上中断标志。在获取锁的途中响应中断,则是从LockSupport.park检查中断标志,如果被中断了说明是由于中断从LockSupport.park中返回,这时候将抛出中断异常。

2.4 写锁支持基于Condition的等待唤醒

写锁支持Condition,但是读锁不支持Condition等待唤醒,读锁本身是共享的,需要所有读锁释放后才有必要唤醒写锁。

二丶读写锁使用范例

image-20221115194755280

这里使用读写锁实现了一个线程安全,读写分离的TreeMap,doug lea还提醒到想让这个TreeMap并发性能很好,必须实在读多写少的情况下。

三丶属性和构造方法

image-20221115195616688

可以看到读写锁,内部使用final修饰读锁和写锁,然后通过writeLock,readLock两个方法将锁暴露出去。有意思的是其构造方法,构造读写锁把this传递了进去

image-20221115195824982

image-20221115195836667

可以看到传递this的目的,是让读写锁使用sync = fair ? new FairSync() : new NonfairSync();生成的AQS子类对象,让读写锁使用同一个Sync对象,这样才能做到读写互斥,下面我们分析FairSync,NonfairSync是怎么一回事

三丶源码解析

1.FairSync,NonfairSync类结构

image-20221115201109822

熟悉的套路,把具体锁的实现,放到内部类Sync中,读写锁只是调用对应的Sync的方法,整个Sync内容内容很多,我们先看具体源码,Sync自然就柳暗花明了。

2.读锁加锁-ReadLock#lock

2.1源码解析

问题:
读锁如何实现公平,
读锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的),
写锁被获取如何让其他线程无法获取读锁,写锁如何降级为读锁
获取读锁然后获取写锁为啥会死锁

ReadLock的lock方法直接调用SyncacquireShared(1)方法,此方法在AQS中进行了实现

image-20221115201725052

最终是调用Sync的tryAcquireShared方法

protected final int tryAcquireShared(int unused) {
    //当前线程
    Thread current = Thread.currentThread();
	//状态 低16为写锁重入次数 高16位读锁被多少个线程持有
    int c = getState();
    
    //写锁被占有 且不是自己占有写锁 返回-1 表示获取失败,这个时候会共享入队
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    
    //写锁共享次数
    int r = sharedCount(c);
    //readerShouldBlock 公平情况下就是看前面是否有人排队
    //非公平情况下就是看头结点的下一个节点是否是共享模式,如果是共享说明,写锁被持有多个读锁都被阻塞了
    if (!readerShouldBlock() &&
        //读锁被持有小于最大值
        r < MAX_COUNT &&
        //CAS更改成功读锁数量
        compareAndSetState(c, c + SHARED_UNIT)) {
        //当前读锁是第一个拿读锁的
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {//读锁线程再次获取读锁
            firstReaderHoldCount++;
        } else {
            //记录当前线程持有读锁数量
            //利用ThreadLocal进行记录
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    
    //到这 1.当前线程需要排队,2.读锁被持有数量大于最大值 3.CAS失败,多个线程在拿读锁
    return fullTryAcquireShared(current);
}

这段代码可以看做两部分,最后一句return之前的内容,我称为快速获取写锁fullTryAcquireShared我称之为完全尝试获取写锁

  • 快速获取写锁

    首先如果发现独占锁重入数量不为0,且独占的线程不是自己,也就是说当前写锁被其他线程持有,那么直接返回-1,这里可以看出写锁被持有,其他线程无法获取读锁

    其次readerShouldBlock这个方法返回true 代表当前读线程需要阻塞,什么是否需要阻塞?

    1. 公平锁FairSync的逻辑是看是否有排队的线程,前面有人排队为什么说读线程需要阻塞——说明写锁被持有,多个读线程在排队,这时候是看是否有人排队,这是公平的体现
    2. 非公平锁是看第一个排队的线程是否是独占模式,这是不公平的体现,如果第一个排队的线程是共享模式,那么还是会尝试获取锁,相当于插队

    douglea,使用CAS修改state记录写锁被多少线程持有,这里CAS是因为可能存在多个线程获取读锁,修改成功后,会用ThreadLocal记录当前这个线程写锁重入数量

  • 完全获取写锁

    进入完全获取写锁的条件

    1. if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)判断写锁没有被持有,但是readerShouldBlock发现读线程需要排队,可能是突然写锁被持有,也可能是读锁被很多线程持有,当前线程需要排队
    2. cas更新state失败,意味着多个线程获取写锁,当前线程cas失败,有点类似之前AQS中的快速入队

    完全获取写锁的逻辑和快速类似,但是它是自选+CAS保证,要么获取到写锁,要么返回-1进行排队

2.2读锁如何实现公平

上面说到了,是readerShouldBlock来实现公平,在公平锁的情况下,加入当前写锁被持有,多个读线程在排队,如 A->B->C,然后线程D尝试获取读锁,这时候是看队列中是否有排队的线程,如果有那么线程D会进行排队。

非公平锁是看第一个排队的线程是否是独占模式,如果队列头是独占,那么会进行排队,这样做的目的是避免写线程一直饥饿,如果不是那么会尝试获取锁,这相当于厕所门口多人(读线程)排队,你硬抢,是非公平的体现

2.3读锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的)

对于第一个获取写锁的线程,它会使用firstReader,firstReaderHoldCount记录线程和其重入数量,对于后面获取写锁的线程,会使用ThreadLocal进行记录

2.4写锁被获取如何让其他线程无法获取读锁,写锁如何降级为读锁

首先快速获取读锁的有如下判断

 if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;

如果持有写锁的线程不是当前线程,那么返回-1,进行排队,在完全入队的自旋中也有这一段逻辑

如果当前线程就是持有写锁的线程则不会返回-1,依旧还是自旋+CAS获取读锁,从而获取到读锁

然后此时线程再释放写锁,就实现了写锁降级为读锁。

2.5获取读锁然后获取写锁为啥会死锁

image-20221129193108122

执行上述代码,你会发现发生了死锁,make it永远不会打印出来,为啥呢?这里需要我们看完写锁获取的源码。

3.读锁释放ReadLock#unLock

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    
    //更新当前线程的重入数量
    if (firstReader == current) {
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    } else {
        
        HoldCounter rh = cachedHoldCounter;
        if (rh == null || rh.tid != getThreadId(current))
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        --rh.count;
    }
    
    //cas改变state 
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

源码不难,主要两部分

  1. 更改ThreadLocal,或者是firstReaderHoldCount中记录的当前线程写锁重入数量

    如果是第一个获取写锁的线程那么firstReaderHoldCount--完全释放的时候firstReader设置为null

    如果不是第一个或者说第一个线程重入2次,释放3此,都会进入到else分支,减少ThreadLocal中记录的重入数量,如果发现释放次数>重入次数,会抛出异常

  2. cas改变state,state使用低16位记录写锁重入数量,使用高16为记录读锁重入数量

    低16位 = 写锁重入数

    高16位 = 每一个读锁持有线程的重入数之和

    这里需要使用CAS修改state,因为存在多个读线程同时释放读锁的情况

读锁的tryLock lockInterruptibly()还是哪些老套路,没啥好看的

4.写锁加锁-WriteLock#Lock

问题
写锁如何实现公平,
写锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的),
获取读锁然后获取写锁为啥会死锁

4.1.源码解析

写锁加锁调用的是AQS的acquire方法

image-20221130113218461

我们在AQS独占源码分析中,说到过,如果tryAcquire失败,意味着后续会调用acquireQueued入队然后获取锁后才能出队,其中selfInterrupt是为了将获取锁途中受到的中断补上,tryAcquire被读写锁中的Sync内部类重写,如下

protected final boolean tryAcquire(int acquires) {
    
            Thread current = Thread.currentThread();
            int c = getState();
    		//写锁被重入数
            int w = exclusiveCount(c);
            //c!=0说明写锁或者读锁至少有一个锁被持有
    		if (c != 0) {
			   //w == 0说明读锁被持有,那么写锁需要阻塞返回false
                //w!=0 但是 current != getExclusiveOwnerThread() 说明写锁被其他线程持有
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                //到这说明写锁被当前线程持有,那么线程不需要使用CAS
                //确保重入数不能大于 MAX_COUNT
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                
                //重入+1
                setState(c + acquires);
                return true;
            }
    		
    		//writerShouldBlock——写锁是否需要阻塞
    		//公平锁的情况下:如果有排队的线程 那么返回true
    		//非公平的情况下,恒定返回false
            if (writerShouldBlock() ||
                //如果cas失败 说明写锁被其他线程持有 那么返回false需要进行排队 写写互斥
                !compareAndSetState(c, c + acquires))
                return false;
    		
    		//到此说明当前线程 拿到了写锁 记录下独占的线程
            setExclusiveOwnerThread(current);
            return true;
        }

源码相比于共享更简单,主要分为两部分

  1. 写锁或者读锁被持有

    这部分的重点在于处理重入

    C!=0说明state不为0,那么写或者读锁必定有一种锁被持有,然后(w == 0 || current != getExclusiveOwnerThread(),如果w==0成立,说明读锁被持有了,这时候直接返回false,因为读写互斥,如果w!=0说明此时写锁被持有,继续判断current != getExclusiveOwnerThread(),当前线程是否是持有写锁的线程,如果不是返回false,说明写锁被其他线程持有。

    如果当前线程就是持有写锁的线程,接下来就是使用setState设置重入数量,这一步不需要CAS,本身便是线程安全的

  2. 写锁和读锁都未被持有

    这里的未被持有,是上面第一个if中的判断结果,也许第一个if执行完就有其他线程获取到了读锁或者写锁

    首先writerShouldBlock判断写线程是否需要阻塞,在公平情况下是看是否具备排队的线程,非公平情况下恒定返回false,只有第一个if结束有其他线程抢先获取了写锁,并且后续有其他线程获取锁,才可能出现公平情况下判断得到有排队的线程,这是一种公平的体现,其他线程在排队那么当前获取锁的线程也需要加入队列尾部。非公平情况下,writerShouldBlock恒定返回false,这里可以看出写锁偏好——即使前面有A已经获取了写锁,BC两个线程排队获取读锁,非公平情况下,只要A释放了写锁的一瞬间,当前线程可不管释放有人排毒,就是一个CAS抢锁,这里即是非公平也是写锁偏好的体现

    compareAndSetState(c, c + acquires)这便是当前线程CAS抢锁,保证线程安全,后续如果抢锁成功 setExclusiveOwnerThread(current)记录写锁被当前线程获取。

4.2写锁如何实现公平

上面说到,公平情况下,写锁的获取需要看是否具备排队的线程,如果具备其他线程排队,那么直接返回false,这样会让当前线程调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)进行排队并等待其他线程释放锁后等待,这便是公平。

4.3写锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的)

和ReentrantLock一样,通过state记录重入数量,只是低16位才是写锁的重入数量,每当写锁被重入便setState进行记录

4.4获取读锁然后获取写锁为啥会死锁

image-20221129193108122

前面读锁加锁解读,我们就抛出了这个问题,这里我们结合写锁加锁源码看下,为什么会死锁

image-20221130223959205

5.写锁释放锁-WriteLock#unLock

写锁的释放就是调用内部类Sync的release方法,此方法会调用tryRelease如果返回true后续会唤醒其他等待的线程

image-20221130224152776

ReentrantReadWriteLock内部类Sync的tryRelease方法如下

image-20221130224630897

这里首先会判断当前线程释放是独占写锁的线程,如果不是那么就是其他线程想释放持有写锁线程的锁,这是不允许的

然后计算释放后的重入数量,这里一般是重入数量-1,如果减少重入后的数量为0,那么free为true,这意味着完全释放了写锁,会将独占写锁的线程属性置为null,如果free为true这个方法结束后就会调用AQS中的unparkSuccessor唤醒其他线程

setState改变重入数量,这里不需要加锁因为还没有唤醒其他线程,所以此时不会存在线程安全问题,这是独占释放和共享释放的一个区别,共享锁的释放需要使用自旋+CAS保证线程安全的更新state

标签:重入,JUC,ReentrantReadWriteLock,写锁,获取,读锁,源码,线程,公平
From: https://www.cnblogs.com/cuzzz/p/16940131.html

相关文章

  • Caffeine 源码、架构、原理(史上最全,10W超级字长文)
    文章很长,而且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录博客园版为您奉上珍贵的学习资源:免费赠送:《尼恩Java面试宝典》持续更新+史上最全+面试必备2000页+面......
  • drupal源码分析
    说实在的,drupal确实很复杂,在国内用的人也不多,唯一看的懂的中文技术网站估计就是drupalchina.org了,但是对于源代码的分析是少之又少.没有办法,只能自己来从头看起了!越......
  • RocketMQ系列-搭建Namesrv源码调试环境
    RocketMQ系列-搭建Namesrv源码调试环境在学习任何一个技术框架的时候,我们通常都是先了解是什么,有什么作用、解决什么问题、设计亮点和设计思想是什么;当然对于技术学习上来......
  • MySQL源码编译安装
    MySQL源码编译安装Linux环境:CentOS7.6MySQL版本:MySQL5.7.37安装路径:/usr/local/mysql1.创建相关目录#创建用户useradd-s/sbin/nologinmysql#创建安装目录并......
  • 计算机视觉的常用测试数据集和源码
    以下是computervision:algorithmandapplication计算机视觉算法与应用这本书中附录里的关于计算机视觉的一些测试数据集和源码站点,整理了下,加了点中文注解。ComputerVision......
  • 搞清楚Spring事件机制后:Spring的源码看起来简单多了
    Spring框架已然是Javaeee开发领域的霸主,无论是使用SpringBoot还是SpringCloud,都离不开Spring框架。作为Java开发者,无论是面试求职还是日常开发,就必须得熟练掌握、运用Sprin......
  • php版学生成绩管理系统源码(文末有下载方式)
    大家好, 我是程序猿零壹。给大家分享一款学生成绩管理系统,该系统使用php+mysql开发,是一款用于管理课程信息、老师信息、学生信息及成绩的系统,包含最基础的增删改查,可以作......
  • ArrayList源码
    //属性//默认初始大小privatestaticfinalintDEFAULT_CAPACITY=10;//空数组用这个privatestaticfinalObject[]EMPTY_ELEMENTDATA={};//扩展数组时用来和EM......
  • vue2源码分析- 数据响应简单模拟
    工作中大部分项目使用vue2做,但一直局限于使用,终于有闲暇时间可以学习下源码,网上优秀的源码分析很多,此文章只是记录个人所学,有问题欢迎大家指出,欢迎讨论,互相学习。下面是我......
  • 基于SpringBoot+Layui的物业管理系统【完整项目源码】
    使用建议业务逻辑简略,需要细化业务,增加业务开发,如未交费提醒等技术架构数据库:MySQL8.X后端技术:SpringBoot2.3.0,MyBatisPlus数据连接池:Druid前端技术:La......