读写锁全过程原理
本页专门把 ReentrantReadWriteLock 从“会用”讲到“知道为什么这样工作”。读写锁不是比 synchronized 一定更高级,它只是在读多写少的共享数据场景里,把“互斥”拆成了“读读共享、读写互斥、写写互斥”。
学习目标
学完本页你要能回答:
| 目标 | 要能说清楚什么 |
|---|---|
| 使用场景 | 为什么配置缓存、字典缓存、规则缓存适合读写锁 |
| 互斥规则 | 多个读能不能同时进,写和读为什么不能同时进 |
| 底层状态 | AQS 的一个 int state 怎么同时记录读锁和写锁 |
| 执行流程 | 读锁、写锁分别怎么获取、失败后怎么排队、释放后怎么唤醒 |
| 锁降级 | 为什么写锁可以降级成读锁,为什么读锁升级写锁危险 |
| 生产排查 | 读写锁卡住、写线程饥饿、锁内慢 IO 怎么分析 |
| 面试回答 | 能把原理、场景、坑点组织成标准回答 |
为什么需要读写锁
假设系统里有一份本地缓存:医院编码映射、业务字典、租户配置、风控规则。请求进来时大量线程都只是读取配置,偶尔后台任务才刷新配置。
如果使用普通互斥锁:
synchronized (lock) {
return cache.get(key);
}即使所有线程都只是读,也必须一个一个进入。读操作本身不会破坏共享数据,却被强行串行化了。
读写锁解决的是这个浪费:
flowchart TD
A["共享数据"] --> B{"访问类型"}
B --> C["读操作"]
B --> D["写操作"]
C --> E["多个读线程可以同时进入"]
D --> F["写线程必须独占进入"]
E --> G["读多写少时提升吞吐"]
F --> H["避免读到修改一半的数据"]一句话:
普通锁保护的是“同一时间只能一个线程访问”;读写锁保护的是“同一时间可以多个线程读,但写必须独占”。
读写互斥矩阵
| 当前状态 | 新读线程能进吗 | 新写线程能进吗 | 原因 |
|---|---|---|---|
| 没有任何锁 | 能 | 能 | 没有冲突 |
| 已有读锁 | 能 | 不能 | 多个读不破坏数据,写会修改数据 |
| 已有写锁 | 不能,除非当前写线程自己降级 | 不能,除非当前写线程重入 | 写线程正在独占修改 |
| 当前线程持有写锁 | 能 | 能 | 写锁可重入,也允许写锁获取读锁做降级 |
| 当前线程持有读锁 | 能 | 通常不能 | 读锁升级写锁容易互相等待 |
这个矩阵是理解读写锁所有流程的基础。读写锁不是“读不加锁”,读也加锁,只是读锁之间可以共享。
底层状态怎么存
ReentrantReadWriteLock 基于 AQS。AQS 只有一个核心状态字段:
private volatile int state;读写锁却要同时记录两件事:
| 要记录的信息 | 含义 |
|---|---|
| 写锁次数 | 当前写线程重入了几次 |
| 读锁次数 | 当前一共有多少次读锁持有 |
它的做法是把 int 拆成高低两段:
32 bit state
高 16 位:读锁共享次数
低 16 位:写锁重入次数示意:
state = 00000000 00000011 00000000 00000010
└────── 读锁次数 3 ──────┘ └ 写锁次数 2 ┘源码里常见的思想可以理解为:
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 内部还需要记录每个线程自己的读锁重入次数。
写锁获取流程
写锁是独占锁。它要求没有其他线程读,也没有其他线程写。
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 就一定真正释放。
flowchart TD
A["线程调用 writeLock.unlock"] --> B{"当前线程是否 owner"}
B -->|否| C["抛出 IllegalMonitorStateException"]
B -->|是| D["写锁重入次数 - 1"]
D --> E{"写锁次数是否为 0"}
E -->|否| F["仍然持有写锁"]
E -->|是| G["清空 owner"]
G --> H["唤醒 AQS 队列后继节点"]所以加锁几次,就必须释放几次:
writeLock.lock();
writeLock.lock();
try {
// 临界区
} finally {
writeLock.unlock();
writeLock.unlock();
}如果少释放一次,写锁仍然被当前线程持有,其他读写线程都会长期等待。
读锁获取流程
读锁是共享锁。只要没有其他线程持有写锁,多个读线程就可以同时进入。
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 阻塞,等待唤醒后重试"]读锁的核心规则:
| 场景 | 结果 |
|---|---|
| 没有写锁 | 读锁可以共享进入 |
| 其他线程持有写锁 | 读锁不能进 |
| 当前线程持有写锁 | 当前线程可以再拿读锁,用于锁降级 |
| 公平锁队列前面有等待线程 | 读线程通常要排队,避免插队 |
| 非公平锁有写线程排队 | 读线程可能会受限制,避免写线程长期饥饿 |
为什么读锁也要记录当前线程次数?
因为读锁也是可重入的:
readLock.lock();
readLock.lock();
try {
// 当前线程重复进入读区域
} finally {
readLock.unlock();
readLock.unlock();
}AQS 的高 16 位只能知道总读锁次数,不能知道“某个线程自己拿了几次”。如果不单独记录当前线程读锁次数,就无法判断当前线程释放读锁是否合法,也无法支持读锁重入。
读锁释放流程
flowchart TD
A["线程调用 readLock.unlock"] --> B{"当前线程是否持有读锁"}
B -->|否| C["抛出 IllegalMonitorStateException"]
B -->|是| D["当前线程读锁次数 - 1"]
D --> E["AQS 总读锁次数 - 1"]
E --> F{"总读锁次数是否为 0"}
F -->|否| G["还有读线程,写线程继续等待"]
F -->|是| H["唤醒队列中的写线程或后继节点"]写线程最关心的是“最后一个读锁什么时候释放”。只要还有一个读锁没释放,写线程就不能进入。
公平锁与非公平锁
构造方法:
ReentrantReadWriteLock nonFair = new ReentrantReadWriteLock();
ReentrantReadWriteLock fair = new ReentrantReadWriteLock(true);| 模式 | 特点 | 优点 | 风险 |
|---|---|---|---|
| 非公平锁 | 新线程可能直接竞争,不严格排队 | 吞吐通常更高 | 排队线程可能等待更久 |
| 公平锁 | 线程按队列先后获取锁 | 等待时间更可控 | 上下文切换更多,吞吐可能下降 |
读写锁的公平性比普通互斥锁更微妙。因为读锁是共享的,如果不断有新读线程进来,写线程可能长时间等不到所有读线程释放。JDK 的非公平读写锁也会在某些情况下阻止读线程插队,目的就是避免写线程长期饥饿。
商业项目怎么选?
| 场景 | 建议 |
|---|---|
| 读很多,写很少,追求吞吐 | 默认非公平 |
| 写请求不能长时间等待,例如配置发布必须及时生效 | 可以评估公平锁或缩短读锁持有时间 |
| 写比较频繁 | 不要急着用读写锁,普通 ReentrantLock 可能更稳定 |
锁降级为什么安全
锁降级是:
先持有写锁 -> 再获取读锁 -> 再释放写锁 -> 保留读锁继续读流程:
flowchart TD
A["获取写锁"] --> B["刷新共享数据"]
B --> C["在写锁内获取读锁"]
C --> D["释放写锁"]
D --> E["持有读锁读取刚刷新的数据"]
E --> F["释放读锁"]为什么要这样?
假设缓存过期,需要刷新:
- 线程 A 获取写锁并刷新缓存。
- 如果 A 直接释放写锁,再重新获取读锁,中间可能有线程 B 抢到写锁又改了一次缓存。
- A 以为自己读取的是刚才刷新后的结果,实际上可能读到 B 改过的状态。
- 写锁内先拿读锁,再释放写锁,可以保证 A 后续读到的是一个受读锁保护的稳定版本。
锁降级常用于缓存刷新:写线程完成刷新后,还要继续以读者身份使用刷新后的结果。
为什么不建议锁升级
锁升级是:
先持有读锁 -> 再申请写锁这很危险。因为写锁要求“没有任何读锁”。如果两个线程都先拿了读锁,再都想升级写锁,就会互相等待。
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错误示例:
readLock.lock();
try {
if (cacheExpired()) {
// 错误:持有读锁时申请写锁,容易卡住。
writeLock.lock();
try {
refreshCache();
} finally {
writeLock.unlock();
}
}
} finally {
readLock.unlock();
}正确思路是:先释放读锁,再竞争写锁,并且在写锁内二次检查。
商业 Demo:字典缓存刷新
这个 demo 模拟商业系统中非常常见的“读多写少缓存”:业务请求频繁读取字典,后台发现缓存过期后刷新。
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() 放在写锁内。真实生产要谨慎:如果数据库查询很慢,所有读线程都会被写锁阻塞。
常见优化是先在锁外准备数据,再在写锁内快速替换:
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 的乐观读示意:
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 不高但请求堆积。
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”。更完整的回答是:
我会先看线程栈里大量线程是否 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内存模型 | 理解锁释放和获取之间的可见性关系 |
