轻松理解java读写锁ReentrantReadWriteLock的底层原理

1.读写锁的介绍

在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java提供的关键字synchronized或者concurrents包中实现了Lock接口的ReentrantLock。它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。
针对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。读写所允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。在分析WirteLock和ReadLock的互斥性时可以按照WriteLock与WriteLock之间,WriteLock与ReadLock之间以及ReadLock与ReadLock之间进行分析。更多关于读写锁特性介绍大家可以看源码上的介绍(阅读源码时最好的一种学习方式,我也正在学习中,与大家共勉),这里做一个归纳总结:

  1. 公平性选择:支持非公平性(默认)和公平的锁获取方式,吞吐量还是非公平优于公平;
  2. 重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁;
  3. 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁

要想能够彻底的理解读写锁必须能够理解这样几个问题:1. 读写锁是怎样实现分别记录读写状态的?2. 写锁是怎样获取和释放的?3.读锁是怎样获取和释放的?我们带着这样的三个问题,再去了解下读写锁。
先从写锁的加锁过程说起:

WriteLock.lock():

WriteLock.lock()
根据加锁的流程图,配合源码,我们一步一步的分析写锁的加锁流程:
WriteLock.lock():

        public void lock() {
            sync.acquire(1);
        }


可以看到,lock()方法中调用的是AQS抽象类的acquire()方法,该方法用来获取独占锁;
点进该方法:

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

从中可以看到获取独占锁的主要流程可以分为三步:

  1. tryAcquire()方法:用来尝试获取锁;该方法是一个protected修饰的方法, 它在AQS中并没有实现,而是抛出异常;主要是供其子类实现该方法来实现不同的独占锁加锁流程;
  2. addWaiter(),顾名思义, 添加阻塞节点; AQS底层是由一个volatile修饰的int型state变量和一个先进先出的阻塞队列来实现的;当某线程获取锁失败时, 就通过addWaiter()方法将该线程包装到一个Node节点并添加到队列的尾部;
  3. acquireQueued()方法就是获取队列中,判断队列中的节点线程是应该唤醒还是阻塞

了解了大致的流程之后,我们接着往下走:
进入tryAcquire()方法:

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

可以看到,AQS中的tryAcquire()方法并没有具体的实现, 在上面我们说过,该方法是由子类实现具体的加锁逻辑的;
因此,我们查看实现了该方法的子类:

在这里插入图片描述
可以看到,ReentrantLock和线程池ThreadPoolExecutor中的Worker类以及本文的主角ReentrantReadWriteLock都实现了该接口, 我们进入ReentrantReadWriteLock中的tryAcquire()方法:

        protected final boolean tryAcquire(int acquires) {

            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c);//获取写锁数量
            //c!=0表示此时有读锁或者写锁被获取
            if (c != 0) {
                //w==0代表此时的读锁数量不为0, 读锁不能升级成为写锁
                if (w == 0 ||
                        //写锁被其他线程占有
                        current != getExclusiveOwnerThread())//锁被其他线程占有
                    //获取锁失败
                    return false;
                //到这说明是重入
                if (w + exclusiveCount(acquires) > MAX_COUNT)//不能超过重入次数最大值(一般不会超过)
                    throw new Error("Maximum lock count exceeded");

                //更新state的值(ReentrantReadWriteLock中state代表锁重入的次数)
                setState(c + acquires);
                return true;
            }
            //到此处说明锁空闲
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            //将当前线程绑定该锁
            setExclusiveOwnerThread(current);
            return true;
        }

从流程图以及源码中我们可以看到, tryAcquire()方法先判断了AQS中的state的值是否为0; 在ReentrantReadWriteLock中, state是一个32位的int类型的值,高16位代表读锁的重入次数,低16位表示写锁的重入次数;
[1]state不等于0时,说明有读锁或者写锁已经被其他线程占有;此时进入到[3],[3]当读锁已经被占有或者写锁被其他线程占有时,返回false, 即获取锁失败; 从这里就能看出来了, 读锁是不能升级成为写锁的;当写锁是被当前的线程占有时,获取写锁成功(重入);
state等于0时,进入到[2]writerShouldBlock(),writerShouldBlock()方法对应公平锁和非公平锁有不同的处理逻辑;
我们先点进公平锁的逻辑:

        final boolean writerShouldBlock() {
            return hasQueuedPredecessors();
        }
    public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

