跳到主要内容
约 7 分钟3 张卡

读写锁

读写锁允许多个线程同时读取共享数据,并让写入线程独占修改。本文先说明读锁与写锁怎样配合,再讲锁降级。随后介绍 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 吗?​

10 · 面试题卡片​

10.1 StampedLock 的乐观读怎样保证结果可用​

出现公司:美团

考察重点

  • 说明乐观读期间的写入为什么会使多个字段不一致。
  • 解释 stamp 验证成功的含义,以及失败后为什么要重读全部字段。
  • 说明不可重入对方法调用的限制,以及验证前哪些操作有风险。

相关内容:StampedLock 与 stamp和乐观读。

参考回答

乐观读在读取全部字段后验证 stamp,验证成功才使用局部变量中的结果。

所有写入都要使用同一把写锁,这样验证才能发现读取期间的写锁获取。验证失败后,要取得普通读锁,并把组成结果的字段全部重读。

验证前的读取要能容忍临时不一致,产生业务副作用的操作应放在验证之后。验证只覆盖已经完成的读取区间,后续共享字段访问仍需保护。

StampedLock 不可重入,也没有条件队列。因此,组件需要明确哪些方法会再次加锁,以及哪些字段必须保持一致。

10.2 写锁降级时,为什么要先取得读锁再释放写锁?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明先取得读锁怎样阻止其他写线程在降级期间修改数据。

相关内容:写锁降级的持锁区间。

参考回答

先取得读锁,能让写锁释放后仍有读锁保护数据。

当前线程持有写锁时,其他线程无法写入;释放写锁后,读锁继续阻止写入。所以,两次持锁之间没有空档,当前线程可以稳定读取刚更新的数据。

10.3 StampedLock 转换写锁失败后,原来的读锁怎样处理?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释转换失败时返回零的含义,以及为什么还要保留旧 stamp。

相关内容:StampedLock 的模式转换。

参考回答

转换失败会返回零,原来的读锁仍然持有。

代码要保留旧 stamp,用它释放读锁,再申请写锁。两次获取之间数据可以变化,所以取得写锁后要重新检查更新条件。直接用零覆盖旧 stamp,会丢失释放原读锁所需的信息。

11. 参考资料​