之前说的ReentrantLock都是从独占锁的角度去探究Lock的具体实现,所以这篇专门来研究一下共享锁与独占锁的区别。
要想了解CountDownLatch的具体使用和多线程之间通信的事例,可以先阅读:
【多线程】四种种方案实现多线程之间相互协作的通信
如果没有AQS基本原理基础可以先看
【并发编程】AQS源码分析(一) 从ReentrantLock来看AQS的基本数据结构和主要执行流程
【并发编程】AQS源码分析(二)通过生产者和消费者模式理解ReentrantLock的Condition
之后再看这篇说到的源码,应该会更容易理解。
我们通常把CountDownLatch叫做栅栏,他的主要功能是当前线程等待其他线程结束后或执行到某个状态后再继续执行。可以很灵活地控制线程的执行节点。
听起来跟Join方法类似,那么他和JOIN有什么区别呢?
区别就是,CountDownLatch更加的灵活,通过计数器可以很灵活地把控另一个线程执行是在其他线程开始,结束或者执行过程中,二而Join只能等待其他方法结束才可以继续执行。
大体流程图:
//构造方法,初始化计数器
CountDownLatch countDownLatch = new CountDownLatch(10);
//每次调用该方法时,计数器减一
countDownLatch.countDown();
//直到计数器减到0为止,才继续执行
countDownLatch.await();
//获取当前计数器的值
countDownLatch.getCount();
在CountDownLatch内部也是利用了AQS的原理,通过Sync类来重写AQS的方法实现的共享锁。
好了,现在跟着代码,一步一步的了解一下CountDownLatch是怎么实现共享锁的吧
//构造方法,初始化计数器
CountDownLatch countDownLatch = new CountDownLatch(5);
//CountDownLatch 类结构
public class CountDownLatch {
/**
* 内部类Sync,继承AQS来实现锁
*/
private static final class Sync extends AbstractQueuedSynchronizer {
//初始化state的值,传入10,state = 10 ,
//这里state的值是共享锁和独占锁的主要区别
Sync(int count) {
setState(count);
}
}
private final Sync sync;
//构造方法
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
//调用了Sync的构造方法去初始化计数器了
this.sync = new Sync(count);
}
}
好了,类的大体结构已经清楚了,那么在执行代码时,主要是countDown,await这两个方法。
countDown主要功能是每次执行时让state - 1 ,相当于释放锁的过程
await主要功能是阻塞当前线程,尝试去获取锁直到state = 0 时,获取锁成功后继续执行。
接下来就按顺序跟一下这两个方法的源码。
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//尝试获取共享锁,可被中断
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//先尝试获取共享锁
//如果初始化 state = 5,那么第一次进来获取锁state = 5
//tryAcquireShared(arg)结果会返回 -1
if (tryAcquireShared(arg) < 0)
//返回 <0 说明现在还不能获取锁呢
//由于没有获取到锁,
//所以继续将node加入到阻塞队列中不断尝试获取共享锁,可被中断
doAcquireSharedInterruptibly(arg);
}
/**
尝试获取共享锁
如果state == 0 了,说明其他线程已经结束了,返回 1
否则如果state !=0 则返回 -1
**/
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
//这里在源码一里面已经解释过了
//将node加入到阻塞队列中去并自旋将node加入对尾
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
//这里同样要自旋去获取锁
for (;;) {
//获取node前驱节点
final Node p = node.predecessor();
//如果p是头节点,那么node是头节点之后第一个节点
//也就是node是阻塞队列中的第一个节点了
if (p == head) {
//这个时候node可以去尝试竞争锁了
//r = 1成功 r = -1失败
int r = tryAcquireShared(arg);
//最开始,r肯定是 < 0 的,因为此时其他线程都还没有执行完呢
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
/**所以,最开始会先走到这里
shouldParkAfterFailedAcquire 这个方法在第一篇中也讲过了
要根据node前驱节点p来判断当前node是否要执行挂起
并且要将前面可用的节点标记为 -1
走到这里,线程挂起就要等待锁释放了
**/
if (shouldParkAfterFailedAcquire(p, node) &&
//这里是挂起当前线程
parkAndCheckInterrupt())
//如果线程挂起过程中发生了中断则抛出中断异常
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
等待锁释放的过程,我们接下来去看countDown的代码
public void countDown() {
//调用释放
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//尝试去释放共享锁,只有当state 减为0时才返回true,否则false
if (tryReleaseShared(arg)) {
//如果减为0了,那么开始要唤醒等待的线程了
doReleaseShared();
return true;
}
return false;
}
protected boolean tryReleaseShared(int releases) {
// 自旋去将设置为 state - 1
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
//如果设置state成功,则返回true
return nextc == 0;
}
}
如下图:如果此时阻塞队列中有node1,node2,node3
当我们开始调用countDown开始去释放锁时,如果此时state = 0 了
那么开始去唤醒node1了,因为node1是阻塞队列中的第一个节点
//释放锁后开始唤醒其他等待的线程
private void doReleaseShared() {
/*
*自旋设置阻塞队列中节点的状态并依次唤醒节点
*/
for (;;) {
Node h = head;
//判断如果阻塞队列不为空
//如果h == tail的话,说明已经被唤醒了,head是被唤醒的节点
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
//如果头节点状态 -1,则将他修改为0表示h已经唤醒完成了
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//去唤醒头节点之后的节点,
//(head会出队列,新的节点会成为head节点)这个步骤会在唤醒之后的操作中体现
//这里唤醒之后就会回到上面的方法中继续执行了
//====================可以先看下面唤醒的流程
//因为这里是自旋,一直循环,如果队列里面有5个线程
//会先唤醒t1,再接着唤醒t2,t3,t4,t5
unparkSuccessor(h);
}
//如果 ws = 0 了
//那么将状态修改为 -3 代表之后的节点可以一直传播获取共享锁了,
//但是这里在countDown里并没有实际的用处,可以不用管
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0,
Node.PROPAGATE))
continue; // loop on failed CAS
}
//如果接下来的节点改变了,下面会有解释
//那么继续循环唤醒队列中之后的节点直到唤醒完为止
//否则如果h还是原来的head,防止重复唤醒
//直接跳出循环,当然了退出循环后阻塞队列可能还有没有唤醒的节点
//h可能会一样的原因?只有唤醒之后的操作才会将新的node修改为head节点
//但是修改head前提是获取到共享锁,这里是多线程执行可能会存在延迟现象
if (h == head) // loop if head changed
break;
}
}
好了,这里等待的线程已经被唤醒了,那么会回到线程挂起的地方继续执行
for(;;){
//唤醒之后,代码来到了循环里面的这段代码了
if (p == head) {
//这个时候node可以去尝试竞争锁了
//r = 1成功 r = -1失败
int r = tryAcquireShared(arg);
//这个时候,state = 0 了,r == 1
if (r >= 0) {
//将node设置为head节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // 记录老的head为了下面check
//这里唤醒之后会将原来的阻塞队列中的第一个节点node1设置为head
//这也是为啥上面的自旋里面会判断h == head的原因
setHead(node);
/*
propagate > 0 这里仅仅是指可以继续获取锁,
countDownLatch结果都为1
就是可以继续唤醒之后的线程
h == null 是原来的头head为null
h.waitStatus < 0 是原来的头状态不为取消
(h = head) == null || h.waitStatus < 0 这个是对新的head判断
这里只是做一下判空吧。。。。
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
//如果是共享锁,则继续释放共享锁
//好了跟上面的解释对应了,这里唤醒的节点会继续唤醒阻塞队列中新的节点
//Node2,node3
doReleaseShared();
}
}
好了,用图简单总结一下唤醒流程(node为head之后阻塞队列中的第一个节点)
以上就是CountDownLatch的整个执行流程。
可以看出最大的区别是对state值设置的区别
独占锁,state值只能由一个线程去设置,而共享锁可以由多个线程去设置。