前一篇我们已经把 Condition 这套显式条件队列机制拆开了。
你已经知道:
Lock不只是synchronized的“另一种写法”Condition也不只是wait()/notify()的简单翻版- 显式锁体系真正带来的,是更可控的加锁、等待和唤醒机制
- 当线程协作开始变复杂时,
Lock + Condition往往比内置监视器模型更清晰
但讲到这里,很多人会继续往前追一个非常实际的问题:
既然 ReentrantLock 已经很好用了,为什么 Java 还要再提供 ReadWriteLock?
而且这个名字第一次看上去也有点抽象:
- 什么叫“读写锁”?
- 读锁和写锁为什么要分开?
- 难道一把普通互斥锁不能同时保护读和写吗?
- 什么时候“读多写少”真的能靠读写锁提速,什么时候反而会更慢?
ReentrantReadWriteLock到底该怎么用,哪些地方最容易踩坑?
这些问题背后,本质上是在问:
当共享资源的访问模式并不是“大家都在改”,而是“很多人读、少数人写”时,为什么还要坚持让所有线程一律排队?
这篇文章就把这件事讲透。
我想重点讲 6 个问题:
ReadWriteLock是为了解决什么问题- 读锁和写锁分别允许什么、不允许什么
ReentrantReadWriteLock的基本用法是什么- 它为什么在“读多写少”场景更有价值
- 锁降级、锁升级分别是什么意思,为什么要小心
- 真实开发里,什么时候该用它,什么时候别滥用
你把这一篇吃透,后面再去学:
StampedLock- 并发容器中的分段控制思路
- 缓存、配置中心、本地索引等典型并发设计
会顺很多。
一、先说结论:ReadWriteLock 的核心目的,不是“让锁更复杂”,而是“让读操作别被无意义地互相阻塞”
很多人第一次接触读写锁时,会觉得它像是在普通锁上又叠了一层规则。
但它真正解决的问题其实很朴素:
如果多个线程都只是读取共享数据,并没有修改,那它们彼此之间通常并不存在真正的冲突。
普通互斥锁的问题在于:
- 不管你是读还是写
- 只要进临界区
- 其他线程就都得等
这意味着:
- 一个线程读配置
- 另一个线程也只是读配置
- 结果两个人还是得串行排队
从“数据安全”角度看,这当然没错。
但从“并发效率”角度看,往往太保守了。
所以读写锁的核心思想就是把访问拆成两类:
- 读操作:只查看,不修改
- 写操作:会更新共享数据
然后允许:
- 多个读线程同时进入
- 写线程独占进入
- 写时禁止其他读写线程并发进入
一句话理解:
读读可以并发,读写不能并发,写写也不能并发。
这就是读写锁最核心的规则。
二、为什么普通互斥锁在“读多写少”场景下不够优雅?
这个问题特别值得想透。
1)普通互斥锁把“读”和“写”一视同仁
比如你有一个缓存、配置表、商品信息映射或者黑名单集合。
绝大多数时候,线程做的事情可能只是:
- 查一下某个 key 在不在
- 读一下当前配置值
- 根据共享 Map 做个判断
真正修改数据的时刻反而很少。
如果这时你用一把普通互斥锁,无论谁来都必须串行:
- 线程 A 读
- 线程 B 读
- 线程 C 还是读
- 但它们都得排队
这就会让系统吞吐无谓下降。
2)“读操作安全”不等于“读操作免费”
有人会说,那我干脆不加锁,只读总行吧?
不行。
因为“读多写少”并不代表“写不存在”。
只要写线程可能在更新共享数据,读线程就仍然可能读到:
- 中间态
- 不一致状态
- 还没发布完成的对象
- 结构修改过程中的异常结果
所以问题不是“要不要保护读”,而是:
能不能在保证正确性的前提下,让多个纯读线程少互相挡路。
读写锁就是在回答这个问题。
三、读锁和写锁到底怎么协作?先把规则背熟
读写锁的规则并不多,但一定要非常清楚。
1)读锁可以共享
如果当前没有线程持有写锁,那么多个线程可以同时持有读锁。
也就是说:
- 线程 A 读
- 线程 B 也读
- 线程 C 还是读
它们可以一起进。
2)写锁必须独占
如果有线程要修改共享数据,那么它拿到写锁后,其他线程都不能同时进入相关临界区。
也就是说:
- 写的时候,不允许别的写进来
- 写的时候,也不允许读进来
因为写操作最怕在修改过程中被别人看到不完整状态。
3)只要有写锁,后续读通常也得等
这是很多人一开始不适应的地方。
他们会想:
“我只是读一下,为什么还不能进?”
原因很简单:
写操作进行中,数据可能正处在变化过程里。
如果这时放读进来,就破坏了一致性保护。
4)是否允许写线程插队,取决于实现策略
在 Java 里,最常见的实现是 ReentrantReadWriteLock。
它支持:
- 非公平模式
- 公平模式
不同模式下,读线程和写线程的排队体验会不同,后面会单独讲。
四、最常见实现:ReentrantReadWriteLock 到底怎么用?
Java 里最常见的读写锁实现就是:
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
你可以把它理解成:
- 一把总的读写锁对象
- 从里面拆出两把“视图锁”
- 读锁
- 写锁
然后根据操作类型分别加锁。
1)读操作示例
public class ConfigStore {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private final Map<String, String> config = new HashMap<>();
public String get(String key) {
readLock.lock();
try {
return config.get(key);
} finally {
readLock.unlock();
}
}
public void put(String key, String value) {
writeLock.lock();
try {
config.put(key, value);
} finally {
writeLock.unlock();
}
}
}
这个例子里:
get()只是读,所以拿读锁put()会改数据,所以拿写锁
这样带来的效果是:
- 多个
get()可以并发执行 put()执行时会独占put()与get()不会并发穿透
这就是最基本的用法。
五、为什么它在缓存、配置、字典类场景特别常见?
因为这些场景很符合它的强项:
- 读频率高
- 写频率低
- 读操作通常比较快
- 共享数据需要一致性保护
典型例子包括:
- 本地缓存
- 配置中心客户端缓存
- 规则表
- 路由表
- 用户权限快照
- 黑白名单集合
- 读多写少的内存索引
举个很常见的现实判断:
如果你的系统 95% 的请求只是查询缓存,5% 才是刷新缓存,那么让这 95% 的读线程全部互斥排队,通常不太划算。
读写锁的价值就在这里。
六、但注意:不是所有“读多写少”都一定适合上读写锁
这是非常重要的一句。
很多人学完读写锁后,会下意识觉得:
只要读比写多,就应该立刻换成 ReadWriteLock。
这不对。
因为读写锁不是白送收益,它也有成本。
1)锁本身更复杂,管理开销更高
相比一把普通互斥锁,读写锁需要维护:
- 读者计数
- 写者独占状态
- 更复杂的唤醒与排队逻辑
如果临界区非常小,数据结构也很简单,那么这些额外开销可能抵消掉并发收益。
2)如果写并不稀少,收益会迅速下降
只要写操作稍微频繁一点,读线程就还是会经常被挡住。
这时你会发现:
- 写锁一来,读还是得停
- 系统仍然会出现明显串行化
- 复杂度上去了,但吞吐未必明显改善
3)如果读操作本身特别短,锁竞争优势可能不明显
比如只是读取一个简单字段,操作耗时极低。
这时即使允许读并发,真正节省下来的时间也未必显著。
4)如果数据结构本身已有更适合的并发方案,未必该自己手写读写锁
例如某些场景里:
ConcurrentHashMap- 原子类
- copy-on-write 思路
- 不可变对象快照
可能比你手动维护一把读写锁更合适。
所以判断标准不是“读多写少”这四个字本身,而是:
读操作是否真的互相阻塞得很严重,以及这种阻塞是否值得用更复杂的同步机制去换。
七、可重入是什么意思?为什么 ReentrantReadWriteLock 名字里也有 Reentrant
前面学 ReentrantLock 时已经提过“可重入”。
这里再复习一下:
可重入的意思是:同一个线程已经拿到某把锁后,可以再次获取这把锁,而不会把自己锁死。
在 ReentrantReadWriteLock 里,这个特性依然存在。
比如同一个线程在持有写锁时,调用另一个也需要写锁的方法,不会直接死锁。
这让分层调用更安全。
但在读写锁里,还有两个更值得注意的行为:
- 写锁持有者可以再获取读锁
- 读锁持有者通常不能直接安全升级成写锁
后面单独讲。
八、锁降级是什么?为什么它是常见模式
锁降级这个词第一次看会有点绕。
它的意思其实是:
线程先持有写锁,在完成更新后,再获取读锁,最后释放写锁,让自己以“读者”身份继续持有访问权。
顺序通常是:
- 获取写锁
- 修改共享数据
- 获取读锁
- 释放写锁
- 后续以读锁身份继续读取结果
为什么要这么做?
因为有些场景下,线程在更新完共享状态后,还想继续基于“刚更新后的稳定结果”做一段只读逻辑。
如果直接释放写锁再重新拿读锁,中间就可能被别人插进来改掉数据。
锁降级可以避免这个窗口问题。
示例
writeLock.lock();
try {
// 更新共享数据
updateData();
readLock.lock();
} finally {
writeLock.unlock();
}
try {
// 继续读取刚刚更新后的稳定结果
useData();
} finally {
readLock.unlock();
}
这就是典型的降级:
- 写 -> 读
这个方向通常是允许且合理的。
九、锁升级为什么危险?为什么“读锁升级成写锁”通常不推荐
所谓锁升级,是指:
- 线程先拿了读锁
- 读着读着发现要修改数据
- 然后想在不释放读锁的前提下,直接再获取写锁
很多人第一次写缓存逻辑时都容易这么想。
但这个方向通常非常危险,甚至可能直接导致死锁或长期等待。
为什么?
因为写锁要求独占,而你自己手里还拿着读锁。
如果系统里还有其他读线程也持有读锁,那么大家都在读,谁也不给写让路,升级就很容易卡住。
所以常见建议是:
不要指望从读锁平滑升级到写锁。
更稳的做法通常是:
- 先释放读锁
- 再竞争写锁
- 拿到写锁后重新检查条件
为什么要“重新检查条件”?
因为你释放读锁到重新拿到写锁这段时间里,数据可能已经被别人改过了。
这个思路和并发里的很多经典规则一样:
只要中间放开了锁,就必须承认状态可能变化。
十、公平锁和非公平锁在读写锁里怎么理解?
ReentrantReadWriteLock 和 ReentrantLock 一样,也支持公平/非公平策略。
1)非公平模式
默认一般是非公平。
特点是:
- 吞吐通常更高
- 线程不一定严格按排队先后获取锁
- 更强调整体效率
2)公平模式
公平模式更强调:
- 谁先等,谁先拿
- 减少某些线程长期拿不到锁的风险
但公平往往意味着更严格的排队和更多调度成本,吞吐可能下降。
在读写锁里,一个常见担忧是 写线程饥饿。
比如一直有新读线程不断涌入,如果调度策略处理不好,写线程可能长期等不到机会。
这也是为什么具体是否使用公平模式,要结合业务压测,而不是凭感觉决定。
十一、一个更像真实项目的例子:缓存延迟加载
来看一个很经典的场景:
- 大部分请求只是读缓存
- 缓存没命中时,需要加载并写回
很多人会第一反应写成:
- 先读锁检查
- 没有就升级成写锁
但前面说过,升级危险。
更合理的写法通常是“双重检查”风格:
public class CacheService {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private final Map<String, String> cache = new HashMap<>();
public String get(String key) {
readLock.lock();
try {
String value = cache.get(key);
if (value != null) {
return value;
}
} finally {
readLock.unlock();
}
writeLock.lock();
try {
String value = cache.get(key);
if (value == null) {
value = loadFromDb(key);
cache.put(key, value);
}
return value;
} finally {
writeLock.unlock();
}
}
private String loadFromDb(String key) {
return "mock-" + key;
}
}
这里最关键的是第二次检查:
String value = cache.get(key);
if (value == null) {
value = loadFromDb(key);
cache.put(key, value);
}
因为从释放读锁到拿到写锁之间,可能已经有别的线程把数据写进去了。
如果不重查,就容易重复加载、重复写入,甚至引发逻辑错误。
十二、读写锁最容易踩的坑,我建议你提前记住这几个
1)把“读操作”写成了会修改状态的操作
比如某个方法表面叫 get(),但里面偷偷:
- 更新计数器
- 写日志到共享结构
- 修改缓存时间戳
这时它就不再是纯读了。
如果你还拿读锁,逻辑就不安全。
所以使用读写锁的前提之一是:
你必须对“读”和“写”的边界足够清楚。
2)忘记在 finally 中释放锁
这和 ReentrantLock 一样,是基本纪律。
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
不要图省事。
3)错误尝试锁升级
这个前面已经说过,是高频坑。
- 读锁里别贸然去拿写锁
- 真要写,就释放读锁后重新竞争
- 拿到写锁后重新检查状态
4)读写比例根本不适合,却硬上读写锁
如果写很多,或者临界区很短,读写锁常常收益不大,反而让代码更难维护。
5)误以为它一定比 synchronized 或 ReentrantLock 快
并没有这种绝对关系。
并发工具没有“天然更高级就天然更快”这种事。
只有:
- 场景匹不匹配
- 竞争模式合不合适
- 压测结果好不好看
十三、到底什么时候该用 ReadWriteLock?给你一个实用判断法
我自己的判断顺序通常是这样的:
1)先问:共享数据真的需要锁保护吗?
如果可以用不可变对象、线程封闭、消息传递,那往往更简单。
2)再问:读是不是明显多于写?
不是“稍微多一点”,而是明显多。
3)再问:多个读线程互相阻塞,是不是已经成为性能问题?
如果根本不是瓶颈,就别急着加复杂度。
4)再问:现成并发容器能不能解决?
例如:
ConcurrentHashMapCopyOnWriteArrayListAtomicReference
能直接用现成方案时,通常别先手搓锁。
5)最后才问:如果必须自己控读写边界,读写锁是否值得?
到这一步,才是真正适合它的时候。
十四、最后总结:读写锁的价值,不在于“更高级”,而在于“更贴合访问模式”
把这篇最核心的结论收一下。
ReadWriteLock 解决的不是“普通锁不能保护共享数据”,而是:
普通锁太一刀切了,它把本来可以并发的纯读操作也一起串行化了。
所以当你的场景满足:
- 共享数据需要保护
- 读远多于写
- 读线程之间的互斥已经带来明显成本
那么读写锁就会很有价值。
但也别神化它。
它不是并发性能的万能钥匙。
如果访问模式不匹配,或者现成并发容器更合适,硬上读写锁只会让代码更复杂。
一句话总结:
读写锁不是为了让锁“更厉害”,而是为了让同步策略更像真实业务里的访问结构。
下一篇,我们继续往前走,讲一个很容易让人好奇、也很容易被误用的并发工具:
StampedLock 到底比 ReadWriteLock 强在哪?乐观读又为什么听起来很美、用起来要格外小心?