📌 PDF:大白话说Java面试题 — 04-并发篇
第28题:读写锁 ReentrantReadWriteLock 存在的意义
📚 回答:
- 核心考点: ReentrantReadWriteLock(读写锁)是 Java 并发包中针对读多写少场景设计的锁机制。大厂面试不会只问"读锁共享、写锁独占"这种表面概念,而是深入考察 AQS 状态位拆分设计(高16位读锁计数 + 低16位写锁重入)、锁降级的必要性与实现细节、写锁饥饿问题的根因与解决方案,以及 StampedLock 的演进动机。面试官真正想判断的是:你是否理解从 ReentrantLock 到 ReentrantReadWriteLock 再到 StampedLock 的锁演进路线,以及每种锁的适用边界和工程陷阱。
1. 为什么需要读写锁?------排它锁的性能瓶颈
-
1.1 读-读互斥的浪费 在
ReentrantLock中,无论是读-读、读-写还是写-写,都会发生互斥。但在实际业务中,读操作通常远多于写操作,且读操作本身不会修改数据,多个线程同时读取理论上不会引发并发问题。使用排它锁会导致大量读线程串行等待,严重浪费 CPU 资源。 -
1.2 性能对比数据 假设一个缓存系统,读操作占 95%,写操作占 5%:
| 锁类型 | 读-读并发 | 读-写并发 | 写-写并发 | 100线程读吞吐量(估算) |
|---|---|---|---|---|
ReentrantLock | ❌ 互斥 | ❌ 互斥 | ❌ 互斥 | ~1x(串行) |
ReentrantReadWriteLock | ✅ 并发 | ❌ 互斥 | ❌ 互斥 | ~50x+(读并发) |
StampedLock(乐观读) | ✅ 并发(无阻塞) | ⚠️ 乐观读不阻塞写 | ❌ 互斥 | ~100x+ |
- 1.3 典型反例------缓存系统用 ReentrantLock
// ❌ 错误:读操作被不必要的串行化
public class CacheWithReentrantLock {
private final ReentrantLock lock = new ReentrantLock();
private Map<String, Object> cache = new HashMap<>();
public Object get(String key) {
lock.lock(); // 读操作也要排队!
try {
return cache.get(key);
} finally {
lock.unlock();
}
}
public void put(String key, Object value) {
lock.lock();
try {
cache.put(key, value);
} finally {
lock.unlock();
}
}
}
100 个线程同时读取缓存,在 ReentrantLock 下会串行执行,吞吐量极低。而 ReentrantReadWriteLock 下 100 个读线程可以并发执行,性能提升数十倍。
2. ReentrantReadWriteLock 的 AQS 实现原理
- 2.1 状态位拆分设计
ReentrantReadWriteLock基于 AQS 实现,核心创新在于将 32 位state拆分为两部分:
// AQS 的 state 字段:高16位记录读锁,低16位记录写锁
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 65536,读锁计数加1
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 65535,最大重入次数
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 65535,写锁掩码
// 获取读锁持有数量(高16位)
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 获取写锁重入次数(低16位)
static int exclusiveCount(int c){ return c & EXCLUSIVE_MASK; }
| 状态位 | 范围 | 含义 | 最大值 |
|---|---|---|---|
| 高16位 | state >>> 16 | 读锁持有线程数(所有线程的读锁总数) | 65535 |
| 低16位 | state & 0x0000FFFF | 写锁重入次数(仅当前写线程) | 65535 |
设计精妙之处:读锁是共享锁,需要记录所有线程持有的读锁总数;写锁是独占锁,只需记录当前线程的重入次数。
- 2.2 读锁获取规则
1. 检查是否有写锁持有
├─ 无写锁:增加读锁计数(高16位 + 1),获取成功
└─ 有写锁:检查是否当前线程持有(锁降级场景)
├─ 是当前线程:获取成功(锁降级)
└─ 是其他线程:进入 AQS 等待队列
- 2.3 写锁获取规则
1. 检查 state 是否为 0(无任何锁)
├─ 是:设置写锁状态(低16位 = 1),获取成功
└─ 否:检查是否当前线程重入
├─ 是:低16位 + 1(重入)
└─ 否:进入 AQS 等待队列
关键约束:写锁获取时,必须满足 state == 0 或 exclusiveOwnerThread == 当前线程。只要有任何读锁存在(高16位 > 0),写锁就必须等待。
- 2.4 公平模式 vs 非公平模式
| 模式 | 获取策略 | 吞吐量 | 饥饿风险 | 适用场景 |
|---|---|---|---|---|
| 非公平(默认) | 允许插队,新线程可直接尝试获取 | 高 | 写锁可能饥饿 | 读多写少,对延迟敏感 |
| 公平 | 严格按请求顺序排队 | 低 | 基本无饥饿 | 写操作不能长时间等待 |
// 非公平模式(默认)
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 公平模式
ReentrantReadWriteLock fairRwLock = new ReentrantReadWriteLock(true);
3. 锁降级:写锁降级为读锁
- 3.1 什么是锁降级? 锁降级是指线程在持有写锁的情况下,先获取读锁,再释放写锁,将独占访问降级为共享访问的过程。这是
ReentrantReadWriteLock支持的唯一锁转换方向。
标准锁降级流程:
writeLock.lock(); // 1. 获取写锁(独占)
try {
// 修改数据...
data = newValue;
readLock.lock(); // 2. 在写锁保护下获取读锁
} finally {
writeLock.unlock(); // 3. 释放写锁(完成降级)
}
try {
// 4. 此时仅持有读锁,其他读线程可并发进入
use(data);
} finally {
readLock.unlock(); // 5. 释放读锁
}
- 3.2 为什么需要锁降级?------消除危险间隙 如果不使用锁降级,直接释放写锁再获取读锁,存在一个危险窗口:
时间线(无锁降级的危险场景):
1. 线程A获取写锁
2. 线程A修改 data = newValue
3. 线程A释放写锁 ← 【危险间隙开始】
4. 线程B获取写锁
5. 线程B修改 data = anotherValue
6. 线程B释放写锁 ← 【危险间隙结束】
7. 线程A获取读锁
8. 线程A读取 data → 得到 anotherValue(非预期!)
锁降级消除危险间隙:
时间线(锁降级保护):
1. 线程A获取写锁
2. 线程A修改 data = newValue
3. 线程A获取读锁 ← 读锁保护开始
4. 线程A释放写锁 ← 降级完成,但仍持有读锁
5. 线程B尝试获取写锁 → 阻塞(读锁存在)
6. 线程A安全读取 data = newValue
7. 线程A释放读锁
8. 线程B获取写锁
- 3.3 锁降级的核心价值
| 价值点 | 说明 |
|---|---|
| 数据一致性 | 确保线程看到自己修改的最新数据,不被其他写线程覆盖 |
| 写后读原子性 | 消除"释放写锁→获取读锁"之间的危险窗口 |
| 并发性优化 | 降级后释放写锁,允许其他读线程并发访问最新数据 |
- 3.4 经典应用场景------缓存更新
public class CachedData {
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock r = rwl.readLock();
private final ReentrantReadWriteLock.WriteLock w = rwl.writeLock();
private volatile boolean cacheValid;
private Object data;
void processCachedData() {
r.lock(); // 1. 先获取读锁
if (!cacheValid) { // 2. 缓存失效
r.unlock(); // 3. 必须释放读锁!(否则死锁)
w.lock(); // 4. 获取写锁
try {
if (!cacheValid) { // 5. 二次检查(Double Check)
data = fetchFromDB(); // 6. 更新缓存
cacheValid = true;
}
r.lock(); // 7. 锁降级:写锁内获取读锁
} finally {
w.unlock(); // 8. 释放写锁,保留读锁
}
}
try {
use(data); // 9. 安全使用数据(读锁保护)
} finally {
r.unlock(); // 10. 释放读锁
}
}
}
关键注意点:
- 必须先释放读锁再获取写锁:持有读锁时无法直接获取写锁,会导致死锁。
- 写锁内必须二次检查:释放读锁→获取写锁之间可能有其他线程已更新缓存。
- 锁降级顺序不可颠倒:必须是"获取写锁 → 获取读锁 → 释放写锁",否则不是真正的降级。
- 3.5 不支持锁升级
ReentrantReadWriteLock严禁锁升级(读锁 → 写锁)。如果允许,多个线程同时持有读锁并都想升级为写锁时,会互相等待对方释放读锁,导致死锁。
// ❌ 致命错误!锁升级会导致死锁
readLock.lock();
try {
if (needUpdate) {
writeLock.lock(); // 死锁!其他线程也持有读锁,写锁永远无法获取
try {
// 更新数据
} finally {
writeLock.unlock();
}
}
} finally {
readLock.unlock();
}
4. 写锁饥饿问题与解决方案
- 4.1 问题根因 在默认非公平模式下,如果读线程持续不断地获取读锁,写线程可能长时间无法获取写锁,导致写锁饥饿(Write Lock Starvation)。
场景演示:
1. 线程A获取读锁 → 成功(state 高16位 = 1)
2. 线程B请求写锁 → 阻塞(等待读锁释放)
3. 线程C获取读锁 → 成功(state 高16位 = 2)
4. 线程D获取读锁 → 成功(state 高16位 = 3)
5. 线程A释放读锁 → 高16位 = 2,但线程B仍被阻塞
6. 线程E获取读锁 → 成功(高16位 = 3)
... 读线程源源不断,写线程永远轮不到
- 4.2 解决方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 公平模式 | new ReentrantReadWriteLock(true),严格按请求顺序分配 | 彻底避免饥饿 | 吞吐量下降,线程切换频繁 | 写操作不能长时间等待 |
| 限制读线程数量 | 自定义逻辑控制最大并发读线程数 | 灵活可控 | 实现复杂,需额外计数 | 读线程可控的业务 |
| StampedLock | 乐观读不阻塞写,写锁获取机会增加 | 性能最优 | API复杂,不可重入 | 读远多于写,追求极致性能 |
- 4.3 公平模式的实现原理 公平模式下,AQS 的
hasQueuedPredecessors()方法会检查等待队列中是否有排在前面的线程。如果有,即使当前线程能获取锁,也会主动放弃并排队,从而保证写线程的获取机会。
// 公平模式:写线程不会饿死
private final ReentrantReadWriteLock rwLock =
new ReentrantReadWriteLock(true);
5. ReentrantReadWriteLock vs StampedLock
| 对比维度 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 读锁类型 | 悲观读(阻塞写) | 悲观读 + 乐观读(不阻塞写) |
| 乐观读 | ❌ 不支持 | ✅ tryOptimisticRead() |
| 可重入 | ✅ 支持 | ❌ 不支持 |
| Condition | ✅ 支持 | ❌ 不支持 |
| 写锁饥饿 | 非公平模式下可能 | 乐观读不阻塞写,基本避免 |
| API复杂度 | 低 | 高(需管理 stamp) |
| 性能(读多写少) | 高 | 极高(乐观读无锁) |
| 适用场景 | 通用读多写少 | 读极多写极少,追求极致性能 |
StampedLock 乐观读示例:
public class StampedLockExample {
private final StampedLock lock = new StampedLock();
private int value;
public int read() {
long stamp = lock.tryOptimisticRead(); // 1. 乐观读,不阻塞
int current = value;
if (!lock.validate(stamp)) { // 2. 验证期间是否有写操作
stamp = lock.readLock(); // 3. 有写,降级为悲观读
try {
current = value;
} finally {
lock.unlockRead(stamp);
}
}
return current;
}
public void write(int newValue) {
long stamp = lock.writeLock();
try {
value = newValue;
} finally {
lock.unlockWrite(stamp);
}
}
}
性能对比参考(读:写 = 100:1 场景):
| 锁类型 | 平均耗时 |
|---|---|
synchronized | ~450ms |
ReentrantReadWriteLock | ~180ms |
StampedLock(乐观读) | ~120ms |
6. 生产环境避坑指南
-
6.1 严禁在读锁内直接获取写锁 这是最常见的死锁场景。必须先释放读锁,再获取写锁。
-
6.2 锁降级顺序不可颠倒 必须是"写锁 → 读锁 → 释放写锁",如果先释放写锁再获取读锁,就失去了降级的意义。
-
6.3 注意写锁饥饿 非公平模式下,如果读线程源源不断,写线程可能永远获取不到锁。对写延迟敏感的场景必须使用公平模式或 StampedLock。
-
6.4 不可在 finally 中重复释放锁
// ❌ 错误:锁降级场景下,finally 可能重复释放
writeLock.lock();
readLock.lock();
try {
// ...
} finally {
writeLock.unlock();
readLock.unlock(); // 如果上面抛异常,这里可能重复释放!
}
// ✅ 正确:分层 finally
writeLock.lock();
try {
readLock.lock();
try {
// ...
} finally {
readLock.unlock();
}
} finally {
writeLock.unlock();
}
-
6.5 读锁持有时间不宜过长 读锁会阻塞写锁,如果读操作耗时很长(如复杂计算、I/O),会导致写操作长时间等待。应将耗时操作移到锁外。
-
6.6 优先使用 StampedLock 的场景 如果满足以下条件,考虑用
StampedLock替代:- 读操作远多于写操作(>100:1)
- 不需要锁重入
- 不需要 Condition 条件变量
- 读操作耗时短(乐观读验证成功率高)
7. 面试官追问与高分回答模板
-
追问 1:“读写锁的实现原理是什么?”
低分回答:“读锁共享,写锁独占。”(太浅,没有触及 AQS 状态位拆分)
高分回答:
"
ReentrantReadWriteLock基于 AQS 实现,核心设计是将 32 位state拆分为高16位和低16位:高16位记录所有线程持有的读锁总数(共享锁特性),低16位记录当前写线程的重入次数(独占锁特性)。读锁获取时,检查
state低16位是否为0(无写锁),或写锁持有者是当前线程(锁降级场景),满足条件则将高16位 +1。写锁获取时,要求state必须为0(无任何锁)或当前线程重入,然后将低16位 +1。这种设计使得读读并发、读写互斥、写写互斥,在读多写少场景下大幅提升并发性能。"
-
追问 2:“什么是锁降级?为什么需要它?”
低分回答:“写锁变成读锁。”(没有解释危险间隙)
高分回答:
"锁降级是指线程在持有写锁的情况下,先获取读锁,再释放写锁,将独占访问降级为共享访问的过程。
需要锁降级的核心原因是消除危险间隙。如果不降级,直接释放写锁再获取读锁,中间存在一个窗口期:其他写线程可能在此期间获取写锁并修改数据,导致当前线程读到非预期的值。
通过锁降级,线程在释放写锁前就已经持有读锁,利用读锁的共享特性(但阻塞写锁)保护数据,确保自己修改的数据不被其他写线程覆盖,同时允许其他读线程并发访问。经典应用场景是缓存更新:先写锁更新缓存,降级为读锁后释放写锁,其他线程立即并发读取最新数据。"
-
追问 3:“ReentrantReadWriteLock 支持锁升级吗?为什么?”
高分回答:
"不支持锁升级(读锁 → 写锁)。如果支持,会导致死锁。
假设线程 A 和线程 B 都持有读锁,同时都想升级为写锁。线程 A 需要等待线程 B 释放读锁才能获取写锁,线程 B 也需要等待线程 A 释放读锁。两者互相等待,形成死锁。
正确的做法是先释放读锁,再获取写锁(如缓存更新场景中的标准流程)。虽然这会短暂失去锁保护,但可以通过二次检查(Double Check)来弥补。"
-
追问 4:“读写锁的写锁饥饿问题怎么解决?”
高分回答:
"写锁饥饿的根本原因是非公平模式下读线程源源不断获取读锁,写线程永远轮不到。解决方案有三种:
- 公平模式:
new ReentrantReadWriteLock(true),严格按请求顺序分配锁,写线程按排队顺序获取,彻底避免饥饿。代价是吞吐量下降。 - 限制读线程数量:自定义逻辑控制最大并发读线程数,给写线程留出机会。实现复杂,一般不用。
- StampedLock:Java 8 引入,提供乐观读模式(
tryOptimisticRead),读操作不阻塞写操作,从根本上避免写锁饥饿。但 API 复杂,不可重入,无 Condition 支持。
工程选型上,如果写操作不能长时间等待,优先公平模式;如果追求极致性能且读远多于写,用 StampedLock。"
- 公平模式:
-
追问 5:“StampedLock 和 ReentrantReadWriteLock 怎么选?”
高分回答:
"选择取决于业务场景和团队能力:
- ReentrantReadWriteLock:API 简单,支持重入和 Condition,适合大多数读多写少场景。非公平模式下需注意写锁饥饿问题。
- StampedLock:性能更高(乐观读无锁),适合读极多写极少(如 >100:1)且读操作耗时短的场景。但不可重入、无 Condition、API 复杂(需管理 stamp),使用不当容易出 Bug。
压测数据显示,读:写 = 100:1 时,StampedLock 耗时约 120ms,ReentrantReadWriteLock 约 180ms,synchronized 约 450ms。但如果读操作本身耗时较长,乐观读验证失败率高,频繁降级为悲观读,性能优势会大打折扣。"
-
追问 6:“读写锁在什么场景下性能反而不如 ReentrantLock?”
高分回答:
"以下场景读写锁性能可能更差:
- 写多读少:写锁是独占的,且写锁获取前必须等待所有读锁释放。频繁写操作会导致大量读线程阻塞和唤醒,上下文切换开销大。此时 ReentrantLock 更简单高效。
- 读操作极短:如果读操作只是简单的字段访问(纳秒级),读写锁的锁获取/释放开销(涉及 CAS、AQS 队列操作)可能比 ReentrantLock 还高。
- 锁竞争不激烈:单线程或低并发场景下,ReentrantLock 的偏向锁/轻量级锁优化效果更好,读写锁的状态拆分反而增加复杂度。
所以读写锁不是银弹,只有在读多写少且读操作有一定耗时的场景才能发挥优势。"
8. 方案选型速查表
| 业务场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 通用读多写少(缓存、配置) | ReentrantReadWriteLock(非公平) | API简单,支持重入,读并发高 |
| 写操作不能长时间等待 | ReentrantReadWriteLock(公平) | 避免写锁饥饿,按序分配 |
| 读极多写极少,追求极致性能 | StampedLock | 乐观读无锁,吞吐量最高 |
| 需要 Condition 条件变量 | ReentrantReadWriteLock | StampedLock 不支持 Condition |
| 需要锁重入 | ReentrantReadWriteLock | StampedLock 不可重入 |
| 写多读少 | ReentrantLock / synchronized | 读写锁的写锁开销更大 |
| 金融交易(强一致性) | synchronized / ReentrantLock | 避免读写锁的并发隐患 |
💡 面试官想要的满分总结:
ReentrantReadWriteLock的存在意义在于解决读多写少场景下排它锁的性能浪费------读操作不修改数据,多个线程同时读取理应并发执行。它通过 AQS 的状态位拆分设计(高16位读锁计数 + 低16位写锁重入)实现了读读并发、读写互斥、写写互斥。锁降级是其核心高级特性:在写锁保护下获取读锁、再释放写锁,消除"释放写锁→获取读锁"之间的危险间隙,确保数据一致性。经典应用是缓存更新场景。
但读写锁并非银弹:非公平模式下存在写锁饥饿,需通过公平模式或 StampedLock 解决;严禁锁升级(会导致死锁);写多读少场景性能反而不如 ReentrantLock。Java 8 引入的
StampedLock通过乐观读进一步提升了读多写少场景的性能,但牺牲了可重入性和 Condition 支持。工程选型上,先确认场景是否"读多写少",再评估是否需要重入/Condition,最后权衡性能与复杂度------没有最好的锁,只有最适合当前场景的锁。
觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯

1538

被折叠的 条评论
为什么被折叠?



