读写锁模式 Read-Write Lock

读写锁模式 Read-Write Lock

Read-Write Lock(读写锁模式):允许多个线程同时读取共享资源,但在进行写入操作时必须独占资源,不再允许其他线程读或写。

⨳ 共享读(Shared Read):读取操作可以同时被多个线程执行,因为读取操作不修改共享资源的状态,所以可以并行进行。

⨳ 独占写(Exclusive Write):写入操作则必须在没有其他线程正在读取或写入时进行,以确保写入操作的原子性和一致性。

读写锁模式就是在读取操作和写入操作之间进行区分,想想也是,当线程"读取"实例的状态时,实例的状态不会发生变化。实例的状态仅在线程执行"写入"操作时才会发生变化。从实例的状态变化这个观点来看,"读取"和"写入"有着本质的区别。

基本实现

读写锁 ReadWriteLock

读写锁该怎么实现呢?如果以守护条件的角度:

⨳ 读锁的获取 :如有线程正在执行写入,则 wait 等待,也就是说,读锁获取的守护条件是无正在写入的线程。

⨳ 写锁的获取 :如果有线程正在写入,则 wait 等待,如果有线程正在读取,也要wait 等待,也就是说,写锁获取的守护条件是无正在写入和读取的线程。

⨳ 读锁的释放:读操作执行完了即可释放,可以唤醒正在等待获取写锁的线程。

⨳ 写锁的释放:写操作执行完了即可释放,可以唤醒正在等待获取写锁和读锁的线程。

有了守护条件,就可以根据其设置成受保护资源,然后对其的操作加 synchronized 锁保护了:

java 复制代码
public class ReadWriteLock {

    private int readings = 0; // 正在读的线程数
    private int writings = 0; // 正在写的线程数

    // 获取读锁
    public synchronized void readLock() throws InterruptedException {
        while(writings>0){
            wait();
        }
        readings++;
    }

    // 写锁的获取
    public synchronized void writeLock() throws InterruptedException {
        while (readings>0 || writings>0){
            wait();
        }
        writings++;
    }

    // 释放读锁
    public synchronized void readUnLock(){
        readings--;
        notifyAll();
    }

    // 释放写锁
    public synchronized void writeUnLock(){
        writings--;
        notifyAll();
    }
}

当然这个实现很简陋,比如读锁是不支持重入的,同一线程持有读锁后再调 writeLock() 会自死锁(自己等自己释放)。

受保护对象 GuardedObject

用读写锁代替排他锁保护受保护资源:

java 复制代码
public class GuardedObject {

    // 被守护的数字
    private int guardedNum=0;

    private ReadWriteLock lock = new ReadWriteLock();

    // 读锁保护 对 guardedNum 的读取
    public int getGuardedNum() throws InterruptedException {
        lock.readLock(); // 获取读锁
        try{
            Thread.sleep(50); // 模拟读操作
            System.out.println(Thread.currentThread().getName()+"正在读取:"+guardedNum);
            return guardedNum;
        }finally {
            lock.readUnLock(); // 释放读锁
        }
    }

    // 写锁保护 对 guardedNum 的更改
    public int inc() throws InterruptedException {
        lock.writeLock(); // 获取读锁
        try{
            Thread.sleep(70); // 模拟读操作
            System.out.println(Thread.currentThread().getName()+"正在更改:"+guardedNum);
            guardedNum++;
            System.out.println(Thread.currentThread().getName()+"更改后:"+guardedNum);
            return guardedNum;
        }finally {
            lock.writeUnLock(); // 释放读锁
        }
    }


}

注意,上述代码受保护资源 guardedNum,由一把 锁保护(ReadWriteLock)。

如多线程同时调用 getGuardedNum 方法:

⨳ 获取读锁 lock.readLock() 操作是由排他锁 synchronized 保护,所以同一时刻只有一个线程进入readLock() 方法。

⨳ 获取读锁后续业务逻辑操作,不受任何任何排他锁 synchronized 保护,也就是说理论上,获取读锁后续操作允许多个线程同时执行,但这多个线程只有在执行完获取读锁操作后,才能继续执行,获取读锁会将不符合守护条件(无正在写入的线程)的线程挡在了外边。

如多线程同时调用 inc() 递增方法:

⨳ 获取写锁 lock.writeLock() 操作同样由排他锁 synchronized 保护。

