java并发编程(七)ReentrantReadWriteLock

一、写锁加锁解锁源码分析

从宏观上看,ReentrantReadWriteLock (RRWL) 的写锁与 ReentrantLock (RL) 的逻辑非常相似 :它们都是独占锁 、都支持可重入 、都依赖 AQS(AbstractQueuedSynchronizer)实现。具体的实现源码分析可以看ReentrantLock那一篇。

但在底层实现细节和互斥规则 上,两者存在关键区别。主要区别集中在状态位管理对读锁的感知上。

以下是详细的对比分析:

1. 核心状态管理(State 的拆分)

这是两者最大的技术差异。AQS 内部维护了一个 int state(32位)来表示锁的状态。

  • ReentrantLock (RL)

    • state 直接表示锁的重入计数。

    • 0:未加锁。

    • n > 0:被某个线程持有并重入了 n 次。

  • ReentrantReadWriteLock (RRWL)

    • 由于一个 state 既要记录读锁又要记录写锁,它采取了**"位拆分"**:

    • 高 16 位 :记录读锁的状态(共享锁计数)。

    • 低 16 位 :记录写锁的状态(独占锁计数)。

    • 逻辑差异:写锁加锁时,需要通过 state & 0x0000FFFF(位掩码)来提取低 16 位,判断写锁的重入次数。


2. 加锁逻辑对比 (Acquire)

ReentrantLock 的加锁逻辑:
  1. 检查 state 是否为 0。

  2. 如果是 0,尝试 CAS 修改为 1。

  3. 如果不是 0,检查当前线程是否是持有锁的线程(实现可重入)。

  4. 否则,进入等待队列。

ReentrantReadWriteLock 写锁的加锁逻辑:

写锁的加锁更加苛刻,它不仅要看有没有别的写锁,还要看读锁

  1. 获取当前 state。

  2. 如果 state != 0:

    • 情况 A:写锁计数为 0,但 state 不为 0 。这意味着当前有读锁 被持有。此时写锁加锁失败(写锁被读锁阻塞)。

    • 情况 B:写锁计数不为 0 。检查持有写锁的是否是当前线程。如果是,则低 16 位计数递增(可重入);如果不是,加锁失败(写锁被其他写锁阻塞)。

  3. 如果 state == 0:

    • 判断是否需要阻塞(受公平策略影响)。

    • 尝试 CAS 修改低 16 位加锁。

总结差异点 :ReentrantLock 只关心"有没有人拿着锁";ReentrantReadWriteLock 的写锁必须确保"既没有人在读,也没有人在写(除了自己)"。


3. 解锁逻辑对比 (Release)

两者的解锁逻辑基本一致

  1. 检查当前线程是否是锁的持有者。

  2. 对 state(或写锁状态位)进行减法操作。

  3. 如果减完之后计数为 0,则彻底释放锁,将锁持有者设为 null,并唤醒 AQS 队列中的下一个线程。

微小区别:RRWL 在解锁时同样需要位运算来只操作低 16 位,而 RL 是对整个 int 操作。

二、读锁加锁源码

1. 状态位拆分

RRWL 使用 AQS 的 int state 来同时表示读锁和写锁。它将 32 位的 int 拆分为两部分:

  • 高 16 位 :表示读锁(共享锁)持有的次数。

  • 低 16 位 :表示写锁(独占锁)重入的次数。

java 复制代码
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT); // 0x00010000
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF

// 获取读锁数:无符号右移16位
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
// 获取写锁数:位与掩码
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

2.加锁入口

java 复制代码
    public void lock() {
            sync.acquireShared(1);
        }

3.acquireShared

java 复制代码
    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }

4.tryAcquireShared

