前一篇我们已经把 Condition 这套显式条件队列机制拆开了。

你已经知道:

  • Lock 不只是 synchronized 的“另一种写法”
  • Condition 也不只是 wait()/notify() 的简单翻版
  • 显式锁体系真正带来的,是更可控的加锁、等待和唤醒机制
  • 当线程协作开始变复杂时,Lock + Condition 往往比内置监视器模型更清晰

但讲到这里,很多人会继续往前追一个非常实际的问题:

既然 ReentrantLock 已经很好用了,为什么 Java 还要再提供 ReadWriteLock

而且这个名字第一次看上去也有点抽象:

  • 什么叫“读写锁”?
  • 读锁和写锁为什么要分开?
  • 难道一把普通互斥锁不能同时保护读和写吗?
  • 什么时候“读多写少”真的能靠读写锁提速,什么时候反而会更慢?
  • ReentrantReadWriteLock 到底该怎么用,哪些地方最容易踩坑?

这些问题背后,本质上是在问:

当共享资源的访问模式并不是“大家都在改”,而是“很多人读、少数人写”时,为什么还要坚持让所有线程一律排队?

这篇文章就把这件事讲透。

我想重点讲 6 个问题:

  1. ReadWriteLock 是为了解决什么问题
  2. 读锁和写锁分别允许什么、不允许什么
  3. ReentrantReadWriteLock 的基本用法是什么
  4. 它为什么在“读多写少”场景更有价值
  5. 锁降级、锁升级分别是什么意思,为什么要小心
  6. 真实开发里,什么时候该用它,什么时候别滥用

你把这一篇吃透,后面再去学:

  • 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 里,这个特性依然存在。

比如同一个线程在持有写锁时,调用另一个也需要写锁的方法,不会直接死锁。

这让分层调用更安全。

但在读写锁里,还有两个更值得注意的行为:

  • 写锁持有者可以再获取读锁
  • 读锁持有者通常不能直接安全升级成写锁

后面单独讲。


八、锁降级是什么?为什么它是常见模式

锁降级这个词第一次看会有点绕。

它的意思其实是:

线程先持有写锁,在完成更新后,再获取读锁,最后释放写锁,让自己以“读者”身份继续持有访问权。

顺序通常是:

  1. 获取写锁
  2. 修改共享数据
  3. 获取读锁
  4. 释放写锁
  5. 后续以读锁身份继续读取结果

为什么要这么做?

因为有些场景下,线程在更新完共享状态后,还想继续基于“刚更新后的稳定结果”做一段只读逻辑。

如果直接释放写锁再重新拿读锁,中间就可能被别人插进来改掉数据。

锁降级可以避免这个窗口问题。

示例

writeLock.lock();
try {
    // 更新共享数据
    updateData();

    readLock.lock();
} finally {
    writeLock.unlock();
}

try {
    // 继续读取刚刚更新后的稳定结果
    useData();
} finally {
    readLock.unlock();
}

这就是典型的降级:

  • 写 -> 读

这个方向通常是允许且合理的。


九、锁升级为什么危险?为什么“读锁升级成写锁”通常不推荐

所谓锁升级,是指:

  • 线程先拿了读锁
  • 读着读着发现要修改数据
  • 然后想在不释放读锁的前提下,直接再获取写锁

很多人第一次写缓存逻辑时都容易这么想。

但这个方向通常非常危险,甚至可能直接导致死锁或长期等待。

为什么?

因为写锁要求独占,而你自己手里还拿着读锁。

如果系统里还有其他读线程也持有读锁,那么大家都在读,谁也不给写让路,升级就很容易卡住。

所以常见建议是:

不要指望从读锁平滑升级到写锁。

更稳的做法通常是:

  • 先释放读锁
  • 再竞争写锁
  • 拿到写锁后重新检查条件

为什么要“重新检查条件”?

因为你释放读锁到重新拿到写锁这段时间里,数据可能已经被别人改过了。

这个思路和并发里的很多经典规则一样:

只要中间放开了锁,就必须承认状态可能变化。


十、公平锁和非公平锁在读写锁里怎么理解?

ReentrantReadWriteLockReentrantLock 一样,也支持公平/非公平策略。

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)误以为它一定比 synchronizedReentrantLock

并没有这种绝对关系。

并发工具没有“天然更高级就天然更快”这种事。

只有:

  • 场景匹不匹配
  • 竞争模式合不合适
  • 压测结果好不好看

十三、到底什么时候该用 ReadWriteLock?给你一个实用判断法

我自己的判断顺序通常是这样的:

1)先问:共享数据真的需要锁保护吗?

如果可以用不可变对象、线程封闭、消息传递,那往往更简单。

2)再问:读是不是明显多于写?

不是“稍微多一点”,而是明显多。

3)再问:多个读线程互相阻塞,是不是已经成为性能问题?

如果根本不是瓶颈,就别急着加复杂度。

4)再问:现成并发容器能不能解决?

例如:

  • ConcurrentHashMap
  • CopyOnWriteArrayList
  • AtomicReference

能直接用现成方案时,通常别先手搓锁。

5)最后才问:如果必须自己控读写边界,读写锁是否值得?

到这一步,才是真正适合它的时候。


十四、最后总结:读写锁的价值,不在于“更高级”,而在于“更贴合访问模式”

把这篇最核心的结论收一下。

ReadWriteLock 解决的不是“普通锁不能保护共享数据”,而是:

普通锁太一刀切了,它把本来可以并发的纯读操作也一起串行化了。

所以当你的场景满足:

  • 共享数据需要保护
  • 读远多于写
  • 读线程之间的互斥已经带来明显成本

那么读写锁就会很有价值。

但也别神化它。

它不是并发性能的万能钥匙。

如果访问模式不匹配,或者现成并发容器更合适,硬上读写锁只会让代码更复杂。

一句话总结:

读写锁不是为了让锁“更厉害”,而是为了让同步策略更像真实业务里的访问结构。

下一篇,我们继续往前走,讲一个很容易让人好奇、也很容易被误用的并发工具:

StampedLock 到底比 ReadWriteLock 强在哪?乐观读又为什么听起来很美、用起来要格外小心?