概述
ReentrantReadWriteLock
是可重入的读写锁。
其内部除了和一样有个同步器Sync
,还有一个读锁和一个写锁:
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
- 读锁(Read Lock):
- 多个线程可以同时持有读锁,因此它支持并发读取操作。
- 如果没有线程持有写锁,则读锁可以被获取。
- 如果有线程持有写锁,其他线程请求读锁时,它们将被阻塞,直到写锁被释放。
- 写锁(Write Lock):
- 只能被一个线程持有。
- 写锁用于独占式访问共享资源,确保在写操作期间没有其他线程可以持有读锁或写锁。
ReentrantReadWriteLock的特点和使用场景如下:
- 读多写少的情况:在读操作比写操作频繁且读操作不会修改数据的场景下,使用读写锁可以允许多个线程同时读取数据,从而提高并发性能。
- 保护共享资源:读写锁适用于保护共享资源,当有多个读操作和较少的写操作时,读写锁可以提供更高的并发性,并发读取不会互斥,只有写操作会互斥。
- 可重入性:ReentrantReadWriteLock是可重入的,即同一个线程可以重复获取读锁或写锁,而不会造成死锁。这种机制允许线程在持有写锁时再次获取读锁,但反过来是不允许的(即持有读锁时请求写锁会导致线程阻塞)。
- 公平性:ReentrantReadWriteLock提供了公平和非公平两种模式,默认为非公平模式。在非公平模式下,锁的获取是基于竞争的,允许新的获取请求插队,可能导致某些线程长期等待。而在公平模式下,锁的获取按照先来先得的顺序进行,保证所有线程都能公平地获取锁,但可能会降低整体吞吐量。
使用ReentrantReadWriteLock时需要注意以下几点:
- 写锁的获取和释放要保证原子性,避免死锁和竞态条件的发生。
- 读锁不支持条件变量。
- 持有读锁时请求写锁会导致线程阻塞。因为写锁是独占锁,当一个线程持有写锁时,其他线程无法获取读锁或写锁。
- 尽量减少持有写锁的时间,以允许更多的读操作并发执行。
- 读写锁适合于处理读操作远远多于写操作的场景。如果写操作很频繁,可能会导致读操作的吞吐量下降。
使用
使用时可先创建ReentrantReadWriteLock
读写锁对象,然后根据这个对象获取相应的读锁和写锁,读锁和写锁可以分开使用,但是注意都要最后去解锁。
示例:
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockTest {
public static void main(String[] args) throws InterruptedException {
DataContainer dataContainer = new DataContainer();
// 读线程
new Thread(() -> {
dataContainer.read();
}, "t1").start();
Thread.sleep(100);
// 写线程
new Thread(() -> {
dataContainer.write();
}, "t2").start();
}
}
@Slf4j
class DataContainer {
private Object data;
private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
/**
* 读锁
*/
private ReentrantReadWriteLock.ReadLock r = rw.readLock();
/**
* 写锁
*/
private ReentrantReadWriteLock.WriteLock w = rw.writeLock();
public Object read() {
log.info("获取读锁...");
r.lock();
try {
log.info("读取");
Thread.sleep(1000);
return data;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
} finally {
log.info("释放读锁...");
r.unlock();
}
}
public void write() {
log.info("获取写锁...");
w.lock();
try {
log.info("写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
log.info("释放写锁...");
w.unlock();
}
}
}
运行结果如下:
2023-07-13 22:04:13,610 - 0 INFO [t1] up.cys.chapter12.DataContainer:43 - 获取读锁...
2023-07-13 22:04:13,619 - 9 INFO [t1] up.cys.chapter12.DataContainer:46 - 读取
2023-07-13 22:04:13,714 - 104 INFO [t2] up.cys.chapter12.DataContainer:59 - 获取写锁...
2023-07-13 22:04:14,624 - 1014 INFO [t1] up.cys.chapter12.DataContainer:53 - 释放读锁...
2023-07-13 22:04:14,628 - 1018 INFO [t2] up.cys.chapter12.DataContainer:62 - 写入
2023-07-13 22:04:15,633 - 2023 INFO [t2] up.cys.chapter12.DataContainer:67 - 释放写锁...
根据运行结果看到。当获取读锁后,必须等读锁释放之后,才能获取写锁进行写操作。
原理
读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。
写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位 。status
的值可以表示多种情况,具体如下:
- 未锁定状态:
status
为0,表示锁未被任何线程获取。 - 获取写锁状态:
status
为负数,表示有一个线程持有写锁,值的绝对值表示持有写锁的线程数。 - 获取读锁状态:
status
为正数,表示有一个或多个线程持有读锁,值表示持有读锁的线程数。 - 获取读锁和写锁状态:
status
为负数,但值的绝对值大于1,表示有一个线程持有写锁,且还有其他线程持有读锁。
下面是一些可能的status
值和对应的含义:
status = 0
:锁未被任何线程获取。status = 1
:有一个线程持有读锁。status = 2
:有两个线程持有读锁。status = -1
:有一个线程持有写锁。status = -2
:有一个线程持有写锁,且还有一个线程持有读锁。
其构造方法如下:
public ReentrantReadWriteLock() {
// 无参构造,默认是非公平锁
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
有参构造方法里,有一个同步器,根据参数来决定是公平还是非公平的,FairSync
和NonfairSync
是内部自己维护的Sync
同步器,继承自AQS。
除此以外,构造方法里还有读锁readerLock
和writerLock
。
源码
- 写锁上锁
下面来看下写锁的上锁代码,与前面一节讲到的非常类似,也是先调用同步器的方法:
public void lock() {
sync.acquire(1);
}
// 然后是AQS的acquire代码
public final void acquire(int arg) {
if (!tryAcquire(arg) && // tryAcquire尝试获取锁,如果失败
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 尝试创建一个Node对象,加到等待队列中,park等待
selfInterrupt(); // 获取失败并且加入队列成功,就调用自己的Interrupt方法
}
然后是ReentrantReadWriteLock
实现的tryAcquire
方法:
@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
Thread current = Thread.currentThread();
// 获取当前的status状态值c,包含高位和低位,即读锁加写锁的值
int c = getState();
// 获取写锁(也叫独占锁)的状态值
int w = exclusiveCount(c);
// 如果c!=0,即可能是其他线程加了读锁或写锁
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 如果写锁等于0,说明已经被加了读锁(注意前面说过写锁是独占式的),或者锁的所有者不是当前线程,则返回false
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 如果加了写锁之后超过最大范围,则抛错
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire 发生可重入
// 修改写锁的状态,返回true
setState(c + acquires);
return true;
}
// c = 0,即没有其他线程加锁
if (writerShouldBlock() || // writerShouldBlock判断是否应该阻塞,如果是非公平则返回false
!compareAndSetState(c, c + acquires)) // 如果不是阻塞的,则再尝试修改状态值,再不成功,返回false
return false;
// 如果线程不应该阻塞,并且加锁成功了,则把锁的owner给当前线程
setExclusiveOwnerThread(current);
return true;
}
- 写锁解锁
解锁代码如下:
public void unlock() {
sync.release(1);
}
调用同步器的release方法:
public final boolean release(int arg) {
// 如果尝试解锁成功
if (tryRelease(arg)) {
Node h = head;
// 如果头节点不为null,并且头节点的状态不为0,则进行唤醒流程
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
主要调用了tryRelease方法:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 首先状态值减去值releases
int nextc = getState() - releases;
// 然后看下写锁部分是否等于0
boolean free = exclusiveCount(nextc) == 0;
// 如果写锁状态为0
if (free)
// 把锁持有者设为null
setExclusiveOwnerThread(null);
// 更新状态值
setState(nextc);
// 返回free,表示写锁解开了
return free;
}
- 读锁上锁
接下来看下读锁的上锁代码:
public void lock() {
sync.acquireShared(1);
}
然后找到acquireShared
代码,如下:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
主要有两个方法组成:tryAcquireShared
和doAcquireShared
。
其中tryAcquireShared返回值:-1表示失败,大于等于0表示成功,且是需要唤醒的后继节点数量。找到对应的实现代码如下:
@ReservedStackAccess
protected final int tryAcquireShared(int unused) {
// 拿到当前线程
Thread current = Thread.currentThread();
// 获取当前状态值
int c = getState();
if (exclusiveCount(c) != 0 && // 如果写锁不为0
getExclusiveOwnerThread() != current) // 并且写锁的持有者不是当前线程
return -1; // 则返回-1,因为写锁是独占式的,有了写锁,不允许任何线程再加读锁
// 获取读锁状态(共享锁)
int r = sharedCount(c);
if (!readerShouldBlock() && // 如果当前线程不需要被阻塞
r < MAX_COUNT && // 并且读锁的数量没有超过最大值,
compareAndSetState(c, c + SHARED_UNIT)) { // 则可以获取共享锁,尝试修改状态值(总的状态值)成功
if (r == 0) { // 如果上面获取的读锁状态为0,说明前面没有其他线程获取读锁,当前线程是第一个获取读锁的
// firstReader和firstReaderHoldCount是用于记录第一个获取读锁的线程及其读锁的持有计数的变量,这是为了避免在没有其他读锁持有者的情况下,重复获取和释放读锁带来的开销
// 将当前线程的引用存储在firstReader变量中,并且firstReaderHoldCount的初始值会被设置为1。
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) { // 如果当前线程已经是第一个获取锁的线程,则是发生了锁重入
firstReaderHoldCount++; // 把firstReaderHoldCount的值加1
} else {
// rh用于缓存当前线程持有的读锁计数
HoldCounter rh = cachedHoldCounter;
// 如果为空或者cachedHoldCounter对应的线程与当前线程不一致
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
// 就会通过readHolds.get()方法获取当前线程的HoldCounter对象,并将其赋值给cachedHoldCounter
cachedHoldCounter = rh = readHolds.get();
// 如果rh计数等于0,则设置readHolds
else if (rh.count == 0)
readHolds.set(rh);
//
rh.count++;
// cachedHoldCounter是用于缓存当前线程持有的读锁计数的变量,用于提高性能。
// 它的设计是为了避免频繁地创建和销毁HoldCounter对象,并通过缓存的方式快速访问和更新读锁计数。
}
// 最后返回1,表示加锁成功
return 1;
}
// 如果当前线程需要阻塞,或者尝试获取锁失败了,再调用fullTryAcquireShared方法
// 此方法与 tryAcquireShared 功能类似, 但会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞
return fullTryAcquireShared(current);
}
然后是doAcquireShared方法,代码如下:
private void doAcquireShared(int arg) {
// / 将当前线程作为共享模式的等待节点加入队列,注意类型不一样,变成了SHARED类型
final Node node = addWaiter(Node.SHARED);
// 标识是否获取共享锁失败
boolean interrupted = false;
try {
// for循环一直尝试获取锁
for (;;) {
// 获取前驱节点p
final Node p = node.predecessor();
// 如果p是头节点,说明当前线程是第二个真正的节点,可以去获取锁
if (p == head) {
// 再次尝试获取锁,因为这时锁可能已经被释放了,所以再次尝试
int r = tryAcquireShared(arg);
// 返回r>=0,说明获取共享锁成功
if (r >= 0) {
setHeadAndPropagate(node, r); // 设置当前节点为头节点,并可能传播共享锁
p.next = null; // help GC
return;
}
}
// 如果p不是头节点,判断是否要park线程,如果需要,修改interrupted的值
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
} finally {
if (interrupted)
selfInterrupt();
}
}
下面看一下当获取共享锁成功,调用的setHeadAndPropagate方法:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
// 把当前节点设为头节点
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
// 获取当前节点的下个节点
Node s = node.next;
// 如果下个节点为null,或者下个节点是共享(是获取读锁的线程)的状态
if (s == null || s.isShared())
doReleaseShared(); // 传播共享锁,会把后面的读锁线程都唤醒,这也是读写锁在读读方面可以并发的原因
}
}
上面代码看到,如果下个节点是共享的状态,还会执行doReleaseShared方法:
private void doReleaseShared() {
// 无限循环,直到完成共享锁的传播操作或者头节点发生变化
for (;;) {
Node h = head;
// 判断头节点不为空且不是尾节点。这是为了确保存在至少一个共享模式的等待节点
if (h != null && h != tail) {
// 获取头节点的等待状态
int ws = h.waitStatus;
// 如果等待状态是Node.SIGNAL,表示需要唤醒后继节点
if (ws == Node.SIGNAL) {
// 将头节点的等待状态从Node.SIGNAL设置为0,即标记为无等待状态。
// 如果设置成功,表示当前线程负责唤醒后继节点;如果设置失败,继续循环
if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
continue; // loop to recheck cases
// 唤醒后继节点
unparkSuccessor(h);
}
// 如果等待状态为0,表示需要进行传播
else if (ws == 0 &&
// 将头节点的等待状态从0设置为Node.PROPAGATE,即标记为传播状态。
// 如果设置成功,表示当前线程负责进行共享锁的传播;如果设置失败,继续循环。
!h.compareAndSetWaitStatus(0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// 如果头节点没有改变,表示当前线程负责完成共享锁的传播操作
if (h == head) // loop if head changed
break;
}
}
- 读锁解锁
下面看下读锁的解锁代码:
public void unlock() {
sync.releaseShared(1);
}
接着releaseShared:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
然后是tryReleaseShared:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
// 这里会死主要代码,先把状态减去高位的值
int nextc = c - SHARED_UNIT;
// 然后用cas设置状态
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
// 最后看计数书否为0.不是0就会返回fasle,注意即使返回fasle,当前线程的锁也已经释放
return nextc == 0;
}
}
当tryReleaseShared返回真,即所有的锁都释放了,就会执行doReleaseShared方法。
流程
假设现在有两个线程t1和t2,分别来上写锁和读锁
- 写锁上锁流程
1) t1 线程执行lock加锁,执行tryAcquire方法,此时没有其他线程,则会成功上锁,把state的写锁状态改为1,把t1设为owner线程。注意写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位。
2)t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。因为有t1占据写锁即独占锁,所以t2不能再加读锁了,tryAcquireShared 返回 -1 表示获取锁失败。
3)这时t2会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态。
4)t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁。
5)如果没有成功,在 doAcquireShared 内 for (;😉 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;😉 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park。
6)这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子
接着上面开始解锁流程
7)首先t1进行解锁unlock,这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子,state状态变为0,线程owner设为null
8)接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行,这回再来一次 for (;😉 执行 tryAcquireShared 成功则让读锁计数加一。
这时 t3 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点。
9)接着在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒第二个节点,这时 t3 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行。
下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点。
上面可以看到,一旦唤醒线程,就会把共享状态的线程全部唤醒,直到遇到独占锁线程,这就是为什么读读是可以并发的原因。
10)接着t2进行解锁unlock,进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零
11)t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即
12)之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;😉 这次自己是第二个节点,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束
StampedLock
该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【戳】使用
加解读锁:
long stamp = lock.readLock();
lock.unlockRead(stamp);
加解写锁:
long stamp = lock.writeLock();
lock.unlockWrite(stamp);
乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
long stamp = lock.tryOptimisticRead();
// 验戳
if(!lock.validate(stamp)){
// 锁升级
}
示例:
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.locks.StampedLock;
public class StampedTest {
public static void main(String[] args) throws InterruptedException {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1000);
}, "t1").start();
Thread.sleep(500);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}
}
@Slf4j
class DataContainerStamped {
private int data;private final StampedLock lock = new StampedLock();
public DataContainerStamped(int data) {
this.data = data;
}
public int read(int readTime) {
long stamp = lock.tryOptimisticRead();
log.info("optimistic read locking...{}", stamp);
try {
Thread.sleep(readTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (lock.validate(stamp)) {
log.info("read finish...{}, data:{}", stamp, data);
return data;
}
// 锁升级 - 读锁
log.info("updating to read lock... {}", stamp);
try {
stamp = lock.readLock();
log.info("read lock {}", stamp);
try {
Thread.sleep(readTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.info("read unlock {}", stamp);
lock.unlockRead(stamp);
}
}
public void write(int newData) {
long stamp = lock.writeLock();
log.info("write lock {}", stamp);
try {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = newData;
} finally {
log.info("write unlock {}", stamp);
lock.unlockWrite(stamp);
}
}
}
执行结果如下:
2023-07-23 17:48:30,537 - 0 INFO [t1] up.cys.chapter12.DataContainerStamped:38 - optimistic read locking...256
2023-07-23 17:48:31,037 - 500 INFO [t2] up.cys.chapter12.DataContainerStamped:38 - optimistic read locking...256
2023-07-23 17:48:31,038 - 501 INFO [t2] up.cys.chapter12.DataContainerStamped:45 - read finish...256, data:1
2023-07-23 17:48:31,549 - 1012 INFO [t1] up.cys.chapter12.DataContainerStamped:45 - read finish...256, data:1
可以看到,两个线程都是打印的乐观读,都没有上锁。
当我们改下,第二个线程改成读操作,如下:
2023-07-23 17:52:31,980 - 0 INFO [t1] up.cys.chapter12.DataContainerStamped:38 - optimistic read locking...256
2023-07-23 17:52:32,483 - 503 INFO [t2] up.cys.chapter12.DataContainerStamped:68 - write lock 384
2023-07-23 17:52:32,993 - 1013 INFO [t1] up.cys.chapter12.DataContainerStamped:49 - updating to read lock... 256
2023-07-23 17:52:34,490 - 2510 INFO [t2] up.cys.chapter12.DataContainerStamped:77 - write unlock 384
2023-07-23 17:52:34,501 - 2521 INFO [t1] up.cys.chapter12.DataContainerStamped:52 - read lock 513
2023-07-23 17:52:35,507 - 3527 INFO [t1] up.cys.chapter12.DataContainerStamped:58 - read finish...513, data:0
2023-07-23 17:52:35,507 - 3527 INFO [t1] up.cys.chapter12.DataContainerStamped:61 - read unlock 513
可以看到乐观锁获取后,发现有写锁,就升级成为了读锁。
注意StampedLock不能取代ReentrantReadWriteLock:
- StampedLock 不支持条件变量
- StampedLock 不支持可重入