java 复制代码
        protected final int tryAcquireShared(int unused) {

            Thread current = Thread.currentThread();
            int c = getState();
//写锁不为0,当前线程没有写锁,不能读
            if (exclusiveCount(c) != 0 &&
                getExclusiveOwnerThread() != current)
                return -1;
            int r = sharedCount(c);
//第一个不是写锁节点,读锁没有到最大值,高位+1成功,
            if (!readerShouldBlock() &&
                r < MAX_COUNT &&
                compareAndSetState(c, c + SHARED_UNIT)) {
//第一个读锁
                if (r == 0) {
                    firstReader = current;
                    firstReaderHoldCount = 1;
//读锁重入
                } else if (firstReader == current) {
                    firstReaderHoldCount++;
                } else {
//新来的线程不是第一个读锁的,获取线程缓存计数器
                    HoldCounter rh = cachedHoldCounter;
//如果不是自己的或者是空,去找自己的ThreadLocal,更新缓存
                    if (rh == null || rh.tid != getThreadId(current))
                        cachedHoldCounter = rh = readHolds.get();
                    else if (rh.count == 0)
//如果是自己的,count为0,为了防止 ThreadLocal 导致内存泄漏,当一个线程的 count 减到 0 时,JDK 会主动调用 readHolds.remove(),所以需要更新本地的映射关系
                        readHolds.set(rh);
//最后缓存计数器的count+1
                    rh.count++;
                }

                return 1;
            }
//如果第一个是写锁,执行fullTryAcquireShared
            return fullTryAcquireShared(current);
        }

5.readerShouldBlock()

java 复制代码
final boolean readerShouldBlock() {
//第一个等待节点是写锁,返回true
            return apparentlyFirstQueuedIsExclusive();
        }
    }

final boolean apparentlyFirstQueuedIsExclusive() {
    Node h, s;
    return (h = head) != null &&    // 1. 队列头节点不为空(说明队列已初始化)
        (s = h.next)  != null &&    // 2. 存在第一个等待中的节点(head.next)
        !s.isShared()         &&    // 3. 该节点不是共享模式(即:它是独占模式,想加写锁)
        s.thread != null;           // 4. 该节点的线程不为空(确保该节点有效)
}

6.fullTryAcquireShared

java 复制代码
        final int fullTryAcquireShared(Thread current) {

            HoldCounter rh = null;
            for (;;) {
                int c = getState();

                if (exclusiveCount(c) != 0) {
//有人持有写锁并且不是自己的,不能抢读锁(如果是自己的就直接去抢锁,这叫锁降级)
                    if (getExclusiveOwnerThread() != current)
                        return -1;
//第一个是写节点,需要阻塞,但需要确保读锁是不是重入的
                } else if (readerShouldBlock()) {
            
                    if (firstReader == current) {
               // 你是第一个获取到读锁的,你肯定持有锁,这是重入,重入大于一切,(具体原因看下面讲解)【不能阻塞】,直接跳过这个 if 去抢锁
                    } else {
//不是第一个,也要判断一下你有没有获取过读锁
                        if (rh == null) {
                            rh = cachedHoldCounter;
                            if (rh == null || rh.tid != getThreadId(current)) {
                                rh = readHolds.get();
                                if (rh.count == 0)
                                    readHolds.remove();
                            }
                        }
//获取count,确定是个新人,不是重入锁,阻塞
                        if (rh.count == 0)
                            return -1;
                    }
                }

//到达这里的都是可以抢锁的,包括锁降级和锁重入
                if (sharedCount(c) == MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
//state改变成功
                if (compareAndSetState(c, c + SHARED_UNIT)) {
//抢到锁之后就是和之前的逻辑一样了
                    if (sharedCount(c) == 0) {
                        firstReader = current;
                        firstReaderHoldCount = 1;
                    } else if (firstReader == current) {
                        firstReaderHoldCount++;
                    } else {
                        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; // cache for release
                    }
                    return 1;
                }
            }
        }

为什么重入大于一切?

我们来看一个死锁发生的经典剧本:

  1. 背景:线程 A 已经持有了读锁(Read Lock)。

  2. 冲突:线程 B 请求写锁(Write Lock)。由于线程 A 没放手,线程 B 进入 AQS 队列排队,并成为了队列的头节点(Head.next)。

  3. 策略生效:此时,读写锁的策略(readerShouldBlock())为了不让线程 B 饿死,会返回 true,意思是有写者在排队,后续读者请自觉去排队。

  4. 致命重入:此时线程 A 在执行业务逻辑时,又调用了一次 lock() 请求重入。

  5. 假如"策略大于重入"

    • 线程 A 看到策略说"请排队",于是线程 A 跑去队列里排在线程 B 后面。

    • 现在的局面

      • 线程 A 在等 线程 B(因为它在队列里排在 B 后面)。

      • 线程 B 在等 线程 A(因为它要拿写锁,必须等 A 释放掉手中已有的读锁)。

    • 结局死锁。系统所有功能卡死,只能重启。

所以为了避免死锁出现,重入优先,对于已经

7.tryAcquireShared返回

能抢到锁就返回1,不能抢锁就返回-1

8.没抢到锁就执行doAcquireShared,直到获取到锁

java 复制代码
    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
java 复制代码
    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);

                    if (r >= 0) {
//更新头结点,A 拿到了读锁,设置自己为 head 之后,会检查后面的人是不是也在等读锁。如果是,顺便把后面的人也叫醒。
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

三、读锁解锁源码分析

1.解锁入口

java 复制代码
        public void unlock() {
            sync.releaseShared(1);
        }

2.releaseShared

java 复制代码
    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

3.tryReleaseShared

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))
                    rh = readHolds.get();
                int count = rh.count;
