【乐观锁实现】StampedLock 的乐观读机制

StampedLock 的乐观读机制主要解决了读多写少场景下,传统读写锁(如 ReentrantReadWriteLock)可能存在的写线程饥饿或性能瓶颈问题 。它通过一种"乐观"的策略,允许读操作在特定条件下完全不阻塞写操作,从而显著提高系统的整体吞吐量。

解决的问题

  1. 写线程饥饿:
    • 在传统的读写锁(ReentrantReadWriteLock)中,读锁是共享的。当有大量读线程持续持有读锁时,写线程可能长时间无法获取写锁(因为写锁需要独占访问),导致写操作被"饿死"。
  2. 悲观锁的开销:
    • 即使没有写操作正在进行,传统的读锁在获取和释放时仍然需要一定的同步开销(CAS操作、维护内部状态等)。在超高并发的读场景下,这些开销累积起来可能成为性能瓶颈。
  3. 读操作占主导:
    • 在大多数应用场景(如缓存、配置读取)中,读操作的频率远高于写操作。传统读写锁的设计(读优先或公平策略)在这种场景下效率不高。

乐观读机制的核心思想

  1. "乐观"假设: 假设在读操作进行期间,很可能没有写操作发生
  2. 不阻塞写: 乐观读不获取真正的锁 ,因此它完全不会阻塞试图获取写锁的线程。写线程总是可以立即尝试获取写锁。
  3. 版本验证: 在乐观读完成之后、使用读取到的数据之前,必须验证在读操作期间是否发生过写操作。这是通过检查一个"戳记"来实现的。
  4. 失败降级: 如果验证失败(表明在读操作期间发生了写操作),则乐观读的结果可能无效。此时,调用者可以选择重试、放弃或者降级为获取一个悲观的读锁(会阻塞写)来确保读取到一致的数据。

如何使用乐观读

使用 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); // 释放写锁
        }
    }
}

关键步骤详解

  1. long stamp = lock.tryOptimisticRead();

    • 尝试获取一个乐观读戳记 (stamp)。这个方法非常快,通常只是一个内存读取或简单的原子操作,不涉及锁竞争,不会阻塞任何线程(包括写线程)
    • 这个 stamp 代表了数据当前的一个"版本"或"状态"。如果之后没有写操作发生,这个版本应该保持不变。
  2. 读取共享数据到局部变量 (currentX = x; currentY = y;)

    • 将你需要访问的共享数据复制到方法的局部变量中。这是必须的 ,因为后续的验证只保证到验证那一刻为止的状态,如果验证成功后你又去直接读 xy,数据可能又被修改了。
    • 重要: 在这个读取过程中,没有任何锁阻止其他线程(尤其是写线程)修改 xy!所以此时读取到的 currentXcurrentY 可能是不一致 的(例如,x 被修改了但 y 还没改),或者过时的。
  3. if (!lock.validate(stamp)) { ... }

    • 这是最核心 的步骤。调用 validate(stamp) 检查自你获取乐观读戳记 (stamp) 以来,是否有任何线程成功获取了写锁并修改了数据。
    • 如果返回 true (验证成功):
      • 意味着在你获取 stamp 之后,直到 validate 调用执行的那一刻 ,没有发生过写操作。你可以确信第 2 步读取到的 currentXcurrentY一致有效 的(至少是某个一致状态下的快照)。你可以安全地使用它们进行计算 (return Math.sqrt(...))。
    • 如果返回 false (验证失败):
      • 意味着在你读取数据(第 2 步)的过程中或之后,在 validate 调用之前,至少发生了一次成功的写操作 。你第 2 步读取的数据 currentXcurrentY 可能无效(不一致或过时),绝对不能使用它们!
      • 此时,你需要降级 (downgrade) 为传统的、悲观的策略:获取一个读锁 (stamp = lock.readLock();) 。这个调用可能会阻塞,等待当前可能存在的写锁释放。
      • 在获取到读锁后,重新读取 共享数据 (xy) 到局部变量 (currentX, currentY)。因为现在持有读锁,所以能保证在读取过程中不会有写操作发生,读取到的数据是一致的。
      • finally 块中释放读锁 (lock.unlockRead(stamp);)。
      • 最后,使用在悲观读锁保护下读取到的、一致的数据进行计算。
  4. 使用数据 (return Math.sqrt(...))

    • 无论是乐观读验证成功,还是降级到悲观读后成功读取,最终都使用局部变量 currentXcurrentY 进行计算并返回结果。这些局部变量要么代表一个验证通过的快照(乐观成功),要么代表在悲观读锁保护下获取的最新一致状态(乐观失败后降级)。

