ReentrantReadWriteLock实现原理及源码分析

ReentrantLock具有完全互斥排他的效果,即同一时间只有一个线程在执行lock()方法后的任务,这就会出现一个弊端,比如在一些环境里多个线程都是读操作,没有涉及到数据的变更,那么多个读并发时效率就非常低;为了解决这种少写多读场景下的性能问题,JDK中设计了一种读写锁ReentrantReadWriteLock(可重入读写锁)。在读写锁中锁的策略有两种:公平策略和非公平策略。

1.ReentrantReadWriteLock类的内部结构

ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示。

如上图Sync继承自AQS、NonfairSync和FairSync继承自Sync类;ReadLock和WriteLock实现了Lock接口。在Sync中具体实现读写锁状态state的定义和分割,也是读写锁巧妙所在;同时Sync也继承了AQS,并重写了模版方法,如:tryAcquire(),tryRelease()等。

2.锁状态实现原理

虽然ReentrantReadWriteLock 和 ReentrantLock锁的状态都是使用AQS中的状态state,但读写锁的锁状态维护完全不同。在读写锁中既要维护读锁的状态,又要维护写锁的状态,那么读写锁是怎么用AQS状态state一个变量来维护两个状态的呢?

读写锁将AQS 中32位(int 类型)的状态state辦成两份,读锁用高16位,表示持有读锁的线程数(sharedCount);写锁低16位,表示写锁的重入次数 (exclusiveCount)。状态值为 0 表示锁空闲,sharedCount不为 0 表示分配了读锁,exclusiveCount 不为 0 表示分配了写锁。如下图同步锁状态state表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。

  • 写锁状态维护:同一个线程重入一次,就在锁状态state的低16位加1操作,释放一次就减1操作;不同线程的写操作是互斥的(写锁也叫独占锁)。

  • 读锁状态维护:多个线程可以同时获取读锁,每获取一次就在锁状态state的高16位加1操作,释放一次就减1操作,不同线程读操作不是互斥的(因此读锁也叫共享锁)。比如三个线程同时都获取到了读锁,那么锁状态高16位就是0011。

读写锁的互斥关系如下:只有读锁与读锁之间是兼容的,也就是说可以多个线程同时持有读锁。

  读锁 写锁
读锁 兼容 互斥
写锁 互斥 互斥

3.源码分析

(1)ReentrantReadWriteLock构造函数

ReentrantReadWriteLock创建的时候可以指定锁竞争策略:公平竞争和非公平竞争。

  • 锁公平竞争:严格按照FIFO的策略来过去锁状态。

  • 非公平竞争:新进入的线程,不管AQS同步队列中是否有等待获取锁的线程,都是直接参加锁的竞争,先前AQS等待队列里的线程有可能获取不到锁。

//默认锁的竞争策略是非公平竞争
public ReentrantReadWriteLock() {
    this(false);
}
//可以指定锁的竞争策略
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

(2)Sync源码

Sync中主要定义了读锁和写锁运算的规、线程持有的锁计数器、锁获取和释放、读写阻塞等方法。其中使用AQS里锁状态state的高低位来维护读锁、写锁状态的思想值得学习。

abstract static class Sync extends AbstractQueuedSynchronizer {
    //读写锁位偏移大小
    static final int SHARED_SHIFT = 16;
    //由于读锁是高16位,每获取一次读锁高16位就加1,其实是状态值加SHARED_UNIT
    static final int SHARED_UNIT = (1 << SHARED_SHIFT);
    //读锁和写锁允许获取的最大值次数
    static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
    //独占锁(写锁)的mask位
    static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
    //读锁占有次数获取
    static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
    //写锁占有次数获取(重入次数)
    static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
    //线程持有读锁计数器,每个线程特定的read持有计数。存放在Sync的ThreadLocal中
    static final class HoldCounter {
        //当前线程读锁获取次数
        int count = 0;
        //线程tid
        final long tid = getThreadId(Thread.currentThread());
    }
    //当readHolds.get()获取当前读锁计数器时,如果没有则初始化读计数器
    static final class ThreadLocalHoldCounter
        extends ThreadLocal<HoldCounter> {
        //重写ThreadLocal的初始化方法initialValue(),每次在get()如果没找到,就返回一个初始化对象HoldCounter
        public HoldCounter initialValue() {
            return new HoldCounter();
        }
    }
    //保存所有线程的读锁计数器
    private transient ThreadLocalHoldCounter readHolds;
    //最近一个成功获取读锁的线程的计数。避免了同一个线程多次操作去ThreadLocal查找
    private transient HoldCounter cachedHoldCounter;
    //读锁状态从0改为1的线程,不会放入readHolds中
    private transient Thread firstReader = null;
    //firstReader的重入计数
    private transient int firstReaderHoldCount;
    //构造函数:初始化readHolds和锁状态
    Sync() {
        readHolds = new ThreadLocalHoldCounter();
        setState(getState());//cas操作,加内存屏障,保证readHolds的可见性
    }
    //定义读、写锁获取时的阻塞策略,根据策略不同分别在NonfairSync和FairSync中实现
    abstract boolean readerShouldBlock();
    abstract boolean writerShouldBlock();
}

