ReentrantLock和AQS源码解读系列四
共享锁
以CountDownLatch
为例子来讲比较容易理解,我们先来看个例子,有5个线程一期阻塞,然后需要CountDownLatch
来唤醒,而且会同时唤醒所有线程,5个线程在等同一把锁,所有叫共享锁:
public class ShareLockTest {
//等待的数量
private static int num = 5;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(num);
//两个线程共享一把锁,即可以一起获得锁
for (int i = 0; i < 2; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread() + "等待线程启动");
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "等待线程停止");
}, "阻塞线程" + i).start();
}
Thread.sleep(1000);
//工作子线程,只要完成5次任务就可以唤醒所有等待线程
new Thread(() -> {
for (int i = 0; i < num; i++) {
latch.countDown();
System.out.println(latch.getCount());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "工作子线程").start();
}
}
CountDownLatch
里面也是有一个AQS同步器的实现类帮助实现的:
CountDownLatch构造方法
我们首先调用了构造方法CountDownLatch latch = new CountDownLatch(num);
:
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");//小于非法
this.sync = new Sync(count);//初始化同步器
}
//AQS同步器
Sync(int count) {
setState(count);//设置状态
}
实际上就是设置了同步器的状态为5,其实也就是说明起码要释放成功5次。
await
调用同步器的acquireSharedInterruptibly
方法,一看就是可被打断的,而且会抛出异常:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//尝试获取共享状态如果是0的话就中断了
doAcquireSharedInterruptibly(arg);
}
tryAcquireShared
里面就是查看状态是不是0,而不会去修改锁的状态,修改锁的状态不是由自己来完成的,是由其他线程组来完成的:
//如果状态为0,就表示获得共享,否则就没获得锁
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
doAcquireSharedInterruptibly
这里基本和独占的差不多,只是有setHeadAndPropagate
这个,设置头之后还要传播:
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);//添加一个共享结点,nextWaiter为null,即没有条件等待结点,只有独占锁有
try {//下面的大部分和独占的一样,自旋尝试获取锁,或者阻塞
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);//尝试获取共享锁
if (r >= 0) {//如果结果是>=0,表示获取到了共享锁,CountDownLatch的实现返回1或者-1,1表示状态为0成功获得,-1表示不为0
setHeadAndPropagate(node, r);//设置头结点并传播到后面结点
p.next = null; // help GC
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();//可中断,抛出异常
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
setHeadAndPropagate
从这里开始,就是进入共享锁难的地方了,也是很精髓的地方,方可见作者的牛逼之处:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below 缓存一下老的头结点
setHead(node);//设置新头结点
/*
propagate > 0 也就是可以获取锁,而且可以同时多线程都获取锁,这个也算是共享锁的一个点吧。
h为null 也就是说有新头结点了,老头结点h被回收了
老头结点h存在且状态是PROPAGATE或者SIGNAL
新头结点head为null,就是此时有另外一个线程来了,他也进入了这个方法,setHead后,又设置了更新的结点,老的新结点也被回收了
新头结点head状态是PROPAGATE或者SIGNAL
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;//获取后继结点
if (s == null || s.isShared())//获取当前结点的后继,可能没有,当前结点就是最后一个,也可能有其他结点加进来了,但是必须是共享的结点
doReleaseShared();//继续释放
}
}
doReleaseShared
这里就是共享传播的重点了,涉及到很多多线程的情况,这里主要是个无限循环,要的就是把所有共享的结点全部唤醒,退出循环的条件就是头结点不改变了,也就意味着没有能唤醒的结点,或者结点被唤醒了还没进入设置头的地方,或者是没有新加入的结点了,因为所有结点都唤醒后,他们都会去设置头结点,意味着头结点就是在变了,从队头到队尾,直到最后循环发现头结点不变了,那就是没有可以唤醒的了,于是就开始有线程退出循环,注意这里可能是多线程在执行,因为你唤醒了其他线程,其他线程也会到这里来执行,也就是说多个线程在一起换新后续结点,可能A
唤醒了B
,如果B
还没设置头结点setHead
的时候,A
就结束了,因为此时头结点没变,A
退出循环了,不过没关系,只要B
在,他就会到这里来继续释放。如果B
设置了头结点,还没进入到doReleaseShared
,A
也会继续释放B
的后继。这种多线程并发唤醒就保证了极限的性能,因为只要一个线程setHead
后,其他线程就可以进行唤醒,而不需要等到该线程执行doReleaseShared
的unparkSuccessor
的方法,基本是可以达到性能极限了,有点像CPU指令的多级流水线,只要条件满足,立马可以并行执行,大大提高了执行的效率。
private void doReleaseShared() {
/*
* 这里要注意几点:
* 1。只会唤醒SIGNAL的后继,而且不会重复唤醒,提高了性能
* 2。如果唤醒SIGNAL的后继失败,就再次循环去设置PROPAGATE:
* 设置成功的话就等着继续唤醒,或者直接退出循环
* 设置失败就继续循环:
* 此时如果head变了,那就继续处理新head了
* 如果head没变,状态已经别的线程被设置成PROPAGATE了,那就什么都没做,退出循环了
* 3.无论中间有多少多线程一起来参与这个唤醒工作,最后都会给head结点留下PROPAGATE状态
*
*/
for (;;) {//无限循环,直到头结点不变了,线程才退出
Node h = head;//获取头结点,多线程情况,头结点可能时刻在变的,因为后续被唤醒的线程都会去设置头结点
if (h != null && h != tail) {//头结点不等于尾结点的情况,即除了头结点外有结点排队
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {//如果头结点状态是SIGNAL,后续有结点排队呢
if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))//CAS保证多线程的情况只有一个线程能修改这个状态,如果其他线程修改了,就不改了,否则重复唤醒了,浪费资源
continue; // loop to recheck cases 循环去设置状态,成功为止
unparkSuccessor(h);//如果设置成功就唤醒后续的结点
}
else if (ws == 0 &&
!h.compareAndSetWaitStatus(0, Node.PROPAGATE))//如果在设置为0后,不能再设置为PROPAGATE,就继续,直到能设置为止,因为最后一个头结点状态是0,为了要继续传播唤醒,所以要留下状态,也就是设置为PROPAGATE,设置成功后最后一次循环就可以跳出循环了
continue; // loop on failed CAS 循环设置,成功为止
}
if (h == head) // loop if head changed
break;//头结点没有变化就退出,否则继续
}
}
基本一些思路理解了,但是这可能是冰山一角啊,毕竟大神写的东西,而且是多线程的,很多情况要考虑,还得慢慢看,多想想,不是一蹴而就的,或许我也只是分析了不到50%的情况,感觉多线程确实是比较难的一块。
比如简单的可能是这样:
也可能这样,多线程一起唤醒嘛:
countDown
主要先尝试释放,锁状态改了才去释放共享的线程doReleaseShared
:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared
这里保证了能够将锁释放完,CAS保证的原子性,只有有状态释放完了才返回true,如果已经释放完了就返回false,也就是只保证一次真的释放,否则什么都不做:
//释放锁
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))//最终改为0才返回true,否则为false
return nextc == 0;
}
}
Semaphore
其实他内部和ReentrantLock很像,也有公平和非公平区分,默认也是非公平的:
简单例子
用了一个信号量,只有2个许可,但是有5个线程需要,所以应该是2个2个1个的情况执行:
public class SemaphoreTest {
//就给2个通道
private static Semaphore semaphore = new Semaphore(2);
public static void main(String[] args) {
//5个线程抢
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
semaphore.acquire();
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName()+"执行");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}, "线程"+i).start();
}
}
}
FairSync和NonfairSync
这个代码比较简单,看过ReentrantLock
之后这个就很好理解了,我就不多说了,比如:
所以我们知道remaining>=0
才算是成功。
acquire
我们来讲下常用的几个方法,其他都差不多acquire
:
一看就是可以中断的,会抛出异常,跟CountDownLatch
的await
一样
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
调用AQS内部的,跟CountDownLatch
一样:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//尝试获取共享状态如果是0的话就中断了
doAcquireSharedInterruptibly(arg);
}
tryAcquireShared
内部调用了非公平锁的nonfairTryAcquireShared
,CAS保证了状态的原子操作,返回状态<0
获取失败,成功可能是0
或者>0
,而CountDownLatch
成功一定是1
:
//非公平直接获取
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
如果条件不满足就调用doAcquireSharedInterruptibly
,这个在文章前面已经讲过,就不说了。
release
public void release() {
sync.releaseShared(1);
}
内部有调用了和CountDownLatch
的countDown
一样的方法,只是tryReleaseShared
实现不同:
//增加一定数量
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
其他一些方法就是超时,不可被中断,还有获取一些信息的都比较好理解,我就不多说了。注意里面有acquire(int permits)
这种带参数的,明显就是可以一次新申请多个资源,当然你释放的时候也要释放多个release(int permits)
,否则就可能死锁啦。
PROPAGATE的作用
其实一直没搞明白PROPAGATE
状态到底是怎么用的,后来看了一篇大神的文章才豁然开朗。原来是因为早期出的BUG,后来才引入了这个状态:
左边是早期的,没有对头结点进行验证会有bug
,随后才添加了头结点验证,以及到现在有新老头结点验证。我们先来看看那个时候到底是什么情况引起的bug
。简单来说,就是现在有2个线程A
,B
阻塞在,有两个线程C
,D
将要去唤醒他们,理论上是肯定可以的,但是一种特殊情况就会出BUG。
我还是画下图比较好理解,刚开始的情况:
然后C
线程来释放,锁状态+1,发现头结点状态是SIGNAL
,唤醒后继,把A
唤醒:
然后D
线程来释放,发现头结点没变,状态是0,于是就退出了,此时C
线程也发现头结点没变,退出了,而此时就剩线程A
,当它刚好运行到了propagate > 0
发现自己的propagate = 0
,而那时候条件是:
这下没的进doReleaseShared
了,后续结点也就不释放了:
上面的三个过程就使得2个线程释放资源,2个线程用资源,最后只唤醒了一个,所以后来又补了一个PROPAGATE
状态,配合这个:
遇到刚才那种情况,线程D
就可以把头结点状态从0改成PROPAGATE
状态,而且如果失败会自旋,只要头结点没变,就一定能设置成功。但是发现后续的版本居然又补不了两个条件:
我猜也应该是有类似的情况,会发现老头结点状态也是0,然后这个时候只能判断新头结点了,不然又不满足条件。有兴趣可以去看看作者的更新BUG
记录,应该可以想象出
总结
本篇主要讲了下共享锁的原理,简单来说就是一旦第一个线程被唤醒了,那么共享锁里的所有共享结点都会被唤醒,而且可以是一种多线程流水线的方式,当然里面还是有很多细节值得思考的。还讲解了PROPAGATE
状态的作用,这个还真的花点时间理解理解,不过首先得理解共享锁的一些机制啦。
CountDownLatch
可以被用于一组线程等待另外一组线程完成任务后继续执行任务,而且是等待的线程会被一并唤醒并发执行,这里才是共享的体现。比如说要做查询汇总的功能,主线程阻塞,开10个线程去查询,全部完成后唤醒主线程,再进行汇总。
Semaphore可以用于限流,比如我就两个通道,5个线程去抢,每个线程需要一个通道,那就是同时只能有2个线程在工作,其他的等待,直到释放后继续抢。比如一个线程池,只有10个线程在工作,如果来了20个请求,那就另外10个等待了,这样就限流了,作为一种保护机制。当然可能还有比较特殊的情况,比如来了一个很紧急的请求,他希望可以尽快的计算完成,希望CPU就给他服务,这样他可以直接就申请所有现有的资源,不让其他线程拿资源了,尽量保证自己现在可以获得尽可能多的资源。
CountDownLatch
可以把共享的结点全放出来,而Semaphore
只能放有限个。
参考
https://www.cnblogs.com/micrari/p/6937995.html
好了,今天就到这里了,希望对学习理解有帮助,大神看见勿喷,仅为自己的学习理解,能力有限,请多包涵。