前言
这篇文章讨论读写锁。文章根据《Java并发编程的艺术》这本书以及黑马的视频 黑马多线程 做的笔记。
1. 使用ReentrantReadWriteLock
当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。ReentrantReadWriteLock 内部包含两个锁,一个是读锁,一个是写锁。而这两个锁的操作中 读-读 是可以并发的,而 读-写 或者 写-写 是不可以并发的。下面就用代码来测试一下这种情况:
首先是一个容器类:
@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.debug("获取读锁...");
r.lock();
try {
log.debug("读取");
sleep(1);
return data;
} finally {
log.debug("释放读锁...");
r.unlock();
}
}
public void write() {
log.debug("获取写锁...");
w.lock();
try {
log.debug("写入");
sleep(1);
} finally {
log.debug("释放写锁...");
w.unlock();
}
}
}
1. 读读并发
@Slf4j
public class TestReadWriteLock {
public static void main(String[] args) {
DataContainer dataContainer = new DataContainer();
new Thread(()->{
dataContainer.read();
}, "t1").start();
new Thread(()->{
dataContainer.read();
}, "t2").start();
}
}
测试结果:可以看到的是两个线程在同一时间内获取到了锁并开始读取,证明此时是没有互斥的
2. 读写互斥
@Slf4j
public class TestReadWriteLock {
public static void main(String[] args) {
DataContainer dataContainer = new DataContainer();
new Thread(()->{
dataContainer.read();
}, "t1").start();
new Thread(()->{
dataContainer.write();
}, "t2").start();
}
}
测试结果:可以看到写入和读取之间是相差了一秒的,证明了要先写完才可以读
3. 写写互斥
@Slf4j
public class TestReadWriteLock {
public static void main(String[] args) {
DataContainer dataContainer = new DataContainer();
new Thread(()->{
dataContainer.write();
}, "t1").start();
new Thread(()->{
dataContainer.write();
}, "t2").start();
}
}
测试结果:写入之间也是时间相差了一秒,证明写写也是互斥的。
4. 使用的注意事项
- 读锁不支持条件变量
- 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待(可以想象如果为有多个读锁的时候其中一个读锁想要获取写锁是不支持的)
r.lock();
try {
// ...
w.lock();
try {
// ...
} finally{
w.unlock();
}
} finally{
r.unlock();
}
- 重入时降级支持:即持有写锁的情况下去获取读锁(写锁只能有一个线程获取)
//这是 JDK 官方给的例子
class CachedData {
//数据
Object data;
// 是否有效,如果失效,需要重新计算 data,true:有效。false:失效
volatile boolean cacheValid;
//读写锁
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
//先获取读锁
rwl.readLock().lock();
if (!cacheValid) {
// 获取写锁前必须释放读锁
rwl.readLock().unlock();
//释放了读锁再获取写锁
rwl.writeLock().lock();
//下面重新计算数据
try {
// 判断是否有其它线程已经获取了写锁、更新了缓存, 避免重复更新
//单例模式的双重保护
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 降级为读锁, 释放写锁, 这样能够让其它线程读取缓存
rwl.readLock().lock();
} finally {
//写锁释放之前得到读锁
rwl.writeLock().unlock();
}
}
// 自己用完数据, 释放读锁
try {
//加读锁的目的是不被其他线程干扰,读写互斥
use(data);
} finally {
//使用完了之后把锁解开,
rwl.readLock().unlock();
}
}
}
2. 应用之缓存
我们可以使用读写锁保证缓存的一致性。
1. 问题
现在假设有这么一个业务场景,我们要从数据库中查出数据来,然后第一次查询的时候存入缓存 map 中,等到后面再查询的时候就可以直接从 map 中拿数据了。而更新数据的时候先把缓存清除,再更新数据库
//查询方法
@Override
public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
// 先从缓存中找,找到直接返回
SqlPair key = new SqlPair(sql, args);;
//map是普通的 HashMap
T value = (T) map.get(key);
//如果找到了
if(value != null) {
//直接返回
return value;
}
//缓存中没有,查询数据库
value = dao.queryOne(beanClass, sql, args);
map.put(key, value);
return value;
}
//更新方法
@Override
public int update(String sql, Object... args) {
// 先更新库
int update = dao.update(sql, args);
// 清空缓存
map.clear();
return update;
}
看上面这行代码,这行代码是先更新数据库再清空缓存的操作来进行更新功能,那么这在多线程有没有问题呢?
我们来看先清空缓存再更新数据库的结果:在多线程下有可能会造成数据过期的效果,线程 B 还没来得及将新数据存入数据库。线程 A 就从数据库中查到了旧的数据并存入了缓存中,导致以后读到的一直是旧数据。
然后来看先更新数据库再清空缓存的结果:下面可以看到先更新数据库之后尽管线程A查出来的是老数据,但是线程 B 马上又把缓存清空了,这时候线程 A 最多就使用一次的旧数据,但是尽管是一次,还是有问题。
总的来说,我们没有一来对缓存 map 使用线程安全的,二是这个操作的过程也没有加锁这些,就导致了线程安全的问题,下面就来解决这个问题。
2. 解决
我们使用读写锁来解决这个问题,这时候就没必要考虑谁先谁后的问题了
@Override
public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
// 先从缓存中找,找到直接返回
SqlPair key = new SqlPair(sql, args);
//获取读锁
rw.readLock().lock();
try {
T value = (T) map.get(key);
//如果找到了
if(value != null) {
//直接返回
return value;
}
} finally {
//这里注意要释放读锁,否则多线程下是拿不到写锁的
rw.readLock().unlock();
}
//获取写锁
rw.writeLock().lock();
try {
//假如到这里多个线程都要去查数据库
//下面再次判断value防止其他线程直接查数据库,双重检查锁
//再去缓存中找看看有没有value
T value = (T) map.get(key);
if(value == null) {
//缓存中没有,查询数据库
value = dao.queryOne(beanClass, sql, args);
map.put(key, value);
}
return value;
} finally {
rw.writeLock().unlock();
}
}
//更新方法
@Override
public int update(String sql, Object... args) {
rw.writeLock().lock();
try {
// 先更新库
int update = dao.update(sql, args);
// 清空缓存
map.clear();
return update;
} finally {
rw.writeLock().unlock();
}
}
3. 注意
以上体现的是读写锁的应用,但是还有下面的问题没有考虑
- 适合读多写少,如果写比较频繁,上面的操作效率就比较低’
- 没有考虑缓存容量
- 没有考虑缓存过期
- 只适合单机
- 并发还是低,目前只是用了一把锁
- 更新方法太过于简单粗暴,直接清除 map 里面的所有数据
3. 原理
图解流程
注意:读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个
下面以 t1 线程获取写锁, t2 线程获取读锁为例
(1)t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁 使用的是 state 的高 16 位
下面是 write lock 的加锁的源码:
public void lock() {
sync.acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
//当前线程
Thread current = Thread.currentThread();
//获取状态
int c = getState();
int w = exclusiveCount(c);
//这里不等于0表示有可能加了写锁或者加了读锁
if (c != 0) {
//如果w == 0,表示写锁等于0,证明已经加了读锁了,读写互斥,直接return false
//又或者写锁的持有者是不是自己,如果不是,这么别人加了写锁,直接return false
if (w == 0 || current != getExclusiveOwnerThread())
//上面两种情况
return false;
//下面不等于0,证明此时加的是写锁,那么如果是重入,去看看有没有超过
//低16位写锁的数据总数
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//到这是 写锁了, +1 表示发生了可重入
setState(c + acquires);
return true;
}
//下面就是说如果等于0,证明还没有锁,可以尝试加锁
//writerShouldBlock就是前面讲的
//如果是非公平锁,总会返回false,直接获取
//如果是公平锁,就去检查队列,看看当前线程是不是老二,是老二才给机会获取
//compareAndSetState把低16位从0改成1
!compareAndSetState(c, c + acquires))
return false;
//设置当前线程
setExclusiveOwnerThread(current);
return true;
}
(2)t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写 锁占据,那么 tryAcquireShared 返回 -1 表示失败
tryAcquireShared 返回值表示
- -1 表示失败
- 0 表示成功,但后继节点不会继续唤醒
- 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1
public void lock() {
sync.acquireShared(1);
}
public final void acquireShared(int arg) {
//先尝试获取读锁
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
protected final int tryAcquireShared(int unused) {
//获取当前线程
Thread current = Thread.currentThread();
//获取锁的状态
int c = getState();
//判断写锁是不是位0(内部是和低16位与操作实现的),如果不是证明有线程加了写锁了
//那么此时判断写锁是不是当前线程,如果是,就读写互斥了,返回 -1
//这时候t2进来就直接返回 -1 了
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//获取读锁的数量,内部是往右逻辑移位16位,获取有多少个线程得到了写锁
int r = sharedCount(c);
//如果有多个读锁,readerShouldBlock就判断哪个读锁应该获取到,多个读锁情况下只有一个成功
if (!readerShouldBlock() &&
//读锁数量是不是小于 2 的 16 次方倍
r < MAX_COUNT &&
//尝试读锁 +1
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
//第一把写锁就是自己
firstReader = current;
//写锁数量++
firstReaderHoldCount = 1;
} else if (firstReader == current) {
//读锁重入 ++
firstReaderHoldCount++;
} else {
//记录最后一个获取读锁的线程或记录其他线程读锁的重入数。
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//失败的读锁,再次进入这个方法去获取
return fullTryAcquireShared(current);
}
(3) 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态
(4)t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁
(5)如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一 次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park,所以最终park之前尝试了三次获取锁
private void doAcquireShared(int arg) {
//添加一个共享的节点类型
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取 t2 线程的前驱节点
final Node p = node.predecessor();
//如果前驱节点是head,那么 t2 节点就是老二
if (p == head) {
//再次进入去获取锁
int r = tryAcquireShared(arg);
if (r >= 0) {
//如果成功了,进入这个方法,把获取到锁的当前节点抽离出来
//然后设置为 null,保持一个哨兵的作用
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//判断是不是应该阻塞住呢?内部把前驱节点设置为-1,等前驱节点唤醒
//注意这里同一线程第一次进入返回false,下面的park就不执行了,再次for循环
//第二次来到这个方法的时候再执行shouldParkAfterFailedAcquire就返回true
//然后就可以执行下面的parkAndCheckInterrupt方法来park当前线程了
//所以这里其实是第二次for循环才 park 住的
if (shouldParkAfterFailedAcquire(p, node) &&
//就在这里park 住了
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
(6)在原来基础上,这时候又有 t3 r.lock,t4 w.lock,这期间 t1 仍然持有锁,就变成了下面的样子。注意由于t2,t3 加的是读锁,所以是 share 状态,而 t4 加的是 Ex 独占状态
(7)这时候执行 t1 w.unlock,t1 线程释放写锁
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子
protected final boolean tryRelease(int releases) {
//如果当前线程不是占有锁的线程就抛异常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//在原来基础上 -1
int nextc = getState() - releases;
//看看是不是等于 0 了
boolean free = exclusiveCount(nextc) == 0;
if (free)
//如果等于 0 了,写锁就释放掉了
setExclusiveOwnerThread(null);
//设置状态为nextc
setState(nextc);
return free;
}
接下来执行唤醒流程unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行
这回再来一次 for(;;) ,执行tryAcquireShared 方法使得读锁计数 + 1
这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点(setHeadAndPropagate方法是在获取锁那里的,t2线程原来被阻塞住了,现在被唤醒就可以在那个方法中继续运行)
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) {
//拿到当前节点的下一个,上面图是 t3,node 是 t2
Node s = node.next;
//如果 t3 节点是 共享状态
if (s == null || s.isShared())
//
doReleaseShared();
}
}
事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行
private void doReleaseShared() {
for (;;) {
//获取头节点
Node h = head;
if (h != null && h != tail) {
//头节点状态
int ws = h.waitStatus;
//如果是 -1
if (ws == Node.SIGNAL) {
//再次设置头节点为0,防止其他线程看到是-1又唤醒一次t3
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
//唤醒后继节点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
这回 t3 再来一次 for (;😉 执行 tryAcquireShared 成功则让读锁计数加一
此时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点
下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点
总结一下写锁就是:如果当前线程释放了写锁,那么 AQS 中紧跟着这个线程的其他节点的读锁全部被释放。
(8)上面 t2,t3已经获取读锁了,那么这时候执行t2 r.unlock,t3 r.unlock
public void unlock() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零
protected final boolean tryReleaseShared(int unused) {
//获取当前线程
Thread current = Thread.currentThread();
//如果第一个读锁是当前线程
if (firstReader == current) {
//如果读锁为 1
if (firstReaderHoldCount == 1)
//那么此时释放掉就没有了
firstReader = null;
else
//否则就 --
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != 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();
//在原来基础上 - 1
int nextc = c - SHARED_UNIT;
//设置读锁计数为原来的-1
if (compareAndSetState(c, nextc))
//t2 进来的时候减去1,但是此时还有一个 t3,所以 nextc==0返回false
return nextc == 0;
}
}
t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入 doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即
private void doReleaseShared() {
for (;;) {
//获取头节点
Node h = head;
if (h != null && h != tail) {
//获取状态
int ws = h.waitStatus;
//如果是 -1
if (ws == Node.SIGNAL) {
//头节点状态设置为0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
// 然后唤醒上面图中的 t4
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
之后 t4 在 acquireQueued(写锁中的park方法) 中 parkAndCheckInterrupt 处恢复运行,再次 for (;; ) 这次自己是老二,并且没有其他 竞争,tryAcquire(1) 成功,修改头结点,流程结束
最后这部分写完之后还是有些方法不太懂,以后再补上
如有错误,欢迎指出!!!