Skip to content

读写锁全过程原理

本页专门把 ReentrantReadWriteLock 从“会用”讲到“知道为什么这样工作”。读写锁不是比 synchronized 一定更高级,它只是在读多写少的共享数据场景里,把“互斥”拆成了“读读共享、读写互斥、写写互斥”。

学习目标

学完本页你要能回答:

目标要能说清楚什么
使用场景为什么配置缓存、字典缓存、规则缓存适合读写锁
互斥规则多个读能不能同时进,写和读为什么不能同时进
底层状态AQS 的一个 int state 怎么同时记录读锁和写锁
执行流程读锁、写锁分别怎么获取、失败后怎么排队、释放后怎么唤醒
锁降级为什么写锁可以降级成读锁,为什么读锁升级写锁危险
生产排查读写锁卡住、写线程饥饿、锁内慢 IO 怎么分析
面试回答能把原理、场景、坑点组织成标准回答

为什么需要读写锁

假设系统里有一份本地缓存:医院编码映射、业务字典、租户配置、风控规则。请求进来时大量线程都只是读取配置,偶尔后台任务才刷新配置。

如果使用普通互斥锁:

java
synchronized (lock) {
    return cache.get(key);
}

即使所有线程都只是读,也必须一个一个进入。读操作本身不会破坏共享数据,却被强行串行化了。

读写锁解决的是这个浪费:

mermaid
flowchart TD
    A["共享数据"] --> B{"访问类型"}
    B --> C["读操作"]
    B --> D["写操作"]
    C --> E["多个读线程可以同时进入"]
    D --> F["写线程必须独占进入"]
    E --> G["读多写少时提升吞吐"]
    F --> H["避免读到修改一半的数据"]

一句话:

普通锁保护的是“同一时间只能一个线程访问”;读写锁保护的是“同一时间可以多个线程读,但写必须独占”。

读写互斥矩阵

当前状态新读线程能进吗新写线程能进吗原因
没有任何锁没有冲突
已有读锁不能多个读不破坏数据,写会修改数据
已有写锁不能,除非当前写线程自己降级不能,除非当前写线程重入写线程正在独占修改
当前线程持有写锁写锁可重入,也允许写锁获取读锁做降级
当前线程持有读锁通常不能读锁升级写锁容易互相等待

这个矩阵是理解读写锁所有流程的基础。读写锁不是“读不加锁”,读也加锁,只是读锁之间可以共享。

底层状态怎么存

ReentrantReadWriteLock 基于 AQS。AQS 只有一个核心状态字段:

java
private volatile int state;

读写锁却要同时记录两件事:

要记录的信息含义
写锁次数当前写线程重入了几次
读锁次数当前一共有多少次读锁持有

它的做法是把 int 拆成高低两段:

text
32 bit state

高 16 位:读锁共享次数
低 16 位:写锁重入次数

示意:

text
state = 00000000 00000011 00000000 00000010
        └────── 读锁次数 3 ──────┘ └ 写锁次数 2 ┘

源码里常见的思想可以理解为:

java
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

static int sharedCount(int c) {
    return c >>> SHARED_SHIFT;
}

static int exclusiveCount(int c) {
    return c & EXCLUSIVE_MASK;
}

为什么这样设计?

设计好处不这样会怎样
一个 state 同时存读写可以继续复用 AQS 的 CAS、队列、阻塞唤醒能力如果拆成多个状态字段,原子更新更复杂
高位存读锁读锁加一次就是 state + 65536,容易计算读写状态混在一起难判断
低位存写锁写锁重入次数就是低 16 位数字写锁重入和读锁次数容易冲突

注意:高 16 位记录的是读锁持有总次数,不是“有几个线程”。同一个线程读锁重入多次,也会增加读锁次数。为了支持读锁重入,JDK 内部还需要记录每个线程自己的读锁重入次数。

写锁获取流程

写锁是独占锁。它要求没有其他线程读,也没有其他线程写。

