文章目录
【JUC并发编程系列】深入理解Java并发机制:深度解析Java并发控制工具(十、Semaphore、 CyclicBarrier、CountDownLatch)
1. Semaphore
1.1 概念
Semaphore
也是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore
可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。
Semaphore
的主要方法摘要:
-
void acquire()
:从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。 -
void release()
:释放一个许可,将其返回给信号量。 -
int availablePermits()
:返回此信号量中当前可用的许可数。 -
boolean hasQueuedThreads()
:查询是否有线程正在等待获取。
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < 10; i++) {
int finalI = i;
new Thread(() -> {
try {
// 请求获取票据
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + ",拿到了票据。");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放票据
semaphore.release();
}
}).start();
}
}
1.2 原理
Semaphore
基于 AQS 实现的原理
AQS 回顾
- AQS 中的状态值: AQS 使用一个整型成员变量
state
来表示同步状态。 - 双向链表结构: 当线程尝试获取锁失败时,会被插入到 AQS 维护的双向链表中等待。
- CAS 结合自旋: 所有的修改操作都是通过 CAS (Compare and Swap) 操作来实现,并且可能会伴随着自旋以保证线程安全。
Semaphore
的实现细节
当创建一个 Semaphore
对象时,例如 Semaphore semaphore = new Semaphore(3);
,它实际上创建了一个基于 AQS 的非公平锁实现,初始状态值设为 3,这代表了可用的许可数量。
获取许可
-
尝试获取许可:
if (tryAcquireShared(arg) < 0) { doAcquireSharedInterruptibly(arg); }
这里
arg
表示请求的许可数量,默认情况下是 1。 -
非公平获取共享许可:
final int nonfairTryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) { return remaining; } } }
这个方法会检查当前状态是否足够提供所需的许可数量。如果不够,则会尝试通过 CAS 更新状态。
-
更新状态:
如果剩余许可不足或成功更新状态,则返回新的剩余许可数。如果剩余许可数变为负数,则说明资源已耗尽,此时线程将被添加到等待队列中并进入阻塞状态。
释放许可
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) {
throw new Error("Maximum permit count exceeded");
}
if (compareAndSetState(current, next)) {
return true;
}
}
}
这段代码用于释放许可。它首先读取当前状态,然后加上释放的许可数量,再次使用 CAS 尝试更新状态。
示例流程
- 创建一个
Semaphore
实例,初始化状态值为 3。 - 当调用
acquire()
方法时,尝试减少状态值 1。如果状态值减小后仍然大于等于 0,则获取许可成功;否则,线程将被加入到等待队列中,并进入阻塞状态。 - 当调用
release()
方法时,状态值增加 1,如果之前有线程因为获取许可失败而阻塞,则可能会唤醒其中一个线程继续执行。
总结来说,Semaphore
通过 AQS 提供了一种基于许可的并发控制机制。线程可以尝试获取许可来执行某个操作,如果许可不足,则该线程会被挂起直到有足够的许可可用。这种方式非常适合用来实现限流等场景。
2. CyclicBarrier
2.1 概念
CyclicBarrier
是 Java 并行包 (java.util.concurrent
) 中的一个类,它为一组线程提供了一种同步机制。CyclicBarrier
可以想象成一个所有参与线程都需要等待的栅栏或障碍。当所有参与线程到达这个障碍时,它们都会被阻塞,直到最后一个线程到达,这时所有线程会被释放并继续执行。
主要用途:
- 同步多个线程:确保一组线程在某个点上全部完成自己的工作后才一起继续。
- 循环使用:
CyclicBarrier
之所以被称为“循环”障碍是因为它可以被重用。一旦所有线程都通过了障碍,这个障碍就被重新设置好了,可以再次使用。
基本使用方法:
-
构造函数:
CyclicBarrier(int parties)
:创建一个CyclicBarrier
,参数parties
指定了需要等待的线程数量。CyclicBarrier(int parties, Runnable barrierAction)
:创建一个CyclicBarrier
,参数barrierAction
是一个在所有线程到达屏障后执行的操作。
-
主要方法:
await()
:让线程等待其他线程到达障碍。await(long timeout, TimeUnit unit)
:让线程等待其他线程到达障碍,但等待有超时限制。
-
异常处理:
- 如果某个线程在等待过程中抛出了异常或者超时,则其他线程会收到
BrokenBarrierException
。
- 如果某个线程在等待过程中抛出了异常或者超时,则其他线程会收到
示例代码:
CyclicBarrier barrier = new CyclicBarrier(3, new Runnable() {
public void run() {
System.out.println("所有线程已完成,开始执行后续操作");
}
});
Thread t1 = new Thread(() -> {
try {
System.out.println("线程1开始工作...");
Thread.sleep(1000);
System.out.println("线程1到达障碍");
barrier.await(); // 等待其他线程
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
System.out.println("线程2开始工作...");
Thread.sleep(2000);
System.out.println("线程2到达障碍");
barrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
Thread t3 = new Thread(() -> {
try {
System.out.println("线程3开始工作...");
Thread.sleep(500);
System.out.println("线程3到达障碍");
barrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
t3.start();
在这个示例中,三个线程都将执行一些任务,并且在完成这些任务后调用 barrier.await()
。当所有三个线程都到达 await()
方法时,它们将被释放,并且在构造 CyclicBarrier
时提供的 Runnable
将被执行。
2.2 原理
CyclicBarrier
基于 AQS 实现的原理
/**
* 让当前线程等待直到所有参与线程都到达了屏障。
*
* @return 返回一个整数,表示到达屏障的线程数量减去当前线程。
* @throws InterruptedException 如果当前线程被中断。
* @throws BrokenBarrierException 如果屏障被打破(即有一个线程由于超时或中断而提前退出了等待)。
*/
public int await() throws InterruptedException, BrokenBarrierException {
try {
// 调用 dowait 方法进行等待,参数 false 表示无限期等待,
// 第二个参数 0L 是超时时间的占位符,在这里不适用,因为已经指定了 false 表示无限期等待。
return dowait(false, 0L);
} catch (TimeoutException toe) {
// TimeoutException 在这里是不会发生的,因为 await() 方法没有指定超时时间。
// 因此,这里的 catch 子句实际上是为了编译器的要求而存在的,但它永远不会被触发。
// throw new Error(toe); 这一行代码只是形式上的,理论上不应该达到这一行。
throw new Error(toe); // cannot happen
}
}
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock; // 获取锁对象
lock.lock(); // 加锁,确保线程安全
try {
final Generation g = generation; // 获取当前的 Generation 对象
if (g.broken) // 如果屏障已经被打破
throw new BrokenBarrierException(); // 抛出 BrokenBarrierException
if (Thread.interrupted()) { // 检查线程是否被中断
breakBarrier(); // 打破屏障
throw new InterruptedException(); // 抛出 InterruptedException
}
int index = --count; // 减少计数器 count 的值,index 表示当前线程的索引位置
if (index == 0) { // 如果这是最后一个到达屏障的线程
boolean ranAction = false;
try {
final Runnable command = barrierCommand; // 获取在所有线程到达后要执行的操作
if (command != null)
command.run(); // 执行该操作
ranAction = true;
nextGeneration(); // 初始化下一个 Generation
return 0; // 返回 0 表明当前线程是最后一个到达屏障的线程
} finally {
if (!ranAction)
breakBarrier(); // 如果操作没有执行成功,打破屏障
}
}
// 循环等待,直到所有条件满足其中之一:屏障被触发、被打破、线程被中断或超时
for (;;) {
try {
if (!timed) // 如果没有设置超时时间
trip.await(); // 等待所有线程到达
else if (nanos > 0L) // 如果设置了超时时间并且还有剩余时间
nanos = trip.awaitNanos(nanos); // 等待剩余时间
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) { // 如果屏障没有被打破
breakBarrier(); // 打破屏障
throw ie; // 抛出 InterruptedException
} else {
// 如果屏障已经被打破或者当前线程即将被释放,记录中断状态
Thread.currentThread().interrupt();
}
}
if (g.broken) // 如果屏障已经被打破
throw new BrokenBarrierException(); // 抛出 BrokenBarrierException
if (g != generation) // 如果已经进入下一个 Generation
return index; // 返回当前线程的索引位置
if (timed && nanos <= 0L) { // 如果设置了超时时间并且超时
breakBarrier(); // 打破屏障
throw new TimeoutException(); // 抛出 TimeoutException
}
}
} finally {
lock.unlock(); // 释放锁
}
}
通知所有等待的线程,使它们从等待状态中醒来
private void breakBarrier() {
generation.broken = true; // 标记当前 Generation 为已打破状态
count = parties; // 重置计数器 count 为初始值(即参与线程的数量)
trip.signalAll(); // 通知所有等待的线程,使它们从等待状态中醒来
}
3. CountDownLatch
3.1 概念
CountDownLatch
是 Java 并发库 (java.util.concurrent
) 中的一个类,它提供了一个简单的计数器机制,用于协调多个线程的执行顺序。CountDownLatch
允许一个或多个线程等待其他线程完成某些操作。
主要用途:
- 等待一组操作完成:允许一个或多个线程等待一组操作完成。
- 控制线程启动时机:可以用来控制一个线程或一组线程何时开始执行。
基本概念:
- 计数器:
CountDownLatch
有一个内部计数器,表示需要等待的事件数量。 - 等待:线程可以通过调用
await()
方法来等待计数器减至零。 - 倒计数:其他线程通过调用
countDown()
方法来减少计数器的值。
常用方法:
-
构造函数:
CountDownLatch(int count)
:创建一个具有给定计数器初始值的CountDownLatch
。
-
主要方法:
await()
:阻塞当前线程,直到计数器减为零或当前线程被中断。await(long timeout, TimeUnit unit)
:阻塞当前线程,直到计数器减为零或超过了指定的等待时间或当前线程被中断。countDown()
:将计数器减一。
-
辅助方法:
getCount()
:返回当前计数器的值。
示例代码:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(3);
Thread thread1 = new Thread(() -> {
System.out.println("线程1开始工作...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1完成工作");
latch.countDown(); // 完成后减少计数器
});
Thread thread2 = new Thread(() -> {
System.out.println("线程2开始工作...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2完成工作");
latch.countDown(); // 完成后减少计数器
});
Thread thread3 = new Thread(() -> {
System.out.println("线程3开始工作...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程3完成工作");
latch.countDown(); // 完成后减少计数器
});
thread1.start();
thread2.start();
thread3.start();
System.out.println("主线程等待所有子线程完成...");
latch.await(); // 阻塞主线程,直到计数器减为零
System.out.println("所有子线程已完成,主线程继续执行...");
}
}
在这个示例中,三个子线程(thread1
, thread2
, thread3
)分别执行一些任务,并在完成后调用 countDown()
方法来减少计数器的值。主线程则调用 latch.await()
来等待所有子线程完成它们的任务。当计数器减为零时,主线程将继续执行。
总结
CountDownLatch
提供了一种简单有效的方式,用于等待一组操作完成。- 它适用于那些不需要重复使用的场景,因为计数器只能从构造函数初始化时的值递减到零,不能重置。
- 通常用于控制程序中特定点的线程执行顺序,例如等待数据加载完成或等待所有子任务完成后再继续执行。
3.2 原理
CountDownLatch
基于 AQS 实现的原理
await()
/**
* 阻塞当前线程,直到计数器减为零或当前线程被中断。
* 如果计数器已经为零,则立即返回。
* 如果当前线程被中断,则抛出 InterruptedException。
*/
public void await() throws InterruptedException {
// 调用 sync 对象的 acquireSharedInterruptibly 方法来阻塞当前线程,
// 直到计数器减为零或当前线程被中断。
// 参数 1 表示尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
sync.acquireSharedInterruptibly(1);
}
/**
* 尝试获取共享资源,如果当前线程被中断,则抛出 InterruptedException。
* 如果计数器不为零,则阻塞当前线程,直到计数器减为零或当前线程被中断。
*
* @param arg 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
* @throws InterruptedException 如果当前线程被中断。
*/
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
// 如果当前线程被中断,则抛出 InterruptedException。
if (Thread.interrupted())
throw new InterruptedException();
// 尝试获取共享资源。
// 如果计数器不为零,则返回值小于零。
// 如果计数器为零,则返回非负值。
if (tryAcquireShared(arg) < 0) {
// 如果计数器不为零,则调用 doAcquireSharedInterruptibly 方法阻塞当前线程,
// 直到计数器减为零或当前线程被中断。
doAcquireSharedInterruptibly(arg);
}
}
/**
* 尝试获取共享资源。
* 如果计数器为零,则返回 1 表示可以获取资源;
* 否则返回 -1 表示当前线程应被阻塞等待。
*
* @param acquires 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
* @return 如果计数器为零,则返回 1;否则返回 -1。
*/
protected int tryAcquireShared(int acquires) {
// 获取当前计数器的值
return (getState() == 0) ? 1 : -1;
}
/**
* 在可能被中断的情况下尝试获取共享资源。
* 如果计数器为零,则设置当前线程为头节点并传播唤醒信号;
* 否则阻塞当前线程,直到计数器减为零或当前线程被中断。
*
* @param arg 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
* @throws InterruptedException 如果当前线程被中断。
*/
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; // 断开前驱节点的后继指针,帮助垃圾回收
failed = false; // 标记线程成功获取资源
return; // 退出方法
}
}
if (shouldParkAfterFailedAcquire(p, node) && // 如果前驱节点的状态表明应该阻塞当前线程
parkAndCheckInterrupt()) // 阻塞当前线程并检查是否被中断
throw new InterruptedException(); // 如果线程被中断,则抛出 InterruptedException
}
} finally {
if (failed) // 如果线程未能成功获取资源
cancelAcquire(node); // 取消当前线程的获取操作
}
}
/**
* 阻塞当前线程并检查是否被中断。
* 使用 LockSupport.park 方法阻塞当前线程。
* 返回当前线程是否被标记为已中断。
*
* @return 如果当前线程被中断,则返回 true;否则返回 false。
*/
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 阻塞当前线程
return Thread.interrupted(); // 检查并清除中断标志位
}
countDown()
/**
* 将计数器减一。
* 如果计数器为零,则释放所有等待的线程。
*/
public void countDown() {
// 调用 sync 对象的 releaseShared 方法来减少计数器的值。
// 这里的 sync 是 AbstractQueuedSynchronizer 的一个实例,它是 CountDownLatch 的内部类。
// releaseShared 方法用于减少计数器,并在计数器变为零时释放等待的线程。
sync.releaseShared(1);
}
/**
* 尝试减少共享资源的计数器。
* 如果计数器变为零,则释放所有等待的线程。
*
* @param arg 减少的计数值,通常为 1。
* @return 如果成功减少了计数器并且释放了等待的线程,则返回 true;否则返回 false。
*/
protected final boolean releaseShared(int arg) {
// 尝试减少计数器
if (tryReleaseShared(arg)) {
// 如果计数器变为零,则释放所有等待的线程
doReleaseShared();
return true;
}
return false;
}
/**
* 尝试减少共享资源(计数器)的值。
* 如果计数器变为零,则返回 true;否则返回 false。
*
* @param releases 减少的计数值,通常为 1。
* @return 如果计数器变为零,则返回 true;否则返回 false。
*/
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 减少计数器的值;当计数器变为零时发出信号
for (;;) {
int c = getState(); // 获取当前计数器的值
if (c == 0) // 如果计数器已经是零
return false; // 无需再减少计数器,直接返回 false
int nextc = c - 1; // 减少计数器的值
if (compareAndSetState(c, nextc)) { // 尝试原子性地更新计数器的值
return nextc == 0; // 如果计数器变为零,则返回 true;否则返回 false
}
}
}
/**
* 释放所有等待的线程。
* 确保在存在其他正在进行的获取或释放操作的情况下,释放操作也能传播。
* 这个方法试图唤醒头部节点的后继节点,如果头部节点需要信号。
* 如果头部节点不需要信号,则将其状态设置为 PROPAGATE,以确保在释放时传播继续。
* 此外,必须循环处理,以防在执行此操作时有新节点加入队列。
* 与其它使用 unparkSuccessor 的情况不同,这里需要知道 CAS 更新状态是否失败,
* 如果失败,则需要重新检查。
*/
private void doReleaseShared() {
for (;;) {
Node h = head; // 获取当前队列的头部节点
if (h != null && h != tail) { // 如果头部节点不是空,并且不是尾部节点
int ws = h.waitStatus; // 获取头部节点的等待状态
if (ws == Node.SIGNAL) { // 如果头部节点的状态为 SIGNAL
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 尝试将头部节点的状态从 SIGNAL 设置为 0
continue; // 如果 CAS 失败,则继续循环重新检查
unparkSuccessor(h); // 唤醒头部节点的后继节点
}
else if (ws == 0 && // 如果头部节点的状态为 0
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 尝试将头部节点的状态从 0 设置为 PROPAGATE
continue; // 如果 CAS 失败,则继续循环重新检查
}
if (h == head) // 如果头部节点没有改变
break; // 结束循环
}
}
/**
* 唤醒给定节点的后继节点。
* 如果节点的状态为负数(即可能需要信号),尝试清除状态以准备发送信号。
* 如果后继节点被取消或看起来为空,则从尾部向头部遍历找到实际未取消的后继节点。
*
* @param node 给定的节点。
*/
private void unparkSuccessor(Node node) {
// 如果节点的状态为负数(可能需要信号),尝试清除状态以准备发送信号。
// 即使清除状态失败或状态被等待线程更改也没有关系。
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 要唤醒的线程通常位于给定节点的后继节点中。
// 但如果后继节点被取消或看起来为空,则从尾部向头部遍历找到实际未取消的后继节点。
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); // 唤醒该线程
}
3.3 CountDownLatch与Join的区别
CountDownLatch
和 Thread.join()
都是用来控制线程之间的执行顺序的工具,但它们在使用场景和功能上有所不同。下面是一些关键区别:
CountDownLatch
-
用途:
CountDownLatch
主要用于等待一组操作完成。- 它允许一个或多个线程等待其他线程完成一组操作。
-
计数器:
CountDownLatch
有一个可配置的内部计数器,表示需要等待的事件数量。- 计数器的值只能从构造函数初始化时的值递减到零,不能重置。
-
灵活性:
- 更灵活,可以在多个线程之间使用。
- 可以等待任意数量的操作完成。
-
API:
- 提供了
await()
和countDown()
方法。 await()
方法阻塞当前线程,直到计数器减为零或当前线程被中断。countDown()
方法用于减少计数器的值。
- 提供了
-
示例:
- 一个典型的使用场景是在启动一组工作线程之后,主线程等待所有工作线程完成它们的任务。
- 例如,启动多个线程进行文件下载,主线程等待所有下载完成。
Thread.join()
-
用途:
Thread.join()
用于等待单个线程的完成。- 它通常用于确保某个线程在其终止之前不会继续执行。
-
计数器:
- 没有显式的计数器概念。
join()
方法等待指定的线程结束。
-
灵活性:
- 较为简单,仅适用于等待单个线程的完成。
-
API:
- 提供了
join()
和join(long millis)
方法。 join()
方法阻塞当前线程,直到被调用的线程完成。join(long millis)
方法阻塞当前线程,直到被调用的线程完成或超时。
- 提供了
-
示例:
- 一个典型的使用场景是主线程等待一个工作线程完成。
- 例如,启动一个线程进行长时间运行的任务,然后主线程调用
join()
方法等待任务完成。
总结
-
适用场景:
CountDownLatch
更适合于等待一组操作完成,特别是当有多个线程参与时。Thread.join()
适用于等待单个线程完成。
-
灵活性:
CountDownLatch
更加灵活,可以适应更多种情况。Thread.join()
较为简单,适用于基本的等待场景。
-
可重用性:
CountDownLatch
的计数器一旦达到零就不能重置,因此它通常是一次性的。Thread.join()
可以多次调用,但通常只在特定场景下使用。