文章目录

Java StampedLock的tryOptimisticRead乐观读在缓存中的应用

发布于 2026-05-10 11:18:44 · 浏览 14 次 · 评论 0 条

Java StampedLock的tryOptimisticRead乐观读在缓存中的应用

在Java并发编程中,对于读多写少的场景,ReentrantReadWriteLock 是常用工具。但它的读锁会阻塞写锁,即使写操作非常短暂。StampedLock 提供了一种更高效的解决方案,其 tryOptimisticRead 方法允许你在无锁状态下读取数据,仅在最后验证数据是否被修改过。本文将详细介绍 tryOptimisticRead 的工作原理,并通过一个缓存应用的实例,展示如何利用它来提升性能。


一、为什么需要 tryOptimisticRead

传统的 ReentrantReadWriteLock 通过分离读锁和写锁来提高并发性能。读锁允许多个线程同时获取,而写锁是独占的。然而,读锁和写锁之间存在互斥关系:当一个线程持有读锁时,其他线程无法获取写锁;反之亦然。这意味着,即使写操作只需要很短的时间,读线程也必须等待写操作完成并释放写锁后才能继续。

这种“读锁阻塞写锁”的特性,在高并发读多写少的场景下,可能会成为性能瓶颈。例如,一个缓存系统,大部分请求都是读取操作,偶尔有写入。如果每次读取都获取读锁,那么即使写操作很快,也会导致读取线程不必要的等待。

StampedLock 引入了“乐观读”的概念来解决这个问题。乐观读认为在读取数据时,数据很可能没有被修改,因此它首先尝试以无锁的方式读取数据。只有在最后验证时,发现数据确实被修改了,才需要采取进一步的措施(通常是升级为悲观读锁,重新读取数据)。


二、tryOptimisticRead 是如何工作的?

tryOptimisticRead 的工作流程可以概括为以下四个步骤:

  1. 获取乐观读戳:调用 tryOptimisticRead() 方法。该方法会立即返回一个“戳”(stamp),这个戳代表一个乐观读的版本。此操作不会阻塞任何其他线程,无论是读线程还是写线程。
  2. 读取数据:在获取到戳之后,你可以在无锁状态下访问共享数据。这是性能提升的关键,因为此时没有锁竞争。
  3. 验证戳:在读取数据之后,必须调用 validate(stamp) 方法来检查在读取期间,数据是否被其他线程修改过。validate 方法会检查当前锁的状态是否与获取乐观读戳时的状态一致。
  4. 处理结果
    • 如果 validate 返回 true,说明在读取数据期间,没有其他线程获取过写锁,数据是有效的,可以安全使用。
    • 如果 validate 返回 false,说明在读取数据期间,有其他线程获取过写锁并修改了数据。此时,你需要升级为悲观读锁,以获取数据的最新、一致副本。这通常意味着你需要先释放乐观读戳(通过 lock.unlock(stamp)),然后获取读锁(lock.readLock()),最后重新读取数据。

三、在缓存中的应用实战

下面我们通过一个简单的缓存实现,来展示如何使用 StampedLocktryOptimisticRead

我们将创建一个 OptimisticCache 类,它包含 putget 两个核心方法。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.StampedLock;

public class OptimisticCache<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final StampedLock lock = new StampedLock();

    /**
     * 向缓存中放入值。
     * @param key 键
     * @param value 值
     */
    public void put(K key, V value) {
        // 获取写锁
        long stamp = lock.writeLock();
        try {
            cache.put(key, value);
        } finally {
            // 释放写锁
            lock.unlockWrite(stamp);
        }
    }

    /**
     * 从缓存中获取值。
     * @param key 键
     * @return 值,如果不存在则返回null
     */
    public V get(K key) {
        // 1. 尝试乐观读
        long stamp = lock.tryOptimisticRead();
        V value = cache.get(key);

        // 2. 验证数据是否被修改
        if (!lock.validate(stamp)) {
            // 3. 如果数据被修改,升级为悲观读锁
            stamp = lock.readLock();
            try {
                value = cache.get(key);
            } finally {
                // 4. 释放读锁
                lock.unlockRead(stamp);
            }
        }
        return value;
    }
}

代码解析

  • put 方法:这个方法很简单,它使用 writeLock() 获取写锁,确保在写入数据时,其他线程无法读取或写入,从而保证数据的一致性。写入完成后,通过 unlockWrite 释放锁。
  • get 方法:这是核心,它实现了 tryOptimisticRead 的流程。
    1. long stamp = lock.tryOptimisticRead();:尝试获取乐观读戳。这是一个非阻塞操作。
    2. V value = cache.get(key);:在无锁状态下从 HashMap 中读取数据。这是性能的关键,因为此时没有锁竞争。
    3. if (!lock.validate(stamp)):验证在读取数据期间,是否有其他线程获取过写锁。validate 方法会比较当前锁的状态和获取 stamp 时的状态。如果状态不一致(即有写操作发生),则返回 false
    4. stamp = lock.readLock();:如果验证失败,说明数据可能已过期。此时,我们必须确保读取到最新数据,所以需要获取一个悲观读锁。注意,这里我们重新获取了一个新的 stamp
    5. value = cache.get(key);:在持有读锁的情况下,安全地重新读取数据。
    6. lock.unlockRead(stamp);:读取完成后,释放读锁。

通过这种方式,get 方法在大多数情况下(没有写操作发生时)是无锁的,只有在极少数情况下(有写操作发生时)才会降级为读锁,从而最大限度地减少了锁的持有时间,提升了并发读取的性能。


四、适用场景与注意事项

适用场景

tryOptimisticRead 非常适合以下场景:

  • 读多写少:这是最典型的应用场景。读取操作远多于写入操作,乐观读能最大化并发读取的性能。
  • 写操作耗时短:如果写操作本身很快,那么乐观读失败的概率会降低,性能优势更明显。
  • 数据一致性要求非绝对严格:乐观读允许在读取和验证之间存在一个极短的时间窗口,在这个窗口内,数据可能被修改。如果业务能容忍这种“最终一致性”,或者能通过重新读取来处理数据不一致的情况,那么乐观读是理想选择。

注意事项

  • 写操作频繁:如果写操作非常频繁,乐观读会频繁失败,导致 get 方法需要不断升级为悲观读锁,这会抵消乐观读带来的性能优势,甚至可能比直接使用读锁更慢。
  • 复杂计算:如果在无锁状态下读取数据后,需要进行复杂的计算,那么在计算过程中,数据可能被修改。此时,即使 validate 成功,计算结果也可能基于一个已经过时的数据。因此,对于需要长时间处理数据的场景,乐观读可能不适用。
  • StampedLock 是非可重入的:与 ReentrantReadWriteLock 不同,StampedLock 的锁是不可重入的。如果一个线程已经持有了写锁,它不能再次获取同一个写锁,否则会死锁。在升级锁时(从乐观读升级到悲观读)也需要特别注意,确保正确释放旧的锁再获取新的锁。

通过合理运用 StampedLocktryOptimisticRead 机制,我们可以在高并发读多写少的场景下,显著提升应用程序的性能,特别是在缓存、配置中心等对读取性能要求极高的组件中。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文