⨳ 获取写锁后续业务逻辑操作,也不受任何任何排他锁 synchronized 保护,也允许多个线程同时执行,只是获取写锁的线程如不符合写锁的保护条件(无正在写入和读取的线程),同样被阻塞在获取写锁的时刻。

注意,无论是使用读锁保护资源还是写锁保护资源,都需要合理的释放锁。

客户端 CLient

下面使用三个读线程,两个写线程同时访问 GuardedObject 中的受保护资源:

java 复制代码
    final Random random = new Random();
    final GuardedObject guardedObject = new  GuardedObject();
    new Thread(()->{
        while (true){
            try {
                Thread.sleep(random.nextInt(3000));
                guardedObject.inc();
            } catch (InterruptedException e) {
                break;
            }
        }

    },"递增线程A").start();

    new Thread(()->{
        while (true){
            try {
                Thread.sleep(random.nextInt(3000));
                guardedObject.inc();
            } catch (InterruptedException e) {
                break;
            }
        }

    },"递增线程B").start();

    new Thread(()->{
        while (true){
            try {
                Thread.sleep(random.nextInt(3000));
                guardedObject.getGuardedNum();
            } catch (InterruptedException e) {
                break;
            }
        }

    },"读取线程A").start();

    new Thread(()->{
        while (true){
            try {
                Thread.sleep(random.nextInt(3000));
                guardedObject.getGuardedNum();
            } catch (InterruptedException e) {
                break;
            }
        }

    },"读取线程B").start();

    new Thread(()->{
        while (true){
            try {
                Thread.sleep(random.nextInt(3000));
                guardedObject.getGuardedNum();
            } catch (InterruptedException e) {
                break;
            }
        }

    },"读取线程C").start();
}

输出如下:

java 复制代码
读取线程A正在读取:0
递增线程B正在更改:0
递增线程B更改后:1
递增线程A正在更改:1
递增线程A更改后:2
读取线程A正在读取:2
递增线程A正在更改:2
递增线程A更改后:3
读取线程C正在读取:3
递增线程A正在更改:3
递增线程A更改后:4
读取线程B正在读取:4
读取线程C正在读取:4
读取线程A正在读取:4
递增线程A正在更改:4
递增线程A更改后:5
...

可以看到用读写锁代替排它锁保护 guardedNum 时,guardedNum 也不会出现并发问题。

可重入读写锁

JUC包下就有现成读写锁实现 ------ ReentrantReadWriteLock

java 复制代码
public class ReentrantReadWriteLock
    /** Inner class providing readlock */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** Inner class providing writelock */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    final Sync sync;
    
   public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
  }

和可重入锁 ReentrantLock 类似,核心功能也是静态内部类实现的。

ReentrantLock 是内部有个 Sync 实现 AQS,有个 FairSync 和 NonfairSync 分别实现 AQS, 重新 tryAcquire 方法,实现公平锁和非公平锁。

ReentrantReadWriteLock 也一样,也有一个实现 AQS 的 内部类 Sync,也有 FairSync 和 NonfairSync 。

AQS 上节刚讲完,里面有个 volatile 修饰的成员变量 state,记录线程占用状态(state = 0 表示锁空闲)与可重入次数,AQS 的父类 AOS 还记录着当前占用的线程引用。

js 复制代码
public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    
    private transient volatile Node head;
    private transient volatile Node tail;
    private volatile int state;
    
 
