StampedLock 的乐观读机制主要解决了读多写少场景下,传统读写锁(如 ReentrantReadWriteLock
)可能存在的写线程饥饿或性能瓶颈问题 。它通过一种"乐观"的策略,允许读操作在特定条件下完全不阻塞写操作,从而显著提高系统的整体吞吐量。
解决的问题
- 写线程饥饿:
- 在传统的读写锁(
ReentrantReadWriteLock
)中,读锁是共享的。当有大量读线程持续持有读锁时,写线程可能长时间无法获取写锁(因为写锁需要独占访问),导致写操作被"饿死"。
- 在传统的读写锁(
- 悲观锁的开销:
- 即使没有写操作正在进行,传统的读锁在获取和释放时仍然需要一定的同步开销(CAS操作、维护内部状态等)。在超高并发的读场景下,这些开销累积起来可能成为性能瓶颈。
- 读操作占主导:
- 在大多数应用场景(如缓存、配置读取)中,读操作的频率远高于写操作。传统读写锁的设计(读优先或公平策略)在这种场景下效率不高。
乐观读机制的核心思想
- "乐观"假设: 假设在读操作进行期间,很可能没有写操作发生。
- 不阻塞写: 乐观读不获取真正的锁 ,因此它完全不会阻塞试图获取写锁的线程。写线程总是可以立即尝试获取写锁。
- 版本验证: 在乐观读完成之后、使用读取到的数据之前,必须验证在读操作期间是否发生过写操作。这是通过检查一个"戳记"来实现的。
- 失败降级: 如果验证失败(表明在读操作期间发生了写操作),则乐观读的结果可能无效。此时,调用者可以选择重试、放弃或者降级为获取一个悲观的读锁(会阻塞写)来确保读取到一致的数据。
如何使用乐观读
使用 StampedLock 的乐观读遵循以下模式:
java
import java.util.concurrent.locks.StampedLock;
public class OptimisticReadingExample {
private final StampedLock lock = new StampedLock();
private double x, y; // 共享数据
// 计算距离的方法 (使用乐观读)
public double distanceFromOrigin() {
// 1. 尝试乐观读:获取一个戳记(stamp)
long stamp = lock.tryOptimisticRead();
// 2. 将共享变量读入本地局部变量(此时数据可能被其他线程修改!)
double currentX = x;
double currentY = y;
// 3. 关键:验证戳记是否仍然有效(自获取戳记以来是否有过写操作?)
if (!lock.validate(stamp)) {
// 3a. 验证失败:乐观读期间发生了写操作!戳记已失效。
// 降级为获取悲观的读锁(会阻塞写,确保读取一致性)
stamp = lock.readLock(); // 这是一个阻塞调用
try {
// 在悲观读锁保护下重新读取数据
currentX = x;
currentY = y;
} finally {
// 无论如何都要释放读锁
lock.unlockRead(stamp);
}
}
// 3b. 如果验证成功,或者降级后重新读取成功,则使用 currentX 和 currentY 进行计算
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 写操作方法
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock(); // 获取独占写锁
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp); // 释放写锁
}
}
}
关键步骤详解
-
long stamp = lock.tryOptimisticRead();
- 尝试获取一个乐观读戳记 (
stamp
)。这个方法非常快,通常只是一个内存读取或简单的原子操作,不涉及锁竞争,不会阻塞任何线程(包括写线程)。 - 这个
stamp
代表了数据当前的一个"版本"或"状态"。如果之后没有写操作发生,这个版本应该保持不变。
- 尝试获取一个乐观读戳记 (
-
读取共享数据到局部变量 (
currentX = x; currentY = y;
)- 将你需要访问的共享数据复制到方法的局部变量中。这是必须的 ,因为后续的验证只保证到验证那一刻为止的状态,如果验证成功后你又去直接读
x
或y
,数据可能又被修改了。 - 重要: 在这个读取过程中,没有任何锁阻止其他线程(尤其是写线程)修改
x
和y
!所以此时读取到的currentX
和currentY
可能是不一致 的(例如,x
被修改了但y
还没改),或者过时的。
- 将你需要访问的共享数据复制到方法的局部变量中。这是必须的 ,因为后续的验证只保证到验证那一刻为止的状态,如果验证成功后你又去直接读
-
if (!lock.validate(stamp)) { ... }
- 这是最核心 的步骤。调用
validate(stamp)
检查自你获取乐观读戳记 (stamp
) 以来,是否有任何线程成功获取了写锁并修改了数据。 - 如果返回
true
(验证成功):- 意味着在你获取
stamp
之后,直到validate
调用执行的那一刻 ,没有发生过写操作。你可以确信第 2 步读取到的currentX
和currentY
是一致 且有效 的(至少是某个一致状态下的快照)。你可以安全地使用它们进行计算 (return Math.sqrt(...)
)。
- 意味着在你获取
- 如果返回
false
(验证失败):- 意味着在你读取数据(第 2 步)的过程中或之后,在
validate
调用之前,至少发生了一次成功的写操作 。你第 2 步读取的数据currentX
和currentY
可能无效(不一致或过时),绝对不能使用它们! - 此时,你需要降级 (downgrade) 为传统的、悲观的策略:获取一个读锁 (
stamp = lock.readLock();
) 。这个调用可能会阻塞,等待当前可能存在的写锁释放。 - 在获取到读锁后,重新读取 共享数据 (
x
和y
) 到局部变量 (currentX
,currentY
)。因为现在持有读锁,所以能保证在读取过程中不会有写操作发生,读取到的数据是一致的。 - 在
finally
块中释放读锁 (lock.unlockRead(stamp);
)。 - 最后,使用在悲观读锁保护下读取到的、一致的数据进行计算。
- 意味着在你读取数据(第 2 步)的过程中或之后,在
- 这是最核心 的步骤。调用
-
使用数据 (
return Math.sqrt(...)
)- 无论是乐观读验证成功,还是降级到悲观读后成功读取,最终都使用局部变量
currentX
和currentY
进行计算并返回结果。这些局部变量要么代表一个验证通过的快照(乐观成功),要么代表在悲观读锁保护下获取的最新一致状态(乐观失败后降级)。
- 无论是乐观读验证成功,还是降级到悲观读后成功读取,最终都使用局部变量
使用乐观读的注意事项
- 验证 (
validate
) 是必须的: 绝对不能在未验证或验证失败的情况下使用乐观读读取的数据。 - 数据拷贝到局部变量: 必须在获取乐观读戳记后、验证之前,将共享数据复制到方法内部的局部变量。验证通过后只使用这些局部变量。
- 乐观读适合短小的读操作: 乐观读操作本身(从获取戳记到验证)应该尽可能短。如果读操作本身耗时很长,那么在此期间发生写操作的概率就非常大,导致验证失败的概率很高,最终可能还是需要降级为悲观读锁,反而失去了性能优势,甚至更慢。
- 乐观读不修改共享状态: 乐观读只适用于只读操作。如果你需要在读操作中修改状态,必须使用写锁或其它同步机制。
- 乐观读不是锁:
tryOptimisticRead()
返回的只是一个戳记 (stamp),不是锁对象。你不能用它来解锁。 StampedLock
不可重入: 同一个线程试图重复获取锁(即使是读锁)会导致死锁。- 没有条件变量:
StampedLock
不直接支持Condition
,而ReentrantReadWriteLock
的写锁可以。 - 小心转换:
StampedLock
提供了tryConvertToWriteLock
等方法,但使用它们需要非常小心,容易出错。
总结
StampedLock 的乐观读机制通过牺牲"读操作总是能看到最新数据"的绝对保证(通过后续验证和可能的降级来补偿),换取了在读多写少 场景下的超高读并发性能 和避免写线程饥饿 。其核心在于 tryOptimisticRead()
获取戳记、将数据拷贝到局部变量、validate(stamp)
验证、验证失败则降级获取悲观读锁重新读取这一套流程。正确使用乐观读可以显著提升高并发读取场景的系统吞吐量,但必须严格遵守使用模式并理解其注意事项。