并发编程之AQS(抽象队列同步器)共享锁
前言
在上一遍笔记中详细介绍了AQS抽象队列同步器的独占锁,在AQS中,独占锁的实现是使用ReentrantLock来实现的,那么共享锁在AQS中都有哪些是实现呢?在AQS中,共享锁的实现比较常用的有Semaphore,CountDownLatch以及CyclicBarrier,这三个都是AQS中共享锁的实现,其中Semaphore是信号量的意思,并且是可重复使用的,那么这三个在AQS中是如何标记是共享锁的,在AQS的双向链表Node数据结构中有两个属性如下:
/** Marker to indicate a node is waiting in shared mode */
static final Node SHARED = new Node();
/** Marker to indicate a node is waiting in exclusive mode */
static final Node EXCLUSIVE = null;
其中SHARED表示共享锁,而EXCLUSIVE 表示是独占锁,在上一篇笔记中已经介绍了独占锁EXCLUSIVE ,独占锁的逻辑看懂了,那么共享锁的逻辑也就很简单了。
我们记得AQS同步队列有一个非常重要的特点就是永远都是都head的下一个结点唤醒执行,head出队,下一个结点作为head结点,也就是说head结点永远是空的,head存在的意义就是唤醒下一个结点线程,然后head出队,下一个被唤醒的结点又更新成空的,因为它在执行,所以正在执行的线程的Node是空的,它是用来唤醒下一个线程的。
Semaphore信号量
Semaphore是java中线程同步的另一手段,我们知道ReentrantLock是用来线程中保证线程数据安全的,但是因为它是独占锁,并且使用同步队列来唤醒,而Semaphore是可以用于并发访问的线程数,来控制线程访问的数量,比如我们的一个文件读写只能由多少个并发线程来访问就可以通过Semaphore来实现,简单来说就是Semaphore定义了获取资源的线程数量,比如你设置的是1,那么就只能用于一个线程并发访问,它执行完成过后会回收,然后再通知下一个线程进行处理,当只有一个Semaphore信号量的时候,和ReentrantLock能达到差不多的一个效果,但是实现上有一定的区别,举个简单的例子,我们去电影院看电影买票的时候,柜台如果开了3个窗口进行买票,比喻买票的100个人是线程,那么 这100个线程都要在这3个窗口买,这3个窗口一次性只能接受3个线程进行处理,其他线程进入AQS的等待队列,当前面3个人每个人买完过后通知到下一个线程进行处理,也就是唤醒线程开始买票,唤醒下一个线程购票的工程中,下个线程如果获取锁成功,那么会进行传播,依次唤醒等待队列的线程,我们先来看个例子:
public class SemaphoreTest {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);
for(int i = 1; i<= 5;i++){
new Thread(()->{
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName()+" 开始 ");
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName()+" 结束 ");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
semaphore.release();
}
},"Thread"+i).start();
}
}
}
上面的程序的大概思路是我开了3个窗口,有5个人过来买票,那么第一次肯定只有3个人买到票,只有等每个窗口买票的线程走了以后通知到下一个线程,下个线程才能买票.
Semaphore 的构造
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
NonfairSync(int permits) {
super(permits);
}
Sync(int permits) {
setState(permits);
}
上面的构造方法其实只反应了两个信息
1.Semaphore使用的非公平;
2.将系统的资源设置为permits,也就是只有permits个线程能够获取到锁(state在上一篇笔记已经说了,就是锁的资源).
semaphore.acquire()
获取资源所,我们就以上面的代码示例来看,我们有5个线程启动,一步一步来分析下,首先先看静态代码
public void acquire() throws InterruptedException {
//获取资源锁,默认都是1
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
//判断用户线程是否设置了中断
if (Thread.interrupted())
throw new InterruptedException();
//tryAcquireShared最终会调用到nonfairTryAcquireShared
//下面分析了,如果返回值小于0,则证明没有资源了,需要调用
//doAcquireSharedInterruptibly入队了,在队列中等待被唤醒
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
//获取资源锁
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
//首先得到state,state初始化的时候是3,所以这里available=3
int available = getState();
//获取了资源锁1,然后剩余是为2
int remaining = available - acquires;
//这边不要看return,主要看if的两个条件,首先看资源是否小于0
//或者cas修改资源数为remaining ,最后返回资源数,如果资源数》0的
//那么获取锁成功
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
doAcquireSharedInterruptibly(int arg)
下面这个方法就是因为资源不够了,需要入队,进入AQS的等待同步队列
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
//这个方法和独占锁的添加的Waiter一样的意思,唯一不一样的就是
//这边添加的是共享锁,ReentrantLock添加剂的是独占锁
//添加到同步等待队列过后,比如head结点是head,那么
//head.next=node ,node.prev=head
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
//开启循环,这里即是阻塞处,也是被唤醒处
for (;;) {
//这里得到的是head,因为node.prev就是head
final Node p = node.predecessor();
//这里肯定是相等的
if (p == head) {
//再尝试获取锁,如果获取到锁,然后它自己进行传播setHeadAndPropagate
//也就是说获取到锁过后,自己出队了还要传播到下一个线程
int r = tryAcquireShared(arg);
if (r >= 0) {
//如果获取到锁,进行传播
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
//如果上面没有获取到锁,这里先修改waitStatus=-1,然后
//阻塞当前线程,修改的是node.next的线程的waitStatus=-1
//调用parkAndCheckInterrupt进行阻塞
//被唤醒的时候也在这里被唤醒
//for循环第一个修改的是waitStatus=-1,
//第二次调用parkAndCheckInterrupt 阻塞线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
上面就是semaphore加锁过程,整个过程算是比较简单的,把独占锁的代码看懂过后,这里就非常简单了,就是在初始化对象的时候传入信号多少个,也就是只能允许多少个资源同时进行,然后加锁的过程就是每个线程获取一个资源,直到资源用完过后进入等待队列,下面来分析下资源释放的源码;
semaphore.release()
//资源释放,简单理解就是解锁,将自己所用于的资源还回去
public void release() {
//每个线程默认都一次只释放一个资源
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//尝试释放资源,如果成功,进入doReleaseShared
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
//资源释放方法
protected final boolean tryReleaseShared(int releases) {
for (;;) {
//首先回去目前的资源数量,state是在初始化的时候通过传入的资源数
//设置为state,比如state=3
int current = getState();
//资源释放过后,那么原有的资源需要+1,也就是资源还回去,下个线程
//就可以获取到资源
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
//通过cas将当前的资源数修改到next数量上,
//这里为什么要使用cas呢?因为是多线程,要保证数据安全
//并且对其他线程可见
if (compareAndSetState(current, next))
return true;
}
}
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for (;;) {
//如果启动的线程数大于了state,那么这里肯定有阻塞队列
//其实这里不是出队,只是唤醒head.next线程
Node h = head;
//判断这里的head不为空,这个条件我们都能理解
//但是h!=tail为什么要这么写呢?因为AQS同步队列有个特点
//在上一篇笔记中我已经写了,唤醒的永远是头部节点的下一个节点
//而下一个节点的waitStatus永远是0,当下一个节点被唤醒过后
//下一个节点的线程设置为null,waitStatus又等于-1,表示
//它执行完了又可以唤醒下一个节点了,这是独占锁的逻辑
//而共享锁的逻辑是通过传播实现的唤醒,待会儿分析传播的代码
//所以这里要判断是否是最后一个节点,因为最后一个节点已经
//运行到这里了,同步队列中已经没有线程要唤醒的了,而且被唤醒的
//的节点的waiteStatus是0,因为唤醒的是head.next,所以这里就算
//进了循环也毫无意义
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//唤醒的逻辑
unparkSuccessor(h);
}
//对于共享锁来说,这里永远不会进,因为到这里,只要不是tail
//节点,基本上ws都是-1
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
if (h != null && h != tail) 这个判断的解释,我这里贴一下,因为上面的代码块无法标记重要部分
//判断这里的head不为空,这个条件我们都能理解
//但是h!=tail为什么要这么写呢?因为AQS同步队列有个特点
//在上一篇笔记中我已经写了,唤醒的永远是头部节点的下一个节点
//而下一个节点的waitStatus永远是0,当下一个节点被唤醒过后
//下一个节点的线程设置为null,waitStatus又等于-1,表示
//它执行完了又可以唤醒下一个节点了,这是独占锁的逻辑
//而共享锁的逻辑是通过传播实现的唤醒,待会儿分析传播的代码
//所以这里要判断是否是最后一个节点,因为最后一个节点已经
//运行到这里了,同步队列中已经没有线程要唤醒的了,而且被唤醒的
//的节点的waiteStatus是0,因为唤醒的是head.next,所以这里就算
//进了循环也毫无意义
unparkSuccessor(h)
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
//到这里已经要唤醒线程了,唤醒的线程是node.next
//所以目前的head待会儿是要出队的,在AQS中,0是表示正在执行的
//线程,而且head也是正在执行的线程(可以这样认为),而-1是
//要去唤醒它的下一个节点(线程),所以这里修改成0
//其实笔者认为这里可以不修改也可以,反正待会儿要出队
//但是这是要给标准
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
//唤醒下一个线程
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);
}
代码执行到这里的时候,其实已经开始唤醒第四个线程了,这个时候我们的第三个线程还阻塞在for循环哪里在,等待被唤醒,当线程1执行了 LockSupport.unpark(s.thread);,第四个线程就已经被唤醒,我们看第四个线程别阻塞的位置
第四个线程被阻塞到红色方框这里,所以这里唤醒了,我们分析下被唤醒的后的代码,被唤醒过后再次循环,所以看源码分析如下:
for (;;) {
//每个被唤醒的线程的prev节点肯定是head节点,这个不用多说,
//因为每次入队都是尾节点,队列是FIFO,所以只要轮到出队,那么node.prev
//肯定是head
final Node p = node.predecessor();
if (p == head) {
//这里尝试获取所,因为线程1已经释放了锁,所以这个时候state=1
//所以这里获取锁肯定是能够成功的
int r = tryAcquireShared(arg);
if (r >= 0) {
//这里就是重点,这里就是唤醒的传播性,我没有去看过网上
//各大博客有没有说这里是唤醒的传播,我是这样理解的
//这里的node=当前线程的node
setHeadAndPropagate(node, r);
//p.next其实就是node,在方法setHeadAndPropagate
//中设置了node.prev=null,结合这里的p.next=null
//其实就是出队,head出队,执行的线程设置线程为空,
//作为下一个head存在(node)
p.next = null; // help GC
failed = false;
return;
}
}
semaphor的源码大概分析完了,就是给定的一定资源数量,然后根据资源数量的线程获取锁,然后执行,没有获取资源的线程就进入等待队列,然后获取资源的线程处理完了去队列里面唤醒等待的线程,然后唤醒线程有个特性,就是传播性,被唤醒的线程如果是共享锁,那么它会传播下去,依次唤醒在等待队列中的线程。semaphor的总体流程如下:
实列分析
启动5个线程,资源数为3,就以文章刚开始的那段程序,我们debug启动
入队
解锁出队
在资源数为3,启动5个线程执行的过程中,执行流程如上图,因为资源数为3,所以5个线程中只有前3个线程能够获取到锁,第四个和第五个线程进入同步队列中进行阻塞,前3个线程中,只要执行完成自己的业务逻辑过后,就会通知队列中处于阻塞等待的线程,当唤醒了第一个线程过后,第一个线程进行出队执行,在出队执行的过程中,还是判断是否是共享锁,如果是共享锁,那么还是通知下一个被阻塞的线程,依次唤醒后续被阻塞的线程,但是有个前提条件就是被唤醒的线程只有获取锁成功过后执行执行自己线程的业务逻辑之前,才会去唤醒下一个阻塞的线程,唤醒执行也就意味着出队,当出队的线程只有一个的时候,则不需要唤醒了,因为这个时候队列已经到了tail了,而tail目前就是被上一个线程唤醒的,所以到这里就唤醒了所有的线程执行了。而每当一个线程执行完成过后,会把资源还回去,也就是说比如资源是3,当线程获取了线程执行权限过后,当前资源数-1,即为2,如果这个线程执行完成在release过后,资源数也就是state又会+1,这个和独占锁优点类似,独占锁是初始资源数为0,获取了锁为1,来获取锁的线程必须要判断资源数为0才能获取锁,所以独占锁和semaphor共享锁还是有一定的区别的。
CountDownLatch
CoutDownLatch是一个闭锁,通过名字就可以知道是一个类似于计数器的共享锁,初始化需要设置一个计数器大小,
当计数器为0的时候,释放锁,也就是说线程获取锁过后,需要线程自己调用countDown()方法将计数器减一,
CoutDownLatch借助了AQS的实现,实现很简单,我这边就不分析源码了,源码都非常简单,CoutDownLatch使用场景比较常见的就是永远闭锁的场景,它和semaphor最简单的区别就是semaphor定义了的资源数,那么如果启动了线程大于了资源数是需要进行阻塞的,而CoutDownLatch是不需要的,CoutDownLatch如果计数器为0了,也会让线程通过,还是简单看下代码实现:
countDown()
public void countDown() {
//减去计数器其实就是释放锁,和semaphor还是有区别,countDownlatch来就释放资源
//其实原理不一样而已
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//这里就是把计数器state -1,如果为0,返回false,其他返回true
if (tryReleaseShared(arg)) {
//进入了这个方法也没什么用,这里是释放资源,队列都没有,何来释放
doReleaseShared();
return true;
}
return 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))
return nextc == 0;
}
}
}
await
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
//看懂这行代码就可以了,就是资源数如果不是0,那么阻塞,阻塞的代码在doAcquireSharedInterruptibly
//和semaphor的一样的逻辑,所以当线程调用countdown过后,当state=0的时候就释放了
//释放的逻辑和代码和在doAcquireSharedInterruptibly
//和semaphor一模一样,没有任何区别
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
await借助了semaphor的实现,其实这里非常简单,我们一定要理解countdownLatch的实现原理,它是用来做闭锁使用的,简单来说就是比如我们要启动5个线程去处理,那么我们设置计数器为5,那么调用了await过后,只有等到这5个线程执行完成过后,那么才会通过await,所以countDownLatch的阻塞其实是阻塞主线程的,而semaphor是阻塞子线程的,也就是说在执行的时候,await阻塞了主线程,那么所有的子线程在调用countDown过后,计数器-1,当计数器为0的时候,那么主线程就会释放了,所以countDownLatch永远只有2个结点 head 和tail,tail是主线程结点。
所以只要知道countdownlatch是用于什么场景即可以及它的简单实现原理;所以CountDownLatch用于闭锁的场景比较多,感觉就和thead.join()有点类似 ,就是等待线程执行完成,但是countDownlatch是阻塞主多线程的执行结果的。
流程图
简单粗略的画了下countDownlatch的执行过程,这个比较简单,理解原理和使用即可