Java 多线程:StampedLock 乐观读锁与性能优化
一、为什么需要 StampedLock?
1. ReentrantReadWriteLock 存在什么问题?
ReentrantReadWriteLock 支持读读共享、读写互斥、写写互斥。
假设有 100 个线程同时查询数据:
线程 A:获取读锁
线程 B:获取读锁
线程 C:获取读锁
...
线程 100:获取读锁它们可以同时读取。
但是,即使没有线程正在写入,所有读线程仍然需要获取读锁、维护读锁状态。
虽然读锁允许共享,但获取读锁仍然需要进行同步状态管理。
如果业务场景是:
99% 的操作都是读取。
1% 的操作是修改。
写操作非常少。
有没有办法让读取操作更加轻量?
这就是 StampedLock 引入乐观读的原因。
2. 什么是 StampedLock?
StampedLock 是 JDK 8 提供的一种读写锁。
它支持三种访问模式:
其中最重要的是第三种:乐观读。
乐观读不需要真正获取读锁,而是先读取数据,再验证读取期间是否发生过写操作。
如果没有发生写操作,读取结果就可以使用。
如果发生了写操作,就需要重新获取悲观读锁,再读取一次。
二、乐观读与悲观读
1. 什么是悲观读?
悲观读就是我们之前学习的普通读锁。
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
readLock.lock();
try {
// 读取共享数据
} finally {
readLock.unlock();
}执行过程:
线程 A 获取读锁
|
v
读取共享数据
|
v
释放读锁在读取期间:
其他线程可以获取读锁。
其他线程不能获取写锁。
因此,悲观读可以保证读取期间没有其他线程修改受保护的数据。
2. 什么是乐观读?
乐观读的核心思想:
先读取,不阻塞写线程,读取完成后再验证数据是否发生变化。
线程 A:开始乐观读
|
v
读取数据
|
v
验证数据是否有效
|
├── 有效:直接使用
|
└── 无效:重新加读锁读取这里最重要的区别:
乐观读期间,其他线程仍然可以获得写锁并修改数据。
所以乐观读并不能保证读取过程中数据始终不变。
它只是通过版本戳验证,判断读取结果是否可信。
3. 两者的区别
可以这样理解:
悲观读:先加锁,确保别人不能修改,再读取。
乐观读:先读取,读取后检查别人有没有修改。
三、StampedLock 的核心:stamp
1. 什么是 stamp?
StampedLock 的很多方法都会返回一个 long 类型的值。
例如:
long stamp = lock.writeLock();这个 stamp 就是锁状态标识,也可以理解为当前锁状态对应的版本戳。
它不是普通的锁对象,也不是线程 ID。
StampedLock 通过 stamp 来识别锁状态,并在释放锁、验证乐观读或转换锁时使用它。
例如:
StampedLock lock = new StampedLock();
long stamp = lock.writeLock();
try {
// 执行业务
} finally {
lock.unlockWrite(stamp);
}这里:
long stamp = lock.writeLock();表示获取写锁,并返回对应的 stamp。
释放时:
lock.unlockWrite(stamp);需要传入之前获取到的 stamp。
注意:StampedLock 不像 ReentrantLock 一样通过当前线程身份来管理锁的所有权,而是通过 stamp 识别锁状态。
2. 为什么需要 stamp?
因为 StampedLock 支持多种锁模式:
写锁。
悲观读锁。
乐观读。
它需要一种机制区分不同的锁状态。
stamp 就承担了这个作用。
另外,stamp 不应该被当作永久不变的版本号。锁状态发生变化后,后续获取的 stamp 可能不同。
3. stamp 的几个注意事项
stamp 使用 long 类型。
获取锁时需要保存返回的 stamp。
释放锁时需要使用对应的 stamp。
不要将 stamp 当成线程 ID。
不要将 stamp 当成业务数据版本号。
乐观读 stamp 不需要释放,因为乐观读本身没有真正获取锁。
四、StampedLock 三种锁的使用
1. 写锁:writeLock()
写锁与 ReentrantReadWriteLock 的写锁类似。
同一时间只能有一个线程持有写锁。
StampedLock lock = new StampedLock();
long stamp = lock.writeLock();
try {
// 修改共享数据
} finally {
lock.unlockWrite(stamp);
}执行过程:
线程 A 获取写锁
|
v
修改共享数据
|
v
释放写锁写锁是独占模式。
2. 悲观读锁:readLock()
悲观读锁允许多个线程同时读取。
StampedLock lock = new StampedLock();
long stamp = lock.readLock();
try {
// 读取共享数据
} finally {
lock.unlockRead(stamp);
}它和 ReentrantReadWriteLock 的读锁比较类似。
3. 乐观读:tryOptimisticRead()
这是 StampedLock 最重要的方法。
long stamp = lock.tryOptimisticRead();它不会真正获取读锁,而是尝试获取一个乐观读 stamp。
如果当前没有写锁持有者,通常返回非零 stamp。
如果当前已经有写锁持有者,则返回 0。
例如:
StampedLock lock = new StampedLock();
long stamp = lock.tryOptimisticRead();这里并没有真正锁住数据。
其他线程仍然可以获取写锁。
4. validate():验证读取是否有效
乐观读必须配合:
lock.validate(stamp);使用。
它的作用是:
检查从获取乐观读 stamp 到验证时,是否有写锁操作使该 stamp 失效。
返回值:
true:stamp 仍然有效。
false:stamp 已经失效,需要重新读取。
例如:
long stamp = lock.tryOptimisticRead();
// 读取共享数据
int value = data;
// 验证
if (!lock.validate(stamp)) {
// 读取期间可能发生了写操作
// 需要重新读取
}validate() 并不是给数据加锁,而是验证之前的乐观读结果是否仍然可信。
5. 乐观读和 validate() 的关系
tryOptimisticRead()
|
v
获取乐观读 stamp
|
v
读取共享数据
|
v
validate(stamp)
|
├── true
| |
| v
| 使用读取结果
|
└── false
|
v
获取悲观读锁
|
v
重新读取数据
|
v
释放悲观读锁需要特别注意:
validate() 返回 true,代表从获取 stamp 到验证时没有发生使该 stamp 失效的写锁操作。
如果其他线程在 validate() 成功之后才开始写入,并不会使已经复制到局部变量中的读取结果失效。
五、乐观读完整执行流程
1. 没有写操作的情况
线程 A:
获取乐观读 stamp
|
v
读取数据
|
v
validate(stamp)
|
v
返回 true
|
v
直接使用读取结果因为读取期间没有写锁操作,stamp 仍然有效。
因此不需要真正获取读锁。
2. 读取期间发生写操作
例如:
线程 A:
获取乐观读 stamp
|
v
读取数据的一部分
|
|
| 线程 B:
| 获取写锁
| |
| v
| 修改数据
| |
| v
| 释放写锁
|
v
继续读取数据
|
v
validate(stamp)
|
v
返回 false
|
v
重新获取悲观读锁
|
v
重新读取完整数据
|
v
释放读锁这里最关键的是:
乐观读期间,数据可能被其他线程修改,所以验证失败后不能继续使用之前读取的数据。
3. 完整 Java 8 示例
假设有一个坐标对象:
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x;
private double y;
private final StampedLock lock =
new StampedLock();
// 修改坐标
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp);
}
}
// 计算坐标到原点的距离
public double distanceFromOrigin() {
// 第一步:尝试乐观读
long stamp = lock.tryOptimisticRead();
double currentX;
double currentY;
// 第二步:读取数据
if (stamp != 0L) {
currentX = x;
currentY = y;
// 第三步:验证
if (lock.validate(stamp)) {
return Math.sqrt(
currentX * currentX
+ currentY * currentY
);
}
}
// 验证失败,或者当前存在写锁
// 获取悲观读锁,重新读取
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
return Math.sqrt(
currentX * currentX
+ currentY * currentY
);
}
}这里有三个核心步骤:
long stamp = lock.tryOptimisticRead();获取乐观读 stamp。
double currentX = x;
double currentY = y;先将共享数据读取到局部变量。
if (lock.validate(stamp)) {验证期间是否发生写操作。
如果验证失败:
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}重新获取悲观读锁,并重新读取数据。
注意:乐观读应该先复制数据,再验证;验证失败后必须重新读取,不能继续使用之前的局部变量。
4. 乐观读的使用限制
乐观读并不适合所有类型的数据。
比较适合:
简单数值字段。
坐标、统计数据。
多个字段组成的简单状态。
读取完成后可以进行验证的共享数据。
不适合直接用于:
读取期间执行数据库操作。
根据未验证的数据发送消息。
直接遍历可能被并发修改的复杂集合。
读取并操作外部可变对象。
原因是:乐观读期间数据可能正在被修改,读取结果可能暂时不一致。
因此,应该先读取简单数据到局部变量,验证成功后再执行后续业务操作。
六、StampedLock 为什么性能可能更好?
1. ReentrantReadWriteLock
读取时:
获取读锁
|
v
读取数据
|
v
释放读锁多个读线程共享读锁,但仍然需要维护同步状态。
2. StampedLock 悲观读
读取时:
获取悲观读锁
|
v
读取数据
|
v
释放悲观读锁与 ReentrantReadWriteLock 的读锁思路类似。
3. StampedLock 乐观读
读取时:
获取乐观读 stamp
|
v
读取数据
|
v
验证 stamp
|
v
直接使用不需要真正获取读锁,也不会阻塞写线程。
因此,在读多写少、读取操作较简单的场景下,乐观读可能带来更好的性能。
但是:
StampedLock 并不保证一定比 ReentrantReadWriteLock 性能更好。
如果写操作频繁,乐观读可能经常验证失败,导致重复读取,反而增加开销。
七、StampedLock 为什么不支持可重入?
这是一个非常重要的知识点。
回顾 ReentrantLock:
lock.lock();
try {
lock.lock();
try {
// 再次获取同一把锁
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}ReentrantLock 支持可重入。
但是 StampedLock 不支持可重入。
例如:
StampedLock lock = new StampedLock();
long stamp1 = lock.writeLock();
try {
// 再次获取同一把写锁
long stamp2 = lock.writeLock();
} finally {
lock.unlockWrite(stamp1);
}这里可能导致线程自己等待自己。
原因:
StampedLock 不记录当前线程的重入次数,也不使用线程所有权来实现可重入。
它通过 stamp 管理锁状态。
所以:
不支持写锁重入。
不支持传统意义上的可重入读写锁语义。
同一个线程重复获取写锁,可能导致死锁。
持有写锁时再次获取读锁,也可能导致自己等待自己。
需要特别注意:
不支持可重入,不等于同一个线程重复获取读锁一定会自我阻塞。
因为读锁是共享模式,重复获取读锁可能成功,但每次获取都必须保存对应的 stamp,并分别释放。
实际开发中应避免在嵌套方法中重复获取同一把 StampedLock,降低锁管理复杂度。
八、锁转换:tryConvertToWriteLock()
这是 StampedLock 比较有特色的一个功能。
上一课我们学过:
ReentrantReadWriteLock 不支持直接从读锁升级为写锁。
StampedLock 提供了:
lock.tryConvertToWriteLock(stamp);它允许尝试将当前 stamp 转换为写锁。
1. 转换成功的情况
例如:
线程 A 持有读锁
没有其他线程持有读锁
线程 A 尝试转换为写锁
转换成功此时可以直接获得写锁,不需要先释放读锁再重新获取写锁。
2. 转换失败的情况
例如:
线程 A 持有读锁
线程 B 也持有读锁
线程 A 尝试转换为写锁
转换失败因为线程 B 仍然持有读锁,A 无法独占。
方法会返回 0,表示转换失败。
3. tryConvertToWriteLock() 的转换规则
4. 转换方法的完整代码
long stamp = lock.readLock();
try {
// 先读取数据
if (需要更新数据()) {
// 尝试将读锁转换为写锁
long writeStamp =
lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0L) {
// 转换成功,更新 stamp
stamp = writeStamp;
更新数据();
} else {
// 转换失败,先释放读锁
lock.unlockRead(stamp);
// 将 stamp 清零,避免重复释放旧锁
stamp = 0L;
// 重新获取写锁
stamp = lock.writeLock();
// 获取写锁后,必须重新检查条件
if (仍然需要更新数据()) {
更新数据();
}
}
}
} finally {
if (stamp != 0L) {
lock.unlock(stamp);
}
}关键点:
tryConvertToWriteLock() 尝试转换锁。
转换成功,返回新的写锁 stamp。
转换失败,返回 0。
转换失败后,需要释放读锁,再获取写锁。
获取写锁后,必须重新检查业务条件。
stamp 必须正确更新,防止重复释放旧锁。
5. StampedLock 还支持哪些转换?
StampedLock 还提供:
tryConvertToReadLock(stamp);
tryConvertToWriteLock(stamp);
tryConvertToOptimisticRead(stamp);它们分别用于尝试转换为读锁、写锁和乐观读。
转换不一定成功,需要判断返回的 stamp 是否为 0。
例如,写锁转换为读锁可以实现锁降级。
九、StampedLock 与 ReentrantReadWriteLock 对比
什么时候使用 ReentrantReadWriteLock?
需要可重入。
业务逻辑存在嵌套调用。
需要 Condition。
需要相对简单的读写锁机制。
什么时候使用 StampedLock?
读操作远远多于写操作。
读取操作比较简单。
可以接受乐观读取后进行验证。
希望减少读锁竞争。
生产环境中不要因为 StampedLock 看起来更高级,就直接替换 ReentrantReadWriteLock。
应该结合读写比例、竞争情况和压测结果选择。
十、生产环境注意事项
1. 乐观读必须验证
错误:
long stamp = lock.tryOptimisticRead();
double value = x + y;
// 没有验证就直接使用
return value;推荐:
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 currentX + currentY;2. 不要在乐观读期间执行有副作用的操作
例如:
long stamp = lock.tryOptimisticRead();
// 不推荐:根据尚未验证的数据执行业务操作
sendMessage(x, y);因为此时读取的数据可能不一致。
应该先读取数据,验证成功后,再进行后续操作。
3. 不要在同一线程中嵌套获取写锁
StampedLock 不支持可重入。
如果嵌套方法重复获取同一把写锁,可能导致线程自己等待自己。
4. 不要忘记释放锁
悲观读锁:
long stamp = lock.readLock();
try {
// 业务
} finally {
lock.unlockRead(stamp);
}写锁:
long stamp = lock.writeLock();
try {
// 业务
} finally {
lock.unlockWrite(stamp);
}StampedLock 的释放需要使用正确的 stamp。
错误的 stamp 可能导致 IllegalMonitorStateException。
注意:乐观读不是真正获取锁,不需要调用 unlock()。
5. StampedLock 不是基于 AQS 实现的
这一点与前面学习的 ReentrantLock、ReentrantReadWriteLock 不同。
StampedLock 内部自行维护锁状态和等待队列,并没有继承 AQS。
6. 不要把 StampedLock 当成分布式锁
StampedLock 和 ReentrantLock 一样,只能在当前 JVM 内使用。
多个 Docker 容器之间不能通过各自的 StampedLock 实例实现互斥。
十一、重点总结
需要重点记住:
StampedLock 支持写锁、悲观读和乐观读三种模式。
乐观读不是真正获取读锁,不会阻塞写线程。
乐观读必须配合 validate() 验证。
验证失败必须重新获取悲观读锁,并重新读取数据。
stamp 是 long 类型的锁状态标识,不是线程 ID。
StampedLock 不支持可重入。
StampedLock 支持尝试锁转换,但不保证转换一定成功。
乐观读适合读多写少的场景。
StampedLock 不支持 Condition。
StampedLock 不基于 AQS 实现。
乐观读不需要释放锁。
StampedLock 不是分布式锁。
十二、常见问题与答案
1. 什么是乐观读?
乐观读不真正获取读锁,而是先读取数据,再通过 validate() 检查读取期间是否发生写操作。
2. 乐观读期间,其他线程可以写入吗?
可以。乐观读不会阻塞写线程,因此读取期间可能发生数据修改。
3. tryOptimisticRead() 返回 0 是什么意思?
通常表示当前存在写锁持有者,无法获取有效的乐观读 stamp。
4. validate() 返回 false 怎么办?
说明之前的乐观读结果不可信,需要获取悲观读锁,重新读取数据。
5. StampedLock 为什么性能可能更好?
因为乐观读不需要真正获取读锁,可以减少读线程之间的同步开销。
6. StampedLock 支持可重入吗?
不支持。同一个线程重复获取写锁可能导致自己等待自己。
7. StampedLock 和 ReentrantReadWriteLock 最大的区别是什么?
StampedLock 支持乐观读和锁转换,但不支持可重入;ReentrantReadWriteLock 支持可重入和 Condition。
8. StampedLock 能直接将读锁转换为写锁吗?
可以通过 tryConvertToWriteLock() 尝试转换,但不保证成功。失败时需要释放读锁,再获取写锁。
9. 乐观读一定比悲观读好吗?
不一定。如果写操作频繁,乐观读可能经常验证失败,导致重复读取。
10. StampedLock 适合什么场景?
读多写少、读取逻辑相对简单、能够通过版本戳验证读取结果的共享数据场景。
十三、复习测试
StampedLock 是什么?解决了 ReentrantReadWriteLock 的什么问题?
什么是乐观读?它和悲观读有什么区别?
乐观读期间,其他线程能否获取写锁?
tryOptimisticRead() 返回的 stamp 是什么?
tryOptimisticRead() 返回 0 代表什么?
validate() 有什么作用?什么时候返回 false?
为什么乐观读必须先读取数据,再验证?
验证失败后为什么需要重新读取?
StampedLock 为什么不支持可重入?
tryConvertToWriteLock() 有什么作用?
读锁转换为写锁失败后应该怎么处理?
StampedLock 和 ReentrantReadWriteLock 应该如何选择?
