1.读写锁的介绍
在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java提供的关键字synchronized或者concurrents包中实现了Lock接口的ReentrantLock。它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。
针对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。读写所允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。在分析WirteLock和ReadLock的互斥性时可以按照WriteLock与WriteLock之间,WriteLock与ReadLock之间以及ReadLock与ReadLock之间进行分析。更多关于读写锁特性介绍大家可以看源码上的介绍(阅读源码时最好的一种学习方式,我也正在学习中,与大家共勉),这里做一个归纳总结:
- 公平性选择:支持非公平性(默认)和公平的锁获取方式,吞吐量还是非公平优于公平;
- 重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁;
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁
要想能够彻底的理解读写锁必须能够理解这样几个问题:1. 读写锁是怎样实现分别记录读写状态的?2. 写锁是怎样获取和释放的?3.读锁是怎样获取和释放的?我们带着这样的三个问题,再去了解下读写锁。
先从写锁的加锁过程说起:
WriteLock.lock():
根据加锁的流程图,配合源码,我们一步一步的分析写锁的加锁流程:
WriteLock.lock():
public void lock() {
sync.acquire(1);
}
可以看到,lock()方法中调用的是AQS抽象类的acquire()方法,该方法用来获取独占锁;
点进该方法:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
从中可以看到获取独占锁的主要流程可以分为三步:
- tryAcquire()方法:用来尝试获取锁;该方法是一个protected修饰的方法, 它在AQS中并没有实现,而是抛出异常;主要是供其子类实现该方法来实现不同的独占锁加锁流程;
- addWaiter(),顾名思义, 添加阻塞节点; AQS底层是由一个volatile修饰的int型state变量和一个先进先出的阻塞队列来实现的;当某线程获取锁失败时, 就通过addWaiter()方法将该线程包装到一个Node节点并添加到队列的尾部;
- acquireQueued()方法就是获取队列中,判断队列中的节点线程是应该唤醒还是阻塞
了解了大致的流程之后,我们接着往下走:
进入tryAcquire()方法:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
可以看到,AQS中的tryAcquire()方法并没有具体的实现, 在上面我们说过,该方法是由子类实现具体的加锁逻辑的;
因此,我们查看实现了该方法的子类:
可以看到,ReentrantLock和线程池ThreadPoolExecutor中的Worker类以及本文的主角ReentrantReadWriteLock都实现了该接口, 我们进入ReentrantReadWriteLock中的tryAcquire()方法:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);//获取写锁数量
//c!=0表示此时有读锁或者写锁被获取
if (c != 0) {
//w==0代表此时的读锁数量不为0, 读锁不能升级成为写锁
if (w == 0 ||
//写锁被其他线程占有
current != getExclusiveOwnerThread())//锁被其他线程占有
//获取锁失败
return false;
//到这说明是重入
if (w + exclusiveCount(acquires) > MAX_COUNT)//不能超过重入次数最大值(一般不会超过)
throw new Error("Maximum lock count exceeded");
//更新state的值(ReentrantReadWriteLock中state代表锁重入的次数)
setState(c + acquires);
return true;
}
//到此处说明锁空闲
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//将当前线程绑定该锁
setExclusiveOwnerThread(current);
return true;
}
从流程图以及源码中我们可以看到, tryAcquire()方法先判断了AQS中的state的值是否为0; 在ReentrantReadWriteLock中, state是一个32位的int类型的值,高16位代表读锁的重入次数,低16位表示写锁的重入次数;
[1]state不等于0时,说明有读锁或者写锁已经被其他线程占有;此时进入到[3],[3]当读锁已经被占有或者写锁被其他线程占有时,返回false, 即获取锁失败; 从这里就能看出来了, 读锁是不能升级成为写锁的;当写锁是被当前的线程占有时,获取写锁成功(重入);
state等于0时,进入到[2]writerShouldBlock(),writerShouldBlock()方法对应公平锁和非公平锁有不同的处理逻辑;
我们先点进公平锁的逻辑:
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
[4]hasQueuedPredecessors()从方法名称就能大致猜出来其具体的思路了, 这里判断阻塞队列中是否有节点,如果有节点在阻塞队列中等待(hasQueuedPredecessors()返回true), 那么tryAcquire()方法直接返回false(即获取锁失败),然后调用AQS中的addWaiter()方法将当前线程加入队尾;
接着看非公平锁:
final boolean writerShouldBlock() {
return false; // writers can always barge
}
非公平锁直接返回false, 然后会执行compareAndSetState(c, c + acquires)方法通过CAS获取锁;
从以上两点就能看出ReentrantReadWriteLock中公平锁和非公平锁的区别了; 在公平锁中,如果队列中有节点在等待,那么,必须要将当前线程加入到队列的尾部中,等待它前面的节点唤醒完成才会被唤醒(即先进先出, 谁先到,谁先被唤醒); 而非公平锁则不用管队列中到底有没有线程节点在等待, 它可以直接通过CAS尝试获取锁, 即执行一个抢占的操作; 也就是说,它不需要添加到队列中也有机会获得锁;这样就有可能导致队列中的线程节点永远都获取不到锁,有可能都被队列外新到来的线程给抢走; 这样当然就不公平了.
既然不公平, 那么ReentrantReadWriteLock为什么默认采用的是非公平锁的策略呢?
那当然是因为它效率高了. 从上面也可以看出公平锁中每个新到来的线程都需要添加到队列中, 而非公平锁不需要每次都添加到队列中, 这就省掉了一部分添加到队列,阻塞线程,唤醒线程等一系列的操作, 随之而来的效率当然也是更高了.
接着,我们进入[5]addWaiter()方法:
//将获取锁失败的线程加入队列中
private Node addWaiter(Node mode) {
//创建一个新的节点,封装有当前的线程实例
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;//队尾节点
if (pred != null) {
node.prev = pred;
//采用CAS将当前节点设为队尾节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
//采用CAS将当前线程节点插入队列中
private Node enq(final Node node) {
for (;;) {
Node t = tail;//获取队尾
if (t == null) { // Must initialize
//队列为空时初始化队列
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
}
addWaiter()方法是AQS已经实现好的方法, 它采用CAS的方式将当前线程的节点添加到队列尾部;若队列为空,则初始化一个空节点为队列的头结点,然后再将当前线程节点采用CAS的方式添加到队尾;
添加到队列之后, 进入acquireQueued()方法:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();//获取当前节点的前驱节点
//正常情况:前驱节点为首节点而且获取到了前驱节点释放的锁
if (p == head && // 如果前驱为head才有资格抢占锁
tryAcquire(arg)) {//尝试获取锁
//获取锁成功时,会将当前节点设为首节点
setHead(node);//将当前节点设为队列的首节点
p.next = null; // help GC
failed = false;
return interrupted;
}
//到这里,说明获取锁失败了
//如果获取锁失败,则根据节点的waitStatus决定是否需要挂起线程
if (shouldParkAfterFailedAcquire(p, node) &&//是否要阻塞当前线程
parkAndCheckInterrupt())//阻塞当前线程
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
从acquireQueued()方法的代码中可以看到, 只有前置节点为首节点的节点(即队列中的第二个节点)才有机会调用tryAcquire()方法尝试获取锁, 当其获取锁成功时,会将原先的首节点移出队列, 并将该获取到锁的节点置为首节点.; 如果获取锁失败, 则再通过shouldParkAfterFailedAcquire(p, node)方法判断是否应该阻塞该线程;
自此,写锁的加锁流程就结束了;
接着看写锁释放锁的流程:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {//释放锁(state-1),若释放后锁可被其他线程获取(state=0),返回true
Node h = head;
//当前队列不为空且头结点状态不为初始化状态(0)
if (h != null && h.waitStatus != 0)
//唤醒同步队列中后续被阻塞的线程
unparkSuccessor(h);
return true;
}
return false;
}
AQS中的release()方法就是用来释放独占锁的;tryRelease()和tryAcquire()方法类似,也是由子类实现;因此我们直接点进ReentrantReadWriteLock中的tryRelease()方法:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())//判断当前锁是否是当前线程持有
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)//重入次数为0时释放锁
setExclusiveOwnerThread(null);
//更新state的值
setState(nextc);
return free;
}
可以看出来,释放写锁的流程就比较简单了, 当写锁的重入次数(state的低16位)为0时,释放写锁;
释放写锁后,回到release()方法中的 unparkSuccessor(h)方法,该方法用来唤醒队列中的一个节点(独占锁只会唤醒一个节点);
private void unparkSuccessor(Node node) {
//如果状态为负(即,可能需要信号)尝试在预期的信令中清除。如果这失败或者如果状态通过等待线程而改变,则是OK。
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
//如果下一个节点不存在或者已经取消等待,那么从尾节点向前遍历,找到离当前节点最近的而且在等待中的节点
//疑问:为什么从队尾遍历不从当前节点开始遍历?
//答:因为当前节点的下一个节点可能为空,往下遍历不了
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//唤醒离当前节点最近的且在等待中的节点
if (s != null)
LockSupport.unpark(s.thread);
}
可以看出,unparkSuccessor()方法先看队列中的第二个节点是否处于等待状态,如果处于等待状态则直接唤醒;如果不是等待状态,则通过队尾节点往前遍历, 获取最靠近首节点的处于等待状态的节点; 概括起来说就是唤醒离首节点最近的处于等待状态的线程节点;
自此,解锁流程结束
读锁(ReadLock)的加锁流程
public void lock() {
sync.acquireShared(1);
}
AQS中的acquireShared()方法就是获取共享锁的方法
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)//获取共享锁失败
doAcquireShared(arg);
}
有了以上的经验,咱们直奔主题, 直接看ReentrantReadWriteLock中的tryAcquireShared()方法:
//获取共享锁
protected final int tryAcquireShared(int unused) {
/*
* 演练:1.如果写锁由另一个线程持有,失败。
* 2.否则,此线程适合锁定wrt状态,因此请询问是否应该由于队列策略而阻止。如果没有,尝试通过CASing状态和更新计数授予。
* 注意,步骤不检查可重入获取,其被推迟到完整版本,以避免在更典型的不可重入的情况下检查保持计数。
* 3.如果步骤2失败,或者因为线程显然不合格,或者CAS失败或计数饱和,则使用完全重试循环链接到版本。
*
*/
Thread current = Thread.currentThread();
int c = getState();
// 1
if (exclusiveCount(c) != 0 //写锁已被获取
&& getExclusiveOwnerThread() != current)//不是当前线程获取的写锁
// 获取锁失败
return -1;
//到这里说明写锁未被获取或者是当前线程获取的写锁
int r = sharedCount(c);// c >>> 16 高16位记录的是读锁的重入次数
// !readerShouldBlock() 根据公平锁和非公平锁的策略来判断是否要阻塞线程;公平锁和写锁一样,如果队列中有节点,那么必须要排队;
//但是非公平锁和写锁不太一样;写锁直接返回false,即表示不管队列中有哪些节点,队列外的线程都可以抢占写锁;而读锁的非公平锁还是有一定的公平性的;
//即当队列中首个即将要唤醒的节点是请求写锁的时候,队列外的线程不能抢占锁;如果请求的是读锁,则可以抢占
if (!readerShouldBlock()
//CAS增加读锁的数量
&& r < 65535 && compareAndSetState(c, c + 65536)) {
// 如果读锁是空闲的, 获取锁成功(非重入)。
if (r == 0) {
// 将当前线程设置为第一个读锁线程
firstReader = current;
// 计数器为1
firstReaderHoldCount = 1;
}// 如果读锁不是空闲的,且第一个读线程是当前线程。获取锁成功(重入)。
else if (firstReader == current) {
// 将计数器加一
firstReaderHoldCount++;
// 如果不是第一个线程,获取锁成功(共享锁)。
} else {
// cachedHoldCounter 代表的是最后一个获取读锁的线程的计数器。
HoldCounter rh = cachedHoldCounter;
// 如果最后一个线程计数器是 null 或者不是当前线程,那么就新建一个 HoldCounter 对象
if (rh == null || rh.tid != getThreadId(current))
// 给当前线程新建一个 HoldCounter
cachedHoldCounter = rh = readHolds.get();
// 如果不是 null,且 count 是 0,就将上个线程的 HoldCounter 覆盖本地的。
else if (rh.count == 0)
readHolds.set(rh);
// 对 count 加一
rh.count++;
}
return 1;
}
// 死循环获取读锁。包含锁降级策略。
return fullTryAcquireShared(current);
}
读锁是共享锁,共享锁就是多个线程可以获取到同一把锁, 而且读锁也支持锁降级策略,即获取了写锁的线程可以再继续获取到读锁;
从代码中也可以看到,在第1步,如果写锁不是被当前线程获取的,那么直接获取失败;只有当写锁是被当前线程获取的时候代码才能继续往下走;
readerShouldBlock(), 读锁的公平锁和非公平锁的策略;我们来看看和写锁的策略有什么不一样
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
可以看到,读锁和写锁的公平锁的策略是一样的,都是直接将线程添加到阻塞队列中
看看读锁的非公平锁:
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
/*判断队列中的下一个要唤醒的节点(即头结点的后置节点)是不是在请求写锁,
如果请求写锁则返回true(返回true表示要阻塞在队列外的要抢占锁的线程)
从这里可以看出,读锁中的非公平锁是有限制的,即它只能抢占队列中想请求读锁的节点,
不能抢占请求写锁的节点;这样是为了防止写锁饥饿,无法获取到写锁*/
return (h = head) != null &&
(s = h.next) != null &&
!s.isShared() &&
s.thread != null;
}
从代码中可以看出,读锁和写锁的非公平策略是不一样的. 写锁不管队列中有没有节点都可以抢占锁;而读锁是只有当队列中的第二个节点请求的是读锁的时候才能抢占,也就是说,当队列中的第二个节点是想请求写锁的时候,读锁的非公平策略是不能够抢占的;
这是为什么呢?这也是为了防止写锁饥饿,毕竟在实际业务中,读操作的数量要远远多于写操作的数量, 如果每个请求读锁的线程都能抢占,那么请求写锁的线程有可能永远也得不到运行; 不能写数据了, 对业务有多大影响自然也不用多说了. 所以,在读锁中,非公平策略实际上还是有一点公平性的.
再往下就是获取读锁了,目前暂不赘述;
回到acquireShared()方法:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)//获取共享锁失败
doAcquireShared(arg);
}
我们看看当获取写锁失败时是怎么处理的:doAcquireShared(arg)
private void doAcquireShared(int arg) {
//将获取锁失败的节点添加到队列
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取前置节点
final Node p = node.predecessor();
//如果前置节点是头结点, 那么尝试获取共享锁(由此可以看出,阻塞队列中的节点,只有前置节点为头结点时,才有机会抢占锁)
if (p == head) {
int r = tryAcquireShared(arg);
//r>=0说明获取共享锁成功
if (r >= 0) {
//将当前节点设为头结点,并将节点中包装的线程等属性清空,
// 由此可以看出,AQS中的头结点中是没有线程数据的,相当于一个空节点
setHeadAndPropagate(node, r);
//清空原先头结点的next,帮助GC
p.next = null; // help GC
//判断是否要中断
if (interrupted)
selfInterrupt();
failed = false;
//获取到锁之后才能正常退出死循环
return;
}
}
//到这里说明队列中的节点没有获取到锁,通过死循环阻塞队列中所有未获取到锁的节点
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
可以看到,和获取写锁失败时一样,也是先将线程节点加入到队列的尾部;然后判断是要唤醒线程还是阻塞线程
自此, 读锁加锁的流程结束
锁降级的必要性:
锁降级中读锁的获取是否必要呢?答案是必要的。
- 首先, 主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 写锁释放之后可能会被其他的线程获取到.假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程是无法感知线程T的数据更新的。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
- 还有一种情况是当某些操作特别的耗时的时候,如果长时间的用写锁独占, 其他线程无法对线程进行读操作,这会大大降低系统的性能;所以,当完成必要的写操作之后,将写锁降级成读锁,可以让其他线程也能进行读操作,这样可以提高系统的吞吐量; 这时有人就会说了,通过缩小锁的粒度, 只将写锁锁住写操作的部分不是更好吗; 是的,缩小锁的粒度的确可以提高并发量; 但是也有些事务操作中,包含了写操作和读操作, 如果只将写操作锁住将会无法保证原子性, 容易引发线程安全问题; 而锁降级之后的代码块依然具备原子性;所以锁降级是很必要的;