(3)独占锁的实现

1)独占锁加锁过程

独占锁ReentrantReadWriteLock.WriteLock中非公平策略的加锁lock()方法源码调用过程的时序图如下:

//独占锁lock调用的是AQS里的acquire方法
public void lock() {
    sync.acquire(1);
}

//AQS中定义了模板方法tryAcquire,具体实现是在ReentrantReadWriteLock中
public final void acquire(int arg) {
    //如果获取写锁失败,则把当前线程分装成node添加到AQS同步队列尾部
    if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

tryAcquire()源码

tryAcquire方法通过锁状态state 和 state的低16位来判断写锁能否获取。该方法除了当前线程重入条件之外,增加了一个读锁是否存在的判断。 只要读锁存在,则当前线程获取写失败;如果读锁没被占有,再判断当前线程是否是写锁重入;如果是重入锁则获取锁重构,否则使用CAS操作更新锁状态。

protected final boolean tryAcquire(int acquires) {
    //获取当前线程
    Thread current = Thread.currentThread();
    int c = getState(); //获取锁状态state
    //获取写锁状态,即取state的低16位
    int w = exclusiveCount(c);
    if (c != 0) {
        //state不为0,说明有写锁或读锁被占用;w=0表示读锁被占用,则获取写锁失败(读写互斥);如果w!=0写锁被占用,再判断是否是重入
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
            //写锁状态不能操作最大值(1<<16 - 1)
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        //重入写锁成功,更新state值
        setState(c + acquires);
        return true;
    }
    //当不是写锁重入时,通过writerShouldBlock(公平、非公平策略)判断是否要阻塞,不需阻塞时CAS更新锁状态
    if (writerShouldBlock() ||
            !compareAndSetState(c, c + acquires))
        return false;
    //CAS更新锁成功,则获取锁成功,设置独占锁线程
    setExclusiveOwnerThread(current);
    return true;
}

这儿有个点需要注意,用writerShouldBlock()方法来判断当前线程是否需要被阻塞。那什么在情况下会阻塞当前线程,什么情况下不需要阻塞?先看看NonfairSync和FairSync实现的源码 。

//1.写锁-公平策略FairSync实现
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    //只要AQS同步队列里有等待线程、且等待线程不是当前线程时,获取锁阻塞
    return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}

//2.写锁-非公平策略NonFairSync的实现
final boolean writerShouldBlock() {
    return false; // writers can always barge
}

所以在判断写锁获取的是否需要阻塞时,公平策略 和 非公平策略实现的方式不一样:

  • 公平写锁获取时,需要判断AQS同步队列里是否有等待获取锁的节点;如果没有,当前线程就不需阻塞;如果有,需要判断是否是锁重入情况。

  • 非公平写锁获取时,不需要阻塞,直接参与写锁竞争。

两种方式阻塞策略是不一样的,例下图:线程A获取了写锁,AQS同步队列中线程B等待获取读锁、线程C等待获取写锁;当线程A释放写锁的时候,线程D请求一个写锁。如果是FairSync公平锁,线程D阻塞入队列;如果是NonFairSync非公平锁,线程D直接竞争写锁。

当tryAcquire获取锁失败后,跟ReentrantLock一样通过addWaiter()将线程分装成节点添加到AQS同步队列尾部,再通过acquireQueued()方法将线程阻塞,直到被唤起。

【注释:addWaiter()、acquireQueued()方法解析请参考:深入图解AQS实现原理和源码分析

2)独占锁释放过程

独占锁ReentrantReadWriteLock.WriteLock中非公平策略的释放锁unlock()方法源码调用过程的时序图如下:

//writeLock调用的是AQS中的release()方法
public void unlock() {
    sync.release(1);
}

//tryRelease()在AQS里是模板方法,具体实现是在Sync中
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        //如果获取锁成功,唤醒head的后继节点
        Node h = head;
        if (h != null && h.waitStatus != 0)
            //唤醒head后面处于等待状态的节点(这和ReentrantLock原理一样)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

tryRelease()源码

在写锁释放时,会取state-1的低16位判断是否等于0;如果等于零表示写锁释放成功,其它线程可以获取读或写锁;如果不等于零,表示锁重入情况下释放一次写锁,其它线程不能获取读或写锁。

protected final boolean tryRelease(int releases) {
    //判断写锁占有线程不是当前线程,就抛异常。
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    //释放一次锁,state就减1操作
    int nextc = getState() - releases;
    //判断写锁是否被释放;取nextc的低16位如果等于0,释放锁成功;不等于0,表示重入锁情况下,释放一次锁
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        //释放锁成功
        setExclusiveOwnerThread(null);
    setState(nextc);
    return free;
}

