Java 并发之 ReentrantReadWriteLock

作者: 小鱼人爱编程 | 来源:发表于2021-10-21 21:50 被阅读0次

    前言

    线程并发系列文章:

    Java 线程基础
    Java 线程状态
    Java “优雅”地中断线程-实践篇
    Java “优雅”地中断线程-原理篇
    真正理解Java Volatile的妙用
    Java ThreadLocal你之前了解的可能有误
    Java Unsafe/CAS/LockSupport 应用与原理
    Java 并发"锁"的本质(一步步实现锁)
    Java Synchronized实现互斥之应用与源码初探
    Java 对象头分析与使用(Synchronized相关)
    Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
    Java Synchronized 重量级锁原理深入剖析上(互斥篇)
    Java Synchronized 重量级锁原理深入剖析下(同步篇)
    Java并发之 AQS 深入解析(上)
    Java并发之 AQS 深入解析(下)
    Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
    Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
    Java 并发之 ReentrantReadWriteLock 深入分析
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
    最详细的图文解析Java各种锁(终极篇)
    线程池必懂系列

    上篇文章分析了AQS的实际应用之一:ReentrantLock 的实现。ReentrantLock 和synchronized 都是独占锁,而AQS还支持共享锁,本篇就来分析AQS 共享锁的实际应用。
    通过本篇文章,你将了解到:

    1、共享锁、独享锁区别
    2、读锁的实现原理
    3、写锁的实现原理
    4、读写锁 tryLock 原理
    5、读写锁的应用

    1、共享锁、独享锁区别

    基本差别

    共享锁、独占锁是在AQS里实现的,核心是"state"的值:


    image.png

    如上图,对于共享锁来说,允许多个线程对state进行有效修改。

    读写锁的引入

    根据上面的图,state 同时只能表示一种锁,要么独占锁,要么共享锁。而在实际的应用场景里经常会碰到多个线程读,多个线程写的情况,此时为了能够协同读、写线程,需要将state改造。
    先来看AQS state 定义:

    #AbstractQueuedSynchronizer.java
    private volatile int state;
    

    可以看出是int 类型的(当然也有long 类型的,在AbstractQueuedLongSynchronizer.java 里,本文以int 为例)

    image.png

    state 被分为两部分,低16位表示写锁(独占锁),高16位表示读锁(共享锁),这样一个32位的state 就可以同时表示共享锁和独占锁了。

    2、读锁的实现原理

    ReentrantReadWriteLock 的构造

    ReentrantReadWriteLock 并没有像ReentrantLock一样直接实现Lock 接口,而是内部分别持有ReadLock、WriteLock类型的成员变量,两者均实现了Lock 接口。

    #ReentrantReadWriteLock.java
        public ReentrantReadWriteLock() {
            //默认非公平锁
            this(false);
        }
    
        public ReentrantReadWriteLock(boolean fair) {
            sync = fair ? new FairSync() : new NonfairSync();
            //构造读锁
            readerLock = new ReadLock(this);
            //构造写锁
            writerLock = new WriteLock(this);
        }
    

    ReentrantReadWriteLock 默认实现非公平锁,读锁、写锁支持非公平锁和公平锁。
    读写锁构造之后,将锁暴露出来给外部使用:

    #ReentrantReadWriteLock.java
        //获取写锁对象
        public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
        //获取读锁对象
        public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }
    

    获取锁

    在ReentrantLock 分析独占锁时有如下图:


    image.png

    与独占锁类似,AQS虽然已经实现了共享锁的基本逻辑,但是真正获取锁、释放锁的操作还是需要子类实现,共享锁需要实现方法:

    tryAcquireShared & tryReleaseShared

    来看看获取锁的过程:

    #ReentrantReadWriteLock.ReadLock
        public void lock() {
                //共享锁
                sync.acquireShared(1);
            }
    
    #AbstractQueuedSynchronizer.java
        public final void acquireShared(int arg) {
            if (tryAcquireShared(arg) < 0)
                //doAcquireShared 在AQS里实现
                doAcquireShared(arg);
        }    
    

    重点是tryAcquireShared(xx):

    #ReentrantReadWriteLock.java
            protected final int tryAcquireShared(int unused) {
                Thread current = Thread.currentThread();
                //获取同步状态
                int c = getState();
                //此处exclusiveCount作用是取state 低16位,若是不等于0,说明有线程占有了写锁
                //若是有线程占有了写锁,而这个线程不是当前线程,则直接退出------------>(1)
                if (exclusiveCount(c) != 0 &&
                    getExclusiveOwnerThread() != current)
                    return -1;
                //获取state 高16位,若是大于0,说明有线程占有了读锁
                int r = sharedCount(c);
                //当前线程是否应该阻塞
                if (!readerShouldBlock() &&//------------>(2)
                    r < MAX_COUNT &&//若是不该阻塞,则尝试CAS修改state高16位的值
                    compareAndSetState(c, c + SHARED_UNIT)) {
                    //--------记录线程/重入次数----------->(3)
                    //修改state 成功,说明成功占有了读锁
                    if (r == 0) {
                        //记录第一个占有读锁的线程
                        firstReader = current;
                        //占有次数为1
                        firstReaderHoldCount = 1;
                    } else if (firstReader == current) {
                        //第一个占有读锁的线程重入了该锁
                        firstReaderHoldCount++;
                    } else {
                        //是其它线程占有锁
                        //取出缓存的HoldCounter
                        HoldCounter rh = cachedHoldCounter;
                        //若是缓存为空,或是缓存存储的不是当前的线程
                        if (rh == null || rh.tid != getThreadId(current))
                            //从threadLocal里获取
                            //readHolds 为ThreadLocalHoldCounter 类型,继承自ThreadLocal
                            cachedHoldCounter = rh = readHolds.get();
                        else if (rh.count == 0)
                            //说明cachedHoldCounter 已经被移出threadLocal,
                            //重新加入即可------------>(4)
                            readHolds.set(rh);
                        //记录重入次数
                        rh.count++;
                        //--------记录线程/重入次数-----------
                    }
                    return 1;
                }
                //------------>(5)
                return fullTryAcquireShared(current);
            }
    

    以上是获取读锁的核心代码,标注了5个重点,分别来分析。
    (1)
    此处表明了一个信息:

    若是当前线程已经获取了写锁,那么它可以继续尝试获得读锁。
    当它把写锁释放后,只剩读锁了。这个过程可以理解为锁的降级。

    (2)
    线程能否有机会获取读锁,还需要经过两个判断:

    1、判定readerShouldBlock()。
    2、判定读锁个数用完了没,阈值是2^16-1。

    而读锁公平与否就体现在readerShouldBlock()的实现上。

    先来看非公平读锁:

    #ReentrantReadWriteLock.java
            final boolean readerShouldBlock() {
                return apparentlyFirstQueuedIsExclusive();
            }
    
    #AbstractQueuedSynchronizer.java
           final boolean apparentlyFirstQueuedIsExclusive() {
            //判断等待队列里的第二个节点是否在等待写锁
            Node h, s;
            return (h = head) != null &&
                (s = h.next)  != null &&
                !s.isShared()         &&
                s.thread != null;
        }
    

    若等待队列里的第二个节点是在等待写锁,那么此时不能去获取读锁。
    这与ReentrantLock不一样,ReentrantLock 非公平锁的实现是不管等待队列里有没有节点,都会去尝试获取锁。

    再来看公平读锁

    #ReentrantReadWriteLock.java
            final boolean readerShouldBlock() {
                return hasQueuedPredecessors();
            }
    

    判断队列里是否有更早于当前线程排队的节点,该方法在上篇分析ReentrantLock 时有深入分析,此处不再赘述。

    (3)
    这部分代码看起来多,实际上就是为了记录重入次数以及为了效率考虑引入了一些缓存。
    考虑到有可能始终只有一个线程获取读锁,因此定义了两个变量还记录重入次数:

    #ReentrantReadWriteLock.java
            //记录第一个获取读锁的线程
            private transient Thread firstReader = null;
            //第一个获取读锁的线程获取读锁的个数
            private transient int firstReaderHoldCount;
    

    再考虑到有多个线程获取锁,它们也需要记录获取锁的个数,与线程绑定的数据我们想到了ThreadLocal,于是定义了:

    private transient ThreadLocalHoldCounter readHolds;
    

    来记录HoldCounter(存储获取锁的个数及绑定的线程id)。
    最后为了不用每次都去ThreadLocal里查询数据,再定义了变量来缓存HoldCounter:

    #ReentrantReadWriteLock.java
    private transient HoldCounter cachedHoldCounter;
    

    (4)
    cachedHoldCounter.count == 0,是在tryReleaseShared(xx)里操作的,并且判断当线程已经彻底释放了读锁后,将HoldCounter 从ThreadLocal里移除,因此此处需要加回来。

    (5)
    走到这一步,说明之前获取锁的操作失败了,原因有三点:

    1、readerShouldBlock() == true。
    2、r >= MAX_COUNT。
    3、中途有其它线程修改了state。

    fullTryAcquireShared(xx)与tryAcquireShared(xx)很类似,目的就是为了获取锁。
    针对第三点,fullTryAcquireShared(xx)里有个死循环,不断获取state值,若是符合1、2点,则退出循环,否则尝试CAS修改state,若是失败,则继续循环获取state值。

    小结一下:

    1、fullTryAcquireShared(xx) 获取锁失败返回-1,接下来的处理逻辑流转到AQS里,线程可能会被挂起。
    2、fullTryAcquireShared(xx) 获取锁成功则返回1。

    释放锁

    释放锁的逻辑比较简单:

    #ReentrantReadWriteLock.ReadLock
        public void lock() {
                sync.acquireShared(1);
            }
    #AbstractQueuedSynchronizer.java
        public final boolean releaseShared(int arg) {
            if (tryReleaseShared(arg)) {
                //在AQS里实现
                doReleaseShared();
                return true;
            }
            return false;
        }
    

    重点是tryReleaseShared(xx):

    #ReentrantReadWriteLock.java
            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))
                        //取不到,则需要从ThreadLocal里取
                        rh = readHolds.get();
                    int count = rh.count;
                    if (count <= 1) {
                        //若是当前线程不再占有锁,则清除对应的ThreadLocal变量
                        readHolds.remove();
                        if (count <= 0)
                            throw unmatchedUnlockException();
                    }
                    --rh.count;
                }
                for (;;) {
                    //修改state
                    int c = getState();
                    int nextc = c - SHARED_UNIT;
                    if (compareAndSetState(c, nextc))
                        //若是state值变为0,说明读锁、写锁都释放完了
                        return nextc == 0;
                }
            }
    

    此处需要注意的是:
    tryReleaseShared(xx)释放读锁时候,若是没有完全释放读锁、写锁,那么将会返回false。
    而在AQS里释放共享锁流程如下:

    #AbstractQueuedSynchronizer.java
        public final boolean releaseShared(int arg) {
            if (tryReleaseShared(arg)) {
                doReleaseShared();
                return true;
            }
            return false;
        }
    

    也就是说此种情况下,doReleaseShared() 将不会被调用,也就不会唤醒同步队列里的节点。
    这么做的原因是:

    若只释放完读锁,还剩写锁被占用。而因为写锁是独占锁,其它线程无法获取锁,那么即使唤醒了它们也没有用。

    3、写锁的实现原理

    获取锁

    写锁是独占锁,因此重点关注tryAcquire(xx):

    #ReentrantReadWriteLock.java
            protected final boolean tryAcquire(int acquires) {
                Thread current = Thread.currentThread();
                //获取同步状态
                int c = getState();
                //获取当前写锁个数
                int w = exclusiveCount(c);
                if (c != 0) {
                    //1、若是w==0,而c!= 0,说明有线程占有了读锁,不能再获取写锁了
                    //2、若是写锁被占用,但是不是当前线程,则不能再获取写锁了
                    if (w == 0 || current != getExclusiveOwnerThread())
                        return false;
                    //锁个数超限了
                    if (w + exclusiveCount(acquires) > MAX_COUNT)
                        throw new Error("Maximum lock count exceeded");
    
                    //走到此处,说明重入,直接设置,同一时刻只有一个线程能走到这
                    setState(c + acquires);
                    return true;
                }
                //若c==0,此时读锁、写锁都没线程占用
                //判断线程是否应该被阻塞,否则尝试获取写锁------->(1)
                if (writerShouldBlock() ||
                    !compareAndSetState(c, c + acquires))
                    return false;
                //独占锁需要关联线程
                setExclusiveOwnerThread(current);
                return true;
            }
    

    来看看writerShouldBlock(),写锁公平/非公平就在此处实现的。

    先来看非公平写锁:

    #ReentrantReadWriteLock.java
            final boolean writerShouldBlock() {
                //不阻塞
                return false; // writers can always barge
            }
    
    

    非公平写锁不应该阻塞。

    再来看公平写锁:

    #ReentrantReadWriteLock.java
            final boolean writerShouldBlock() {
                //判断队列是否有有效节点等待
                return hasQueuedPredecessors();
            }
    

    和公平读锁一样的判断条件。

    小结

    1、读锁/写锁 已被其它线程占用,那么新来的线程将无法获取写锁。
    2、写锁可重入。

    释放锁

    释放锁重点关注tryRelease(xx):

    ##ReentrantReadWriteLock.java
            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);
                return free;
            }
    

    若tryRelease(xx)返回true,则AQS里会唤醒等待队列的线程。

    4、读写锁 tryLock 原理

    读锁tryLock

    #ReentrantReadWriteLock.java
            public boolean tryLock() {
                return sync.tryReadLock();
            }
    
            final boolean tryReadLock() {
                Thread current = Thread.currentThread();
                for (;;) {
                //for 循环为了检测最新的state
                    int c = getState();
                    if (exclusiveCount(c) != 0 &&
                        getExclusiveOwnerThread() != current)
                        return false;
                    int r = sharedCount(c);
                    if (r == MAX_COUNT)
                        throw new Error("Maximum lock count exceeded");
                    if (compareAndSetState(c, c + SHARED_UNIT)) {
                    //记录次数
                        if (r == 0) {
                            firstReader = current;
                            firstReaderHoldCount = 1;
                        } else if (firstReader == current) {
                            firstReaderHoldCount++;
                        } else {
                            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 true;
                    }
                }
            }
    

    可以看出tryReadLock(xx)里: 只要不是别的线程占有写锁并且读锁个数没超出限制,那么它将一直尝试获取读锁,直到得到为止。

    写锁tryLock

            public boolean tryLock() {
                return sync.tryWriteLock();
            }
            final boolean tryWriteLock() {
                Thread current = Thread.currentThread();
                int c = getState();
                if (c != 0) {
                    int w = exclusiveCount(c);
                    if (w == 0 || current != getExclusiveOwnerThread())
                        return false;
                    if (w == MAX_COUNT)
                        throw new Error("Maximum lock count exceeded");
                }
                if (!compareAndSetState(c, c + 1))
                    return false;
                setExclusiveOwnerThread(current);
                return true;
            }
    

    写锁只尝试一次CAS,失败就返回。
    最终,用图表示读锁、写锁实现的功能:


    image.png

    读锁与写锁关系:


    image.png

    5、读写锁的应用

    分析完原理,来看看简单应用。

    public class TestThread {
    
        static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
        static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
    
        public static void main(String args[]) {
            //读
            for (int i = 0; i < 10; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        String threadName = Thread.currentThread().getName();
                        try {
                            System.out.println("thread " + threadName + " acquire read lock");
                            readLock.lock();
                            System.out.println("thread " + threadName + " read locking");
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            readLock.unlock();
                            System.out.println("thread " + threadName + " release read lock remain read count:" + readWriteLock.getReadLockCount());
                        }
                    }
                }, "" + i).start();
            }
    
            //写
            for (int i = 0; i < 10; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        String threadName = Thread.currentThread().getName();
                        try {
                            System.out.println("thread " + threadName + " acquire write lock");
                            writeLock.lock();
                            System.out.println("thread " + threadName + " write locking");
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            writeLock.unlock();
                            System.out.println("thread " + threadName + " release write lock remain write count:" + readWriteLock.getWriteHoldCount());
                        }
                    }
                }, "" + i).start();
            }
        }
    }
    

    10个线程获取读锁,10个线程获取写锁。
    读写锁应用场景:

    • ReentrantReadWriteLock 适用于读多写少的场景,提高多线程读的效率、吞吐量。

    同一线程读锁、写锁关系:

    public class TestThread {
    
        static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
        static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
    
        public static void main(String args[]) {
    //        new TestThread().testReadWriteLock();------>1、先读锁,后写锁
    //        new TestThread().testWriteReadLock();------>2、先写锁、后读锁
        }
    
        private void testReadWriteLock() {
            System.out.println("before read lock");
            readLock.lock();
            System.out.println("before write lock");
            writeLock.lock();
            System.out.println("after write lock");
        }
    
        private void testWriteReadLock() {
            System.out.println("before write lock");
            writeLock.lock();
            System.out.println("before read lock");
            readLock.lock();
            System.out.println("after read lock");
        }
    }
    

    分别打开1、2 注释,发现:

    1、先获取读锁,再获取写锁,则线程在写锁处挂起。
    2、先获取写锁,再获取读锁,则都能正常获取锁。
    这与我们上述的理论分析一致。

    下篇将会分析Semaphore、CountDownLatch、 CyclicBarrier原理及其应用。

    本文基于jdk1.8。

    您若喜欢,请点赞、关注,您的鼓励是我前进的动力

    持续更新中,和我一起步步为营系统、深入学习Android/Java

    相关文章

      网友评论

        本文标题:Java 并发之 ReentrantReadWriteLock

        本文链接:https://www.haomeiwen.com/subject/fupqaltx.html