mermaid
flowchart TD
    A["线程调用 writeLock.lock"] --> B["读取 AQS state"]
    B --> C{"state 是否为 0"}
    C -->|是| D{"公平锁是否需要排队"}
    D -->|不需要| E["CAS 设置写锁次数为 1"]
    E --> F["设置 owner 为当前线程"]
    F --> G["获取写锁成功"]
    C -->|否| H{"当前线程是否已经持有写锁"}
    H -->|是| I["写锁重入次数 + 1"]
    I --> G
    H -->|否| J["获取失败,进入 AQS 队列"]
    D -->|需要| J
    J --> K["park 阻塞,等待前驱释放后唤醒"]

关键判断:

判断说明
state == 0没有读锁也没有写锁,可以尝试抢写锁
exclusiveCount(state) > 0 且 owner 是当前线程当前线程写锁重入,允许进入
sharedCount(state) > 0有读线程正在读,写线程必须等
owner 不是当前线程其他写线程持有写锁,必须等

为什么写锁不能和读锁同时存在?

写操作可能正在修改多个字段。例如刷新配置时先清空旧缓存,再放入新缓存。如果读线程在中间读,可能读到半新半旧的数据。写锁独占就是为了保证写入过程对外不可见,等写完后再让读线程看到完整状态。

写锁释放流程

写锁可重入,所以释放不是一次 unlock 就一定真正释放。

mermaid
flowchart TD
    A["线程调用 writeLock.unlock"] --> B{"当前线程是否 owner"}
    B -->|否| C["抛出 IllegalMonitorStateException"]
    B -->|是| D["写锁重入次数 - 1"]
    D --> E{"写锁次数是否为 0"}
    E -->|否| F["仍然持有写锁"]
    E -->|是| G["清空 owner"]
    G --> H["唤醒 AQS 队列后继节点"]

所以加锁几次,就必须释放几次:

java
writeLock.lock();
writeLock.lock();
try {
    // 临界区
} finally {
    writeLock.unlock();
    writeLock.unlock();
}

如果少释放一次,写锁仍然被当前线程持有,其他读写线程都会长期等待。

读锁获取流程

读锁是共享锁。只要没有其他线程持有写锁,多个读线程就可以同时进入。

mermaid
flowchart TD
    A["线程调用 readLock.lock"] --> B["读取 AQS state"]
    B --> C{"是否有写锁"}
    C -->|没有| D{"公平策略是否允许读"}
    D -->|允许| E["CAS 增加读锁次数"]
    E --> F["记录当前线程读锁重入次数"]
    F --> G["获取读锁成功"]
    D -->|不允许| H["进入 AQS 队列等待"]
    C -->|有| I{"写锁 owner 是否当前线程"}
    I -->|是| J["允许写线程获取读锁,形成锁降级"]
    J --> E
    I -->|否| H
    H --> K["park 阻塞,等待唤醒后重试"]

读锁的核心规则:

场景结果
没有写锁读锁可以共享进入
其他线程持有写锁读锁不能进
当前线程持有写锁当前线程可以再拿读锁,用于锁降级
公平锁队列前面有等待线程读线程通常要排队,避免插队
非公平锁有写线程排队读线程可能会受限制,避免写线程长期饥饿

为什么读锁也要记录当前线程次数?

因为读锁也是可重入的:

java
readLock.lock();
readLock.lock();
try {
    // 当前线程重复进入读区域
} finally {
    readLock.unlock();
    readLock.unlock();
}

AQS 的高 16 位只能知道总读锁次数,不能知道“某个线程自己拿了几次”。如果不单独记录当前线程读锁次数,就无法判断当前线程释放读锁是否合法,也无法支持读锁重入。

读锁释放流程

