并发编程-并发工具类
本篇我们主要围绕线程的并发工具类的内容作展开,众所周知,在Java并发编程中提供了很多的工具类,这些工具类有个特点就是使线程按照业务的某种约束来执行,在本文中我们着重描述CountDownLatch、Semaphore、CyclicBarrier这三个工具类
CountDownLatch
概念
CountDownLatch是Java并发编程中的一个常用类,也被称为“倒计时锁存器”。它主要用来确保一组线程中的某些操作在其他线程完成各自的任务之后才能继续执行(可以粗略理解为就好比厨师在炒菜之前,配菜人员需要把所有的菜品和配料都要准备好之后,厨师才开始炒菜)。它主要通过一个计数器来实现,计数器的初始值是线程的数量。每当一个线程完成其任务后,计数器的值就会减1。当计数器的值达到0时,所有在CountDownLatch上等待的线程就会被唤醒,继续执行后续的任务。
应用场景
- 启动信号控制:当需要将多个线程同时启动时,可以将 CountDownLatch 的计数值设置为1。所有线程在 CountDownLatch 的 await() 方法处等待。当主线程或其他控制线程准备好让其他线程启动时,调用 countDown() 方法将计数器减为0,此时所有在 await() 处等待的线程将同时被唤醒并开始执行。这种场景在模拟高并发启动或需要精确控制线程启动顺序的情况下非常有用。
- 任务完成信号:当需要等待多个线程完成某项任务后,主线程才能继续执行时,可以使用 CountDownLatch。例如,如果有一个任务需要由 N 个线程并行处理,并且主线程需要在所有线程都完成处理后才能汇总结果,那么可以初始化一个计数值为 N 的 CountDownLatch。每个线程在处理完任务后调用 countDown() 方法,主线程则在 await() 处等待,直到计数器的值减为0,表示所有线程都已完成任务。
- 资源初始化:在复杂的系统中,经常需要等待某些资源或组件被完全初始化后才能继续执行。CountDownLatch 可以用来确保这些资源或组件在继续执行之前已经被正确初始化。
- 多线程并行处理后的汇总:在需要进行大量计算或数据处理,并且这些计算或处理可以并行进行时,可以使用 CountDownLatch 来等待所有线程完成后再进行汇总操作。
使用
说一千道一万都不如实际使用得来的理解更深刻,我们通过一个例子来感受一下CountDownLatch是怎么体现计数的,不过在这之前我们也还是先来看一下它的类图
从类图中我们可以看到几个非常眼熟的类 其中的AbstractQueueSynchronizer(简称AQS)更是在之前的文章中反复被提到,从图中可以看出它的实现肯定跟AQS有关,这里还没到分析源码的那一步,我们先有个大体的印象。我们来看下CountDownLatch的使用
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
// 初始化 CountDownLatch,设置计数值为2,表示需要等待两个线程完成
CountDownLatch latch = new CountDownLatch(2);
// 创建并启动两个线程
Thread thread1 = new Thread(() -> {
try {
System.out.println("线程1正在执行...");
// 模拟线程1执行耗时任务
Thread.sleep(1000);
System.out.println("线程1执行完毕!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 线程1完成后,计数器减1
latch.countDown();
}
});
Thread thread2 = new Thread(() -> {
try {
System.out.println("线程2正在执行...");
// 模拟线程2执行耗时任务
Thread.sleep(2000);
System.out.println("线程2执行完毕!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 线程2完成后,计数器减1
latch.countDown();
}
});
// 启动线程
thread1.start();
thread2.start();
// 主线程等待 CountDownLatch 计数器归零
System.out.println("主线程等待两个线程执行完毕...");
// 主线程阻塞,等待两个线程都执行完毕
latch.await();
System.out.println("所有线程执行完毕,主线程继续执行!");
}
}
运行结果
在这个例子中,我们创建了一个 CountDownLatch 对象,并设置其初始计数值为2。这意味着我们需要等待两个线程都完成它们的任务。我们创建了两个线程 thread1 和 thread2,并在它们的任务完成后分别调用 latch.countDown() 方法来减少 CountDownLatch 的计数器。
主线程在启动两个线程后,调用 latch.await() 方法,这将使主线程阻塞,直到 CountDownLatch 的计数器减至0。当两个线程都执行完毕并调用 countDown() 方法后,主线程将被唤醒,并继续执行后续的代码。用图形的方式来理解的话可以这么表示
需求分析
在有了前面代码和类图的铺垫之后,我们来做一下需求分析,梳理逻辑,看实现CountDownLatch 的效果要怎么做
- 能够阻塞一个或多个线程
- 涉及到了AbstractQueueSynchronizer,就必然涉及到了锁,依照CountDownLatch的使用来说肯定不是独占锁,那它就是根据AQS共享锁实现的
- 既然用到了AQS的锁,那肯定也会涉及到它的成员变量state的使用,也一定会有等待队列的运用,CountDownLatch的计数应该就是state的递减
源码分析
在使用示例和整体需求的推测的基础上,我们大致对CountDownLatch有了相对成型的一个印象。那接下来我们着重通过await() 和countDown()两个最主要的方法展开源码分析。常规操作,我们还得先看看它的构造方法
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
Sync(int count) {
setState(count);
}
从CountDownLatch的构造方法中我们很明显的可以看出,传入的count就是state的值,这跟我们初步推测的一样
await()
await()的所用是使当前线程阻塞,视情况而定,也能阻塞多个线程,我们来看一下await()的源码
//await() 方法其实是调用的 Sync这个内部类的acquireSharedInterruptibly()方法
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//很明显主要的方法就是tryAcquireShared(arg) 和 doAcquireSharedInterruptibly(arg)
public final void acquireSharedInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
//我们先来看tryAcquireShared(arg)
protected int tryAcquireShared(int acquires) {
//该方法是获取state的值,我们在前面Lock篇中解释过,state是用来标记获得锁的状态,state == 0 无锁返回1.
//state!=0 有锁返回-1
return (getState() == 0) ? 1 : -1;
}
//当state!=0 ,当前线程需要阻塞
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
/**看到Node的操作小伙伴们应该非常熟悉,构建链表,这里是个双向链表且加入节点的状态是SHARED。看到这里就跟我们推测的 CountDownLatch用到了AQS共享锁接近了
*/
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
//自旋操作
for (;;) {
//获取当前节点的上一个节点
final Node p = node.predecessor();
//如果当前节点的上一个节点是头节点,那么这个节点就是个需要抢占锁的节点
if (p == head) {
//tryAcquireShared() 返回的是(getState() == 0) ? 1 : -1的值,这里代表的是尝试去抢占锁
int r = tryAcquireShared(arg);
//因为此时的state!=0 所以 r>=0为false 不会执行下面的逻辑
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
//这段代码就比较眼熟了,跟我们在Lock篇章中一模一样,整体上是修改上一个节点的状态为signal,并挂起当前线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
doAcquireSharedInterruptibly(int arg)这个方法我们在大方向上做了一下梳理,关于await() 还有很多细节没有进行分析。不用着急,我们以先调用await()方法得顺序进行分析,阻塞之后就需要被唤醒。所以我们把思绪从await()中剥离出来,我们来看countDown()方法
countDown()
public void countDown() {
//调用releaseShared() 将state减1
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//当tryReleaseShared(arg) == true 也就是state == 0时才能执行 doReleaseShared()方法
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
/**
这个方法才是真正得把state进行递减,需要注意的是这里的自旋并不是直到state递减为0,而是在多线程环境下多个线程可能同时尝试更新 state,因此需要使用循环和 compareAndSetState 方法来确保操作的原子性。最后在判断nextc这个递减后的值是不是为0 ,不为0返回false。也就是说假设 我们是new CountDownLatch(5),此时有一个线程CAS成功 nextc == 4 返回false
*/
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
//获取state的值
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
//因为这个操作可能存在多个线程同时操作,所以用CAS保证安全性
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
//countDown方法中最重要的一个方法,用来唤醒共享锁阻塞队列中的线程
private void doReleaseShared() {
for (;;) {
//获取到头节点
Node h = head;
//h节点不为空,并且也不是尾节点,则证明链表中还有其他的节点等待唤醒
if (h != null && h != tail) {
//获取节点的状态
int ws = h.waitStatus;
//如果状态为SIGNAL
if (ws == Node.SIGNAL) {
//通过CAS操作把SIGNAL修改为0,如果CAS失败则说明当前节点状态修改了,不需要唤醒,进入下一次循环
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//唤醒头节点的下一个节点的线程
unparkSuccessor(h);
}
//如果ws== 0 修改节点状态为PROPAGATE
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
doReleaseShared() 这个方法还是有必要展开说一下,虽然代码不多但是它的这些临界条件的判断可能小伙伴们第一次看的时候感受不到,这里我们详细解释一下
-
从整理代码结构来看,for ( ;; ){} 这同样也是一个自旋操作,这是基于共享锁的机制要唤醒所有状态为SHARED节点的线程,而unparkSuccessor(h)唤醒的是signal状态节点的下一个线程,链表的结构会发生变化。所以 Node h = head;每次获得的是最新的头节点。
-
循环的最后有个 if(h == head)的判断 这是判断头节点有没有变化,如果在循环内部,其他线程修改了队列的头节点(例如,有线程被唤醒并离开了队列),则继续循环,以确保基于最新的队列状态进行处理。
-
在if (ws == 0 && !compareAndSetWaitStatus(h,0,Node.PROPAGATE)) 这个条件中 什么情况下ws == 0?
第一个情况就是当队列中最后一个节点刚好做为头节点,又刚好此时有一个线程获取锁失败加入到队列中且没有调用shouldParkAfterFailedAcquire(),这时ws== 0 。
第二个情况就是当队列中有多个等待的线程,这时刚好有一个线程释放锁,并且刚执行了unparkSuccessor(h),把head节点的状态改为0,唤醒head节点的下一个节点,这个被唤醒的节点获得锁到成为新的head节点的过程中,原来的head节点状态一直为0。
compareAndSetWaitStatus(h,0,Node.PROPAGATE) == false说明在修改状态为PROPAGATE的时候已经有其他线程把状态改为了SIGNAL,说明此时有新的线程加入队列。
再重温一下unparkSuccessor()方法,通过之前的分析与推导,再看unparkSuccessor()方法就清晰很多
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
//状态为-1也就是SIGNAL状态时,通过CAS操作修改为0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
//找到下一个节点
Node s = node.next;
//这个判断是清理无效的节点,s=null等待内存回收
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);
}
从哪里阻塞的,唤醒之后就得回到阻塞的地方来,所以我们把目光重新放到await()来
再回到await()
在await()方法中 阻塞的位置是doAcquireSharedInterruptibly()这个方法
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
/**唤醒之后的操作是在if (r >= 0)这个判断的代码块中,被唤醒的线程进入循环执行到这里此时 r >= 0 执行 * setHeadAndPropagate()
*/
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
//我们来看setHeadAndPropagate()方法
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
//设置被唤醒的节点作为头节点
setHead(node);
//满足指定条件唤醒后续节点
// 如果propagate > 0 则是共享锁,需要传递唤醒
//h == null 和 (h = head) == null是临界条件判断,避免空指针异常,临界条件是原来的head节点刚好从链表中断开
//这里判断了两次h.waitStatus < 0并不是冗余,而是多线程环境下h.waitStatus状态的检查
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();
}
}
Semaphore
概念
Semaphore是一种计数信号量,用于管理一组资源,通过规定一个量来控制允许活动的线程数。它有两种模式:公平模式和非公平模式。当多个线程访问某个限制访问流量的资源时,需要调用它的acquire() 方法获得访问令牌,获得成功则允许访问,如果令牌不够,则阻塞当前线程,当某个获得令牌的线程调用release() 方法释放一个令牌后,被阻塞的线程就会被唤醒从而重新抢占令牌,获得访问权限。
概念我们清楚了,那Semaphore要怎么用呢。我们通过模拟数据库连接来看一下Semaphore的使用
使用
public class SemaphoreExample {
/**
* 假设我们有一个数据库连接池,最多只能有3个并发连接
*/
private static final int MAX_CONNECTIONS = 3;
private static Semaphore semaphore = new Semaphore(MAX_CONNECTIONS);
public static void main(String[] args) {
// 创建多个线程模拟并发访问数据库连接池
for (int i = 0; i < 10; i++) {
new Thread(new DatabaseTask()).start();
}
}
static class DatabaseTask implements Runnable {
@Override
public void run() {
try {
// 尝试获取一个许可,如果没有可用的许可,线程将阻塞
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 获取了数据库连接");
// 模拟数据库操作
Thread.sleep((long) (Math.random() * 1000));
System.out.println(Thread.currentThread().getName() + " 已经释放数据库连接");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放许可,允许其他线程获取许可
semaphore.release();
}
}
}
}
执行结果如图
在这个示例中,我们创建了一个 Semaphore 对象,其初始许可数量为3,这模拟了一个最多只能有3个并发连接的数据库连接池。然后,我们创建了10个线程来模拟并发访问数据库连接池的情况。每个线程在尝试执行数据库操作之前,都会调用 semaphore.acquire()方法来获取一个许可。如果没有可用的许可,线程将阻塞,直到有其他线程调用 semaphore.release()方法释放一个许可。
在 DatabaseTask 的 run方法中,我们模拟了数据库操作(通过Thread.sleep),并在操作完成后释放了许可。这样,我们就能够确保在任何时候,最多只有3个线程能够同时执行数据库操作,从而防止了过多的并发连接导致资源耗尽或性能下降的问题。我们用一张图来形象的描述这个过程,如下图所示
需求分析
我们来猜测一下Semaphore 要实现功能要做哪些事情
- 它的核心功能是限制线程对资源的访问,那怎么才能区分要限制的线程呢。本质上它是通过抢占一个令牌(或者说做一个标记),如果抢占成功就不限制,反之就阻塞线程。
- Semaphore 必然涉及到了多个线程同时抢占到令牌和多个线程的阻塞和唤醒,那它肯定是基于AQS共享锁来实现
- Semaphore 提供了公平性和非公平性两种模式 没有抢占到令牌的线程阻塞,被阻塞的线程应该还是存储在一个CLH(等待队列)中,这样才能在唤醒的时候实现公平与非公平
源码分析
在了解了 Semaphore 的使用以及它的基本概念之后,我们来分析下它的源码,在Semaphore 中有两个主要的方法acquire() 和 release() 。通过代码示例细心的小伙伴能够发现,Semaphore貌似也是有计数的逻辑,Semaphore 的这两个方法的作用跟 CountDownLatch 的await() 和 countDown很接近。所以这两个类是有一定相通的地方。我们还是以构造方法作为入口
//从构造方法我们可以看出它是有公平和非公平的区分,默认非公平,条件是参数fair
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
NonfairSync(int permits) {
super(permits);
}
//无论是公平还是非公平,permits 都是跟AQS中的state挂钩的
Sync(int permits) {
setState(permits);
}
acquire()
//Semaphore 的acquire() 方法跟CountDownLatch的await()方法大致相同,区别在于它重写了tryAcquireShared()方法
public void acquire() 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);
}
tryAcquireShared()
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
//重点是nonfairTryAcquireShared()
final int nonfairTryAcquireShared(int acquires) {
//对于非公平策略的特性来说,不管有没有等待队列,都尝试去获取令牌
for (;;) {
//获取state的值也就是令牌数量
int available = getState();
//每次令牌数减1
int remaining = available - acquires;
//当remaining < 0 也就是state == 0 否的话通过CAS操作修改已经递减后的state的值
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
//公平策略下的tryAcquireShared() 获取令牌
protected int tryAcquireShared(int acquires) {
for (;;) {
//唯一的区别就是判断有没有等待队列,也就是我们常说的 CLH
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
没有获取到令牌的线程加入等待队列也就是 doAcquireSharedInterruptibly(arg) 方法做的事情
doAcquireSharedInterruptibly()
//这个方法跟CountDownLatch调用的一样,这里就不再重复解释了
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
acquire() 主要内容就分析完了,因为里面很多内容跟CountDownLatch调用的方法一样,我们就不重复解析了,主要分析它俩不一样的地方,接下来我们看release() 方法是如何释放令牌的
release()
release() 的释放令牌和 acquire() 的抢占令牌正好相反,核心是state 的累加
public void release() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//如何累加是在tryReleaseShared() 方法中实现的
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared()
主要看tryReleaseShared() 方法
protected final boolean tryReleaseShared(int releases) {
/**通过自旋不断尝试累加state的值,自旋是为了不断尝试是否满足条件直到更新值成功,不是立即阻塞或等待,好处是减少了线程切换的 * 开销提升性能
*/
for (;;) {
//获取state值
int current = getState();
//计算加1
int next = current + releases;
//超过最大令牌数抛出异常
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
//通过CAS操作更新已累加的state的值
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 (;;) {
Node h = head;
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);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
doReleaseShared()
唤醒阻塞的线程,跟CountDownLatch唤醒调用的方法一样
private void doReleaseShared() {
for (;;) {
Node h = head;
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);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
CyclicBarrier
概念
CyclicBarrier是一个线程同步工具,它允许多个线程在达到一个共同屏障点之前相互等待。它是Java并发包(java.util.concurrent)中的一部分,用于解决多线程场景下的同步问题。与其他同步工具(如CountDownLatch)不同的是,CyclicBarrier可以被重复使用。它被称为“循环”屏障,是因为一旦所有线程都到达屏障点,它就会重新计数,再次等待所有线程。
用个通俗的例子来理解 CyclicBarrier 就像一群人在跑步比赛前等待的起点线。在这个起点线上,运动员们需要互相等待,直到所有人都到达这个线(也就是屏障点)后,裁判才会让他们开始跑,细心的小伙伴会发现,它的实现跟CountDownLatch有点接近,相当于 多个线程通过CountDownLatch的await 。然后另外一个线程使用countDown方法来唤醒。我们通过CountDownLatch 的另一个种使用示例来帮助我们加深理解
public class CountDownLatchDemo implements Runnable{
private CountDownLatch countDownLatch;
public CountDownLatchDemo(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
try {
Thread.sleep(1000);
System.out.println(new Date()+"模拟线程阻塞,等待条件满足时同时释放"+ Thread.currentThread().getName());
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class CountDownLatchExample {
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i=0;i<10;i++){
new Thread(new CountDownLatchDemo(countDownLatch)).start();
}
countDownLatch.countDown();
System.out.println("条件满足,同时释放十个线程");
}
}
在上述的例子中,我们模拟当阻塞线程达到十个时,同时唤醒线程。我们通过CountDownLatchDemo() 的构造方法传入一个CountDownLatch(10) , 并在run() 方法中打印线程唤醒的时间和线程名,从而体现同时唤醒的效果。运行结果如下图所示
既然他俩相近 那CyclicBarrier 的使用和 CountDownLatch有什么不同呢,接下来我们看一下 CyclicBarrier 的使用
使用
public class CyclicBarrierExample {
public static void main(String[] args) {
// 设定屏障点需要的线程数量
int parties = 5;
// 创建CyclicBarrier对象,并设置屏障点需要等待的线程数量
CyclicBarrier cyclicBarrier = new CyclicBarrier(parties, () -> {
// 当所有线程都到达屏障点后,会执行这里的代码
System.out.println("所有线程都到达了屏障点,开始执行屏障后的操作");
});
// 创建并启动5个线程
for (int i = 0; i < parties; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 准备到达屏障点");
// 线程到达屏障点,并等待其他线程
cyclicBarrier.await();
System.out.println(Thread.currentThread().getName() + " 已通过屏障点,继续执行后续任务");
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}).start();
}
}
}
在这个示例中:
- 我们首先定义了一个屏障点需要的线程数量 parties,这里设定为5。
- 然后,我们创建了一个 CyclicBarrier 对象,并传递了两个参数给它的构造函数:第一个是线程数量,表示需要等待的线程数;第二个是当所有线程都到达屏障点时执行的Runnable对象。
- 接着,我们创建了5个线程,并在每个线程中执行一段代码。每个线程到达屏障点时,都会调用 cyclicBarrier.await() 方法,这会使得线程在屏障点等待,直到所有线程都到达。
- 当所有线程都到达屏障点后,我们之前传递给 CyclicBarrier 构造函数的Runnable对象会被执行,输出一条消息。
- 所有线程通过屏障点后,会继续执行 await() 方法之后的代码。
运行结果如下
通过概念的描述和示例的展示,我们大致能感受到 CyclicBarrier 是怎么做的。我们还是通过图型来加深下理解
原理分析
CyclicBarrier 概念和使用都清楚了,那我们来分析下它的原理。
- CyclicBarrier从字面英文直译 是 Cyclic(可循环)Barrier(屏障)。Barrier(屏障)我们都能体会的到,线程调用await() 方法阻塞在屏障点。等到所有线程都到达之后同时唤醒。而Cyclic(可循环)是指 。所有线程到达屏障点后,又可以进入下一个屏障点,屏障是可以有多个的。
- CyclicBarrier 定义了两个变量 parties 和 count 。 parties 是初始化的时候定义的进入屏障点的线程数。而count 是作为一个计数器,初始值是parties 。每当一个线程到达屏障点,count减1 直到count为0 时触发内部的机制,唤醒阻塞的线程通过屏障点。从而结束当前的屏障周期进入下一个屏障周期
源码分析
在了解了CyclicBarrier 的核心原理之后,我们还是得通过源码一探究竟。常规操作,先从构造方法开始
public CyclicBarrier(int parties) {
this(parties, null);
}
//需要注意的是构造方法中可以传入回调函数,也就是当所有线程到达屏障点之后可以触发回调
public CyclicBarrier(int parties, Runnable barrierAction) {
if (parties <= 0) throw new IllegalArgumentException();
//定义进入屏障点的线程数
this.parties = parties;
//计数器,判断线程是否全部进入屏障点
this.count = parties;
//回调函数
this.barrierCommand = barrierAction;
}
另外,CyclicBarrier的成员变量我们也需要看一下
private static class Generation {
boolean broken = false;
}
/** The lock for guarding barrier entry */
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
private final Condition trip = lock.newCondition();
/** The number of parties */
private final int parties;
/* The command to run when tripped */
private final Runnable barrierCommand;
/** The current generation */
//这个Generation字面意思是一代,也可理解为一个周期,CyclicBarrier 的循环的含义就是通过它来实现的
private Generation generation = new Generation();
private int count;
从CyclicBarrier的成员变量中我们发现了两个老朋友 ReentrantLock 和 Condition ,由此我们可以推断出线程的阻塞跟唤醒就是通过这哥俩来完成的。
await()
await() 大家都直到这个方法的作用就是阻塞线程,那我们来看一下其中的细节是怎么做的
//很明显await()的核心方式是 dowait()来实现的
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}
//我们来看dowait()方法,别看这个方法很长,但其实原理很简单
private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException,TimeoutException {
//加锁,这个就不多解释了
final ReentrantLock lock = this.lock;
lock.lock();
try {
//获取当前的Generation
final Generation g = generation;
//如果为true 则抛出异常
if (g.broken)
throw new BrokenBarrierException();
//判断线程是否被中断了
if (Thread.interrupted()) {
//屏障点失效,稍后我们会详细看这个方法
breakBarrier();
throw new InterruptedException();
}
//统计到达屏障点的线程数
int index = --count;
//如果为0,所有线程都到达了屏障点
if (index == 0) { // tripped
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
//判断有没有回调函数,有的话执行回调函数
if (command != null)
command.run();
ranAction = true;
//进入下一个屏障点
nextGeneration();
return 0;
} finally {
//如果没有回调函数,屏障点失效
if (!ranAction)
breakBarrier();
}
}
//一直循环等待所有线程到达屏障点,判断屏障点失效情况、线程中断、等待是否超时
for (;;) {
try {
//判断是否带超时时间 await()有带超时时间的参数,如果没有则直接阻塞线程 Condition.await()
if (!timed)
trip.await();
//带超时时间则 等待指定的时间
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
//这个catch是被其他线程通过 interrupted() 方法唤醒
} catch (InterruptedException ie) {
//如果当前的generation不变,并且broken == false,则屏障点失效,唤醒阻塞的线程并抛出异常
if (g == generation && ! g.broken) {
breakBarrier();
throw ie;
} else {
Thread.currentThread().interrupt();
}
}
if (g.broken)
throw new BrokenBarrierException();
//被唤醒的线程的generation和当前的generation不同不做处理
if (g != generation不同不做处理)
return index;
//如果等待超时之后被唤醒,则线程没有到达屏障点,屏障点失效
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
lock.unlock();
}
}
breakBarrier()
//屏障点失效的方法
private void breakBarrier() {
//broken改为true
generation.broken = true;
//重置计数
count = parties;
//唤醒当前已经到达屏障点的线程
trip.signalAll();
}
nextGeneration()
//进入下一个屏障周期的方法
private void nextGeneration() {
//唤醒当前已经到达屏障点的线程
trip.signalAll();
//重置计数
count = parties;
//重新定义一个 Generation
generation = new Generation();
}
reset()
//重置方法,把已经阻塞的线程全部唤醒,使当前屏障点失效,进入下一个屏障周期
public void reset() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
breakBarrier(); // break the current generation
nextGeneration(); // start a new generation
} finally {
lock.unlock();
}
}
总结
本篇我们着重讲述了三个并发的工具类 CountDownLatch、Semaphore、CyclicBarrier的使用及原理。 他们三个在功能实现效果上有些相似,具体使用还得结合业务场景灵活选择。其中 CountDownLatch与Semaphore 都是基于AQS共享锁的特性来实现的,而CyclicBarrier则是利用的 ReentrantLock 和Condition实现的,整体上CyclicBarrier比较简单。若文中出现错误之处,还请批评指正