(4)共享锁的实现过程

相对于写锁,读锁的获取流程更复杂一些;读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数,只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。读锁的复杂性主要体现在:

  • (1)读读不互斥,多个线程可以同时获取读锁,所以维护读锁状态更复杂。

  • (2)写锁释放后唤醒AQS等待队列中的节点时,需要考虑到可以唤醒多个读锁线程。

1)共享锁加锁过程

共享锁ReentrantReadWriteLock.ReadLock中非公平策略的加锁lock()方法源码调用过程的时序图如下:

//readLock的lock方法调用的是AQS中的acquireShared()
public void lock() {
    sync.acquireShared(1);
}

//AQS中定义了共享锁获取模板方法tryAcquireShared,具体实现是在ReentrantReadWriteLock中
public final void acquireShared(int arg) {
    //尝试获取共享锁
    if (tryAcquireShared(arg) < 0)
        //共享锁获取失败,将当前线程封装成node添加到队列AQS尾部,同时阻塞当前线程
        doAcquireShared(arg);
}

tryAcquireShared源码

共享锁获取时,会先判断是否有线程持有独占锁,如果有进一步判断独占锁是不是当前线程重入,此时持有写锁的线程,可以再获取读锁。当没有线程持有独占锁时,会进行CAS操作将state锁的高16位加1操作。

//尝试获取共享锁(读锁)
protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
    //判断持有写锁的线程,可以再获取读锁
    if (exclusiveCount(c) != 0 &&   //已分配了写锁
        getExclusiveOwnerThread() != current)  //且不是当前线程持有写锁的线程
        return -1;
    //取读锁计数
    int r = sharedCount(c);
    //公平策略决定不阻塞 + 读锁数量还没达到最大值 + CAS尝试获取读锁
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) { //c + SHARED_UNIT表示state锁高16位加1操作
        // 成功获取读锁,判断是不是firstReader,计数不会放入readHolds
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            //firstReader 重入
            firstReaderHoldCount++;
        } else {
            //非 firstReader 读锁重入计数更新
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                //从ThreadLocal中获取当前线程的计数器
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    //如果获取读锁失败,会进入自旋重试,会根据公平策略判断是否需要阻塞。自旋直到获取读锁或获取失败
    return fullTryAcquireShared(current);
}

这儿有个点需要注意一下,readerShouldBlock()会根据不同的公平策略来决定需不需要阻塞当前线程。那什么在情况下会让当前读线程阻塞,什么情况下不需要阻塞?先看看NonfairSync和FairSync读阻塞策略实现的源码

//.FairSync公平策略:新来的线程获取读锁时,会判断AQS同步队列里的是否有其他等待的节点,如果有则添加到AQS尾部。
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

//2.NonFairSync非公平策略:新来的线程获取读锁时,会判断AQS等待队列中第一个等待线程是否是获取写锁,如果是先让写锁先执行,当前线程会添加到等待队列尾部。
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
    Node h, s;
    //当AQS等待队列中第一个等待线程是获取写锁时,返回false。之所以这样处理一方面是为了效率,一方面是为了避免想获取写锁的线程饥饿,老是得不到执行的机会
    return (h = head) != null &&
		(s = h.next)  != null &&
		!s.isShared() &&
		s.thread != null;
}

主要注意一点是:NonfairSync非公平策略,如果当前处于读锁状态,且等待队列中第一个等待线程是写锁,则当前线程不进行抢占读锁。这样处理一方面是为了效率,一方面是为了避免想获取写锁的线程饥饿,老是得不到执行的机会。

如下图线程A获取了读锁,AQS等待队列中线程B等待获取写锁、线程C等待获取读锁。此时新来了一个线程D获取读锁,线程D会被添加到队列的尾部,因为AQS等待队列中第一节点(线程)是写锁。

doAcquireShared()源码

如果线程获取读锁失败后,会进入doAcquireShared()方法将当前线程封装成node添加到队列AQS尾部,同时阻塞线程,直到线程被唤醒。