mermaid
flowchart TD
    A["线程调用 readLock.unlock"] --> B{"当前线程是否持有读锁"}
    B -->|否| C["抛出 IllegalMonitorStateException"]
    B -->|是| D["当前线程读锁次数 - 1"]
    D --> E["AQS 总读锁次数 - 1"]
    E --> F{"总读锁次数是否为 0"}
    F -->|否| G["还有读线程,写线程继续等待"]
    F -->|是| H["唤醒队列中的写线程或后继节点"]

写线程最关心的是“最后一个读锁什么时候释放”。只要还有一个读锁没释放,写线程就不能进入。

公平锁与非公平锁

构造方法:

java
ReentrantReadWriteLock nonFair = new ReentrantReadWriteLock();
ReentrantReadWriteLock fair = new ReentrantReadWriteLock(true);
模式特点优点风险
非公平锁新线程可能直接竞争,不严格排队吞吐通常更高排队线程可能等待更久
公平锁线程按队列先后获取锁等待时间更可控上下文切换更多,吞吐可能下降

读写锁的公平性比普通互斥锁更微妙。因为读锁是共享的,如果不断有新读线程进来,写线程可能长时间等不到所有读线程释放。JDK 的非公平读写锁也会在某些情况下阻止读线程插队,目的就是避免写线程长期饥饿。

商业项目怎么选?

场景建议
读很多,写很少,追求吞吐默认非公平
写请求不能长时间等待,例如配置发布必须及时生效可以评估公平锁或缩短读锁持有时间
写比较频繁不要急着用读写锁,普通 ReentrantLock 可能更稳定

锁降级为什么安全

锁降级是:

text
先持有写锁 -> 再获取读锁 -> 再释放写锁 -> 保留读锁继续读

流程:

mermaid
flowchart TD
    A["获取写锁"] --> B["刷新共享数据"]
    B --> C["在写锁内获取读锁"]
    C --> D["释放写锁"]
    D --> E["持有读锁读取刚刷新的数据"]
    E --> F["释放读锁"]

为什么要这样?

假设缓存过期,需要刷新:

  1. 线程 A 获取写锁并刷新缓存。
  2. 如果 A 直接释放写锁,再重新获取读锁,中间可能有线程 B 抢到写锁又改了一次缓存。
  3. A 以为自己读取的是刚才刷新后的结果,实际上可能读到 B 改过的状态。
  4. 写锁内先拿读锁,再释放写锁,可以保证 A 后续读到的是一个受读锁保护的稳定版本。

锁降级常用于缓存刷新:写线程完成刷新后,还要继续以读者身份使用刷新后的结果。

为什么不建议锁升级

锁升级是:

text
先持有读锁 -> 再申请写锁

这很危险。因为写锁要求“没有任何读锁”。如果两个线程都先拿了读锁,再都想升级写锁,就会互相等待。

mermaid
flowchart TD
    A["线程 A 持有读锁"] --> B["A 申请写锁"]
    C["线程 B 持有读锁"] --> D["B 申请写锁"]
    B --> E["写锁要求所有读锁释放"]
    D --> E
    E --> F["A 等 B 释放读锁"]
    E --> G["B 等 A 释放读锁"]
    F --> H["死锁风险"]
    G --> H

错误示例:

java
readLock.lock();
try {
    if (cacheExpired()) {
        // 错误:持有读锁时申请写锁,容易卡住。
        writeLock.lock();
        try {
            refreshCache();
        } finally {
            writeLock.unlock();
        }
    }
} finally {
    readLock.unlock();
}

正确思路是:先释放读锁,再竞争写锁,并且在写锁内二次检查。

商业 Demo:字典缓存刷新

这个 demo 模拟商业系统中非常常见的“读多写少缓存”:业务请求频繁读取字典,后台发现缓存过期后刷新。