public abstract class AbstractOwnableSynchronizer
    implements java.io.Serializable {
    private transient Thread exclusiveOwnerThread;    

AQS 就一个 state,怎么记录两个锁(读锁和写锁)的线程占用状态与可重入次数呢?

ReentrantReadWriteLock 很聪明, state 是 int 类型的变量,int 一共 32 位 ,能存储上亿的数字,那就让高 16 位 存放读锁重入总次数,低 16 位 记录写锁重入次数,这样拆分,还能记录 65,535 次重入次数,谁家好线程能重入这么多。

读写锁线程占用状态和对重入次数的问题解决,那占用的线程记录在哪呢?AOS 就一个 exclusiveOwnerThread 肯定是不够的。

所以对于写锁来说,同一时刻最多只有一个写线程持有,所以直接复用 AQS 的 exclusiveOwnerThread 就够了。

对于读锁来说,就要 ReentrantReadWriteLock 的内部类 Sync 自己搞一个地方存读线程的信息了。

下面结合代码看看具体是怎么实现的。

抽象队列同步器 AQS

java 复制代码
abstract static class Sync extends AbstractQueuedSynchronizer {

    /**
     * state 的位分割参数。
     * AQS 只有一个 int state,读写锁需要同时记录两类计数,
     * 因此约定:高 16 位 = 读锁(共享)重入总次数,低 16 位 = 写锁(独占)重入次数。
     */
    static final int SHARED_SHIFT   = 16;
    /** 共享计数每次增减的步长,即 1 << 16,用于对高 16 位做加减 */
    static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
    /** 读锁计数上限,65535;超出说明使用方式异常,直接抛 Error */
    static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
    /** 独占(写锁)计数的掩码,用于从 state 中剥离出低 16 位 */
    static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

    /**
     * 单个线程的读锁持有计数。
     * tid 在构造时即固化,用于后续校验缓存是否属于当前线程,
     * 避免线程复用(线程池场景)导致的误命中。
     */
    static final class HoldCounter {
        int count;          // initially 0
        final long tid = LockSupport.getThreadId(Thread.currentThread());
    }

    /**
     * 读锁计数的兜底存储:每个线程一份独立的 HoldCounter。
     * 之所以用 ThreadLocal,是因为读锁是共享的,可能有 N 个线程同时持有,
     * AQS 的 exclusiveOwnerThread 只能标识一个独占线程,无法覆盖读线程。
     */
    static final class ThreadLocalHoldCounter
        extends ThreadLocal<HoldCounter> {
        public HoldCounter initialValue() {
            return new HoldCounter();
        }
    }

    /**
     * 缓存"上一次释放读锁"的线程的 HoldCounter。
     * 嵌套读释放时,同一线程往往连续操作,可直接命中此字段,
     * 省掉一次 ThreadLocal 查询。属于纯性能优化,不参与正确性判断。
     */
    private transient HoldCounter cachedHoldCounter;

    /**
     * 记录第一个获取读锁的线程。
     * 针对"系统中只有一个读者"这一最高频场景做零开销优化。
     */
    private transient Thread firstReader;
    /** 与 firstReader 配对,记录其读锁重入次数 */
    private transient int firstReaderHoldCount;

    Sync() {
        readHolds = new ThreadLocalHoldCounter();
        // 空写一次 state,借助 volatile 写语义建立 happens-before,
        // 保证其他线程能看到 readHolds 的初始化结果
        setState(getState());
    }
}

代码还是很清晰的,HoldCounter 同时承担了"身份标识"和"计数"两个职责,既记录线程的id,又记录重入次数。

这里用 LockSupport.getThreadId(current) 而不是直接比对象引用,是为了在线程池复用场景下避免缓存误命中。

每个读线程自己的存储柜 ThreadLocal 记录自己的 HoldCounter。

而且对于读线程状态的查询,还有三层缓存的完整查找链

js 复制代码
final int getReadHoldCount() {
    if (getReadLockCount() == 0)
        return 0;

    Thread current = Thread.currentThread();
    // 第1层:firstReader 是否就是当前线程?
    // 直接返回 firstReaderHoldCount(零开销,无 ThreadLocal 查询)
    if (firstReader == current)
        return firstReaderHoldCount;
    // cachedHoldCounter 的 tid 是否匹配当前线程?
    // 直接返回 count  
    HoldCounter rh = cachedHoldCounter;
    if (rh != null && rh.tid == LockSupport.getThreadId(current))
        return rh.count;
    // 第3层:ThreadLocal 查询(兜底,覆盖所有其他线程)
    int count = readHolds.get().count;
    if (count == 0) readHolds.remove();
    return count;
}

下面就结合读锁、写锁具体获取锁,释放锁的方法,看看这三层缓存是怎么协作的?

读锁 ReadLock

java 复制代码
public static class ReadLock implements Lock, java.io.Serializable {
    private final Sync sync;
    protected ReadLock(ReentrantReadWriteLock lock) {
        sync = lock.sync;
    }
    public void lock() {
        sync.acquireShared(1);
    }
    public void unlock() {
        sync.releaseShared(1);
    }
        

读锁的 lock 方法是委派 AQS 的 acquireShared 方法实现的。

js 复制代码
  public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        acquire(null, arg, true, false, false, 0L);
  }

AQS 的子类也就是 ReentrantReadWriteLock 的内部类 Sync 实现了 tryAcquireShared 方法,标准的模板方法。

js 复制代码
protected final int tryAcquireShared(int unused) {
 
    Thread current = Thread.currentThread();
    int c = getState();

    // ① 写锁互斥检查:低 16 位非 0 说明有写锁被持有;
    //    若持有者不是当前线程,读锁直接获取失败(返回 -1 表示未获取到)。
    //    若持有者正是当前线程,则属于"写锁降级为读锁"的合法路径,继续往下走。
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;

    // ② 取出高 16 位,即当前读锁的总重入次数
    int r = sharedCount(c);

    // ③  CAS 成功:把高 16 位加一个 SHARED_UNIT,原子地增加读锁计数。
    if (compareAndSetState(c, c + SHARED_UNIT)) {

        // ④ state 更新成功后,再维护"每个线程的读锁持有计数",分三种情况:
        if (r == 0) {
            // 之前没有任何读线程,当前线程就是第一个读者,
            // 用两个普通字段直接记录,避免走 ThreadLocal
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            // 当前线程就是第一个读者,属于其重入,直接自增字段计数
            firstReaderHoldCount++;
        } else {
            // 其他线程:走缓存 + ThreadLocal 兜底
            HoldCounter rh = cachedHoldCounter;
            if (rh == null ||
                rh.tid != LockSupport.getThreadId(current)) {
                // 缓存为空或 tid 不匹配(可能是线程池复用导致),
                // 从 ThreadLocal 取当前线程自己的 HoldCounter,并刷新缓存
                cachedHoldCounter = rh = readHolds.get();
            } else if (rh.count == 0) {
                // 缓存命中但计数为 0,说明之前被清理过,需要重新放回 ThreadLocal
                readHolds.set(rh);
            }
            rh.count++;
        }
        return 1;   // 返回 >= 0 表示获取成功,AQS 会据此决定是否传播唤醒
    }

    // ⑤ 快速路径没走通
    //    交给带完整自旋重试逻辑的 full 版本处理,其中也包含重入场景
    return fullTryAcquireShared(current);
}

代码还是很清晰的,线程进入读锁的前提是没有其他线程的写锁;或者虽有写锁,但调用线程和持有写锁的线程是同一个。

因为读与读之间不互斥,所以不需要检查"有没有其他读锁"。

如果获取读锁时,有线程持有写锁,tryAcquireShared 会返回 -1,读线程依旧会加入阻塞队列,如果只是其他线程已抢先获取到了读锁, 那会 CAS 失败,到 fullTryAcquireShared 方法中自旋再次获取读锁 。

读锁的释放同样是委派 AQS 的 releaseShared 方法实现的。

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

同样,tryReleaseShared 方法的实现还是由ReentrantReadWriteLock 的内部类 Sync 完成。

js 复制代码
protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();

    // ① 先维护"当前线程自己的读锁重入计数",再动全局 state,
    //    顺序不能反:线程级计数是释放合法性的前置校验
    if (firstReader == current) {
        // 当前线程是第一个读者,走零开销的普通字段路径,不碰 ThreadLocal
        // assert firstReaderHoldCount > 0;
        if (firstReaderHoldCount == 1)
            firstReader = null;      // 计数归零,第一个读者的身份随之清除
        else
            firstReaderHoldCount--;  // 仍有重入,仅递减计数
    } else {
        // ② 非 firstReader:先尝试命中缓存,未命中再走 ThreadLocal 兜底
        HoldCounter rh = cachedHoldCounter;
        if (rh == null ||
            rh.tid != LockSupport.getThreadId(current))
            // 缓存为空或 tid 不匹配(线程池复用场景),取当前线程自己的计数器
            rh = readHolds.get();

        int count = rh.count;
        if (count <= 1) {
            // 本次释放后该线程的读锁计数将归零,直接从 ThreadLocal 移除,
            // 避免线程池场景下 Entry 长期驻留造成内存泄漏
            readHolds.remove();
            if (count <= 0)
                // count 为 0 说明当前线程并未持有读锁,属于未配对解锁
                throw unmatchedUnlockException();
        }
        --rh.count;
    }

    // ③ 全局计数更新:自旋 CAS,把 state 高 16 位减一个 SHARED_UNIT
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            // Releasing the read lock has no effect on readers,
            // but it may allow waiting writers to proceed if
            // both read and write locks are now free.
            // 释放读锁对其它读者没有影响,但若此时读写锁都已空闲,
            // 则可能让等待中的写线程得以继续
            return nextc == 0;
    }
}

