跳到主要内容

ReentrantReadWriteLock 与 StampedLock

问题

读写锁的实现原理是什么?什么是锁降级?StampedLock 解决了什么问题?

答案

为什么需要读写锁?

普通的互斥锁(ReentrantLock / synchronized)在读多写少场景下性能不佳:多个线程同时读取共享数据不会产生竞态,但互斥锁让所有读操作也串行化了。

读写锁的核心思想:

  • 读读共享:多个读线程可以同时获取读锁
  • 读写互斥:读锁与写锁互斥
  • 写写互斥:同一时刻只能有一个写线程

ReentrantReadWriteLock

ReadWriteLockExample.java
import java.util.concurrent.locks.*;

public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();

// 读操作:多线程可并发读取
public V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}

// 写操作:独占访问
public void put(K key, V value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
}

底层原理:state 的高低 16 位

ReentrantReadWriteLock 基于 AQS 实现,巧妙地将 state32 位拆分为两部分:

|<--- 高 16 位(读锁计数)--->|<--- 低 16 位(写锁重入次数)--->|
| 读锁持有数 | 写锁重入次数 |
state 位运算
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 65536
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF

// 读锁数量 = state 无符号右移 16 位
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 写锁重入数 = state & 低 16 位掩码
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
读锁的线程重入计数

高 16 位只记录了读锁的总持有数(所有线程的读锁总和),每个线程各自的读锁重入次数通过 ThreadLocal<HoldCounter> 单独维护。

获取写锁流程

tryAcquire(写锁,简化)
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 写锁重入次数

if (c != 0) {
// state != 0 说明有锁被持有
// w == 0 说明有读锁(高位不为 0),写锁不能获取(读写互斥)
// w != 0 但不是当前线程(写写互斥)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 当前线程持有写锁,重入
setState(c + acquires);
return true;
}

// state == 0,无锁状态,CAS 获取写锁
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}

获取读锁流程

tryAcquireShared(读锁,简化)
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();

// 如果有写锁且不是当前线程持有 → 失败(读写互斥)
// 注意:如果写锁是当前线程持有 → 可以获取读锁(锁降级)
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return -1;

int r = sharedCount(c); // 读锁计数
// CAS 增加读锁计数(高 16 位 +1)
if (!readerShouldBlock() && r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// 更新当前线程的 HoldCounter
return 1;
}
return fullTryAcquireShared(current); // 完整版处理
}

锁降级

锁降级:持有写锁的线程获取读锁,然后释放写锁,最终持有读锁。

LockDegradeExample.java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 锁降级过程
writeLock.lock(); // 1. 获取写锁
try {
// 修改数据
data = newData;
readLock.lock(); // 2. 获取读锁(降级的关键步骤)
} finally {
writeLock.unlock(); // 3. 释放写锁(此时仍持有读锁)
}

try {
// 读取数据(此时是读锁保护)
use(data);
} finally {
readLock.unlock(); // 4. 释放读锁
}
不支持锁升级

ReentrantReadWriteLock 不支持从读锁升级为写锁。如果持有读锁时尝试获取写锁,会导致死锁(写锁等待读锁释放,而读锁等待获取写锁后才释放)。

锁降级的意义:写操作完成后,保持读锁可以防止其他写线程在中间修改数据,确保当前线程能读到自己刚写入的最新值。

写锁饥饿问题

在读多写少的场景下,读锁持续被获取,写线程可能长时间无法拿到写锁,造成写锁饥饿

公平模式 new ReentrantReadWriteLock(true) 可以缓解,但会降低吞吐量。

StampedLock(JDK 8)

StampedLock 是 JDK 8 引入的新读写锁,解决了 ReentrantReadWriteLock 的写锁饥饿问题,并提供更高性能的乐观读

StampedLockExample.java
import java.util.concurrent.locks.StampedLock;

public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();

// 写锁(独占)
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}

// 乐观读(无锁)—— 性能最高
public double distanceFromOrigin() {
// 1. 获取乐观读戳记(不加锁,不阻塞写线程)
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;

// 2. 检查期间是否有写操作发生
if (!sl.validate(stamp)) {
// 3. 有写操作,升级为悲观读锁
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}

return Math.sqrt(currentX * currentX + currentY * currentY);
}

// 悲观读锁(与 ReadWriteLock 的读锁类似)
public double getX() {
long stamp = sl.readLock();
try {
return x;
} finally {
sl.unlockRead(stamp);
}
}
}

StampedLock 三种模式

模式特点适用场景
写锁独占,与其他所有锁互斥写操作
悲观读锁共享,与写锁互斥读操作(需要一致性保证)
乐观读无锁,不阻塞写读操作(读多写少,允许重试)

StampedLock vs ReentrantReadWriteLock

对比ReentrantReadWriteLockStampedLock
乐观读不支持支持(tryOptimisticRead
可重入
Condition支持不支持
写锁饥饿可能不会
锁降级/升级支持降级支持 tryConvertToReadLock
StampedLock 的注意事项
  1. 不可重入:同一线程再次获取会死锁
  2. 不支持 Condition
  3. 不要在 interrupt 中使用:如果线程在 readLock()/writeLock() 时被中断,可能导致 CPU 100%(JDK bug,后续版本已修复)
  4. 使用乐观读时必须复制变量到局部变量再 validate

常见面试问题

Q1: 读写锁的适用场景是什么?

答案

读写锁适用于读多写少的场景,如缓存、配置管理、数据统计等。在这些场景下,读操作占绝大多数,用读写锁可以让读操作并发执行,显著提升吞吐量。

如果读写比例差不多甚至写多读少,读写锁的开销(state 拆分、HoldCounter 维护)反而不如普通的 ReentrantLock。

Q2: 什么是锁降级?为什么不支持锁升级?

答案

  • 锁降级:写锁 → 获取读锁 → 释放写锁 → 持有读锁。目的是保证写操作后能安全读取自己刚写入的数据。
  • 锁升级:读锁 → 获取写锁。不支持,因为如果两个线程同时持有读锁并都尝试升级为写锁,会导致死锁(互相等待对方释放读锁)。

Q3: StampedLock 的乐观读是怎么实现的?

答案

乐观读不是真正的加锁,只是记录一个 stamp(版本号)。读取数据后通过 validate(stamp) 检查期间是否有写操作。如果没有则数据有效,如果有则升级为悲观读锁重新读取。

这种方式类似数据库的 MVCCCAS 思想:先假设不会冲突,读完再验证。

Q4: ReentrantReadWriteLock 的公平性如何体现?

答案

  • 非公平模式(默认):读线程在队头是写线程时让步(apparentlyFirstQueuedIsExclusive),写线程直接 CAS 竞争
  • 公平模式:严格按队列顺序,读写都先检查 hasQueuedPredecessors()

非公平模式写线程优先的设计可以一定程度缓解写锁饥饿。

Q5: ReentrantReadWriteLock 中读锁和写锁共用一个 AQS 吗?

答案

是的,共用同一个 AQS 实例。state 的高 16 位表示读锁持有数量,低 16 位表示写锁重入次数。这也意味着读锁和写锁的最大重入/持有数量都是 2161=655352^{16} - 1 = 65535

相关链接