java
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class DictCache {
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    private final Lock readLock = rw.readLock();
    private final Lock writeLock = rw.writeLock();

    private Map<String, String> dict = new HashMap<>();
    private Instant expireAt = Instant.MIN;

    public String getName(String code) {
        readLock.lock();
        try {
            if (!isExpired()) {
                return dict.get(code);
            }
        } finally {
            readLock.unlock();
        }

        writeLock.lock();
        try {
            // 二次检查:可能别的线程已经刷新过了。
            if (isExpired()) {
                Map<String, String> newDict = loadFromDatabase();
                dict = newDict;
                expireAt = Instant.now().plus(Duration.ofMinutes(5));
            }

            // 锁降级:释放写锁前先拿读锁,后续读取仍处于受保护状态。
            readLock.lock();
        } finally {
            writeLock.unlock();
        }

        try {
            return dict.get(code);
        } finally {
            readLock.unlock();
        }
    }

    private boolean isExpired() {
        return Instant.now().isAfter(expireAt);
    }

    private Map<String, String> loadFromDatabase() {
        Map<String, String> data = new HashMap<>();
        data.put("A01", "门诊");
        data.put("B02", "住院");
        return data;
    }
}

这个 demo 体现了几个生产原则:

原则为什么
先读锁判断大多数请求缓存没过期,直接共享读,吞吐高
释放读锁再拿写锁避免读锁升级写锁导致死锁
写锁内二次检查防止多个线程排队后重复刷新
刷新后整体替换 Map避免在读线程可见时修改半成品
锁降级释放写锁后仍能稳定读取刷新后的数据

锁内不要做慢操作

上面的 demo 为了完整展示,把 loadFromDatabase() 放在写锁内。真实生产要谨慎:如果数据库查询很慢,所有读线程都会被写锁阻塞。

常见优化是先在锁外准备数据,再在写锁内快速替换:

java
Map<String, String> newDict = loadFromDatabase();

writeLock.lock();
try {
    if (isExpired()) {
        dict = newDict;
        expireAt = Instant.now().plus(Duration.ofMinutes(5));
    }
} finally {
    writeLock.unlock();
}

但这样要注意:锁外加载期间,可能多个线程同时加载数据库。是否接受重复加载,要看业务成本。如果不能接受,可以配合单飞机制、CompletableFuture 缓存加载任务或分布式锁。

和 synchronized、ReentrantLock、StampedLock 对比

工具适合场景优点风险
synchronized简单互斥、小临界区语法简单,自动释放读读也互斥
ReentrantLock需要可中断、超时、公平锁、Condition控制能力强仍然读读互斥
ReentrantReadWriteLock读多写少,共享数据结构读读共享写多时收益差,容易锁升级踩坑
StampedLock极端读多,允许乐观读校验乐观读性能高不可重入,使用复杂,忘记校验会读错

StampedLock 的乐观读示意:

java
long stamp = stampedLock.tryOptimisticRead();
String value = config;
if (!stampedLock.validate(stamp)) {
    stamp = stampedLock.readLock();
    try {
        value = config;
    } finally {
        stampedLock.unlockRead(stamp);
    }
}

它不是 ReentrantReadWriteLock 的简单替代。StampedLock 不可重入,而且乐观读必须 validate。如果开发团队对并发控制不熟,生产里更建议先用更稳妥的读写锁或不可变快照。

常见坑

为什么出问题正确做法
持有读锁申请写锁写锁等待所有读锁释放,自己又不释放读锁释放读锁后再申请写锁,写锁内二次检查
读锁里修改对象多个读线程会同时修改,数据竞争读锁里只读,写操作必须写锁
写锁范围太大所有读线程被阻塞,接口延迟上涨写锁里只做内存替换,不做慢 IO
忘记 finally unlock异常后锁不释放,线程长期卡住lock 后立刻 try/finally
写多场景使用读写锁读写频繁互斥,维护成本超过收益写多用普通锁、分段锁或无锁快照
返回可变对象引用调用方可能在锁外修改共享对象返回不可变对象、拷贝或只读视图

线上排查流程

读写锁问题通常表现为:接口突然变慢、线程大量 WAITING、刷新配置卡住、CPU 不高但请求堆积。

