当前位置: 首页 > news >正文

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 的乐观读操作如下:

  1. 尝试乐观读 (tryOptimisticRead):

    • 线程想读取共享数据,它先调用 lock.tryOptimisticRead()
    • 这个方法不会加任何锁,不会阻塞线程,它只是瞬间获取一下当前的“戳”(版本号),然后立即返回。这个过程几乎没有开销,速度极快。
  2. 读取共享数据:

    • 线程拿着这个“戳”,然后去读取共享变量(比如 x, y 的值)。
  3. 验证“戳” (validate):

    • 读完数据后,线程必须调用 lock.validate(stamp),并传入第一步获取的那个“戳”。
    • validate 方法会检查从第一步到当前时刻,有没有写操作发生过。它的判断依据就是**“戳”的版本号有没有变**。
      • 如果版本号没变 (validate返回true):这说明在刚才的读取期间,没有任何写操作来干扰。那么我们刚才读取的数据就是一致的、有效的。这次“乐观读”成功了!
      • 如果版本号变了 (validate返回false):这说明在我们读取数据的过程中,有一个“写线程”插了进来,获取了写锁,并修改了数据。那么我们刚才读到的数据就是“脏”的、不可信的。
  4. 失败后的补偿:

    • 如果 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(),就可以看作是在同时、并行地执行。

我们来梳理一下实际的事件时间线

  1. T=0ms: main线程调用了 reader.start()writer.start()。此时,读线程和写线程都进入了“就绪”状态,随时可以被CPU执行。

  2. T=~1ms (举例): 读线程抢到了CPU时间片。

    • 它执行 lock.tryOptimisticRead(),获取了版本号(比如512)。
    • 它读取了 x 的值(3.0)。
    • 然后它调用 Thread.sleep(200)主动放弃CPU,进入了休眠状态。它要等200毫秒后才能醒来。
  3. T=~2ms: 写线程抢到了CPU时间片。

    • 它调用 Thread.sleep(100),也主动放弃CPU,进入休眠。它只需要等100毫秒。
  4. T=~102ms: 写线程的100ms睡眠时间结束了!

    • 它被唤醒,重新进入“就绪”状态,并很快抢到CPU。
    • 它调用 move(10, 10),成功获取了写锁(因为此时没有其他锁),将 xy 修改为 (13.0, 14.0)。
    • 写线程的工作完成了。
  5. T=~201ms: 读线程的200ms睡眠时间现在才结束!

    • 它被唤醒,从 sleep(200) 的下一行代码继续执行。
    • 它开始读取 y 的值。但此时的 y 已经是被写线程修改后的 14.0
    • 它读取完毕后,调用 lock.validate(512)
    • StampedLock 发现,从它获取版本号512到现在,中间发生了一次写操作(版本号已经变了)。
    • 因此 validate 返回 false,乐观读失败。

结论:代码完美地达到了演示失败的目的。正是因为写线程的睡眠时间(100ms)比读线程的睡眠时间(200ms)短,所以写操作总能发生在读操作的“读取x”和“读取y”这两个动作之间,从而导致乐观读验证失败。


问题二:锁升级这块不是很理解,为什么结果是成功的?

我们来深入理解一下 tryConvertToWriteLock 这个“锁升级”操作。

1. 什么是锁升级?为什么需要它?

想象一个场景:你需要先读取一个共享数据,根据数据的值,再决定是否要修改它。

  • 常规的笨办法

    1. 先加读锁
    2. 读取数据,发现需要修改。
    3. 释放读锁
    4. 再加写锁
    5. (问题来了) 在你释放读锁和获取写锁的这个“空档期”,很可能有另一个线程冲进来修改了数据,那你刚才的判断就白费了,你必须重新读取和判断,非常麻烦。
  • 锁升级的聪明办法
    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 块的逻辑。

🎯总结

  1. 为什么叫"戳"(Stamp)?

    • 每次获取锁都会返回一个唯一的数字(戳)
    • 释放锁时必须提供对应的戳
    • 就像票据系统,确保锁的正确配对
  2. 为什么能提高读性能?

    • 乐观读:不加锁,直接读取,性能最高
    • 只在数据被修改时才升级为真正的锁
    • 适合读多写少的场景
  3. 使用场景

    • 读操作远多于写操作
    • 对读性能要求很高
    • 可以容忍偶尔的读重试
  4. 注意事项

    • 不支持重入
    • 必须正确管理戳
    • 不支持条件变量(Condition)
http://www.xdnf.cn/news/1038331.html

相关文章:

  • 面试问题总结——关于C++(四)
  • 【卫星通信】3GPP标准提案:面向NB-IoT(GEO)场景的IMS信令优化方案-降低卫星通信场景下的语音呼叫建立时延
  • ELK日志文件分析系统——L(Logstash)
  • Flutter 状态管理与 API 调用的完美结合:从理论到实践
  • python实战:使用Python合并PDF文件
  • pyqt5,python开发软件,文件目录如何设置,如何引用,软件架构如何设计
  • 洛谷 P5711:闰年判断
  • 基于Python学习《Head First设计模式》第十一章 代理模式
  • 「Linux中Shell命令」Shell常见命令
  • Vue 3 砸金蛋互动抽奖游戏
  • Redis事务与驱动的学习(一)
  • 出现端口占用,关闭端口进程命令
  • Redis三种集群概述:主从复制、哨兵模式与Cluster模式
  • MySQL 究极奥义·动态乾坤大挪移·无敌行列转换术
  • SSH参数优化与内网穿透技术融合:打造高效远程访问解决方案
  • Android 获取签名 keystore 的 SHA1和MD5值
  • transactional-update原子性更新常用命令
  • 数据库期末
  • LangChain开发智能问答(RAG)系统实战教程:从零构建知识驱动型AI助手
  • 推荐一个轻量级跨平台打包工具 PakePlus:重塑前端项目桌面化体验
  • 微软云注册被阻止怎么解决?
  • uniapp 腾讯地图服务
  • 【DSP笔记 · 第3章】数字世界的“棱镜”:离散傅里叶变换(DFT)完全解析
  • 自定义 eslint 规则
  • 基于Java开发的浏览器自动化Playwright-MCP服务器
  • 图表工具 ECharts vs Chart.js 对比
  • 问题记录_如何让程序以root权限启动_如何无视系统的路径问题
  • 从零开始:VMware上的Linux与Java开发环境配置
  • Python训练营-Day31-文件的拆分和使用
  • 自编码模型原理