[4]hasQueuedPredecessors()从方法名称就能大致猜出来其具体的思路了, 这里判断阻塞队列中是否有节点,如果有节点在阻塞队列中等待(hasQueuedPredecessors()返回true), 那么tryAcquire()方法直接返回false(即获取锁失败),然后调用AQS中的addWaiter()方法将当前线程加入队尾;
接着看非公平锁:

        final boolean writerShouldBlock() {
            return false; // writers can always barge
        }

非公平锁直接返回false, 然后会执行compareAndSetState(c, c + acquires)方法通过CAS获取锁;
从以上两点就能看出ReentrantReadWriteLock中公平锁和非公平锁的区别了; 在公平锁中,如果队列中有节点在等待,那么,必须要将当前线程加入到队列的尾部中,等待它前面的节点唤醒完成才会被唤醒(即先进先出, 谁先到,谁先被唤醒); 而非公平锁则不用管队列中到底有没有线程节点在等待, 它可以直接通过CAS尝试获取锁, 即执行一个抢占的操作; 也就是说,它不需要添加到队列中也有机会获得锁;这样就有可能导致队列中的线程节点永远都获取不到锁,有可能都被队列外新到来的线程给抢走; 这样当然就不公平了.

既然不公平, 那么ReentrantReadWriteLock为什么默认采用的是非公平锁的策略呢?

那当然是因为它效率高了. 从上面也可以看出公平锁中每个新到来的线程都需要添加到队列中, 而非公平锁不需要每次都添加到队列中, 这就省掉了一部分添加到队列,阻塞线程,唤醒线程等一系列的操作, 随之而来的效率当然也是更高了.

接着,我们进入[5]addWaiter()方法:

    //将获取锁失败的线程加入队列中
    private Node addWaiter(Node mode) {
        //创建一个新的节点,封装有当前的线程实例
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;//队尾节点
        if (pred != null) {
            node.prev = pred;
            //采用CAS将当前节点设为队尾节点
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
        }


    //采用CAS将当前线程节点插入队列中
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;//获取队尾
            if (t == null) { // Must initialize
                //队列为空时初始化队列
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    }

addWaiter()方法是AQS已经实现好的方法, 它采用CAS的方式将当前线程的节点添加到队列尾部;若队列为空,则初始化一个空节点为队列的头结点,然后再将当前线程节点采用CAS的方式添加到队尾;
添加到队列之后, 进入acquireQueued()方法:

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();//获取当前节点的前驱节点
                //正常情况:前驱节点为首节点而且获取到了前驱节点释放的锁
                if (p == head &&    // 如果前驱为head才有资格抢占锁
                        tryAcquire(arg)) {//尝试获取锁
                    //获取锁成功时,会将当前节点设为首节点
                    setHead(node);//将当前节点设为队列的首节点
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }

                //到这里,说明获取锁失败了
                //如果获取锁失败,则根据节点的waitStatus决定是否需要挂起线程
                if (shouldParkAfterFailedAcquire(p, node) &&//是否要阻塞当前线程
                    parkAndCheckInterrupt())//阻塞当前线程
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

从acquireQueued()方法的代码中可以看到, 只有前置节点为首节点的节点(即队列中的第二个节点)才有机会调用tryAcquire()方法尝试获取锁, 当其获取锁成功时,会将原先的首节点移出队列, 并将该获取到锁的节点置为首节点.; 如果获取锁失败, 则再通过shouldParkAfterFailedAcquire(p, node)方法判断是否应该阻塞该线程;
自此,写锁的加锁流程就结束了;

接着看写锁释放锁的流程:

        public void unlock() {
            sync.release(1);
        }

    public final boolean release(int arg) {

        if (tryRelease(arg)) {//释放锁(state-1),若释放后锁可被其他线程获取(state=0),返回true

            Node h = head;
            //当前队列不为空且头结点状态不为初始化状态(0)
            if (h != null && h.waitStatus != 0)
                //唤醒同步队列中后续被阻塞的线程
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

AQS中的release()方法就是用来释放独占锁的;tryRelease()和tryAcquire()方法类似,也是由子类实现;因此我们直接点进ReentrantReadWriteLock中的tryRelease()方法:

        protected final boolean tryRelease(int releases) {
            if (!isHeldExclusively())//判断当前锁是否是当前线程持有
                throw new IllegalMonitorStateException();
            int nextc = getState() - releases;
            boolean free = exclusiveCount(nextc) == 0;
            if (free)//重入次数为0时释放锁
                setExclusiveOwnerThread(null);
            //更新state的值
            setState(nextc);
            return free;
        }

可以看出来,释放写锁的流程就比较简单了, 当写锁的重入次数(state的低16位)为0时,释放写锁;
释放写锁后,回到release()方法中的 unparkSuccessor(h)方法,该方法用来唤醒队列中的一个节点(独占锁只会唤醒一个节点);

    private void unparkSuccessor(Node node) {
        //如果状态为负(即,可能需要信号)尝试在预期的信令中清除。如果这失败或者如果状态通过等待线程而改变,则是OK。
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        Node s = node.next;
        //如果下一个节点不存在或者已经取消等待,那么从尾节点向前遍历,找到离当前节点最近的而且在等待中的节点
        //疑问:为什么从队尾遍历不从当前节点开始遍历?
        //答:因为当前节点的下一个节点可能为空,往下遍历不了
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }

        //唤醒离当前节点最近的且在等待中的节点
        if (s != null)
            LockSupport.unpark(s.thread);
    }

可以看出,unparkSuccessor()方法先看队列中的第二个节点是否处于等待状态,如果处于等待状态则直接唤醒;如果不是等待状态,则通过队尾节点往前遍历, 获取最靠近首节点的处于等待状态的节点; 概括起来说就是唤醒离首节点最近的处于等待状态的线程节点;
自此,解锁流程结束

读锁(ReadLock)的加锁流程

        public void lock() {
            sync.acquireShared(1);
        }

AQS中的acquireShared()方法就是获取共享锁的方法

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)//获取共享锁失败
            doAcquireShared(arg);
    }