//count=1,直接清理ThreadLocal
                if (count <= 1) {
                    readHolds.remove();
                    if (count <= 0)
                        throw unmatchedUnlockException();
                }
//count大于1
                --rh.count;
            }

            for (;;) {
                int c = getState();
                int nextc = c - SHARED_UNIT;
                if (compareAndSetState(c, nextc))
              
                    return nextc == 0;
            }
        }

tryReleaseShared会返回释放锁之后state是不是0,如果是,就返回true,不是就返回false。

4.releaseShared

java 复制代码
    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

tryReleaseShared如果返回true,证明现在读锁锁完全空闲,执行 doReleaseShared();唤醒其他等待节点。

为什么是返回state,因为你既然去释放的是读锁,证明肯定没有写锁,所以你只需要判断读锁有没有完全释放,只有读锁完全释放了,state才能是0,才能去唤醒队列里的写锁。

java 复制代码
private void doReleaseShared() {

        for (;;) {

            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
//有等待节点,唤醒后续节点
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            
                    unparkSuccessor(h);
                }
//它是一个特殊的"传播"状态。设置这个状态,相当于留下一个标记:"我是一个共享锁的释放操作,请确保这个唤醒信号能继续向后传播下去!"(下面有具体解释)
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;               
            }
//队列状态没变,结束循环
            if (h == head)                   
                break;
        }
    }
  • ws == 0 的场景:当一个节点刚刚成为 head 时,它的 waitStatus 可能是 0。此时,如果一个释放操作和一个加锁操作(新线程入队)同时发生,可能会导致唤醒信号"断链"。

  • PROPAGATE (-3) 的作用 :它是一个特殊的"传播"状态。设置这个状态,相当于留下一个标记:"我是一个共享锁的释放操作,请确保这个唤醒信号能继续向后传播下去!"

  • 为什么需要它?

    1. 线程 A 释放最后一个读锁,开始执行 doReleaseShared。

    2. 它唤醒了线程 B(读者)。线程 B 成功拿到锁,并成为新的 head。

    3. 在 B 成为 head 的一瞬间,线程 C(读者)也来排队,挂在了 B 后面。

    4. 此时,线程 A 的 doReleaseShared 可能还没结束。它看到新的 head(即 B)的 waitStatus 是 0,如果不做任何事,唤醒链就可能到此为止,C 就醒不过来了。

    5. 通过 compareAndSetWaitStatus(h, 0, Node.PROPAGATE),线程 A 强制给新的 head(B)打上了一个"请继续传播"的标记。这样,当 B 拿到锁后,它看到 head 状态是 PROPAGATE,就知道自己有责任继续唤醒 C。

java 复制代码
    private void unparkSuccessor(Node node) {
        
        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);
    }

5.releaseShared返回

java 复制代码
    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

只有state为0,证明已经完全释放读锁,并且唤醒后续节点,releaseShared返回true.

如果没有完全释放锁,就返回false.

四、读锁的加锁解锁总结

加锁步骤

第 1 阶段:快速路径 (tryAcquireShared)
  • 判断是否有写锁:
  1. 是,且不是我:失败,转入第 2 阶段。

  2. 是,且是我(锁降级):通过,继续。

  3. :通过,继续。

  • 判断是否需要阻塞(readerShouldBlock())

上一步判断了没有写锁,为什么阻塞的时候要再判断写锁在队首?