mermaid
flowchart TD
    A["接口慢或线程堆积"] --> B["jstack 查看线程状态"]
    B --> C{"大量 WAITING/PARKED?"}
    C -->|是| D["搜索 ReentrantReadWriteLock 或 AQS"]
    D --> E{"等待读锁还是写锁"}
    E --> F["等待写锁:检查是否有读锁长期不释放"]
    E --> G["等待读锁:检查写锁是否执行慢 IO"]
    F --> H["排查读锁内是否调用远程接口、DB、sleep"]
    G --> I["排查写锁内刷新、批量计算、日志阻塞"]
    C -->|否| J["继续看 CPU、GC、数据库、下游耗时"]

排查清单:

现象优先怀疑
大量线程等待写锁读锁持有时间太长,或者读锁忘记释放
大量线程等待读锁写锁持有时间太长,写锁里可能有慢 SQL、远程调用、文件 IO
写请求一直不生效读请求太密集,写线程饥饿,或公平性策略不合适
CPU 不高但接口慢线程多半阻塞在锁、DB、HTTP、队列而不是计算
偶发缓存不一致写锁外修改共享对象,或者返回可变引用被外部修改

面试时不要只说“看 jstack”。更完整的回答是:

text
我会先看线程栈里大量线程是否 park 在 ReentrantReadWriteLock/AQS 上,再区分是读锁等待还是写锁等待。等写锁通常说明读锁长期占用或未释放;等读锁通常说明写锁内做了慢操作。然后结合接口耗时、数据库慢 SQL、远程调用耗时、锁内代码范围,把慢操作移出锁或缩短锁粒度。

面试标准回答

ReentrantReadWriteLock 是什么

ReentrantReadWriteLock 是基于 AQS 实现的可重入读写锁。它把访问共享数据的操作分成读和写:读锁之间共享,写锁独占,读写互斥。它适合读多写少的场景,比如本地配置缓存、字典缓存、规则缓存;如果写很多,读写锁维护状态和排队唤醒的成本可能比普通互斥锁还高。

state 为什么能同时表示读锁和写锁

读写锁复用了 AQS 的 volatile int state。低 16 位表示写锁重入次数,高 16 位表示读锁总次数。写锁加锁主要改低位,读锁加锁主要让高位加 1 << 16。这样可以用一个原子状态同时判断是否有人读、是否有人写、写锁重入了几次。

读写锁获取流程怎么说

写锁获取时,如果没有读锁也没有写锁,就 CAS 设置写锁次数并设置 owner;如果当前线程已经持有写锁,就写锁重入;否则进入 AQS 队列等待。读锁获取时,如果没有其他线程持有写锁,就增加读锁次数;如果其他线程持有写锁,就排队等待;如果当前线程自己持有写锁,可以再获取读锁完成锁降级。

什么是锁降级

锁降级是先持有写锁,再获取读锁,然后释放写锁,最后继续持有读锁读取数据。它常用于缓存刷新,能保证刷新后的线程继续看到一个稳定版本。读锁升级写锁不推荐,因为写锁要求所有读锁释放,多个线程同时持有读锁并申请写锁时容易互相等待。

为什么读写锁不一定比 synchronized 快

读写锁只有在读多写少、读锁持有时间短、写操作不频繁时才明显有收益。如果写操作很多,读写会频繁互斥;如果锁内有慢 IO,所有线程都会等待;如果读操作本身很快,读写锁维护状态、CAS、队列和唤醒的成本也可能抵消收益。

关联知识点

知识点为什么要看
ReentrantReadWriteLock基础快速回顾基础 API 和入门示例
AQS与JUC全过程理解 AQS 队列、state、CAS、park/unpark
ReentrantLock对比普通独占锁和读写锁
线程池生命周期与排查读写锁卡住经常会在线程池中表现为任务堆积
Java内存模型理解锁释放和获取之间的可见性关系