导读:本期聚焦于小伙伴创作的《如何实现可重入的读写锁?深入理解ReentrantReadWriteLock的持锁逻辑》,敬请观看详情。线程在持有写锁时再次获取写锁却被阻塞,这种反直觉的死锁往往源于对重入机制理解不足。ReentrantReadWriteLock用同步状态的高低位分别记录读写持锁数,写锁以独占加锁计数实现重入,读锁借助ThreadLocal存放重入次数避免相互干扰。本文从AQS状态拆分、获取释放流程与锁降级三个层面拆解其持锁逻辑,并给出易错代码示例,帮助你在自定义同步器时正确复用重入设计。

在Java并发包中,ReentrantReadWriteLock是常见的读写锁实现。它允许同一时刻多个读线程访问共享资源,但写线程必须独占访问。其“可重入”特性意味着同一线程可以多次获取已持有的锁而不会自己阻塞自己。理解它背后的持锁逻辑,对排查死锁、设计自定义同步器都很关键。

如何实现可重入的读写锁?深入理解ReentrantReadWriteLock的持锁逻辑

一、同步状态的高低位拆分

ReentrantReadWriteLock内部基于AbstractQueuedSynchronizer(AQS)实现,但并没有直接使用单一的state整数来表示锁状态,而是把一个int类型的state拆成了两部分:高16位表示读锁的持有数量,低16位表示写锁的持有数量。这种设计让读写状态可以共存于同一个原子变量中,减少了额外的字段开销。

具体来说,写锁每次获取成功,低16位加一;释放时低16位减一。读锁则稍复杂,高16位记录所有线程持有的读锁总数,而每个线程自身的重入次数保存在ThreadLocal中(具体为HoldCounter结构)。这样即使多个读线程同时重入,也不会互相覆盖计数。

// 状态拆分示意(来自ReentrantReadWriteLock源码逻辑)
static final int SHARED_SHIFT   = 16;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0xFFFF
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; } // 读锁数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; } // 写锁数

二、写锁的持锁与重入逻辑

写锁是独占锁。当一个线程调用writeLock().lock()时,AQS会进入tryAcquire方法。该方法先读取当前state,若读锁计数不为0,说明有线程正在读,写线程必须等待;若写锁计数不为0,再判断当前线程是否为持锁线程,若是则重入,低16位加一并返回成功。

如果当前没有写锁也没有读锁,或者当前线程就是持有写锁的线程,那么通过CAS设置state的低16位。重入次数理论上受限于低16位的最大值(65535),超出会抛出Error。释放写锁时,tryRelease将低16位减一,直到为0才彻底释放,此时会唤醒后续等待节点。

// 写锁重入获取的核心片段(简化)
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) > 65535)
            throw new Error("Maximum lock count exceeded");
    }
    if (!compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

三、读锁的持锁与重入逻辑

读锁是共享锁。线程调用readLock().lock()时进入tryAcquireShared。若写锁被其他线程持有,则获取失败;若当前线程持有写锁,也可以获取读锁,这就是“锁降级”的基础。读锁重入时,利用ThreadLocalHoldCounter记录每个线程的重入次数,同时高16位总读计数加一。

释放读锁时,先减少ThreadLocal中的重入计数,再减少高16位总读计数。只有当总读计数为0时,才完全释放读锁。由于读锁是共享模式,AQS会以传播方式唤醒后续连续的读节点,提升并发吞吐。

// 读锁重入获取简化逻辑
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);
    if (r < 65535 && compareAndSetState(c, c + (1 << 16))) {
        // 首次获取读锁,记录HoldCounter
        return 1;
    }
    return fullTryAcquireShared(current); // 处理重入与CAS重试
}

四、锁降级与常见误区

ReentrantReadWriteLock支持锁降级:持有写锁的线程可以获取读锁,然后释放写锁,从而把独占访问降级为共享访问。这能防止在修改完数据后、准备读取结果时,被其他写线程插空修改。但需要注意的是,锁升级(先读后写)是不支持的,如果线程先拿读锁再尝试拿写锁,会直接死锁。

一个常见误区是认为读锁和写锁完全独立。实际上,由于state共用,写锁重入时若中途获取读锁,读锁计数也会变化;释放顺序错误可能导致写锁提前释放。因此在编码时应明确“谁持有、谁释放、按相反顺序释放”的原则。

// 正确的锁降级示例
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.writeLock().lock();
try {
    // 修改数据
    rw.readLock().lock();  // 降级:获取读锁
} finally {
    rw.writeLock().unlock(); // 释放写锁,仍持有读锁
}
// 后续可读数据
rw.readLock().unlock();

五、自定义可重入读写锁的要点

如果你想基于AQS自己实现一个类似的可重入读写锁,核心就是复用“高低位拆分state”的思路,并分别为独占模式与共享模式实现tryAcquire/tryRelease和tryAcquireShared/tryReleaseShared。重入判断都依赖“当前线程是否等于owner线程”或“ThreadLocal计数是否大于0”。

此外,要避免在写锁临界区内调用可能再获取写锁的外部方法,否则虽不会死锁但会增加重入层数,难以维护。读锁重入则需注意ThreadLocal的内存泄漏问题,应在释放时清理计数器。理解ReentrantReadWriteLock的持锁逻辑,能让你在并发设计中少走很多弯路。

锁类型模式重入记录位置释放条件
写锁独占state低16位+owner线程低16位归零
读锁共享state高16位+ThreadLocal高16位归零

ReentrantReadWriteLock可重入锁读写锁修改时间:2026-08-07 20:57:32

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。