StampedLock入门教程
文章目录
- 一、理解“戳” (Stamp)
- 二、为什么 `StampedLock` 能提高读性能?秘密在于“乐观读”
- StampedLock性能对比
- 性能对比结果图
- 总结
- StampedLock完整演示代码
- 对代码的疑问之处
- 问题一:为什么 `demonstrateOptimisticReadFailure` 中写线程能修改成功?
- 问题二:锁升级这块不是很理解,为什么结果是成功的?
- 1. 什么是锁升级?为什么需要它?
- 2. 锁升级什么时候会成功?什么时候会失败?
- 3. 为什么您的代码里升级成功了?
- 4. `finally` 块中的 `lock.unlock(stamp)`
- 🎯总结
直击了 StampedLock
的设计核心。解释它提升读性能的秘密。
一、理解“戳” (Stamp)
首先,我们来理解“戳”(Stamp)是什么。
在 ReentrantReadWriteLock
中,lock()
和 unlock()
是没有参数和返回值的。你只要调用 lock()
获取锁,用完后调用 unlock()
释放锁即可。
StampedLock
完全不同。它的所有“上锁”操作都会返回一个long
类型的数字,这个数字就是所谓的**“戳” (Stamp)。而它所有的“解锁”操作,都必须传入这个“戳”**。
long stamp = lock.writeLock(); // 上写锁,返回一个戳
try {// ...
} finally {lock.unlockWrite(stamp); // 解锁时必须传入获取锁时得到的那个戳
}
为什么需要“戳”?
这个“戳”本质上是一个版本号或者状态快照。
- 当没有任何锁时,它是一个初始值。
- 当有线程获取写锁时,这个“戳”的值会发生改变(比如增加一个版本号)。
- 每次上锁操作返回的“戳”都是独一无二的。
所以,这句话 “在使用读锁、写锁时都必须配合【戳】使用”,指的就是这种 lock()
返回戳、unlock()
传入戳的使用模式。这个“戳”是锁状态的凭证。
特别强调:获取写锁的时候,版本号才会改变!!如果我们获取读锁,是不会修改版本号的!!!!
二、为什么 StampedLock
能提高读性能?秘密在于“乐观读”
ReentrantReadWriteLock
无论如何,都是一种悲观锁。即使是读锁,当多个读线程和写线程竞争时,仍然需要排队、阻塞、上下文切换,这些都有性能开销。它总是悲观地认为“我读的时候,很可能会有别人来写”。
StampedLock
之所以性能更高,是因为它引入了一种全新的、ReentrantReadWriteLock
没有的模式——乐观读 (Optimistic Reading)。
乐观读的核心思想是:我非常乐观地认为,在我读取共享数据期间,根本不会有线程来修改它。
基于这个乐观的假设,StampedLock
的乐观读操作如下:
-
尝试乐观读 (
tryOptimisticRead
):- 线程想读取共享数据,它先调用
lock.tryOptimisticRead()
。 - 这个方法不会加任何锁,不会阻塞线程,它只是瞬间获取一下当前的“戳”(版本号),然后立即返回。这个过程几乎没有开销,速度极快。
- 线程想读取共享数据,它先调用
-
读取共享数据:
- 线程拿着这个“戳”,然后去读取共享变量(比如
x
,y
的值)。
- 线程拿着这个“戳”,然后去读取共享变量(比如
-
验证“戳” (
validate
):- 读完数据后,线程必须调用
lock.validate(stamp)
,并传入第一步获取的那个“戳”。 validate
方法会检查从第一步到当前时刻,有没有写操作发生过。它的判断依据就是**“戳”的版本号有没有变**。- 如果版本号没变 (
validate
返回true
):这说明在刚才的读取期间,没有任何写操作来干扰。那么我们刚才读取的数据就是一致的、有效的。这次“乐观读”成功了! - 如果版本号变了 (
validate
返回false
):这说明在我们读取数据的过程中,有一个“写线程”插了进来,获取了写锁,并修改了数据。那么我们刚才读到的数据就是“脏”的、不可信的。
- 如果版本号没变 (
- 读完数据后,线程必须调用
-
失败后的补偿:
- 如果
validate
失败了,说明乐观失败了,我们不能再这么乐观。 - 此时,程序必须“升级”为悲观的读锁,即调用
lock.readLock()
来老老实实地加锁,然后重新读取一遍数据。
- 如果
性能提升的关键点:
在“读多写少”的场景下,绝大多数的乐观读操作都会成功。成功的乐观读,其开销仅仅是两次方法调用和一次版本号比较,完全没有线程阻塞和上下文切换的开销,甚至没有CAS操作的开销,性能几乎和无锁操作一样快。
只有在极少数情况下(读的过程中发生了写),乐观读才会失败,并升级为悲观读锁,付出一点额外代价。但总体算下来,性能提升是巨大的。
StampedLock性能对比
package StampLock;import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.StampedLock;public class StampedLockPerformanceDemo {// 共享数据static class SharedData {private int value = 0;// 三种不同的锁private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();private final StampedLock stampedLock = new StampedLock();// 1. 使用 ReentrantReadWriteLock 读取public int readWithRWLock() {rwLock.readLock().lock();try {return value;} finally {rwLock.readLock().unlock();}}// 2. 使用 StampedLock 的悲观读public int readWithStampedLock() {long stamp = stampedLock.readLock();try {return value;} finally {stampedLock.unlockRead(stamp);}}// 3. 使用 StampedLock 的乐观读(性能最好!)public int readWithOptimisticRead() {// 获取乐观读戳long stamp = stampedLock.tryOptimisticRead();// 读取数据(无锁!)int currentValue = value;// 验证期间是否有写操作if (!stampedLock.validate(stamp)) {// 升级为悲观读stamp = stampedLock.readLock();try {currentValue = value;} finally {stampedLock.unlockRead(stamp);}}return currentValue;}// 写操作(使用 StampedLock)public void write(int newValue) {long stamp = stampedLock.writeLock();try {value = newValue;} finally {stampedLock.unlockWrite(stamp);}}}public static void main(String[] args) throws InterruptedException {SharedData data = new SharedData();int threadCount = 100;int iterations = 100000;// 测试不同读锁的性能System.out.println("开始性能测试...\n");// 1. 测试 ReentrantReadWriteLocklong startTime = System.currentTimeMillis();testReadPerformance(data, threadCount, iterations, "RWLock");long rwLockTime = System.currentTimeMillis() - startTime;System.out.println("ReentrantReadWriteLock 耗时: " + rwLockTime + " ms");// 2. 测试 StampedLock 悲观读startTime = System.currentTimeMillis();testReadPerformance(data, threadCount, iterations, "StampedLock");long stampedLockTime = System.currentTimeMillis() - startTime;System.out.println("StampedLock 悲观读 耗时: " + stampedLockTime + " ms");// 3. 测试 StampedLock 乐观读startTime = System.currentTimeMillis();testReadPerformance(data, threadCount, iterations, "OptimisticRead");long optimisticTime = System.currentTimeMillis() - startTime;System.out.println("StampedLock 乐观读 耗时: " + optimisticTime + " ms");System.out.println("\n性能提升: " +String.format("%.2f", (double)rwLockTime / optimisticTime) + " 倍");}private static void testReadPerformance(SharedData data, int threadCount,int iterations, String lockType)throws InterruptedException {CountDownLatch latch = new CountDownLatch(threadCount);for (int i = 0; i < threadCount; i++) {new Thread(() -> {for (int j = 0; j < iterations; j++) {switch (lockType) {case "RWLock":data.readWithRWLock();break;case "StampedLock":data.readWithStampedLock();break;case "OptimisticRead":data.readWithOptimisticRead();break;}}latch.countDown();}).start();}latch.await();}
}
无锁的乐观读对性能提升极其明显!提升足足150倍!!!但是呢,StampedLock也不是万能的,他只是首先尝试使用乐观读(无锁),如果发现版本号不对劲,中间被修改过了,就需要加锁,重新读取,丢掉脏数据!!
关键代码:
// 3. 使用 StampedLock 的乐观读(性能最好!)public int readWithOptimisticRead() {// 获取乐观读戳long stamp = stampedLock.tryOptimisticRead();// 读取数据(无锁!)int currentValue = value;// 验证期间是否有写操作if (!stampedLock.validate(stamp)) {// 升级为悲观读stamp = stampedLock.readLock();try {currentValue = value;} finally {stampedLock.unlockRead(stamp);}}return currentValue;}
性能对比结果图
总结
- “配合【戳】使用”:指的是
StampedLock
所有上锁/解锁操作都围绕一个long
类型的版本号(戳)来进行。 - 提高读性能的原因:
StampedLock
引入了乐观读机制。在“读多写少”的场景下,乐观读允许线程在不加锁的情况下读取数据,并通过“戳”来验证数据的一致性。这个过程避免了绝大多数读操作的加锁、阻塞和线程切换开销,从而极大地提升了读取性能。它是用一种“先上车后补票”的乐观策略换来了性能的飞跃。
StampedLock完整演示代码
package StampLock;import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;
import java.util.Random;
import java.util.concurrent.locks.StampedLock;
public class SimpleStampedLockDemo {private static class Point {private double x, y;private final StampedLock lock = new StampedLock();// 移动点的位置(写操作)public void move(double deltaX, double deltaY) {long stamp = lock.writeLock();try {x += deltaX;y += deltaY;System.out.println(Thread.currentThread().getName() +" 移动点到: (" + x + ", " + y + ")");} finally {lock.unlockWrite(stamp);}}// 计算到原点的距离(乐观读)- 修正版public double distanceFromOrigin() {// 1. 尝试乐观读long stamp = lock.tryOptimisticRead();// 2. 读取数据double currentX = x;double currentY = y;// 模拟读取过程需要一些时间(让写线程有机会介入)try {Thread.sleep(100); // 模拟复杂计算} catch (InterruptedException e) {e.printStackTrace();}// 3. 验证在读取过程中数据是否被修改if (!lock.validate(stamp)) {// 数据被修改了,需要加锁重新读取System.out.println(Thread.currentThread().getName() +" 乐观读失败,升级为悲观读");stamp = lock.readLock();try {currentX = x;currentY = y;} finally {lock.unlockRead(stamp);}} else {System.out.println(Thread.currentThread().getName() +" 乐观读成功!");}return Math.sqrt(currentX * currentX + currentY * currentY);}// 专门用于演示乐观读失败的方法public void demonstrateOptimisticReadFailure() {System.out.println("开始演示乐观读失败场景...");// 读线程Thread reader = new Thread(() -> {long stamp = lock.tryOptimisticRead();System.out.println(Thread.currentThread().getName() +" 获取乐观读戳: " + stamp);// 读取第一个值double currentX = x;System.out.println(Thread.currentThread().getName() +" 读取 x = " + currentX);// 故意等待,让写线程有机会修改数据try {Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}// 读取第二个值double currentY = y;System.out.println(Thread.currentThread().getName() +" 读取 y = " + currentY);// 验证if (!lock.validate(stamp)) {System.out.println(Thread.currentThread().getName() +" ❌ 乐观读失败!数据在读取过程中被修改了");// 重新用悲观读stamp = lock.readLock();try {currentX = x;currentY = y;System.out.println(Thread.currentThread().getName() +" 使用悲观读重新读取: (" + currentX + ", " + currentY + ")");} finally {lock.unlockRead(stamp);}} else {System.out.println(Thread.currentThread().getName() +" ✓ 乐观读成功");}}, "读线程");// 写线程Thread writer = new Thread(() -> {try {// 等待读线程开始Thread.sleep(100);System.out.println(Thread.currentThread().getName() +" 准备修改数据...");move(10, 10);} catch (InterruptedException e) {e.printStackTrace();}}, "写线程");try {reader.start();writer.start();reader.join();writer.join();} catch (InterruptedException e) {e.printStackTrace();}}// 锁升级示例public void moveToOriginIfInFirstQuadrant() {long stamp = lock.readLock();try {if (x > 0 && y > 0) {// 尝试将读锁升级为写锁long writeStamp = lock.tryConvertToWriteLock(stamp);if (writeStamp != 0) {stamp = writeStamp;System.out.println(Thread.currentThread().getName() +" ✓ 成功将读锁升级为写锁");x = 0;y = 0;} else {System.out.println(Thread.currentThread().getName() +" ❌ 读锁升级失败,重新获取写锁");lock.unlockRead(stamp);stamp = lock.writeLock();try {x = 0;y = 0;} finally {lock.unlockWrite(stamp);return;}}}} finally {lock.unlock(stamp);}}}public static void main(String[] args) throws InterruptedException {Point point = new Point();System.out.println("=== StampedLock 简单示例 ===\n");// 示例1:基本的读写操作System.out.println("1. 基本读写操作:");point.move(3, 4);System.out.println("距离原点: " + point.distanceFromOrigin());System.out.println();// 示例2:单线程乐观读(肯定成功)System.out.println("2. 单线程乐观读(肯定成功):");double distance = point.distanceFromOrigin();System.out.println("距离: " + distance);System.out.println();// 示例3:演示乐观读失败System.out.println("3. 并发场景下的乐观读失败:");point.demonstrateOptimisticReadFailure();System.out.println();// 示例4:演示锁升级System.out.println("4. 锁升级示例:");point.move(5, 5); // 确保在第一象限Thread upgrader = new Thread(() -> {point.moveToOriginIfInFirstQuadrant();}, "升级线程");upgrader.start();upgrader.join();System.out.println("\n最终位置: (" + point.x + ", " + point.y + ")");}
}
对代码的疑问之处
当时我对控制台打印的疑问是:
sleep不释放锁,为什么reader在sleep了200ms之后,虽然Writer还有100ms的时间可以获取锁,但是reader不释放锁啊,为什么Writer还是能获取到锁??
其实我是理解错误了!在乐观读模式下,读线程在调用sleep时,根本没有持有任何锁!
我们来逐一详细拆解。
问题一:为什么 demonstrateOptimisticReadFailure
中写线程能修改成功?
您对线程执行顺序的理解出现了一点偏差,这也是并发编程初学者最容易遇到的一个困惑点。您可能是这样想的:
- 您的设想(串行思路):读线程启动 -> 读x -> 睡200ms -> 读y -> 结束。然后写线程启动 -> 睡100ms -> 修改。
但实际情况是,t.start()
只是告诉操作系统“这个线程可以开始运行了”,但具体什么时候运行、运行多长时间,都由 CPU的线程调度器来决定。两个线程一旦 start()
,就可以看作是在同时、并行地执行。
我们来梳理一下实际的事件时间线:
-
T=0ms:
main
线程调用了reader.start()
和writer.start()
。此时,读线程和写线程都进入了“就绪”状态,随时可以被CPU执行。 -
T=~1ms (举例): 读线程抢到了CPU时间片。
- 它执行
lock.tryOptimisticRead()
,获取了版本号(比如512)。 - 它读取了
x
的值(3.0)。 - 然后它调用
Thread.sleep(200)
,主动放弃CPU,进入了休眠状态。它要等200毫秒后才能醒来。
- 它执行
-
T=~2ms: 写线程抢到了CPU时间片。
- 它调用
Thread.sleep(100)
,也主动放弃CPU,进入休眠。它只需要等100毫秒。
- 它调用
-
T=~102ms: 写线程的100ms睡眠时间结束了!
- 它被唤醒,重新进入“就绪”状态,并很快抢到CPU。
- 它调用
move(10, 10)
,成功获取了写锁(因为此时没有其他锁),将x
和y
修改为 (13.0, 14.0)。 - 写线程的工作完成了。
-
T=~201ms: 读线程的200ms睡眠时间现在才结束!
- 它被唤醒,从
sleep(200)
的下一行代码继续执行。 - 它开始读取
y
的值。但此时的y
已经是被写线程修改后的 14.0。 - 它读取完毕后,调用
lock.validate(512)
。 StampedLock
发现,从它获取版本号512到现在,中间发生了一次写操作(版本号已经变了)。- 因此
validate
返回false
,乐观读失败。
- 它被唤醒,从
结论:代码完美地达到了演示失败的目的。正是因为写线程的睡眠时间(100ms)比读线程的睡眠时间(200ms)短,所以写操作总能发生在读操作的“读取x”和“读取y”这两个动作之间,从而导致乐观读验证失败。
问题二:锁升级这块不是很理解,为什么结果是成功的?
我们来深入理解一下 tryConvertToWriteLock
这个“锁升级”操作。
1. 什么是锁升级?为什么需要它?
想象一个场景:你需要先读取一个共享数据,根据数据的值,再决定是否要修改它。
-
常规的笨办法:
- 先加读锁。
- 读取数据,发现需要修改。
- 释放读锁。
- 再加写锁。
- (问题来了) 在你释放读锁和获取写锁的这个“空档期”,很可能有另一个线程冲进来修改了数据,那你刚才的判断就白费了,你必须重新读取和判断,非常麻烦。
-
锁升级的聪明办法:
tryConvertToWriteLock
提供了一个在持有读锁的情况下,直接尝试转变为写锁的机会,中间不释放任何锁,从而避免了上述的“空档期”问题。这是一种优化。
2. 锁升级什么时候会成功?什么时候会失败?
tryConvertToWriteLock
是一个“乐观”的尝试,它成功的条件非常苛刻:
- 成功条件:当尝试升级时,当前线程必须是唯一的读者。也就是说,不能有任何其他线程持有读锁。如果
StampedLock
发现只有你这一个读者,它就会很顺利地把你的读锁“升级”成写锁,并返回一个新的、代表写锁的“戳”。 - 失败条件:只要当时还有任何一个其他线程也持有读锁,升级就会立即失败,并返回
0
。这是为了防止死锁(如果两个读线程都想升级成写锁,它们会相互等待对方释放读锁,从而死锁)。
3. 为什么您的代码里升级成功了?
我们看一下您的 main
函数中调用这部分的代码:
// 示例4:演示锁升级
System.out.println("4. 锁升级示例:");
point.move(5, 5); // 确保在第一象限
Thread upgrader = new Thread(() -> {point.moveToOriginIfInFirstQuadrant();
}, "升级线程");
upgrader.start();
upgrader.join();
在这里,您只创建了一个名为“升级线程”的线程去执行 moveToOriginIfInFirstQuadrant
这个方法。
所以,当这个线程执行到 lock.tryConvertToWriteLock(stamp)
时,它自己是当前唯一的读者,没有任何其他线程持有读锁。因此,它完全满足了升级成功的苛刻条件,所以升级必然成功,并打印出 ✓ 成功将读锁升级为写锁
。
4. finally
块中的 lock.unlock(stamp)
您可能会注意到,finally
块里只有一个 lock.unlock(stamp)
,它是如何知道该解锁读锁还是写锁的呢?
这也是StampedLock
的一个巧妙之处。unlock(stamp)
方法会根据传入的“戳”的类型,来自动判断是该执行 unlockRead
还是 unlockWrite
。
- 如果升级失败,
stamp
变量里保存的还是最初的读锁戳,unlock(stamp)
就执行读锁释放。 - 如果升级成功,代码
stamp = writeStamp;
会把写锁戳赋给stamp
变量,unlock(stamp)
就执行写锁释放。
这种设计简化了 finally
块的逻辑。
🎯总结
-
为什么叫"戳"(Stamp)?
- 每次获取锁都会返回一个唯一的数字(戳)
- 释放锁时必须提供对应的戳
- 就像票据系统,确保锁的正确配对
-
为什么能提高读性能?
- 乐观读:不加锁,直接读取,性能最高
- 只在数据被修改时才升级为真正的锁
- 适合读多写少的场景
-
使用场景
- 读操作远多于写操作
- 对读性能要求很高
- 可以容忍偶尔的读重试
-
注意事项
- 不支持重入
- 必须正确管理戳
- 不支持条件变量(Condition)