一文学会ReentrantReadWriteLock(读写锁)

811 阅读14分钟

什么是ReentrantReadWriteLock

如果需要并发地访问竞争资源,你会怎么做?答案大家都知道,对临界区上锁,即对将要访问到竞争资源的代码进行所控制。进一步的,如果访问更多的是读操作,而更多的写操作,那你又会怎么做?同样的做法,一样能达成目的,但是,当更追求并发效率时,我们更希望大多数读操作能想没有锁一样通畅地运行,并且在有写操作时,又能保证并发的正确性。

为了竞争条件而引起的阻塞是合理的。但在这样的读操作更多的场景里,频繁地引发了无效率的阻塞。毕竟,读操作又不会改变资源,也就不会产生竞争条件。

ReentrantReadWriteLock为上面所描述的场景而服务。ReentrantReadWriteLock认为,在它所面对的场景中,读操作占据绝大多数,而改变数据的写操作频率较低。那么,它保证并发时,都是读操作时,不会阻塞,而当有写操作需要处理时,共同等待写操作完成,如此,就能提高了整体的效率。

ReentrantReadWriteLock不仅完成了这项承诺,还具备了如下特性:

  • 同时支持独占锁和共享锁,且对外部来说,是透明的
  • 可重入,意味着拿到锁的线程可以重复地上锁
  • 公平锁与不公平锁,意味着可以控制申请锁的线程要不要按申请顺序拿到锁
  • 继承了AQS的其他特性

那么,ReentrantReadWriteLock是如何实现的呢?

锁的只是分为两部分,一部分为如何加解锁,另一部分为把锁分配给谁。ReentrantReadWriteLock依赖于AQS的实现,AQS解决了把锁分配给谁的问题,ReentrantReadWriteLock就聚焦于如何加解锁即可。

AQS基础

既然如此,了解AQS内部机制能使对ReentrantReadWriteLock的认识充分,如果还未了解过,也不妨碍对本文的理解。

AQS原理可以参考:一文了解AQS

这里,权且大致了解AQS如何运转,更容易继续之后的阅读。

AQS运行概要.png

  1. 当申请锁,即调用了与acquire()类似语义的方法时,AQS将询问子类是否上锁成功,成功则继续运行。否则,AQS将以Node为粒度,记录这个申请锁的请求,将其插入自身维护的CLH队里中并挂起这个线程
  2. 在CLH队列中,只有最靠近头节点的未取消申请锁的节点,才有资格申请锁
  3. 当线程被唤醒时,会尝试获取锁,如果获取不到继续挂起;获取得到则继续运行
  4. 当一个线程释放锁,即调用release()类似语义的方法时,AQS将询问子类是否解锁成功,有锁可以分配,如果有,AQS从CLH队列中主动唤起合适的线程,过程为2、3
  5. 如果需要等待条件满足再去申请锁,即调用了wait()类似语义的方法时,在AQS中表现为,以Node为粒度,维护一个单向等待条件队列,把Node所代表的线程挂起
  6. 当条件满足时,即调用了signal()类似语义的方法时,唤醒等待条件队列最前面的未取消等待的Node,执行1
  7. 子类可以维护AQS的state属性来记录加解锁状态,AQS也提供了CAS的方法compareAndSetState()抢占更新state

简要来说,AQS分配锁时,当前线程可能会被挂起,接着被唤醒继续尝试申请锁,重复此过程直到获取到锁或取消等待。从外部看,就如入口方法被阻塞并在未来被恢复了一样。

Sync

ReentrantReadWriteLock解决读多写少的场景的做法为,要求读/写方分别获取读锁写锁,接着,对调用方来说,就像正常的申请锁、释放锁那样的方式使用。操作方通过writeLock()与readLock()就能分别获取读锁和写锁的使用入口。

在ReentrantReadWriteLock中,以它的内部类Sync继承AQS,并完成AQS要求子类实现的语义方法。实际上,核心内容也位于Sync。

abstract static class Sync extends AbstractQueuedSynchronizer {
    static final int SHARED_SHIFT   = 16;
    static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
    static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
    static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
}

上面的内容,是Sync用来协助记录加了多少锁的。AQS提供了int类型的state属性,给子类记录加锁状态,而ReentrantReadWriteLock同时要支持读锁和写锁,因此,它将state的高16位记录读锁,低16位记录写锁,如图。

锁记录.png

因此

获取写锁上锁次数 = state & EXCLUSIVE_MASK // 消除高16位

写锁上锁次数加1 = state + 1