private void doAcquireShared(int arg) {
    //跟ReentrantLock一样,将当前线程分装成node添加到AQS等待队列尾部
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            //如果node是等待队列中head.next(第二个节点)节点,则尝试获取读锁
            if (p == head) {
                int r = tryAcquireShared(arg);
                //获取读锁成功后,唤醒后继节点
                if (r >= 0) {
                    //这里是重点,获取到锁以后的唤醒操作,这个操作会唤醒多个读锁等待的node           
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            //线程挂起逻辑跟ReentrantLock一样,会移除一些取消状态的节点
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        //当出现异常时,从等待队列中移除添加的节点,这个和独占锁一样
        if (failed)
            cancelAcquire(node);
    }
}

【注释:shouldParkAfterFailedAcquire()方法解析请参考这篇文章:深入图解AQS实现原理和源码分析

共享锁模式获取成功以后,调用了setHeadAndPropagate()方法,从方法名就可以看出除了设置新的头结点以外还有一个传递动作,一起看下代码:

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; //记录当前头节点
    //设置新的头节点,即把当前获取到锁的节点设置为头节点
    //注:这里是获取到锁之后的操作,不需要并发控制
    setHead(node);
    //这里意思有两种情况是需要执行唤醒操作
    //1.propagate > 0 表示调用方指明了后继节点需要被唤醒
    //2.头节点后面的节点需要被唤醒(waitStatus<0),不论是老的头结点还是新的头结点
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        //如果当前节点的后继节点是共享类型获取没有后继节点,则进行唤醒
        //这里可以理解为除非明确指明不需要唤醒(后继等待节点是独占类型),否则都要唤醒
        if (s == null || s.isShared())
            //醒操作,比较复杂
            doReleaseShared();
    }
}

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

//使用自旋直到唤醒后继节点为止
private void doReleaseShared() {
    for (;;) {
        //唤醒操作由头结点开始,注意这里的头节点已经是上面新设置的头结点了
        //其实就是唤醒上面新获取到共享锁的节点的后继节点
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            //表示后继节点需要被唤醒
            if (ws == Node.SIGNAL) {
                //这里需要控制并发,因为入口有setHeadAndPropagate跟release两个,避免两次unpark
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;      
                //执行唤醒操作
                unparkSuccessor(h);
            }
            //如果后继节点暂时不需要唤醒,则把当前节点状态设置为PROPAGATE确保以后可以传递下去
            else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                
        }
        //如果头结点没有发生变化,表示设置完成,退出循环
        //如果头结点发生变化,比如说其他线程获取到了锁,为了使自己的唤醒动作可以传递,必须进行重试
        if (h == head)                   
            break;
    }
}

上面的setHeadAndPropagate()方法表示:唤醒同步队列中等待获取共享锁的节点,唤醒操作有一个传递动作。我们举个例子来说明这个传递过程。如图AQS同步队列里依次是线程B、C、D读锁等待、线程E写锁等待,线程A获取了独占锁(写锁)。当线程A释放锁后,唤醒线程B;因为线程B、C、D都是读锁等待,线程B在唤醒后会再唤醒线程C,C唤醒D,直到遇到写锁等待的。等到B、C、D线程读锁都释放后,最后才能唤醒E线程。

2)共享锁释放过程

共享锁ReentrantReadWriteLock.ReadLock中非公平策略的释放锁unLock()方法源码调用过程的时序图如下:

//1.读锁释放:调用AQS的releaseShared()释放共享锁
public void unlock() {
    sync.releaseShared(1);
}

//2.尝试释放共享锁tryReleaseShared方法,是AQS里的模板方法具体实现在Sync中
public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        //释放读锁成功,即当前线程不持有读锁时,唤醒后面的等待节点(前面已讲到)
        doReleaseShared();
        return true;
    }
    return false;
}

tryReleaseShared()源码

tryReleaseShared()释放锁时,会使用自旋 + CAS操作将读锁(锁状态state的高16位)减1,同时会维护当前线程的读锁计数器。当同一个线程同时占有写锁和读锁,tryReleaseShared释放读锁后返回false,不会唤醒队列里等待的节点。

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    //当前线程是firstReader时,清理firstReader和重入计数次数
    if (firstReader == current) {
        if (firstReaderHoldCount == 1)
            //清除firstReader
            firstReader = null;
        else
            //firstReader读锁重入计算次数减1
            firstReaderHoldCount--;
    } else {
        //非firstReader读计数器,从readHolds取出操作
        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高16位)减1操作,直到CAS成功为止
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT; //锁状态state高16位减1操作
        if (compareAndSetState(c, nextc))
            return nextc == 0; //CAS成功,如果next不为0则是读锁重入或同一个线程获取了写和读锁
    }
}

(5)锁降级

是指线程A获取了(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。从现象上看好像是线程A从写锁降级成为读锁,但实际上读、写锁的状态记录是相互独立的,不存在所谓的降级转换。

总结

  • 1.当有线程占有了写锁,其它线程获取读锁或写锁会被阻塞,占有写锁的线程可以再获取读锁。

  • 2.线程在已经获取了写锁情况下,同一个线程还可以重入读锁;反过来就不成立了。

  • 3.公平及非公平这两种策略下,一般而言,非公平锁吞吐会比较大,所以默认情况下都是使用的非公平策略。

  • 4.公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

                                                                                                                                              2020年05月24日 于北京记

猜你喜欢

转载自blog.csdn.net/Seky_fei/article/details/106316535