有了以上的经验,咱们直奔主题, 直接看ReentrantReadWriteLock中的tryAcquireShared()方法:

        //获取共享锁
        protected final int tryAcquireShared(int unused) {
            /*
             *  演练:1.如果写锁由另一个线程持有,失败。
             * 2.否则,此线程适合锁定wrt状态,因此请询问是否应该由于队列策略而阻止。如果没有,尝试通过CASing状态和更新计数授予。
             * 注意,步骤不检查可重入获取,其被推迟到完整版本,以避免在更典型的不可重入的情况下检查保持计数。
             * 3.如果步骤2失败,或者因为线程显然不合格,或者CAS失败或计数饱和,则使用完全重试循环链接到版本。
             *
             */
            Thread current = Thread.currentThread();
            int c = getState();
			// 1
            if (exclusiveCount(c) != 0 //写锁已被获取
                    && getExclusiveOwnerThread() != current)//不是当前线程获取的写锁
                // 获取锁失败
                return -1;
            //到这里说明写锁未被获取或者是当前线程获取的写锁
            int r = sharedCount(c);// c >>> 16  高16位记录的是读锁的重入次数

            // !readerShouldBlock() 根据公平锁和非公平锁的策略来判断是否要阻塞线程;公平锁和写锁一样,如果队列中有节点,那么必须要排队;
            //但是非公平锁和写锁不太一样;写锁直接返回false,即表示不管队列中有哪些节点,队列外的线程都可以抢占写锁;而读锁的非公平锁还是有一定的公平性的;
            //即当队列中首个即将要唤醒的节点是请求写锁的时候,队列外的线程不能抢占锁;如果请求的是读锁,则可以抢占
            if (!readerShouldBlock()
                    //CAS增加读锁的数量
                    && r < 65535 && compareAndSetState(c, c + 65536)) {
                // 如果读锁是空闲的, 获取锁成功(非重入)。
                if (r == 0) {
                    // 将当前线程设置为第一个读锁线程
                    firstReader = current;
                    // 计数器为1
                    firstReaderHoldCount = 1;

                }// 如果读锁不是空闲的,且第一个读线程是当前线程。获取锁成功(重入)。
                else if (firstReader == current) {
                    // 将计数器加一
                    firstReaderHoldCount++;
                    // 如果不是第一个线程,获取锁成功(共享锁)。
                } else {
                    // cachedHoldCounter 代表的是最后一个获取读锁的线程的计数器。
                    HoldCounter rh = cachedHoldCounter;
                    // 如果最后一个线程计数器是 null 或者不是当前线程,那么就新建一个 HoldCounter 对象
                    if (rh == null || rh.tid != getThreadId(current))
                        // 给当前线程新建一个 HoldCounter
                        cachedHoldCounter = rh = readHolds.get();
                        // 如果不是 null,且 count 是 0,就将上个线程的 HoldCounter 覆盖本地的。
                    else if (rh.count == 0)
                        readHolds.set(rh);
                    // 对 count 加一
                    rh.count++;
                }
                return 1;
            }
            // 死循环获取读锁。包含锁降级策略。
            return fullTryAcquireShared(current);
        }