获取读锁上锁次数 = state >>> SHARED_SHIFT // 无符号右移16位

读锁上锁次数加1 = state + SHARED_UNIT

abstract static class Sync extends AbstractQueuedSynchronizer {
    static final class HoldCounter {
        // 记录当前线程加锁的次数,>1 说明重入
        int count = 0;
        // 线程id
        final long tid = getThreadId(Thread.currentThread());
    }
        
    static final class ThreadLocalHoldCounter
        extends ThreadLocal<HoldCounter> {
        public HoldCounter initialValue() {
            return new HoldCounter();
            }
        }
        
    // 借助 ThreadLocal 帮助获取每个线程的 HoldCounter
    private transient ThreadLocalHoldCounter readHolds;
    // 为了加快效率而存在的HoldCounter,总是记录最后一个获取读锁的线程的HoldCounter
    private transient HoldCounter cachedHoldCounter;
}   

Sync借助ThreadLocal的子类ThreadLocalHoldCounter让每个线程保存了记录自身加锁次数的数据HoldCounter。因为是可以重复请求锁的,因此当HoldCounter.count > 1时,说明发生了重入。

值得一提的属性是cachedHoldCounter。对于实际的场景来说,发生重入的情况,更可能发生于最后一个请求锁的线程,因此,对最后一个拿到锁的线程冗余记录,进一步提高效率。

写锁如何实现

写锁于ReentrantReadWriteLock中是一级公民,可以想见的是,当有写操作到来时,其他的写操作与读操作应该进行等待,毕竟,并发的写操作才是引发竞争条件的原因。因此,写锁是独占锁

Sync对于写锁的加解锁的应答方法分别为tryAcquire()和tryRelease(),AQS分配写锁时,将通过这两个方法询问Sync加解锁情况。

protected final boolean tryAcquire(int acquires) {
    // 当前线程
    Thread current = Thread.currentThread();
    int c = getState();
    // 写锁加锁次数
    int w = exclusiveCount(c);
    if (c != 0) {
        // 写锁是独占锁,进入此处说明发生重入
        if (w == 0 || current != getExclusiveOwnerThread())
            // 当且仅当同一线程的写锁才能重入
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            // 超过最大上锁值
            throw new Error("Maximum lock count exceeded");
        // 更新写锁加锁次数
        setState(c + acquires);
        // 告诉AQS,加锁成功
        return true;
    }
    // 来到这里说明当前没有线程获取到写锁
    if (writerShouldBlock() /*要等之前已经在申请读锁的线程处理完毕,及能否插队*/
        || !compareAndSetState(c, c + acquires) /**CAS更新状态来尝试获取锁**/)
        // 告诉AQS加锁失败
        return false;
                
    // 记住当前获取到写锁的线程
    setExclusiveOwnerThread(current);
    // 告诉AQS获取写锁成功
    return true;
}

首先,写锁是独占锁,意味着当exclusiveCount() > 0时,只有已经获取到锁的线程才能重复申请读锁,完成重入,也因此更新写锁加锁次数时不需要考虑并发。

然后,writerShouldBlock()语义如何实现,就决定了申请锁的线程能否插队。此时没有线程占有读锁,writerShouldBlock()可以选择遵守或无视CLH队里正在排队的Node的情况。

并且,writerShouldBlock()存在的另一原因为,如果在申请写锁之前,已经有读锁在等待分配锁了,那么,应该等待读锁处理完毕后,再处理写锁,这也是合意的。

因此,申请写锁可能经历的过程为:

写锁获取.png

相对的,解锁也将包含了上图每种情况的处理:

protected final boolean tryRelease(int releases) {
    if (!isHeldExclusively())
        // 只有持有锁的线程才能释放锁,独占锁只有一个线程能拿到锁
        throw new IllegalMonitorStateException();
    // 要更新的加锁值
    int nextc = getState() - releases;
    // 用来判断,锁是否完全释放了,包括可重入的情况
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        // 现在线程占有锁了
        setExclusiveOwnerThread(null);
    // 更新加锁值
    setState(nextc);
    // 告诉AQS,解锁是否成功,成功意味着有锁可以分配给其他人了
    return free;
}

读锁释放锁的逻辑简单,因为独占锁只有一个线程会获得,那么,释放锁时只要处理好重入的情况即可。

读锁如何实现

读锁是共享锁,读操作不会引起竞争条件。读锁会让步于写锁,但在没有写锁的时候,读锁不应造成阻塞。 读锁的加解锁的应答方法分别为tryAcquireShared()和tryReleaseShared()。

