1. 从一次缓存优化说起:为什么单靠互斥锁不够
前两年我接手过一个内部配置中心的服务,核心逻辑很简单:一份全量配置放在内存里,后台线程每隔几秒从远端拉取更新,前端几十个业务方高频读取这份配置做路由判断。上线初期QPS不高,用synchronized锁一个loadConfig()方法也没什么感觉。等业务量上来之后,监控面板上锁等待时间一路飙红,服务线程大量堆积在synchronized入口上,CPU没跑满,但吞吐就是上不去。
当时第一反应是"锁竞争太激烈了",于是把读取逻辑改成无锁的volatile加不可变对象,看似解决了问题——配置整体替换时发布一个新对象引用。但后台更新配置时如果正在被读取,会出现读取到一半混着新旧数据的情况,比如路由表里A规则是新的、B规则是旧的,这在生产上是不可接受的。
后来我把目光转到读写锁上:读操作之间根本不冲突,大家一起读完全没问题;只有读和写、写和写之间才需要互斥。这一个思路转变,直接让那个服务的读吞吐翻了接近三倍。也是从那时候开始,我才认真去啃读写锁的内部实现、各种语言的差异、以及使用它时那些防不胜防的坑。
这篇文章就围绕读写锁展开。我会先从它的核心语义讲起,再深入到底层实现原理,然后用Java、Go这些主流语言的具体实现做对比,最后把我这些年用读写锁踩过的坑、总结的选型经验一次性讲清楚。无论你是写业务代码时想优化并发瓶颈,还是单纯想理解ReentrantReadWriteLock、RWMutex这些类的设计动机,这篇都能给你一份可以存下来反复翻的参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读写锁到底解决了什么:三条规则和它的适用边界
2.1 三种并发控制方式的核心差异
要理解读写锁,最好的方式是先把它和传统的互斥锁放在一起对比。
普通的互斥锁(Java里的synchronized、ReentrantLock,Go里的sync.Mutex)遵循一个简单粗暴的原则:同一时刻只能有一个线程持有锁。这个原则保证数据绝对安全,但代价是所有的读操作也被强制串行化了。假设一个对象90%的操作都是读取,10%是更新,互斥锁等于让90%本来可以并行执行的读操作排成一条队。
读写锁的思路则是把"读"和"写"分开对待:
- 读锁(共享锁):多个线程可以同时持有,大家一起读,互不干扰。
- 写锁(排他锁):同一时刻只能有一个线程持有,其他线程无论是想读还是想写都必须等待。
这个设计对应了现实世界中最朴素的事实:如果多个读者只是在看一份数据而不修改它,让他们排队等待是纯粹的时间浪费。
2.2 读写锁的三条基本规则
读写锁的行为可以归纳为三条规则,任何体现了这三条规则的锁,本质上都是读写锁:
- 读-读不互斥:多个线程可以同时加读锁,并发执行读操作。
- 读-写互斥:读的时候不能写,写的时候不能读。
- 写-写互斥:多个写线程必须串行执行。
这三条规则保证了数据的最终一致性和操作的原子性:写线程改数据的时候,读线程不会看到中间状态;写线程之间也不可能互相覆盖对方的结果。
2.3 一个"读多写少"的典型场景:缓存
读写锁最常见的应用场景就是缓存。读缓存是高频操作,写缓存(刷新、淘汰、更新)是低频操作。用读写锁保护缓存结构,多个业务线程可以同时从缓存读数据,只有当缓存需要重建或更新时,写锁才会短暂地阻塞所有读线程。
我在上面提到的配置中心就是典型例子。还有一个常见场景是HashMap的无锁替换——用读写锁包住整个HashMap,读多写少时性能比ConcurrentHashMap还要好,因为ConcurrentHashMap在扩容和写操作时依然有锁竞争,而读写锁让所有读操作完全并行。
其他的例子包括:
- 数据库连接池:获取连接(读)高频,设置连接参数、关闭连接(写)低频。
- 热点配置表:应用启动时加载,运行中偶尔刷新。
- 统计指标聚合:每秒写入一次统计结果,但几百个线程同时读取展示。
- 开关切换:比如灰度开关、熔断器状态,读的频率远高于写的频率。
2.4 不适合用读写锁的场景
不是所有读多写少都适合读写锁,有几种情况要特别谨慎:
- 写操作本身太频繁或太重。如果写锁持有时间很长,而读操作也时不时的来,读线程会被写锁长期阻塞,反而比互斥锁更糟。
- 数据结构本身已经有无锁并发实现。比如
ConcurrentHashMap、CopyOnWriteArrayList,在它们的适用场景里通常比读写锁更高效。 - 读操作不是简单的内存读取,而是涉及外部IO或重量级计算。这种情况下读锁被长时间占用的概率高,会间接导致写线程长时间饥饿。
所以读写锁不是"并发优化的银弹",它是一种特定场景下的低成本高收益方案,用得好是利器,用错了就是隐患。
3. ReentrantReadWriteLock的底层设计:状态拆分、锁降级和写饥饿
3.1 AQS状态位的前16位和后16位
Java里最常用的读写锁是ReentrantReadWriteLock,它的核心是基于AbstractQueuedSynchronizer(AQS)实现的。AQS本身维护了一个int state,这个int的32位被拆成了两段:
- 高16位记录读锁被持有的总次数(所有线程获取读锁的次数之和)。
- 低16位记录写锁被持有的次数(因为写锁是互斥的,这个值通常是0或1,但考虑到重入,可以大于1)。
这种设计非常巧妙,用一个32位的原子变量同时表达了两种锁的状态,配合CAS操作,可以无锁化地完成状态更新。
获取写锁时,
