读写锁:读优先、写优先与公平读写锁的实际应用
读写锁这东西,说白了就是一把可以「区别对待」的锁。读操作可以共享,写操作必须互斥。听起来很简单对吧?但真正用起来,里面的门道可不少。
我在设计缓存系统的时候,就因为这个锁的选择问题折腾了好几个版本。今天咱们就来聊聊三种主流模式:读优先、写优先,还有公平读写锁。每个模式都有它自己的脾气。
为什么需要读写锁?
你先想想看,高并发场景下,读操作通常远多于写操作。如果一视同仁,把所有读写操作都串行化,那性能损失太大了。读写锁的核心思想就是:读读不互斥,读写互斥,写写互斥。
读优先锁(Read-Preferring Lock)
读优先锁,顾名思义,就是读线程优先获得锁。只要有一个读线程正在持有锁,其他读线程都可以同时进入。写线程呢?得等到所有读线程都释放了才行。
听起来挺合理的,对吧?但你想想看,如果读操作一直源源不断,写线程可能会被活生生饿死。我在一个缓存系统项目中就踩过这个坑。
实际案例:缓存系统的读优先陷阱
当时我们做了一个内存缓存,用来存热点数据。读请求高峰期,每秒几十万次。用的是读优先锁。结果呢?写线程(也就是缓存更新线程)等了整整几分钟都拿不到锁。
为什么呢?因为读操作太快了,一个刚释放,另一个又进来了。写线程永远排在后面。这不叫并发控制,这叫写操作罢工。
// 读优先锁的典型实现(Java ReadWriteLock 默认就是读优先)
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadPreferringCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
private String cachedData;
public String getData() {
readLock.lock();
try {
return cachedData;
} finally {
readLock.unlock();
}
}
public void updateData(String newData) {
writeLock.lock(); // 写线程可能永远等不到
try {
cachedData = newData;
} finally {
writeLock.unlock();
}
}
}
写优先锁(Write-Preferring Lock)
写优先锁就反过来。只要有一个写线程在排队,新的读线程就不能进入。写线程能更快获得锁。
但这里也有问题:如果写线程太频繁,读线程的延迟会暴涨。我记得在一次数据库连接池的项目中,就因为锁切换不留意,导致读超时暴增。
// 写优先锁的典型实现(Java StampedLock 支持写优先模式)
import java.util.concurrent.locks.StampedLock;
public class WritePreferringCache {
private final StampedLock stampedLock = new StampedLock();
private String cachedData;
public String getData() {
long stamp = stampedLock.readLock();
try {
return cachedData;
} finally {
stampedLock.unlockRead(stamp);
}
}
public void updateData(String newData) {
long stamp = stampedLock.writeLock();
try {
cachedData = newData;
} finally {
stampedLock.unlockWrite(stamp);
}
}
}
公平读写锁(Fair Read-Write Lock)
公平模式,说白了就是按排队顺序来。不管你是读还是写,先到先得。这样不会饿死任何一方。
Java 的 ReentrantReadWriteLock(true) 就支持公平模式。我一般在高并发、读写比例比较均衡的场景下用这个。
// 公平读写锁
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class FairCache {
private final ReentrantReadWriteLock fairLock =
new ReentrantReadWriteLock(true); // true 表示公平
private final ReentrantReadWriteLock.ReadLock readLock = fairLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = fairLock.writeLock();
private String cachedData;
public String getData() {
readLock.lock();
try {
return cachedData;
} finally {
readLock.unlock();
}
}
public void updateData(String newData) {
writeLock.lock();
try {
cachedData = newData;
} finally {
writeLock.unlock();
}
}
}
- 避免线程饥饿,所有请求都有机会执行
- 吞吐量通常略低于非公平模式(因为有排队开销)
- 适合读写频率接近、延迟要求稳定的场景
三种模式的对比表格
| 特性 | 读优先 | 写优先 | 公平读写锁 |
|---|---|---|---|
| 读线程延迟 | 低(写线程少时) | 可能较高 | 中等 |
| 写线程延迟 | 可能极高(读频繁时) | 低 | 中等 |
| 线程饥饿风险 | 写线程可能饿死 | 读线程可能饿死 | 无饥饿 |
| 适用场景 | 读多写少、写可容忍延迟 | 写少但写后需立即生效 | 读写均衡、延迟敏感 |
| 吞吐量 | 高(读操作多时) | 高(写操作多时) | 中等 |
我在缓存系统中的锁选择经验
最后分享一下我个人的经验。在做缓存系统时,我试过三种模式。最后怎么选的?看场景。
如果缓存更新频率极低(比如每小时一次),我选读优先。因为写几乎不会发生,读性能最大化。
如果缓存更新必须在毫秒级完成(比如秒杀系统的价格更新),我选写优先。为了写性能,牺牲一点读延迟也值得。
如果读写比例接近 7:3,而且延迟抖动不能超过 10%,我选公平锁。虽然吞吐量会降一点,但能保证两边都不挨饿。
关于锁的升级与降级
还有一个点你可能第一次接触——锁的升级和降级。Java 的 ReentrantReadWriteLock 不支持从读锁升级到写锁(会死锁)。但 StampedLock 支持乐观读,可以尝试转换为读锁或者写锁。
// StampedLock 的乐观读与锁升级
import java.util.concurrent.locks.StampedLock;
public class StampedCache {
private final StampedLock lock = new StampedLock();
private String data;
private long version;
public String readOptimistically() {
long stamp = lock.tryOptimisticRead();
String currentData = this.data;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentData = this.data;
} finally {
lock.unlockRead(stamp);
}
}
return currentData;
}
public void updateAndUpgrade(String newData) {
long stamp = lock.readLock();
try {
// 检查是否需要更新...
long ws = lock.tryConvertToWriteLock(stamp);
if (ws != 0L) {
stamp = ws;
this.data = newData;
this.version++;
} else {
lock.unlockRead(stamp);
stamp = lock.writeLock();
this.data = newData;
this.version++;
}
} finally {
lock.unlock(stamp);
}
}
}
StampedLock 的乐观读模式在缓存读取场景下特别好用。它能避免大部分锁竞争。但要注意,乐观读的使用场景是有条件的——你得允许读到脏数据,或者在验证失败后回退到悲观读。
选型建议总结
- 如果读操作占比 > 95%,写操作可以等几秒→用读优先
- 如果写操作必须秒级生效,读操作可以等→用写优先
- 如果读写比例差不多,而且延迟都很敏感→用公平锁
- 如果读操作极其频繁,且可以接受偶尔读到旧数据→用StampedLock 乐观读
嗯,这就是我这些年用读写锁的心得。记住一点:没有万能的锁策略,只有适合你场景的锁策略。多测试,多看实际压测数据,比光看理论强。下次你就可以自信地跟别人说「这个场景该用哪一种读写锁」了。