protected final int tryAcquireShared(int unused) {
    // 当前线程
    Thread current = Thread.currentThread();
    // 当前的上锁数量
    int c = getState();
    if (exclusiveCount(c) != 0 /*是否有线程获取了写锁*/
        && getExclusiveOwnerThread() != current /*只有获取了写锁的线程可以继续申请读锁成功*/)
        // 告诉AQS,加锁不成功
        return -1;
    // 当前读锁次数
    int r = sharedCount(c);
    if (!readerShouldBlock()/*要等之前已经在申请锁的线程处理完毕,及能否插队*/ &&
        r < MAX_COUNT /*小于最大读锁上锁次数*/
        && compareAndSetState(c, c + SHARED_UNIT) /*通过CAS更新读锁加锁值抢占加锁*/) {
        if (r == 0) {
                // 进到这里说明,没有其他线程获得过读锁
            // 更快地处理重入,记录了第一个申请读锁的信息
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            // 之前提到的,cachedHoldCounter为了更快地处理重入
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                // 找到每个线程的HoldCounter,更新冗余信息
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                // 把HoldCounter加入到属于他的线程内
                readHolds.set(rh);
            // 更新这个线程写锁上锁的次数
            rh.count++;
        }
        // 告诉AQS,加锁成功
        return 1;
    }
    // 在上面的加锁竞争落败了,继续尝试加锁
    return fullTryAcquireShared(current);
}

读锁上锁的步骤为:

  1. 如果有写锁已经分配出去,除了被分配写锁的线程申请读锁才能分配到读锁,其他申请的读锁线程无条件进入CLH队列排队
  2. readerShouldBlock()决定了申请读锁的线程有没有插队机会
  3. 每个获取读锁线程,存储了类型为HoldCounter的属性于各自的的线程,记录了此线程上读锁的次数
  4. HoldCounter的存在说明了读锁的可重入,firstReaderHoldCount、cachedHoldCounter 均用来加快读锁重入的处理
  5. 如果加锁竞争失败,继续通过fullTryAcquireShared()尝试加锁
final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    for (;;) {
        int c = getState();
        if (exclusiveCount(c) != 0) {
            // 有写锁被分配了
            if (getExclusiveOwnerThread() != current)
                // 被分配写锁的线程才能继续申请读锁
                // 告诉AQS申请读锁失败了
                return -1;
        } else if (readerShouldBlock()) {
            /*要等之前已经在申请锁的线程处理完毕,及能否插队*/ 
            if (firstReader == current) {
            // 这个在调用此方法函数里已经处理过了
            } else {
                f (rh == null) {
                    rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current)) {
                        // 拿到记录读锁的加锁次数的HoldCounter
                        rh = readHolds.get();
                        if (rh.count == 0)
                        // 读锁需要阻塞,说明有写锁在使用,那么,除了被分配写锁的线程,不应有记录其他线程写锁加锁的记录,移除掉就好
                            readHolds.remove();
                    }
                 }
                if (rh.count == 0)
                    // 告诉AQS加锁失败了
                    return -1;
            }
        }
        if (sharedCount(c) == MAX_COUNT)
            /*超过读锁加锁最大值*/ 
            throw new Error("Maximum lock count exceeded");
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            //  通过AQS更重新读锁加锁次数,竞争锁成功
            if (sharedCount(c) == 0) {
                // 同tryAcquireShared()
                firstReader = current;
                firstReaderHoldCount = 1;
            } else if (firstReader == current) {
                // 同tryAcquireShared()
                firstReaderHoldCount++;
            } else {
                // 同tryAcquireShared()
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                // 记住最后一个写锁加锁成功的记录
                cachedHoldCounter = rh; 
            }
            // 告诉AQS上锁成功
            return 1;
        }
    // 否则继续自旋尝试加锁
    }
}

虽然fullTryAcquireShared()的代码较长,实际上,可以把它看成是tryAcquireShared()获取锁的逻辑的自旋版本。

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))
            // 拿到该线程的HoldCounter
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            // 说明重入已处理完毕
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        // 更新该线程的读锁次数
        -rh.count;
    }
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        // 自旋更新读锁的上锁次数
        if (compareAndSetState(c, nextc))
            // 告诉AQS解锁成功
            return nextc == 0;
    }
}

读锁解锁的操作为:

  • 从每个线程取出HoldCounter,次数减一,也处理了重入
  • 自旋更新总的总的上锁自旋次数