读锁是共享锁,共享锁就是多个线程可以获取到同一把锁, 而且读锁也支持锁降级策略,即获取了写锁的线程可以再继续获取到读锁;
从代码中也可以看到,在第1步,如果写锁不是被当前线程获取的,那么直接获取失败;只有当写锁是被当前线程获取的时候代码才能继续往下走;
readerShouldBlock(), 读锁的公平锁和非公平锁的策略;我们来看看和写锁的策略有什么不一样

    static final class FairSync extends Sync {
        private static final long serialVersionUID = -2274990926593161451L;
        final boolean writerShouldBlock() {
            return hasQueuedPredecessors();
        }
        final boolean readerShouldBlock() {
            return hasQueuedPredecessors();
        }
    }

可以看到,读锁和写锁的公平锁的策略是一样的,都是直接将线程添加到阻塞队列中
看看读锁的非公平锁:

        final boolean writerShouldBlock() {
            return false; // writers can always barge
        }
        final boolean readerShouldBlock() {
            return apparentlyFirstQueuedIsExclusive();
        }

    final boolean apparentlyFirstQueuedIsExclusive() {
        Node h, s;
        /*判断队列中的下一个要唤醒的节点(即头结点的后置节点)是不是在请求写锁,
        如果请求写锁则返回true(返回true表示要阻塞在队列外的要抢占锁的线程)
        从这里可以看出,读锁中的非公平锁是有限制的,即它只能抢占队列中想请求读锁的节点,
        不能抢占请求写锁的节点;这样是为了防止写锁饥饿,无法获取到写锁*/
        return (h = head) != null &&
            (s = h.next)  != null &&
            !s.isShared()         &&
            s.thread != null;
    }

从代码中可以看出,读锁和写锁的非公平策略是不一样的. 写锁不管队列中有没有节点都可以抢占锁;而读锁是只有当队列中的第二个节点请求的是读锁的时候才能抢占,也就是说,当队列中的第二个节点是想请求写锁的时候,读锁的非公平策略是不能够抢占的;
这是为什么呢?这也是为了防止写锁饥饿,毕竟在实际业务中,读操作的数量要远远多于写操作的数量, 如果每个请求读锁的线程都能抢占,那么请求写锁的线程有可能永远也得不到运行; 不能写数据了, 对业务有多大影响自然也不用多说了. 所以,在读锁中,非公平策略实际上还是有一点公平性的.
再往下就是获取读锁了,目前暂不赘述;
回到acquireShared()方法:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)//获取共享锁失败
            doAcquireShared(arg);
    }

我们看看当获取写锁失败时是怎么处理的:doAcquireShared(arg)

    private void doAcquireShared(int arg) {
        //将获取锁失败的节点添加到队列
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                //获取前置节点
                final Node p = node.predecessor();
                //如果前置节点是头结点, 那么尝试获取共享锁(由此可以看出,阻塞队列中的节点,只有前置节点为头结点时,才有机会抢占锁)
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    //r>=0说明获取共享锁成功
                    if (r >= 0) {
                        //将当前节点设为头结点,并将节点中包装的线程等属性清空,
                        // 由此可以看出,AQS中的头结点中是没有线程数据的,相当于一个空节点
                        setHeadAndPropagate(node, r);
                        //清空原先头结点的next,帮助GC
                        p.next = null; // help GC
                        //判断是否要中断
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        //获取到锁之后才能正常退出死循环
                        return;
                    }
                }
                //到这里说明队列中的节点没有获取到锁,通过死循环阻塞队列中所有未获取到锁的节点
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

可以看到,和获取写锁失败时一样,也是先将线程节点加入到队列的尾部;然后判断是要唤醒线程还是阻塞线程

自此, 读锁加锁的流程结束

锁降级的必要性:

锁降级中读锁的获取是否必要呢?答案是必要的。

  1. 首先, 主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 写锁释放之后可能会被其他的线程获取到.假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程是无法感知线程T的数据更新的。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
  2. 还有一种情况是当某些操作特别的耗时的时候,如果长时间的用写锁独占, 其他线程无法对线程进行读操作,这会大大降低系统的性能;所以,当完成必要的写操作之后,将写锁降级成读锁,可以让其他线程也能进行读操作,这样可以提高系统的吞吐量; 这时有人就会说了,通过缩小锁的粒度, 只将写锁锁住写操作的部分不是更好吗; 是的,缩小锁的粒度的确可以提高并发量; 但是也有些事务操作中,包含了写操作和读操作, 如果只将写操作锁住将会无法保证原子性, 容易引发线程安全问题; 而锁降级之后的代码块依然具备原子性;所以锁降级是很必要的;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值