读写锁
读写锁允许多个线程同时读取共享数据,并让写入线程独占修改。本文先说明读锁与写锁怎样配合,再讲锁降级。随后介绍 StampedLock 的乐观读、验证过程和模式转换。
1. 面试核心
先记住这几条,再读正文- 读写互斥关系。 多个线程可以同时持有读锁,写锁则与其他线程的读锁和写锁互斥。
- 锁降级。
ReentrantReadWriteLock支持先获取读锁再释放写锁来降级,读线程申请写锁前需释放读锁。卡 · 写锁降级的持锁区间 - StampedLock 的能力。
StampedLock使用获取时返回的 stamp 释放锁或转换模式,且不支持重入和条件队列。 - 乐观读。
StampedLock的乐观读先把字段读入局部变量,再验证 stamp,失败后加读锁重读全部字段。卡 · StampedLock 与 stamp - 转换失败。 持有读锁时,
tryConvertToWriteLock转换失败会返回零,原读锁仍保持持有。卡 · StampedLock 的模式转换
2. 读锁与写锁
读取共享数据时,多个线程可以使用读锁并行访问。需要修改数据的线程使用写锁,让修改过程与其他读写操作互斥。
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock read = rw.readLock();
private final Lock write = rw.writeLock();
Value find(Key key) {
read.lock();
try {
return values.get(key);
} finally {
read.unlock();
}
}
多个读线程可以同时执行 find,写入方则必须取得同一读写锁的写锁。读锁内的操作应保持共享状态不变,否则并行读取也会造成竞争。
锁只保护方法内的访问。返回的 Value 若可变,调用者在方法外修改它时,仍需遵守同一套同步规则。
读写锁需要维护读线程数量,并安排竞争线程排队。读取很短或写入频繁时,这些成本会抵消并行读取的收益。因此,应与普通互斥锁实测比较。
3. 锁降级与锁升级
更新完成后,如果还需要继续读取刚写入的数据,可以把写锁降级为读锁。写线程先获取读锁,再释放写锁。这样既允许其他线程读取,又能继续阻止其他写线程修改数据。
ReentrantReadWriteLock 的读锁无法直接升级为写锁。写锁要等所有读锁释放,而当前线程仍持有读锁,所以直接等待会使自己卡住。多个读线程同时这样做,也会彼此等待。
需要更新时,应先释放读锁,再获取写锁。两次获取之间,其他写线程可以修改数据。所以,拿到写锁后必须重新检查更新条件。
4. StampedLock 与 stamp
StampedLock 用 stamp 表示一次获取得到的访问凭据,其中包含锁模式和版本信息。释放锁或转换模式时,要传入仍然有效的 stamp。
long stamp = lock.readLock();
try {
return new Point(x, y);
} finally {
lock.unlockRead(stamp);
}
示例先获取读锁,再读取两个坐标,最后用对应的 stamp 释放锁。StampedLock 通过 stamp 管理访问权限,因此获取和释放可以由不同线程完成。
StampedLock 不可重入。比如,持有写锁的方法再次调用 writeLock(),就会等待自己释放锁。因此,持锁代码调用其他方法前,要确认它是否会再次获取同一把锁。
它的锁视图也不支持 Condition,也就是让线程等待业务条件的条件队列。因此,需要重入或条件队列的代码,应继续使用对应的锁实现。
5. 乐观读
乐观读先尝试读取,再检查期间是否有写线程介入。它省去了正常读锁的获取与释放,但需要程序验证读取结果,并处理验证失败。
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return new Point(currentX, currentY);
本例要求所有坐标写入都使用同一把写锁。tryOptimisticRead() 在写锁已被持有时返回零,否则返回用于验证的 stamp。
乐观读取期间没有持有读锁,所以读到的 x 和 y 可以来自不同次更新。读取结束后,validate 检查获取 stamp 后是否有线程取得过写锁。验证成功,代码才接受局部变量中的坐标。验证失败,则在读锁内重新读取全部字段,避免混用两次读取的结果。
验证前的代码需要容忍临时不一致。例如,沿着可变引用访问对象或数组时,会遇到空引用或失效下标。因此,乐观读适合简单字段组合,付款等不可撤销操作应放在验证成功之后。
验证只覆盖获取 stamp 到验证之间的读取区间。之后再读取共享字段,仍需重新验证或持锁。
6. 不可变快照与并发容器
选择读写锁前,可以先判断数据是否适合以下三种共享方式:
- 配置整体更新时,用
volatile发布不可变快照,让读取者直接使用完整配置。 - 按键查询和更新时,使用
ConcurrentHashMap管理并发访问。 - 读极多、写极少的小集合,可以考虑 Copy-on-Write,即修改时复制,读取时使用已有快照。
这些方案把发布或同步封装在明确的操作中,能减少手动维护读写锁的工作。具体机制见 安全发布、ConcurrentHashMap 和 CopyOnWriteArrayList。
读写锁适合需要一起保护多个字段的场景。读取占比高、读操作有一定成本,而且写竞争可控时,并行读取才更有机会受益。评估时应测量以下四项指标:
- 读写比例,确认写入是否足够少。
- 临界区耗时,也就是每次持锁执行代码的时间。
- 写线程等待时间,检查持续读取是否拖延更新。
- p99 延迟,即 99% 的请求都能在该时间内完成,用它观察较慢请求的等待。
7. 写锁降级的持锁区间
降级后要继续使用刚更新的数据,就要让写锁和读锁的持有时间有重叠。下面的代码让读锁在写锁释放前接过保护职责:
write.lock();
try {
refresh();
read.lock();
} finally {
write.unlock();
}
try {
useSnapshot();
} finally {
read.unlock();
}
refresh 成功后,当前线程先取得读锁,再释放写锁。这时其他写线程仍被读锁挡住,所以 useSnapshot 读取期间数据保持稳定。若先释放写锁再申请读锁,其他写线程就能在两者之间修改数据。
8. StampedLock 的模式转换
读取后发现需要更新时,可以调用 tryConvertToWriteLock(stamp) 尝试转换为写模式。有效 stamp 对应以下三种情况时,转换可以成功:
- 已经持有写锁,继续保留写模式。
- 持有读锁,并且当前线程是唯一的读锁持有者。
- 持有乐观读 stamp,而且锁处于空闲状态。
转换成功会返回可用于释放写锁的 stamp。转换失败返回零,原来的读锁仍保持持有。因此,要先用临时变量接收结果,保留失败时释放读锁所需的旧 stamp。
long writeStamp = lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) {
stamp = writeStamp;
} else {
lock.unlockRead(stamp);
stamp = lock.writeLock();
// 重新检查更新条件。
}
此片段假设进入时持有有效读 stamp。后续更新结束后要用 unlockWrite(stamp) 释放写锁。失败时保留旧 stamp,才能正确释放原读锁。
9. 常见问题
9.1 乐观读可以直接替代读锁吗?
乐观读适合读取简单字段组合,并且读取过程能容忍暂时不一致的代码。它仍包含获取 stamp、读取、验证和失败重读几个环节。复杂可变结构需要检查每一次引用访问,因此迁移前要逐处检查读取代码。
9.2 读写锁支持公平策略和 Condition 吗?
ReentrantReadWriteLock 默认采用非公平策略。公平模式会约束排队读者和写者的进入顺序。无超时 tryLock() 仍允许插队。
只有写锁支持 newCondition()。读锁调用该方法会抛出 UnsupportedOperationException。
10 · 面试题卡片
10.1 StampedLock 的乐观读怎样保证结果可用
出现公司:美团
考察重点
- 说明乐观读期间的写入为什么会使多个字段不一致。
- 解释 stamp 验证成功的含义,以及失败后为什么要重读全部字段。
- 说明不可重入对方法调用的限制,以及验证前哪些操作有风险。
相关内容:StampedLock 与 stamp和乐观读。
参考回答
乐观读在读取全部字段后验证 stamp,验证成功才使用局部变量中的结果。
所有写入都要使用同一把写锁,这样验证才能发现读取期间的写锁获取。验证失败后,要取得普通读锁,并把组成结果的字段全部重读。
验证前的读取要能容忍临时不一致,产生业务副作用的操作应放在验证之后。验证只覆盖已经完成的读取区间,后续共享字段访问仍需保护。
StampedLock 不可重入,也没有条件队列。因此,组件需要明确哪些方法会再次加锁,以及哪些字段必须保持一致。
10.2 写锁降级时,为什么要先取得读锁再释放写锁?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明先取得读锁怎样阻止其他写线程在降级期间修改数据。
相关内容:写锁降级的持锁区间。
参考回答
先取得读锁,能让写锁释放后仍有读锁保护数据。
当前线程持有写锁时,其他线程无法写入;释放写锁后,读锁继续阻止写入。所以,两次持锁之间没有空档,当前线程可以稳定读取刚更新的数据。
10.3 StampedLock 转换写锁失败后,原来的读锁怎样处理?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释转换失败时返回零的含义,以及为什么还要保留旧 stamp。
相关内容:StampedLock 的模式转换。
参考回答
转换失败会返回零,原来的读锁仍然持有。
代码要保留旧 stamp,用它释放读锁,再申请写锁。两次获取之间数据可以变化,所以取得写锁后要重新检查更新条件。直接用零覆盖旧 stamp,会丢失释放原读锁所需的信息。