Semaphore 信号量
信号量的一个最主要的作用就是,来控制那些需要限制并发访问量的资源。具体来讲,信号量会维护“许可证”的计数,而线程去访问共享资源前,必须先拿到许可证。线程可以从信号量中去“获取”一个许可证,一旦线程获取之后,信号量持有的许可证就转移过去了,所以信号量手中剩余的许可证要减一。
同理,线程也可以“释放”一个许可证,如果线程释放了许可证,这个许可证相当于被归还给信号量了,于是信号量中的许可证的可用数量加一。当信号量拥有的许可证数量减到 0 时,如果下个线程还想要获得许可证,那么这个线程就必须等待,直到之前得到许可证的线程释放,它才能获取。由于线程在没有获取到许可证之前不能进一步去访问被保护的共享资源,所以这就控制了资源的并发访问量,这就是整体思路。
正常情况下获取许可证的流程
这张图的方框代表一个许可证为 3 的信号量,每一个绿色的长条代表一个许可证(permit)。现在我们拥有 3 个许可证,并且信号量的特点是非常“慷慨”,只要它持有许可证,别人想请求的话它都会分发的。假设此时 Thread 1 来请求了,在这种情况下,信号量就会把一个许可证给到这边的第一个线程 Thread 1。于是 Thread 1 获得了许可证。
当三个线程获取获取到信号量,请求服务。此时信号量为0。
没许可证时,会阻塞前来请求的线程
有线程释放信号量后
假设此时线程 1 因为最早去的,它执行完了这个任务,于是返回了。返回的时候它会调用 release 方法,表示“我处理完了我的任务,我想把许可证还回去”,所以,此时线程 1 就释放了之前持有的许可证,把它还给了我们的信号量,于是信号量所持有的许可证数量从 0 又变回了 1。
此时由于许可证已经归还给了信号量,那么刚才找我们要许可证的线程 4 就可以顺利地拿到刚刚释放的这个许可证了。于是线程 4 也就拥有了访问慢服务的访问权。
信号量的使用流程
首先初始化一个信号量,并且传入许可证的数量,这是它的带公平参数的构造函数:public Semaphore(int permits, boolean fair),传入两个参数,第一个参数是许可证的数量,另一个参数是是否公平。如果第二个参数传入 true,则代表它是公平的策略,会把之前已经等待的线程放入到队列中,而当有新的许可证到来时,它会把这个许可证按照顺序发放给之前正在等待的线程;如果这个构造函数第二个参数传入 false,则代表非公平策略,也就有可能插队,就是说后进行请求的线程有可能先得到许可证。
第二个流程是在建立完这个构造函数,初始化信号量之后,我们就可以利用 acquire() 方法。在调用慢服务之前,让线程来调用 acquire 方法或者 acquireUninterruptibly方法,这两个方法的作用是要获取许可证,这同时意味着只有这个方法能顺利执行下去的话,它才能进一步访问这个代码后面的调用慢服务的方法。如果此时信号量已经没有剩余的许可证了,那么线程就会等在 acquire 方法的这一行代码中,所以它也不会进一步执行下面调用慢服务的方法。我们正是用这种方法,保护了我们的慢服务。
acquire() 和 acquireUninterruptibly() 的区别是:是否能响应中断。acquire() 是可以支持中断的,也就是说,它在获取信号量的期间,假设这个线程被中断了,那么它就会跳出 acquire() 方法,不再继续尝试获取了。而 acquireUninterruptibly() 方法是不会被中断的。
第三步就是在任务执行完毕之后,调用 release() 来释放许可证,比如说我们在执行完慢服务这行代码之后,再去执行 release() 方法,这样一来,许可证就会还给我们的信号量了。
信号量的方法
public boolean tryAcquire();
tryAcquire 和之前介绍锁的 trylock 思维是一致的,是尝试获取许可证,相当于看看现在有没有空闲的许可证,如果有就获取,如果现在获取不到也没关系,不必陷入阻塞,可以去做别的事。
public boolean tryAcquire(long timeout, TimeUnit unit)
同样有一个重载的方法,它里面传入了超时时间。比如传入了 3 秒钟,则意味着最多等待 3 秒钟,如果等待期间获取到了许可证,则往下继续执行;如果超时时间到,依然获取不到许可证,它就认为获取失败,且返回 false。
availablePermits()
这个方法用来查询可用许可证的数量,返回一个整型的结果。
信号量还有几个注意点:
获取和释放的许可证数量尽量保持一致,否则比如每次都获取 2 个但只释放 1 个甚至不释放,那么信号量中的许可证就慢慢被消耗完了,最后导致里面没有许可证了,那其他的线程就再也没办法访问了;
在初始化的时候可以设置公平性,如果设置为 true 则会让它更公平,但如果设置为 false 则会让总的吞吐量更高。
信号量是支持跨线程、跨线程池的,而且并不是哪个线程获得的许可证,就必须由这个线程去释放。事实上,对于获取和释放许可证的线程是没有要求的,比如线程 A 获取了然后由线程 B 释放,这完全是可以的,只要逻辑合理即可。(这是什么操作???)
CountDownLatch
比如我们去游乐园坐激流勇进,有的时候游乐园里人不是那么多,这时,管理员会让你稍等一下,等人坐满了再开船,这样的话可以在一定程度上节约游乐园的成本。座位有多少,就需要等多少人,这就是 CountDownLatch 的核心思想,等到一个设定的数值达到之后,才能出发。
流程图
我们把激流勇进的例子用流程图的方式来表示:
可以看到,最开始 CountDownLatch 设置的初始值为 3,然后 T0 线程上来就调用 await 方法,它的作用是让这个线程开始等待,等待后面的 T1、T2、T3,它们每一次调用 countDown 方法,3 这个数值就会减 1,也就是从 3 减到 2,从 2 减到 1,从 1 减到 0,一旦减到 0 之后,这个 T0 就相当于达到了自己触发继续运行的条件,于是它就恢复运行了。
主要方法介绍
构造方法
public CountDownLatch(int count) { };
它的构造函数是传入一个参数,该参数 count 是需要倒数的数值。
等待方法
await();
//调用 await() 方法的线程开始等待,直到倒数结束,也就是 count 值为 0 的时候才会继续执行。
await(long timeout, TimeUnit unit);
await() 有一个重载的方法,里面会传入超时参数,这个方法的作用和 await() 类似,但是这里可以设置超时时间,如果超时就不再等待了。
计数减一
countDown();
把数值倒数 1,也就是将 count 值减 1,直到减为 0 时,之前等待的线程会被唤起。
用法**
用法一:一个线程等待其他多个线程都执行完毕,再继续自己的工作
在实际场景中,很多情况下需要我们初始化一系列的前置条件(比如建立连接、准备数据),在这些准备条件都完成之前,是不能进行下一步工作的,所以这就是利用 CountDownLatch 的一个很好场景,我们可以让应用程序的主线程在其他线程都准备完毕之后再继续执行。
举个生活中的例子,那就是运动员跑步的场景,比如在比赛跑步时有 5 个运动员参赛,终点有一个裁判员,什么时候比赛结束呢?那就是当所有人都跑到终点之后,这相当于裁判员等待 5 个运动员都跑到终点,宣布比赛结束。我们用代码的形式来写出运动员跑步的场景,代码如下:
public class RunDemo1 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
ExecutorService service = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
final int no = i + 1;
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Thread.sleep((long) (Math.random() * 10000));
System.out.println(no + "号运动员完成了比赛。");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}
};
service.submit(runnable);
}
System.out.println("等待5个运动员都跑完.....");
latch.await();
System.out.println("所有人都跑完了,比赛结束。");
}
}
运行结果如下所示:
等待5个运动员都跑完.....
4号运动员完成了比赛。
3号运动员完成了比赛。
1号运动员完成了比赛。
5号运动员完成了比赛。
2号运动员完成了比赛。
所有人都跑完了,比赛结束。
用法二:多个线程等待某一个线程的信号,同时开始执行
比如在运动会上,刚才说的是裁判员等运动员,现在是运动员等裁判员。在运动员起跑之前都会等待裁判员发号施令,一声令下运动员统一起跑,我们用代码把这件事情描述出来,如下所示:
public class RunDemo2 {
public static void main(String[] args) throws InterruptedException {
System.out.println("运动员有5秒的准备时间");
CountDownLatch countDownLatch = new CountDownLatch(1);
ExecutorService service = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
final int no = i + 1;
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(no + "号运动员准备完毕,等待裁判员的发令枪");
try {
countDownLatch.await();
System.out.println(no + "号运动员开始跑步了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
service.submit(runnable);
}
Thread.sleep(5000);
System.out.println("5秒准备时间已过,发令枪响,比赛开始!");
countDownLatch.countDown();
}
}
程序的运行结果如下:
运动员有5秒的准备时间
2号运动员准备完毕,等待裁判员的发令枪
1号运动员准备完毕,等待裁判员的发令枪
3号运动员准备完毕,等待裁判员的发令枪
4号运动员准备完毕,等待裁判员的发令枪
5号运动员准备完毕,等待裁判员的发令枪
5秒准备时间已过,发令枪响,比赛开始!
2号运动员开始跑步了
1号运动员开始跑步了
5号运动员开始跑步了
4号运动员开始跑步了
3号运动员开始跑步了
总结
CountDownLatch 类在创建实例的时候,需要在构造函数中传入倒数次数,然后由需要等待的线程去调用 await 方法开始等待,而每一次其他线程调用了 countDown 方法之后,计数便会减 1,直到减为 0 时,之前等待的线程便会继续运行。
CyclicBarrier
CyclicBarrier 可以构造出一个集结点,当某一个线程执行 await() 的时候,它就会到这个集结点开始等待,等待这个栅栏被撤销。直到预定数量的线程都到了这个集结点之后,这个栅栏就会被撤销,之前等待的线程就在此刻统一出发,继续去执行剩下的任务。
假设我们班级春游去公园里玩,并且会租借三人自行车,每个人都可以骑,但由于这辆自行车是三人的,所以要凑齐三个人才能骑一辆,而且从公园大门走到自行车驿站需要一段时间。那么我们模拟这个场景,写出如下代码:
public class CyclicBarrierDemo {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
for (int i = 0; i < 6; i++) {
new Thread(new Task(i + 1, cyclicBarrier)).start();
}
}
static class Task implements Runnable {
private int id;
private CyclicBarrier cyclicBarrier;
public Task(int id, CyclicBarrier cyclicBarrier) {
this.id = id;
this.cyclicBarrier = cyclicBarrier;
}
@Override
public void run() {
System.out.println("同学" + id + "现在从大门出发,前往自行车驿站");
try {
Thread.sleep((long) (Math.random() * 10000));
System.out.println("同学" + id + "到了自行车驿站,开始等待其他人到达");
cyclicBarrier.await();
System.out.println("同学" + id + "开始骑车");
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
}
}
程序,结果如下所示:
同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学5到了自行车驿站,开始等待其他人到达
同学2到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学3开始骑车
同学5开始骑车
同学2开始骑车
同学6到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学1到了自行车驿站,开始等待其他人到达
同学1开始骑车
同学6开始骑车
同学4开始骑车
执行动作 barrierAction
public CyclicBarrier(int parties, Runnable barrierAction):当 parties 线程到达集结点时,继续往下执行前,会执行这一次这个动作。
接下来我们再介绍一下它的一个额外功能,就是执行动作 barrierAction 功能。CyclicBarrier 还有一个构造函数是传入两个参数的,第一个参数依然是 parties,代表需要几个线程到齐;第二个参数是一个 Runnable 对象,它就是我们下面所要介绍的 barrierAction。
CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new Runnable() {
@Override
public void run() {
System.out.println("凑齐3人了,出发!");
}
});
执行结果如下所示:
同学1现在从大门出发,前往自行车驿站
同学3现在从大门出发,前往自行车驿站
同学2现在从大门出发,前往自行车驿站
同学4现在从大门出发,前往自行车驿站
同学5现在从大门出发,前往自行车驿站
同学6现在从大门出发,前往自行车驿站
同学2到了自行车驿站,开始等待其他人到达
同学4到了自行车驿站,开始等待其他人到达
同学6到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学6开始骑车
同学2开始骑车
同学4开始骑车
同学1到了自行车驿站,开始等待其他人到达
同学3到了自行车驿站,开始等待其他人到达
同学5到了自行车驿站,开始等待其他人到达
凑齐3人了,出发!
同学5开始骑车
同学1开始骑车
同学3开始骑车
值得注意的是,这个语句每个周期只打印一次,不是说你有几个线程在等待就打印几次,而是说这个任务只在“开闸”的时候执行一次。
CyclicBarrier 和 CountDownLatch 的异同**
下面我们来总结一下 CyclicBarrier 和 CountDownLatch 有什么异同。
相同点:都能阻塞一个或一组线程,直到某个预设的条件达成发生,再统一出发。
但是它们也有很多不同点,具体如下。
- 作用对象不同:CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;CountDownLatch 是在调用了 countDown 方法之后把数字倒数减 1,而 CyclicBarrier 是在某线程开始等待后把计数减 1。
- 可重用性不同:CountDownLatch 在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用,在刚才的代码中也可以看出,每 3 个同学到了之后都能出发,并不需要重新新建实例。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。
- 执行动作不同:CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能。