JUC源码学习笔记6——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

Tags: