Semaphore&CountDownLatch详解
Semaphore
Semaphore,俗称信号量,它是操作系统中PV操作的原语在java的实现,它是基于AbstractQueuedSynchronizer实现的。Semaphore的功能非常强大,大小为1的信号量就类似于互斥锁,通过同时只能有一个线程获 取信号量实现。大小为n(n>0)的信号量可以实现限流的功能,它可以实现只能有n个线程同 时获取信号量。
PV操作是操作系统一种实现进程互斥与同步的有效方法。PV操作与信号量(S)的处理相关,P表示通 过的意思,V表示释放的意思。用PV操作来管理共享资源时,首先要确保PV操作自身执行的正确性。
P操作的主要动作是: ①S减1; ②若S减1后仍大于或等于0,则进程继续执行; ③若S减1后小于0,则该进程被阻塞后放入等待该信号量的等待队列中,然后转进程调度。
V操作的主要动作是: ①S加1; ②若相加后结果大于0,则进程继续执行; ③若相加后结果小于或等于0,则从该信号的等待队列中释放一个等待进程,然后再返回原进程继续执 行或转进程调度。
Semaphore常用方法
构造器
int表示许可资源 boolean表示是否使用公平锁。公平、非公平和ReentrantLock一样通过sycn的不同子类来决定
public void acquire() throws InterruptedException // 表示阻塞并获取许可
public boolean tryAcquire() // 方法在没有许可的情况下会立即返回 false,要获取许可的线程不会阻塞
public void release() // release() 表示释放许可
public int availablePermits() // 返回此信号量中当前可用的许可证数
public final int getQueueLength() // 返回正在等待获取许可证的线程数
public final boolean hasQueuedThreads() // 是否有线程正在等待获取许可证
protected void reducePermits(int reduction) // 减少 reduction 个许可证
protected Collection<Thread> getQueuedThreads() //返回所有等待获取许可证的线程集合
案例
public class SemaphoneTest2 {
/**
* 实现一个同时只能处理5个请求的限流器
*/
private static Semaphore semaphore = new Semaphore(5);
/**
* 定义一个线程池
*/
private static ThreadPoolExecutor executor = new ThreadPoolExecutor
(10, 50, 60,
TimeUnit.SECONDS, new LinkedBlockingDeque<>(200));
/**
* 模拟执行方法
*/
public static void exec() {
try {
//占用1个资源
semaphore.acquire(1);
//TODO 模拟业务执行
System.out.println("执行exec方法");
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放一个资源
semaphore.release(1);
}
}
public static void main(String[] args) throws InterruptedException {
for (; ; ) {
Thread.sleep(100);
// 模拟请求以10个/s的速度
executor.execute(() -> exec());
}
}
}
用法比较简单,下面通过源码分析Semaphore是如何实现,Semaphore 也是基于AQS实现的,ReentrantLock是AQS独占锁的实现,Semaphore是AQS共享锁的实现
Semaphore源码分析
关注点
- Semaphore的加锁解锁(共享锁)逻辑实现
- 线程竞争锁失败入队阻塞逻辑和获取锁的线程释放锁唤醒阻塞线程竞争锁的逻辑实现
通过构造方法默认创建非公平锁
super(permits);
最终会调到AQS的setState,设置资源数。
构造方法创建了非公平锁,并且设置了资源数。创建好了Semaphore之后通过acquire
方法来获取许可。
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
//中断
if (Thread.interrupted())
throw new InterruptedException();
//tryAcquireShared 获取锁
if (tryAcquireShared(arg) < 0)
//获取锁失败后入队阻塞
doAcquireSharedInterruptibly(arg);
}
先来看如何获取锁 tryAcquireShared
非公平锁当中底层调用的是nonfairTryAcquireShared(acquires)
final int nonfairTryAcquireShared(int acquires) { //入参是获取的资源数 等于acquire(1)方法中的入参值
for (;;) {
//available 就是创建Semaphore的许可资源数
int available = getState();
int remaining = available - acquires;
//当资源数小于0 或者CAS成功 则返回资源数
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
nonfairTryAcquireShared的返回值如果>=0说明是CAS获取资源成功了,可以执行业务代码。如果返回值小于0,说明没有许可资源没有了,执行doAcquireSharedInterruptibly(arg)
入队阻塞。
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) {
//尝试获取锁 失败返回-1 成功返回正数
int r = tryAcquireShared(arg);
//如果获取到了锁
if (r >= 0) {
//删除当前节点的前置指针 和 释放资源逻辑
setHeadAndPropagate(node, r);
//删除当前节点的后置指针 当前置指针和后置指针都删除后 那么该节点就是出队了 等待GC回收
p.next = null; // help GC
failed = false;
return;
}
}
//这部分阻塞逻辑和ReentrantLock的逻辑是一样的 上篇说过不再重复
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//parkAndCheckInterrupt()底部调用了park,当其他资源释放后会调用unpark唤醒阻塞的线程
//阻塞的线程就会接着从这里开始执行代码(自旋)
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
//获取链表的头节点
Node h = head; // Record old head for check below
//设置当前节点为头节点 就是把当前节点的前置指针删除
setHead(node);
//propagate > 0 说明有资源 h == null 说明头节点为空 h.waitStatus < 0说明 可以唤醒下一个线程
//如果有资源 或者 头节点为空 或者 根据头节点状态可以唤醒下一个线程
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
//获取当前节点的下一个节点
Node s = node.next;
//如果是下一个节点为null 或者 是共享锁
//这是共享锁和独占锁不同之处,下一个节点唤醒后继续执行释放资源的逻辑。这样有什么好处
//如果有三个资源释放了那么会有三个线程获取资源
if (s == null || s.isShared())
//执行释放资源的逻辑 这个是释放资源的核心逻辑
doReleaseShared();
}
}
setHeadAndPropagate
是获取到资源后出队的核心逻辑
阻塞入队的逻辑和ReentrantLock大体相同。再来看下释放资源的源码
释放资源的核心逻辑就是tryReleaseShared(arg
和doReleaseShared()
protected final boolean tryReleaseShared(int releases) {
for (;;) {
//获取当前可用资源数
int current = getState();
//释放资源就是当前可用资源数+释放的资源数
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
//通过CAS修改资源数 成功返回true
if (compareAndSetState(current, next))
return true;
}
}
tryReleaseShared
逻辑比较简单,就是释放资源,但是释放完资源还要唤醒其他阻塞的线程,那么doReleaseShared()
就是用来唤醒线程的
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
//获取节点状态
int ws = h.waitStatus;
//满足可唤醒状态
if (ws == Node.SIGNAL) {
//通过CAS把waitStatus设置为0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//CAS成功执行解锁逻辑
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
private void unparkSuccessor(Node node) {
//获取节点状态
int ws = node.waitStatus;
//如果状态小于0 通过CAS修改为0
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;
}
//如果当前节点的下一个节点不是null 唤醒下一个节点中的线程
if (s != null)
LockSupport.unpark(s.thread);
}
当调用了unpark方法后线程唤醒,重新开始自旋然后获取到资源后执行setHeadAndPropagate
。
CountDownLatch
CountDownLatch(闭锁)是一个同步协助类,允许一个或多个线程等待,直到其他线程完成操作集。 CountDownLatch使用给定的计数值(count)初始化。await方法会阻塞直到当前的计数值 (count)由于countDown方法的调用达到0,count为0之后所有等待的线程都会被释放,并且随后对await方法的调用都会立即返回。这是一个一次性现象 —— count不会被重置。如果你需要一个重置count的版本,那么请考虑使用CyclicBarrier。
使用场景
常用方法
// 调用 await() 方法的线程会被挂起,它会等待直到 count 值为 0 才继续执行
public void await() throws InterruptedException { };
// 和 await() 类似,若等待 timeout 时长后,count 值还是没有变为 0,不再等待,继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };
// 会将 count 减 1,直至为 0
public void countDown() { };
案例
英雄联盟都选择好英雄后一起进入游戏
@Slf4j
public class CountDownLatchLOL {
private static final CountDownLatch cdl = new CountDownLatch(1);
public static void main(String[] args) throws InterruptedException {
CountDownLatchLOL lol = new CountDownLatchLOL();
lol.ChooseHero("上单");
lol.ChooseHero("打野");
lol.ChooseHero("中单");
lol.ChooseHero("ADC");
lol.ChooseHero("辅助");
Thread.sleep(2000);
cdl.countDown();
}
private void ChooseHero(String route){
new Thread(()->{
log.info(route+"选择好了英雄,等待进入游戏");
try {
cdl.await(5000, TimeUnit.SECONDS);
log.info(route+"开始进入游戏");
} catch (InterruptedException e) {
e.printStackTrace();
log.info(route+"等待进入游戏超时");
}
}).start();
}
}
看看另一种方案:
@Slf4j
public class CountDownLatchLOL2 {
private static final CountDownLatch cdl = new CountDownLatch(5);
public static void main(String[] args) throws InterruptedException {
CountDownLatchLOL2 lol = new CountDownLatchLOL2();
lol.intoLOL();
Thread.sleep(1000);
lol.ChooseHero("上单");
lol.ChooseHero("打野");
lol.ChooseHero("中单");
lol.ChooseHero("ADC");
lol.ChooseHero("辅助");
}
private void intoLOL() {
new Thread(() -> {
log.info("进入英雄选择界面,等待英雄选择");
try {
cdl.await(5000, TimeUnit.SECONDS);
log.info("英雄选择完成,开始进入游戏");
} catch (InterruptedException e) {
e.printStackTrace();
log.info("英雄选择超时,游戏结束");
}
}).start();
}
private void ChooseHero(String route) {
log.info(route + "选择好了英雄,等待进入游戏");
cdl.countDown();
}
}
CountDownLatch 源码分析
CountDownLatch 只有一个构造方法
看他的阻塞逻辑
这里入参写死了就是1。
这部分阻塞代码和Semaphorer是一样的。像doAcquireSharedInterruptibly(arg);
方法,作用获取锁失败后入队阻塞。这部分和Semaphorer是一样的,但是tryAcquireShared(arg)
获取锁的方法有所不同
针对于不同的类有不同的实现,对于CountDownLatch 的实现就很简单
如果资源数不等于0就返回-1,等于0就返回1。如果返回-1就调用doAcquireSharedInterruptibly(arg);
入队阻塞。如果返回1就放行。
入队阻塞代码Semaphorer讲过了不在重复,逻辑是一样的。
再来看下释放逻辑
这部分释放资源和Semaphorer也是一样的。 doReleaseShared();
用来唤醒阻塞线程的这部分逻辑和Semaphorer一样。
tryReleaseShared
这尝试释放资源的代码有不同的实现
protected boolean tryReleaseShared(int releases) {
for (;;) {//自旋
//获取当前资源数
int c = getState();
//如果资源数为0 返回false
if (c == 0)
return false;
//否则资源数减一
int nextc = c-1;
//CAS修改资源数
if (compareAndSetState(c, nextc))
//修改成功后如果nextc == 0 返回true,否则返回false
return nextc == 0;
}
}
就是说之后资源数==0的时候才返回true。返回true才会唤醒阻塞的线程。
CountDownLatch实现原理总结
底层基于 AbstractQueuedSynchronizer 实现,CountDownLatch 构造函数中指定的 count直接赋给AQS的state;每次countDown()则都是release(1)减1,最后减到0时unpark(唤醒)阻塞的线程;这一步是由最后一个执行countdown方法的线程执行的。 而调用await()方法时,当前线程就会判断state属性是否为0,如果为0,则继续往下执行, 如果不为0,则使当前线程进入等待状态(park),直到某个线程将state属性置为0,其就会唤醒在 await()方法中等待的线程。