Java StampedLock的tryOptimisticRead乐观读在缓存中的应用
在Java并发编程中,对于读多写少的场景,ReentrantReadWriteLock 是常用工具。但它的读锁会阻塞写锁,即使写操作非常短暂。StampedLock 提供了一种更高效的解决方案,其 tryOptimisticRead 方法允许你在无锁状态下读取数据,仅在最后验证数据是否被修改过。本文将详细介绍 tryOptimisticRead 的工作原理,并通过一个缓存应用的实例,展示如何利用它来提升性能。
一、为什么需要 tryOptimisticRead?
传统的 ReentrantReadWriteLock 通过分离读锁和写锁来提高并发性能。读锁允许多个线程同时获取,而写锁是独占的。然而,读锁和写锁之间存在互斥关系:当一个线程持有读锁时,其他线程无法获取写锁;反之亦然。这意味着,即使写操作只需要很短的时间,读线程也必须等待写操作完成并释放写锁后才能继续。
这种“读锁阻塞写锁”的特性,在高并发读多写少的场景下,可能会成为性能瓶颈。例如,一个缓存系统,大部分请求都是读取操作,偶尔有写入。如果每次读取都获取读锁,那么即使写操作很快,也会导致读取线程不必要的等待。
StampedLock 引入了“乐观读”的概念来解决这个问题。乐观读认为在读取数据时,数据很可能没有被修改,因此它首先尝试以无锁的方式读取数据。只有在最后验证时,发现数据确实被修改了,才需要采取进一步的措施(通常是升级为悲观读锁,重新读取数据)。
二、tryOptimisticRead 是如何工作的?
tryOptimisticRead 的工作流程可以概括为以下四个步骤:
- 获取乐观读戳:调用
tryOptimisticRead()方法。该方法会立即返回一个“戳”(stamp),这个戳代表一个乐观读的版本。此操作不会阻塞任何其他线程,无论是读线程还是写线程。 - 读取数据:在获取到戳之后,你可以在无锁状态下访问共享数据。这是性能提升的关键,因为此时没有锁竞争。
- 验证戳:在读取数据之后,必须调用
validate(stamp)方法来检查在读取期间,数据是否被其他线程修改过。validate方法会检查当前锁的状态是否与获取乐观读戳时的状态一致。 - 处理结果:
- 如果
validate返回true,说明在读取数据期间,没有其他线程获取过写锁,数据是有效的,可以安全使用。 - 如果
validate返回false,说明在读取数据期间,有其他线程获取过写锁并修改了数据。此时,你需要升级为悲观读锁,以获取数据的最新、一致副本。这通常意味着你需要先释放乐观读戳(通过lock.unlock(stamp)),然后获取读锁(lock.readLock()),最后重新读取数据。
- 如果
三、在缓存中的应用实战
下面我们通过一个简单的缓存实现,来展示如何使用 StampedLock 和 tryOptimisticRead。
我们将创建一个 OptimisticCache 类,它包含 put 和 get 两个核心方法。
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的流程。long stamp = lock.tryOptimisticRead();:尝试获取乐观读戳。这是一个非阻塞操作。V value = cache.get(key);:在无锁状态下从HashMap中读取数据。这是性能的关键,因为此时没有锁竞争。if (!lock.validate(stamp)):验证在读取数据期间,是否有其他线程获取过写锁。validate方法会比较当前锁的状态和获取stamp时的状态。如果状态不一致(即有写操作发生),则返回false。stamp = lock.readLock();:如果验证失败,说明数据可能已过期。此时,我们必须确保读取到最新数据,所以需要获取一个悲观读锁。注意,这里我们重新获取了一个新的stamp。value = cache.get(key);:在持有读锁的情况下,安全地重新读取数据。lock.unlockRead(stamp);:读取完成后,释放读锁。
通过这种方式,get 方法在大多数情况下(没有写操作发生时)是无锁的,只有在极少数情况下(有写操作发生时)才会降级为读锁,从而最大限度地减少了锁的持有时间,提升了并发读取的性能。
四、适用场景与注意事项
适用场景
tryOptimisticRead 非常适合以下场景:
- 读多写少:这是最典型的应用场景。读取操作远多于写入操作,乐观读能最大化并发读取的性能。
- 写操作耗时短:如果写操作本身很快,那么乐观读失败的概率会降低,性能优势更明显。
- 数据一致性要求非绝对严格:乐观读允许在读取和验证之间存在一个极短的时间窗口,在这个窗口内,数据可能被修改。如果业务能容忍这种“最终一致性”,或者能通过重新读取来处理数据不一致的情况,那么乐观读是理想选择。
注意事项
- 写操作频繁:如果写操作非常频繁,乐观读会频繁失败,导致
get方法需要不断升级为悲观读锁,这会抵消乐观读带来的性能优势,甚至可能比直接使用读锁更慢。 - 复杂计算:如果在无锁状态下读取数据后,需要进行复杂的计算,那么在计算过程中,数据可能被修改。此时,即使
validate成功,计算结果也可能基于一个已经过时的数据。因此,对于需要长时间处理数据的场景,乐观读可能不适用。 StampedLock是非可重入的:与ReentrantReadWriteLock不同,StampedLock的锁是不可重入的。如果一个线程已经持有了写锁,它不能再次获取同一个写锁,否则会死锁。在升级锁时(从乐观读升级到悲观读)也需要特别注意,确保正确释放旧的锁再获取新的锁。
通过合理运用 StampedLock 的 tryOptimisticRead 机制,我们可以在高并发读多写少的场景下,显著提升应用程序的性能,特别是在缓存、配置中心等对读取性能要求极高的组件中。

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