文章目录
1.概述
java并发编程系列博客前面6篇,介绍了synchronized和ReentrantLock的使用和实现原理。博客地址如下:
- synchronized你用对了吗?
- synchronized锁升级就是这么的简单
- wait、notify、notifyAll你知道多少?
- 【图解】一篇搞定ReentrantLock的加锁和解锁过程
- ReentrantLock中四种加锁方式的使用区别和源码实现的细节差异
- ReentrantLock Condition的使用和实现原理(不留死角!!!)
本文将介绍几种常用的JUC并发工具:CountDownLatch、CyclicBarrier、Exchanger、Semaphore
介绍如何使用以及分析源码的实现原理。
2.CountDownLatch
用于一个或者多个线程等待其余线程完成任务后再继续执行。
代码演示
/**
* @author gongsenlin
* @version 1.0
* @date 2021-02-09 17:18
* @Description:
*/
@Slf4j(topic = "s")
public class CountDownLatchTest {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 2; i++) {
new Thread(()->{
try {
log.debug("等待子任务完成");
countDownLatch.await();
log.debug("开始工作");
} catch (InterruptedException e) {
e.printStackTrace();
}
},"T" + (i+1)).start();
}
TimeUnit.MILLISECONDS.sleep(10);
for (int i = 0; i < 10; i++) {
new Thread(()->{
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(2));
log.debug("任务完成");
} catch (InterruptedException e) {
e.printStackTrace();
}
countDownLatch.countDown();
},"t" + (i+1)).start();
}
}
}
代码的意思是首先构建一个CountDownLatch,参数是10,作用就是看需要等待多少个任务,10就表示需要等待10个任务。(注意:任务数量不代表线程数量)
定义了T1和T2两个线程调用了countDownLatch.await方法,进入等待,t1~t10线程完成他们的工作后调用countDownLatch.countDown(),任务完成,需要等待的任务个数-1。
控制台的输出结果如下:
结果可以看出,一开始需要等待任务个数是10,所以T1和T2线程都进入了等待,当t1~t10线程完成任务后并调用countDownLatch.countDown(),需要等待的任务数减少到0的时候。T1和T2又恢复了工作。
源码分析
下面来看看源码是如何实现的。
首先来看一下构造函数
第一行是参数校验,小于0 抛出异常。
第二行实例化了一个AQS,并把参数传递进去,其实就是设置了AQS的状态值为count,也就是需要等待的任务数量为count。
await方法的实现
内部调用了acquireSharedInterruptibly(),这段代码是不是很熟悉,之前看过ReentrantLock系列博客的读者一定了解。等同于调用了可打断的获取锁,也仅有当状态为0的时候,才可能获取到锁。该方法逻辑不清楚的读者可以翻阅之前的博客,这里不再赘述。这也就是为什么一开始T1和T2调用await方法后,会进入阻塞的原因。那个时候状态值还是10,而不是0。
countDown()方法的实现
逻辑也很简单,就是调用了AQS的释放锁,让状态值减1。
当状态值为0的时候,则会唤醒阻塞队列当中的T1和T2线程。
看到这里是不是觉得并发工具底层实现的原理都差不多。所以强烈推荐关于ReentrantLock的三篇博客可以好好看看。
3.CyclicBarrier
该工具相当于一个栅栏,栅栏开放的标准是被栅栏拦住的线程数量等于一开始栅栏定义的个数的时候。这些线程就会被栅栏释放,继续执行。
代码演示
例1:
/**
* @author gongsenlin
* @version 1.0
* @date 2021-02-09 18:06
* @Description:
*/
@Slf4j(topic = "s")
public class CyclicBarrierTest {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(10);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(2));//模拟到达栅栏不同的时间
log.debug("被栅栏拦住了");
cyclicBarrier.await();
log.debug("开始工作");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}, "t" + (i + 1)).start();
}
}
}
首先定义一个CyclicBarrier,然后启动10个线程,内部调用cyclicBarrier.await()方法。
控制台输出如下:
结果很明显了,10个线程都先被栅栏拦住了,最后一个到达栅栏的时候,也就是t8线程达到栅栏,然后就释放掉了。可以理解为太多了拦不住了。
例2:和上面不同的是,CyclicBarrier构造方法传入了Runnable。
@Slf4j(topic = "s")
public class CyclicBarrierTest2 {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(10,()->{
log.debug("被突破了");
});
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(2));//模拟到达栅栏不同的时间
log.debug("被栅栏拦住了");
cyclicBarrier.await();
log.debug("开始工作");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}, "t" + (i + 1)).start();
}
}
}
控制台输出如下:
根据结果可以知道,当最后一个线程到达栅栏后,会执行构造方法中Runnable接口的run方法。
源码分析
同样的先来看看构造方法
两个构造方法,区别在于是否有Runnable。
初始化将参数parties赋值给parties和count。具体什么用,接着往下看就知道了。
await方法的实现
内部调用dowait
- 内部使用ReentrantLock,先进行上锁,拿到锁了才会接着下面的逻辑
- 首先获取当前的栅栏的年代状态。因为栅栏被突破之后还可以再用,新的栅栏并不会重新的去构建CyclicBarrier,而是构建一个新的Generation。generation用于判断是否是同一个栅栏。
- 如果栅栏损坏了,再一次的调用await方法的话,则会抛出BrokenBarrierException异常。出现中断异常,或者jvm异常的时候,会调用breakBarrier方法,将当前这个栅栏设置为损坏;或者调用了reset重置栅栏之后,之前的栅栏就被标记为损坏了。构建新的Generation,并重置count数量。
- count减1,count就是用来表示当前还可以拦截多少个任务
- 如果count减少到了0,那么此时栅栏就被突破了。如果在构造CyclicBarrier的时候有传入Runnable,那么此时会执行它的run方法。调用nextGeneration()方法,唤醒所有等待的线程,并生成下一代栅栏,也就是构建新的Generation,返回0.
- 如果没有到0,那么会进入下面的死循环。当前线程进入等待队列,这也就是为什么可以拦截任务的原因,count-1后,线程进入等待队列。
nextGeneration方法是用于栅栏被突破了唤醒等待队列上的线程,并构建新的栅栏,代码如下:
parties的作用也就是在这里,用来重置count值。
4.Exchanger
用于线程间数据的交换。在一个同步点,两个线程交换彼此的数据。同步点就是调用exchange()的地方,如果第一个线程先执行该方法,那么他会等待到第二个线程也执行该方法。
代码演示
/**
* @author gongsenlin
* @version 1.0
* @date 2021-02-09 19:38
* @Description:
*/
@Slf4j(topic = "s")
public class ExchangerTest {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
Thread t1 = new Thread(()->{
try {
String exchange = exchanger.exchange("你好,我是t1线程");
log.debug("t1线程收到的消息是:" + exchange);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"t1");
Thread t2 = new Thread(()->{
try {
String exchange = exchanger.exchange("你好,我是t2线程");
log.debug("t2线程收到的消息是:" + exchange);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"t2");
t1.start();
t2.start();
}
}
模拟两个线程通信,控制台输出结果如下:
exchange的方法比较复杂,之后再另写一篇博客,详细的介绍该方法。
5.Semaphore
用来控制同时访问特定资源的线程数量。
代码演示
@Slf4j(topic = "s")
public class SemaphoreTest {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(5);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
semaphore.acquire();
log.debug("拿到资源,开始工作");
TimeUnit.SECONDS.sleep(5);
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t" + (i + 1)).start();
}
}
}
初始化信号量为5,表示同时只有5个线程可以拿到资源。
控制台输出如下:
根据结果可以看到前5秒只有5个线程开始工作。后5秒是另外5个线程。说明semaphore可以控制同时访问资源的数量。
源码分析
构造方法
设置AQS的状态值为permits,默认是非公平锁
acquire方法如下:
tryAcquire方法实现不同于其他的并发工具,调用nonfairTryAcquireShared,返回结果大于等于0的话,表示获取信号量成功,可以访问资源。
死循环,获取当前的状态值。计算剩余的数量。如果小于0 或者cas 成功。则返回剩余数量。这里也正是Semaphore不同于其他并发工具的主要区别。
tryAcquire方法获取锁失败了,则会调用doAcquireInterruptibly方法。
该方法之前分析过,之后就和ReentrantLock的逻辑一样了。