解锁是加锁的相反操作,其实从这个代码中也能看出来 AQS 中的 state 高 16 位记录的不是某一个读线程的重入次数,是所有读线程的重入次数。

写锁 WriteLock

js 复制代码
public static class WriteLock implements Lock, java.io.Serializable {
    private final Sync sync;

    protected WriteLock(ReentrantReadWriteLock lock) {
        sync = lock.sync;
    }
    public void lock() {
        sync.acquire(1);
    }
    public void unlock() {
        sync.release(1);
    }

写锁的加锁和释放就简单了,走的是独占模式,不需要像读锁那样维护 per-thread 的计数体系。

和 ReentrantLock 一样,都是通过模板方法模式,调用自己内部类 Sync,直接看模板方法tryAcquire:

java 复制代码
protected final boolean tryAcquire(int acquires) {
 
    Thread current = Thread.currentThread();
    int c = getState();
    // 取出低 16 位,即当前写锁的重入计数
    int w = exclusiveCount(c);

    if (c != 0) {
        // (Note: if c != 0 and w == 0 then shared count != 0)
        // 注意:c 非零但 w 为零,说明高 16 位有值,即当前有读锁被持有
        if (w == 0 || current != getExclusiveOwnerThread())
            // 两种失败情形:
            // ① w == 0:有读锁存在,写锁不能获取(不支持锁升级,避免多读线程同时升级死锁)
            // ② 写锁已被其他线程持有:独占语义下直接失败
            return false;

        // 走到这里说明:写锁持有者就是当前线程,属于重入场景
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            // 重入次数超出低 16 位上限,属于使用异常
            throw new Error("Maximum lock count exceeded");

        // Reentrant acquire
        // 重入时直接 setState 即可,无需 CAS:
        // 独占语义下已确认持有者是自己,不存在其他线程并发修改低 16 位
        setState(c + acquires);
        return true;
    }

    // c == 0:读写锁都空闲,尝试首次获取
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        // 两个失败原因:
        // ① writerShouldBlock():按队列策略当前写线程需要排队
        //    (公平实现用 hasQueuedPredecessors() 判断队列中是否有前驱;非公平实现直接返回 false)
        // ② CAS 失败:并发下有别的线程抢先修改了 state
        return false;

    // 获取成功,登记独占持有者
    setExclusiveOwnerThread(current);
    return true;
}

应用场景

读写锁的价值在于区分读写,让读并发、写独占。如果读操作远多于写操作,相比互斥锁能显著提升并发性能。也就是说只有在"读多写少"的场景下才真正有价值。