使用乐观读的注意事项

  1. 验证 (validate) 是必须的: 绝对不能在未验证或验证失败的情况下使用乐观读读取的数据。
  2. 数据拷贝到局部变量: 必须在获取乐观读戳记后、验证之前,将共享数据复制到方法内部的局部变量。验证通过后只使用这些局部变量。
  3. 乐观读适合短小的读操作: 乐观读操作本身(从获取戳记到验证)应该尽可能短。如果读操作本身耗时很长,那么在此期间发生写操作的概率就非常大,导致验证失败的概率很高,最终可能还是需要降级为悲观读锁,反而失去了性能优势,甚至更慢。
  4. 乐观读不修改共享状态: 乐观读只适用于只读操作。如果你需要在读操作中修改状态,必须使用写锁或其它同步机制。
  5. 乐观读不是锁: tryOptimisticRead() 返回的只是一个戳记 (stamp),不是锁对象。你不能用它来解锁。
  6. StampedLock 不可重入: 同一个线程试图重复获取锁(即使是读锁)会导致死锁。
  7. 没有条件变量: StampedLock 不直接支持 Condition,而 ReentrantReadWriteLock 的写锁可以。
  8. 小心转换: StampedLock 提供了 tryConvertToWriteLock 等方法,但使用它们需要非常小心,容易出错。

总结

StampedLock 的乐观读机制通过牺牲"读操作总是能看到最新数据"的绝对保证(通过后续验证和可能的降级来补偿),换取了在读多写少 场景下的超高读并发性能避免写线程饥饿 。其核心在于 tryOptimisticRead() 获取戳记、将数据拷贝到局部变量、validate(stamp) 验证、验证失败则降级获取悲观读锁重新读取这一套流程。正确使用乐观读可以显著提升高并发读取场景的系统吞吐量,但必须严格遵守使用模式并理解其注意事项。

相关推荐
探索java3 天前
Java并发编程中的StampedLock详解:原理、实践与性能优化
java·stampedlock
蜘蛛侠..2 个月前
并发编程:各种锁机制、锁区别、并发工具类深刻总结
乐观锁··悲观锁·共享锁·公平锁·并发工具类·可中断锁
zfj3217 个月前
学技术学英文:代码中的锁:悲观锁和乐观锁
数据库·乐观锁··悲观锁·竞态条件
菜菜-plus7 个月前
MySQL悲观锁和乐观锁
数据库·mysql·乐观锁·悲观锁
唐僧洗头爱飘柔95278 个月前
(JVM)我们该如何认识 Java的内存模型(Java Memory Model(JMM))? 本篇文章告诉你答案 !带你全面了解JMM
java·jvm·乐观锁·悲观锁·jmm·java必备技能·synchronized优化
bug菌¹10 个月前
滚雪球学MyBatis-Plus(09):乐观锁与性能优化
性能优化·mybatis-plus·乐观锁·零基础入门教学
摇曳的精灵10 个月前
MyBatis-Plus 拦截器
java·mybatis·拦截器·乐观锁·分页
一只BI鱼1 年前
CAS简单解析
java·cas·乐观锁
JavaPub-rodert1 年前
乐观锁 or 悲观锁 你怎么选?
数据库·sql·mysql·oracle·乐观锁··悲观锁