因为如果不判断直接加锁,那么在队首的写者就会一直等待,导致写饥饿

  1. (公平锁有人排队 / 非公平锁写者在队首):失败,转入第 2 阶段。

  2. :通过,继续。

  • CAS 尝试
  1. 成功加锁成功! 流程结束。

  2. 失败(有竞争):转入第 2 阶段。

第 2 阶段:完整路径 (fullTryAcquireShared)
  1. 死循环开始

  2. 写锁冲突检查:同第 1 阶段,有写锁且不是自己,则返回 -1 去排队。

  3. 策略与死锁检查(核心):再次询问 readerShouldBlock()。

    • 如果返回 false(无需阻塞):跳过此检查,直接去 CAS。

    • 如果返回 true (建议阻塞):必须进行"身份政审"以防死锁

      • 你是 firstReader 吗?

        • 无视阻塞建议 ,跳过此检查,去 CAS。(重入 > 一切
      • 你不是 firstReader,查你的账本 HoldCounter

        • count > 0 :你也是"重入",无视阻塞建议,去 CAS。

        • count == 0 :你是"新客户",必须遵守规则,返回 -1 去排队

  4. CAS 尝试 :同第 1 阶段,如果 CAS 成功,则加锁成功并返回。如果失败,则继续死循环,从头再来。

第 3 阶段:排队等待 (doAcquireShared)

如果第 2 阶段返回了 -1,说明必须排队。

  1. 入队:将自己打包成 SHARED 节点,挂到队尾。

  2. 自旋等待

    • 检查位置:我的前面是 head 吗?

      • :说明轮到我了,再次尝试 tryAcquireShared。

        • 成功 :将自己设为新 head,并传播唤醒 后面的读者(setHeadAndPropagate)。加锁成功!

        • 失败:继续下面的步骤。

      • :继续下面的步骤。

    • 准备睡觉:检查并设置前驱节点的状态为 SIGNAL。

    • 执行睡觉:调用 park() 挂起线程,等待被唤醒。

解锁步骤

第 1 阶段:读者自己的结算 (tryReleaseShared - 前半部分)
  1. 身份判定:我是 firstReader 吗?

    • :操作 firstReaderHoldCount 成员变量。

      • count == 1:说明是最后一层锁,将 firstReader 设为 null。

      • count > 1:只是重入,count--。

    • :说明我是其他读者,操作 ThreadLocal 里的 HoldCounter。

      • count <= 1 :说明即将彻底释放,必须调用 readHolds.remove() 防止内存泄漏

      • count > 1:只是重入,count--。

      • count <= 0:非法操作,抛出异常。

第 2 阶段:全局状态state更新与信号判断 (tryReleaseShared - 后半部分)
  1. 自旋 CAS:进入死循环,确保能成功更新全局 state。

  2. 计算新状态:nextc = c - SHARED_UNIT。

  3. 执行 CAS:compareAndSetState(c, nextc)。

  4. 发出信号(核心):CAS 成功后,执行 return nextc == 0;。

    • 返回 false :nextc 不为 0,说明还有其他读者。AQS 收到后什么也不做

    • 返回 true :nextc 为 0,说明我是最后一个读者 。这是一个质变信号。AQS 收到后会执行 doReleaseShared,去唤醒队列里等待的线程。

相关推荐
lang2015092815 小时前
Java并发革命:JSR-133深度解析
java·开发语言
禹凕15 小时前
Python编程——进阶知识(面向对象编程OOP)
开发语言·python
abluckyboy15 小时前
基于 Java Socket 实现多人聊天室系统(附完整源码)
java·开发语言
Re.不晚15 小时前
JAVA进阶之路——数据结构之线性表(顺序表、链表)
java·数据结构·链表
毅炼15 小时前
Java 基础常见问题总结(3)
java·开发语言
m0_7482299915 小时前
PHP简易聊天室开发指南
开发语言·php
码云数智-大飞15 小时前
从回调地狱到Promise:JavaScript异步编程的演进之路
开发语言·javascript·ecmascript
froginwe1115 小时前
jQuery 隐藏/显示
开发语言
一晌小贪欢15 小时前
深入理解 Python HTTP 请求:从基础到高级实战指南
开发语言·网络·python·网络协议·http