公平与不公平特性

ReentranReadWriteLock是否公平,可以通过实例化参数指定。实现由Snyc的子类决定

    static final class FairSync extends Sync {
        
        final boolean writerShouldBlock() {
            // 是否有代表申请锁的线程的Node在CLH队列中
            return hasQueuedPredecessors();
        }
        final boolean readerShouldBlock() {
            // 是否有代表申请锁的线程的Node在CLH队列中
            return hasQueuedPredecessors();
        }
    }
    
    static final class NonfairSync extends Sync {

        final boolean writerShouldBlock() {
            // 无视CLH队列的情况
            return false; 
        }
        final boolean readerShouldBlock() {
            return apparentlyFirstQueuedIsExclusive();
        }
    }
    
    final boolean apparentlyFirstQueuedIsExclusive() {
        Node h, s;
        // CLH队列下一个要被唤醒的Node是否是申请写锁的
        return (h = head) != null &&
            (s = h.next)  != null &&
            !s.isShared()         &&
            s.thread != null;
    }

对于公平锁,只要CLH队列里还有申请锁的线程在排队,那么申请锁就得入队,杜绝了插队。对于不公平锁,写锁发现有锁就申请,无视CLH队列;读锁也如此,只不过它需要考虑CLH队列下一个要唤醒的节点是否申请写锁,是则需要排队。

读锁写锁总览

总的来看,当写锁存在时,申请读锁以及其他写锁将被阻塞;当写锁不存在时,读锁的申请畅通无阻。并且,占有写锁的线程,可以继续获取到读锁。反之则不行,将可能造成死锁,例子如图

先读后写死锁例子.png

  1. 在写锁被占有的时候,有其他读锁的请求进来,进入CLH队列中排队
  2. 一段时间后,代表T的Node节点被唤醒,获取到读锁,然后去向AQS申请写锁
  3. AQS发现,CLH队列中还有Node在排队,获取写锁失败,将T线程挂起
  4. 结果是,T线程无限期挂起,就算因为被唤醒,也会重复3、4过程

与外部而言,通过ReentrantReadWriteLock.readLock()和ReentrantReadWriteLock.WriteLock(),就可以获取读写锁的操作入口内部类,它们均实现了接口Lock的语义,因此按照使用Lock的方式使用即可。Lock代表了一类锁应具有的方法语义模板。

public interface Lock {
    void lock(); // 获取锁,获取不到会被阻塞
    void lockInterruptibly() throws InterruptedException; // 获取锁,可被中断,获取不到会被阻塞
    boolean tryLock(); // 获取锁,无论结果如何不会被阻塞
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 获取锁,最多在unit时间内返回结果,可被中断
    void unlock(); // 释放锁
    Condition newCondition(); // 支持满足一定条件后,再去上锁
}

读锁写锁并发过程

对于读锁与写锁的加解锁,并发时间线可能经历下图任意阶段过程

读锁写锁时间线.png

总结

ReentrantReadWriteLock以共享读锁和独占写锁的方式,借助AQS,实现了在读多写少的场景下更高的并发,概要实现为:

  • 当没有写锁被持有是,读锁畅通无阻
  • 当有写锁申请时,如果CLH队列中有读锁或写锁的申请等待,那么这个写锁的线程将入队,挂起
  • 独占的写锁,通过记住获取写锁的线程,可以重入,而获取几次就要释放几次
  • 读锁的加锁,通过线程保存的变量HoldCounter记录加解锁记录数,获取几次就释放几次
  • 占有写锁的线程可以申请读锁,反之则不行
  • 锁是否公平,取决于当有锁可以获取时,Sync(的子类)是否忽略CLH队列的情况而进行竞争
  • 当锁释放并有更多的锁可以分配时,AQS会从CLH队列中唤醒最接近头结点的未取消等待的线程将其唤醒

当然,ReentrantReadWriteLock支持的其他特性不仅于此,但它更多的是借助了AQS的特性,如可中断地获取锁,可timeout地获取锁。在ReentrantReadWriteLock中没有做过多的封装,因此不做展出。其他加解锁的方法,在了解本文后自行查看也将变得简单,大同小异。

总而言之,AQS解决了锁如何分配的问题,ReentrantReadWriteLock实现了它如何加解锁的问题,但如要彻底理解ReentrantReadWriteLock,需要对AQS有一定的认识,毕竟AQS处理了很多重要的细节。

ASQ学习传送门

参考

Java锁之ReentrantReadWriteLock