  • 缓存系统:读缓存用读锁,更新缓存用写锁
  • 配置中心 / 配置管理:查询配置走读锁,更新配置走写锁
  • 路由表、字典数据、树形结构的统计查询:这类"初始化后极少修改"的结构比较合适
  • 商品详情/库存查询:浏览详情页看库存是高频读,下单更新库存相对较少,是读写锁分离的典型适用场景

有观点认为写操作超过约 30% 的场景(如频繁更新的计数器、实时日志写入)不太合适,此时 CAS 开销可能超过收益,甚至不如独占锁;可考虑 ConcurrentHashMap、CopyOnWriteArrayList 或无锁结构,这个并发数据结构后面再讲。

总结

读写锁的核心就一句话:读可以并发,写必须独占。

本节从守护条件出发,用一段 synchronized 简易实现讲清读写锁语义,再深入 ReentrantReadWriteLock 源码,拆解 state 高低 16 位拆分技巧、读锁三层缓存查找链、读/写锁的获取与释放流程。

相关推荐
SimonKing2 小时前
SSE项目`nexus-sse`持续优化,不一样的视觉效果
java·后端·程序员
Thneonl2 小时前
etcd 磁盘写满的那 6 分钟:控制面是怎么一步步瘫的
后端·架构
Tim0072 小时前
deepseek harness 导出公司报表实战
后端
明月_清风2 小时前
一个完整的数据平台是怎么工作的?从数据源到数据分析
大数据·后端·数据分析
Moment2 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试
信誓旦旦的程序猿2 小时前
【Python 量化取数指南 #09】Python 拿港股通数据:港股通成交与港股财报实测
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据api
Wx-bishekaifayuan3 小时前
django医院营收信息预测系统49414-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
Wx-bishekaifayuan3 小时前
springboot会议室预约管理系统42030-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
AINative软件工程3 小时前
LLM 输出 Guardrail 工程实践:5 层防护栏让 AI 生成的内容不会炸掉生产
后端·llm·ai编程