代码理解java多线程 (三) - JDK工具篇(6)- 通信工具类 & Fork/Join框架

目录

第十七章 通信工具类

17.1 Semaphore

17.1.1 Semaphore介绍

17.1.2 Semaphore案例

17.1.3 Semaphore原理

17.2 Exchanger

17.3 CountDownLatch

17.3.1 CountDownLatch介绍

17.3.2 CountDownLatch案例

17.3.3 CountDownLatch原理

17.4 CyclicBarrier

17.4.1 CyclicBarrier介绍

17.4.2 CyclicBarrier Barrier被破坏

17.4.3 CyclicBarrier案例

17.4.4 CyclicBarrier原理

17.5 Phaser

17.5.1 Phaser介绍

17.5.2 Phaser案例

17.5.3 Phaser原理

第十八章 Fork/Join框架

18.1 什么是Fork/Join

18.2 工作窃取算法

18.3 Fork/Join的具体实现

18.3.1 ForkJoinTask

18.3.2 ForkJoinPool

18.4 Fork/Join的使用


第十七章 通信工具类

JDK中提供了一些工具类以供开发者使用。这样的话我们在遇到一些常见的应用场景时就可以使用这些工具类,而不用自己再重复造轮子了。

它们都在java.util.concurrent包下。先总体概括一下都有哪些工具类,它们有什么作用,然后再分别介绍它们的主要使用方法和原理。

作用
Semaphore限制线程的数量
Exchanger两个线程交换数据
CountDownLatch线程等待直到计数器减为0时开始工作
CyclicBarrier作用跟CountDownLatch类似,但是可以重复使用
Phaser增强的CyclicBarrier

下面分别介绍这几个类。

17.1 Semaphore

17.1.1 Semaphore介绍

Semaphore翻译过来是信号的意思。顾名思义,这个工具类提供的功能就是多个线程彼此“打信号”。而这个“信号”是一个int类型的数据,也可以看成是一种“资源”。

可以在构造函数中传入初始资源总数,以及是否使用“公平”的同步器。默认情况下,是非公平的。

 
  1. // 默认情况下使用非公平
  2. public Semaphore(int permits) {
  3. sync = new NonfairSync(permits);
  4. }
  5.  
  6. public Semaphore(int permits, boolean fair) {
  7. sync = fair ? new FairSync(permits) : new NonfairSync(permits);
  8. }

最主要的方法是acquire方法和release方法。acquire()方法会申请一个permit,而release方法会释放一个permit。当然,你也可以申请多个acquire(int permits)或者释放多个release(int permits)。

每次acquire,permits就会减少一个或者多个。如果减少到了0,再有其他线程来acquire,那就要阻塞这个线程直到有其它线程release permit为止。

17.1.2 Semaphore案例

Semaphore往往用于资源有限的场景中,去限制线程的数量。举个例子,我想限制同时只能有3个线程在工作:

 
  1. public class SemaphoreDemo {
  2. static class MyThread implements Runnable {
  3.  
  4. private int value;
  5. private Semaphore semaphore;
  6.  
  7. public MyThread(int value, Semaphore semaphore) {
  8. this.value = value;
  9. this.semaphore = semaphore;
  10. }
  11.  
  12. @Override
  13. public void run() {
  14. try {
  15. semaphore.acquire(); // 获取permit
  16. System.out.println(String.format("当前线程是%d, 还剩%d个资源,还有%d个线程在等待",
  17. value, semaphore.availablePermits(), semaphore.getQueueLength()));
  18. // 睡眠随机时间,打乱释放顺序
  19. Random random =new Random();
  20. Thread.sleep(random.nextInt(1000));
  21. semaphore.release(); // 释放permit
  22. System.out.println(String.format("线程%d释放了资源", value));
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. }
  27. }
  28.  
  29. public static void main(String[] args) {
  30. Semaphore semaphore = new Semaphore(3);
  31. for (int i = 0; i < 10; i++) {
  32. new Thread(new MyThread(i, semaphore)).start();
  33. }
  34. }
  35. }

输出:

当前线程是1, 还剩2个资源,还有0个线程在等待
当前线程是0, 还剩1个资源,还有0个线程在等待
当前线程是6, 还剩0个资源,还有0个线程在等待
线程6释放了资源
当前线程是2, 还剩0个资源,还有6个线程在等待
线程2释放了资源
当前线程是4, 还剩0个资源,还有5个线程在等待
线程0释放了资源
当前线程是7, 还剩0个资源,还有4个线程在等待
线程1释放了资源
当前线程是8, 还剩0个资源,还有3个线程在等待
线程7释放了资源
当前线程是5, 还剩0个资源,还有2个线程在等待
线程4释放了资源
当前线程是3, 还剩0个资源,还有1个线程在等待
线程8释放了资源
当前线程是9, 还剩0个资源,还有0个线程在等待
线程9释放了资源
线程5释放了资源
线程3释放了资源

可以看到,在这次运行中,最开始是1, 0, 6这三个线程获得了资源,而其它线程进入了等待队列。然后当某个线程释放资源后,就会有等待队列中的线程获得资源。

当然,Semaphore默认的acquire方法是会让线程进入等待队列,且会抛出中断异常。但它还有一些方法可以忽略中断或不进入阻塞队列:

 
  1. // 忽略中断
  2. public void acquireUninterruptibly()
  3. public void acquireUninterruptibly(int permits)
  4.  
  5. // 不进入等待队列,底层使用CAS
  6. public boolean tryAcquire
  7. public boolean tryAcquire(int permits)
  8. public boolean tryAcquire(int permits, long timeout, TimeUnit unit)
  9. throws InterruptedException
  10. public boolean tryAcquire(long timeout, TimeUnit unit)

17.1.3 Semaphore原理

Semaphore内部有一个继承了AQS的同步器Sync,重写了tryAcquireShared方法。在这个方法里,会去尝试获取资源。

如果获取失败(想要的资源数量小于目前已有的资源数量),就会返回一个负数(代表尝试获取资源失败)。然后当前线程就会进入AQS的等待队列。

17.2 Exchanger

Exchanger类用于两个线程交换数据。它支持泛型,也就是说你可以在两个线程之间传送任何数据。先来一个案例看看如何使用,比如两个线程之间想要传送字符串:

 
  1. public class ExchangerDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. Exchanger<String> exchanger = new Exchanger<>();
  4.  
  5. new Thread(() -> {
  6. try {
  7. System.out.println("这是线程A,得到了另一个线程的数据:"
  8. + exchanger.exchange("这是来自线程A的数据"));
  9. } catch (InterruptedException e) {
  10. e.printStackTrace();
  11. }
  12. }).start();
  13.  
  14. System.out.println("这个时候线程A是阻塞的,在等待线程B的数据");
  15. Thread.sleep(1000);
  16.  
  17. new Thread(() -> {
  18. try {
  19. System.out.println("这是线程B,得到了另一个线程的数据:"
  20. + exchanger.exchange("这是来自线程B的数据"));
  21. } catch (InterruptedException e) {
  22. e.printStackTrace();
  23. }
  24. }).start();
  25. }
  26. }

输出:

这个时候线程A是阻塞的,在等待线程B的数据
这是线程B,得到了另一个线程的数据:这是来自线程A的数据
这是线程A,得到了另一个线程的数据:这是来自线程B的数据

可以看到,当一个线程调用exchange方法后,它是处于阻塞状态的,只有当另一个线程也调用了exchange方法,它才会继续向下执行。看源码可以发现它是使用park/unpark来实现等待状态的切换的,但是在使用park/unpark方法之前,使用了CAS检查,估计是为了提高性能。

Exchanger一般用于两个线程之间更方便地在内存中交换数据,因为其支持泛型,所以我们可以传输任何的数据,比如IO流或者IO缓存。根据JDK里面的注释的说法,可以总结为一下特性:

  • 此类提供对外的操作是同步的;
  • 用于成对出现的线程之间交换数据;
  • 可以视作双向的同步队列;
  • 可应用于基因算法、流水线设计等场景。

Exchanger类还有一个有超时参数的方法,如果在指定时间内没有另一个线程调用exchange,就会抛出一个超时异常。

 
  1. public V exchange(V x, long timeout, TimeUnit unit)

那么问题来了,Exchanger只能是两个线程交换数据吗?那三个调用同一个实例的exchange方法会发生什么呢?答案是只有前两个线程会交换数据,第三个线程会进入阻塞状态。

需要注意的是,exchange是可以重复使用的。也就是说。两个线程可以使用Exchanger在内存中不断地再交换数据。

17.3 CountDownLatch

17.3.1 CountDownLatch介绍

先来解读一下CountDownLatch这个类名字的意义。CountDown代表计数递减,Latch是“门闩”的意思。也有人把它称为“屏障”。而CountDownLatch这个类的作用也很贴合这个名字的意义,假设某个线程在执行任务之前,需要等待其它线程完成一些前置任务,必须等所有的前置任务都完成,才能开始执行本线程的任务。

CountDownLatch的方法也很简单,如下:

 
  1. // 构造方法:
  2. public CountDownLatch(int count)
  3.  
  4. public void await() // 等待
  5. public boolean await(long timeout, TimeUnit unit) // 超时等待
  6. public void countDown() // count - 1
  7. public long getCount() // 获取当前还有多少count

17.3.2 CountDownLatch案例

我们知道,玩游戏的时候,在游戏真正开始之前,一般会等待一些前置任务完成,比如“加载地图数据”,“加载人物模型”,“加载背景音乐”等等。只有当所有的东西都加载完成后,玩家才能真正进入游戏。下面我们就来模拟一下这个demo。

 
  1. public class CountDownLatchDemo {
  2. // 定义前置任务线程
  3. static class PreTaskThread implements Runnable {
  4.  
  5. private String task;
  6. private CountDownLatch countDownLatch;
  7.  
  8. public PreTaskThread(String task, CountDownLatch countDownLatch) {
  9. this.task = task;
  10. this.countDownLatch = countDownLatch;
  11. }
  12.  
  13. @Override
  14. public void run() {
  15. try {
  16. Random random = new Random();
  17. Thread.sleep(random.nextInt(1000));
  18. System.out.println(task + " - 任务完成");
  19. countDownLatch.countDown();
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. }
  24. }
  25.  
  26. public static void main(String[] args) {
  27. // 假设有三个模块需要加载
  28. CountDownLatch countDownLatch = new CountDownLatch(3);
  29.  
  30. // 主任务
  31. new Thread(() -> {
  32. try {
  33. System.out.println("等待数据加载...");
  34. System.out.println(String.format("还有%d个前置任务", countDownLatch.getCount()));
  35. countDownLatch.await();
  36. System.out.println("数据加载完成,正式开始游戏!");
  37. } catch (InterruptedException e) {
  38. e.printStackTrace();
  39. }
  40. }).start();
  41.  
  42. // 前置任务
  43. new Thread(new PreTaskThread("加载地图数据", countDownLatch)).start();
  44. new Thread(new PreTaskThread("加载人物模型", countDownLatch)).start();
  45. new Thread(new PreTaskThread("加载背景音乐", countDownLatch)).start();
  46. }
  47. }

输出:

等待数据加载…
还有3个前置任务
加载人物模型 - 任务完成
加载背景音乐 - 任务完成
加载地图数据 - 任务完成
数据加载完成,正式开始游戏!

17.3.3 CountDownLatch原理

其实CountDownLatch类的原理挺简单的,内部同样是一个基层了AQS的实现类Sync,且实现起来还很简单,可能是JDK里面AQS的子类中最简单的实现了,有兴趣的读者可以去看看这个内部类的源码。

需要注意的是构造器中的计数值(count)实际上就是闭锁需要等待的线程数量。这个值只能被设置一次,而且CountDownLatch没有提供任何机制去重新设置这个计数值

17.4 CyclicBarrier

17.4.1 CyclicBarrier介绍

CyclicBarrirer从名字上来理解是“循环的屏障”的意思。前面提到了CountDownLatch一旦计数值count被降为0后,就不能再重新设置了,它只能起一次“屏障”的作用。而CyclicBarrier拥有CountDownLatch的所有功能,还可以使用reset()方法重置屏障。

17.4.2 CyclicBarrier Barrier被破坏

如果在参与者(线程)在等待的过程中,Barrier被破坏,就会抛出BrokenBarrierException。可以用isBroken()方法检测Barrier是否被破坏。

  1. 如果有线程已经处于等待状态,调用reset方法会导致已经在等待的线程出现BrokenBarrierException异常。并且由于出现了BrokenBarrierException,将会导致始终无法等待。
  2. 如果在等待的过程中,线程被中断,也会抛出BrokenBarrierException异常,并且这个异常会传播到其他所有的线程。
  3. 如果在执行屏障操作过程中发生异常,则该异常将传播到当前线程中,其他线程会抛出BrokenBarrierException,屏障被损坏。
  4. 如果超出指定的等待时间,当前线程会抛出 TimeoutException 异常,其他线程会抛出BrokenBarrierException异常。

17.4.3 CyclicBarrier案例

我们同样用玩游戏的例子。如果玩一个游戏有多个“关卡”,那使用CountDownLatch显然不太合适,那需要为每个关卡都创建一个实例。那我们可以使用CyclicBarrier来实现每个关卡的数据加载等待功能。

 
  1. public class CyclicBarrierDemo {
  2. static class PreTaskThread implements Runnable {
  3.  
  4. private String task;
  5. private CyclicBarrier cyclicBarrier;
  6.  
  7. public PreTaskThread(String task, CyclicBarrier cyclicBarrier) {
  8. this.task = task;
  9. this.cyclicBarrier = cyclicBarrier;
  10. }
  11.  
  12. @Override
  13. public void run() {
  14. // 假设总共三个关卡
  15. for (int i = 1; i < 4; i++) {
  16. try {
  17. Random random = new Random();
  18. Thread.sleep(random.nextInt(1000));
  19. System.out.println(String.format("关卡%d的任务%s完成", i, task));
  20. cyclicBarrier.await();
  21. } catch (InterruptedException | BrokenBarrierException e) {
  22. e.printStackTrace();
  23. }
  24. cyclicBarrier.reset(); // 重置屏障
  25. }
  26. }
  27. }
  28.  
  29. public static void main(String[] args) {
  30. CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -> {
  31. System.out.println("本关卡所有前置任务完成,开始游戏...");
  32. });
  33.  
  34. new Thread(new PreTaskThread("加载地图数据", cyclicBarrier)).start();
  35. new Thread(new PreTaskThread("加载人物模型", cyclicBarrier)).start();
  36. new Thread(new PreTaskThread("加载背景音乐", cyclicBarrier)).start();
  37. }
  38. }

输出:

关卡1的任务加载地图数据完成
关卡1的任务加载背景音乐完成
关卡1的任务加载人物模型完成
本关卡所有前置任务完成,开始游戏…
关卡2的任务加载地图数据完成
关卡2的任务加载背景音乐完成
关卡2的任务加载人物模型完成
本关卡所有前置任务完成,开始游戏…
关卡3的任务加载人物模型完成
关卡3的任务加载地图数据完成
关卡3的任务加载背景音乐完成
本关卡所有前置任务完成,开始游戏…

注意这里跟CountDownLatch的代码有一些不同。CyclicBarrier没有分为await()countDown(),而是只有单独的一个await()方法。

一旦调用await()方法的线程数量等于构造方法中传入的任务总量(这里是3),就代表达到屏障了。CyclicBarrier允许我们在达到屏障的时候可以执行一个任务,可以在构造方法传入一个Runnable类型的对象。上述案例就是在达到屏障时,输出“本关卡所有前置任务完成,开始游戏…”。

 
  1. // 构造方法
  2. public CyclicBarrier(int parties) {
  3. this(parties, null);
  4. }
  5. public CyclicBarrier(int parties, Runnable barrierAction) {
  6. // 具体实现
  7. }

17.4.4 CyclicBarrier原理

CyclicBarrier虽说功能与CountDownLatch类似,但是实现原理却完全不同,CyclicBarrier内部使用的是Lock + Condition实现的等待/通知模式。详情可以查看这个方法的源码:

 
  1. private int dowait(boolean timed, long nanos)

17.5 Phaser

17.5.1 Phaser介绍

Phaser这个单词是“移相器,相位器”的意思(好吧,笔者并不懂这是什么玩意,下方资料来自百度百科)。这个类是从JDK 1.7 中出现的。

移相器(Phaser)能够对波的相位进行调整的一种装置。任何传输介质对在其中传导的波动都会引入相移,这是早期模拟移相器的原理;现代电子技术发展后利用A/D、D/A转换实现了数字移相,顾名思义,它是一种不连续的移相技术,但特点是移相精度高。
移相器在雷达、导弹姿态控制、加速器、通信、仪器仪表甚至于音乐等领域都有着广泛的应用

Phaser类有点复杂,这里只介绍一些基本的用法和知识点。详情可以查看JDK文档,文档里有这个类非常详尽的介绍。

前面我们介绍了CyclicBarrier,可以发现它在构造方法里传入“任务总量”parties之后,就不能修改这个值了,并且每次调用await()方法也只能消耗一个parties计数。但Phaser可以动态地调整任务总量!

名词解释:

  • party:对应一个线程,数量可以通过register或者构造参数传入;

  • arrive:对应一个party的状态,初始时是unarrived,当调用arriveAndAwaitAdvance()或者 arriveAndDeregister()进入arrive状态,可以通过getUnarrivedParties()获取当前未到达的数量;

  • register:注册一个party,每一阶段必须所有注册的party都到达才能进入下一阶段;

  • deRegister:减少一个party。

  • phase:阶段,当所有注册的party都arrive之后,将会调用Phaser的onAdvance()方法来判断是否要进入下一阶段。

Phaser终止的两种途径,Phaser维护的线程执行完毕或者onAdvance()返回true
此外Phaser还能维护一个树状的层级关系,构造的时候new Phaser(parentPhaser),对于Task执行时间短的场景(竞争激烈),也就是说有大量的party, 那可以把每个Phaser的任务量设置较小,多个Phaser共同继承一个父Phaser。

Phasers with large numbers of parties that would otherwise experience heavy synchronization contention costs may instead be set up so that groups of sub-phasers share a common parent. This may greatly increase throughput even though it incurs greater per-operation overhead.

翻译:如果有大量的party,那许多线程可能同步的竞争成本比较高。所以可以拆分成多个子Phaser共享一个共同的父Phaser。这可能会大大增加吞吐量,即使它会带来更多的每次操作开销。

17.5.2 Phaser案例

还是游戏的案例。假设我们游戏有三个关卡,但只有第一个关卡有新手教程,需要加载新手教程模块。但后面的第二个关卡和第三个关卡都不需要。我们可以用Phaser来做这个需求。

代码:

 
  1. public class PhaserDemo {
  2. static class PreTaskThread implements Runnable {
  3.  
  4. private String task;
  5. private Phaser phaser;
  6.  
  7. public PreTaskThread(String task, Phaser phaser) {
  8. this.task = task;
  9. this.phaser = phaser;
  10. }
  11.  
  12. @Override
  13. public void run() {
  14. for (int i = 1; i < 4; i++) {
  15. try {
  16. // 第二次关卡起不加载NPC,跳过
  17. if (i >= 2 && "加载新手教程".equals(task)) {
  18. continue;
  19. }
  20. Random random = new Random();
  21. Thread.sleep(random.nextInt(1000));
  22. System.out.println(String.format("关卡%d,需要加载%d个模块,当前模块【%s】",
  23. i, phaser.getRegisteredParties(), task));
  24.  
  25. // 从第二个关卡起,不加载NPC
  26. if (i == 1 && "加载新手教程".equals(task)) {
  27. System.out.println("下次关卡移除加载【新手教程】模块");
  28. phaser.arriveAndDeregister(); // 移除一个模块
  29. } else {
  30. phaser.arriveAndAwaitAdvance();
  31. }
  32. } catch (InterruptedException e) {
  33. e.printStackTrace();
  34. }
  35. }
  36. }
  37. }
  38.  
  39. public static void main(String[] args) {
  40. Phaser phaser = new Phaser(4) {
  41. @Override
  42. protected boolean onAdvance(int phase, int registeredParties) {
  43. System.out.println(String.format("第%d次关卡准备完成", phase + 1));
  44. return phase == 3 || registeredParties == 0;
  45. }
  46. };
  47.  
  48. new Thread(new PreTaskThread("加载地图数据", phaser)).start();
  49. new Thread(new PreTaskThread("加载人物模型", phaser)).start();
  50. new Thread(new PreTaskThread("加载背景音乐", phaser)).start();
  51. new Thread(new PreTaskThread("加载新手教程", phaser)).start();
  52. }
  53. }

输出:

关卡1,需要加载4个模块,当前模块【加载背景音乐】
关卡1,需要加载4个模块,当前模块【加载新手教程】
下次关卡移除加载【新手教程】模块
关卡1,需要加载3个模块,当前模块【加载地图数据】
关卡1,需要加载3个模块,当前模块【加载人物模型】
第1次关卡准备完成
关卡2,需要加载3个模块,当前模块【加载地图数据】
关卡2,需要加载3个模块,当前模块【加载背景音乐】
关卡2,需要加载3个模块,当前模块【加载人物模型】
第2次关卡准备完成
关卡3,需要加载3个模块,当前模块【加载人物模型】
关卡3,需要加载3个模块,当前模块【加载地图数据】
关卡3,需要加载3个模块,当前模块【加载背景音乐】
第3次关卡准备完成

这里要注意关卡1的输出,在“加载新手教程”线程中调用了arriveAndDeregister()减少一个party之后,后面的线程使用getRegisteredParties()得到的是已经被修改后的parties了。但是当前这个阶段(phase),仍然是需要4个parties都arrive才触发屏障的。从下一个阶段开始,才需要3个parties都arrive就触发屏障。

另外Phaser类用来控制某个阶段的线程数量很有用,但它并在意这个阶段具体有哪些线程arrive,只要达到它当前阶段的parties值,就触发屏障。所以我这里的案例虽然制定了特定的线程(加载新手教程)来更直观地表述Phaser的功能,但是其实Phaser是没有分辨具体是哪个线程的功能的,它在意的只是数量,这一点需要读者注意。

17.5.3 Phaser原理

Phaser类的原理相比起来要复杂得多。它内部使用了两个基于Fork-Join框架的原子类辅助:

 
  1. private final AtomicReference<QNode> evenQ;
  2. private final AtomicReference<QNode> oddQ;
  3.  
  4. static final class QNode implements ForkJoinPool.ManagedBlocker {
  5. // 实现代码
  6. }

有兴趣的读者可以去看看JDK源代码,这里不做过多叙述。

总的来说,CountDownLatch,CyclicBarrier,Phaser是一个比一个强大,但也一个比一个复杂。根据自己的业务需求合理选择即可。

 

 

第十八章 Fork/Join框架

18.1 什么是Fork/Join

Fork/Join框架是一个实现了ExecutorService接口的多线程处理器,它专为那些可以通过递归分解成更细小的任务而设计,最大化的利用多核处理器来提高应用程序的性能。

与其他ExecutorService相关的实现相同的是,Fork/Join框架会将任务分配给线程池中的线程。而与之不同的是,Fork/Join框架在执行任务时使用了工作窃取算法

fork在英文里有分叉的意思,join在英文里连接、结合的意思。顾名思义,fork就是要使一个大任务分解成若干个小任务,而join就是最后将各个小任务的结果结合起来得到大任务的结果。

Fork/Join的运行流程大致如下所示:

fork/join流程图

需要注意的是,图里的次级子任务可以一直分下去,一直分到子任务足够小为止。用伪代码来表示如下:

 
  1. solve(任务):
  2. if(任务已经划分到足够小):
  3. 顺序执行任务
  4. else:
  5. for(划分任务得到子任务)
  6. solve(子任务)
  7. 结合所有子任务的结果到上一层循环
  8. return 最终结合的结果

通过上面伪代码可以看出,我们通过递归嵌套的计算得到最终结果,这里有体现分而治之(divide and conquer) 的算法思想。

18.2 工作窃取算法

工作窃取算法指的是在多线程执行不同任务队列的过程中,某个线程执行完自己队列的任务后从其他线程的任务队列里窃取任务来执行。

工作窃取流程如下图所示:

工作窃取算法流程

值得注意的是,当一个线程窃取另一个线程的时候,为了减少两个任务线程之间的竞争,我们通常使用双端队列来存储任务。被窃取的任务线程都从双端队列的头部拿任务执行,而窃取其他任务的线程从双端队列的尾部执行任务。

另外,当一个线程在窃取任务时要是没有其他可用的任务了,这个线程会进入阻塞状态以等待再次“工作”。

18.3 Fork/Join的具体实现

前面我们说Fork/Join框架简单来讲就是对任务的分割与子任务的合并,所以要实现这个框架,先得有任务。在Fork/Join框架里提供了抽象类ForkJoinTask来实现任务。

18.3.1 ForkJoinTask

ForkJoinTask是一个类似普通线程的实体,但是比普通线程轻量得多。

fork()方法:使用线程池中的空闲线程异步提交任务

 
  1. // 本文所有代码都引自Java 8
  2. public final ForkJoinTask<V> fork() {
  3. Thread t;
  4. // ForkJoinWorkerThread是执行ForkJoinTask的专有线程,由ForkJoinPool管理
  5. // 先判断当前线程是否是ForkJoin专有线程,如果是,则将任务push到当前线程所负责的队列里去
  6. if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
  7. ((ForkJoinWorkerThread)t).workQueue.push(this);
  8. else
  9. // 如果不是则将线程加入队列
  10. // 没有显式创建ForkJoinPool的时候走这里,提交任务到默认的common线程池中
  11. ForkJoinPool.common.externalPush(this);
  12. return this;
  13. }

其实fork()只做了一件事,那就是把任务推入当前工作线程的工作队列里

join()方法:等待处理任务的线程处理完毕,获得返回值。

来看下join()的源码:

 
  1. public final V join() {
  2. int s;
  3. // doJoin()方法来获取当前任务的执行状态
  4. if ((s = doJoin() & DONE_MASK) != NORMAL)
  5. // 任务异常,抛出异常
  6. reportException(s);
  7. // 任务正常完成,获取返回值
  8. return getRawResult();
  9. }
  10.  
  11. /**
  12. * doJoin()方法用来返回当前任务的执行状态
  13. **/
  14. private int doJoin() {
  15. int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
  16. // 先判断任务是否执行完毕,执行完毕直接返回结果(执行状态)
  17. return (s = status) < 0 ? s :
  18. // 如果没有执行完毕,先判断是否是ForkJoinWorkThread线程
  19. ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
  20. // 如果是,先判断任务是否处于工作队列顶端(意味着下一个就执行它)
  21. // tryUnpush()方法判断任务是否处于当前工作队列顶端,是返回true
  22. // doExec()方法执行任务
  23. (w = (wt = (ForkJoinWorkerThread)t).workQueue).
  24. // 如果是处于顶端并且任务执行完毕,返回结果
  25. tryUnpush(this) && (s = doExec()) < 0 ? s :
  26. // 如果不在顶端或者在顶端却没未执行完毕,那就调用awitJoin()执行任务
  27. // awaitJoin():使用自旋使任务执行完成,返回结果
  28. wt.pool.awaitJoin(w, this, 0L) :
  29. // 如果不是ForkJoinWorkThread线程,执行externalAwaitDone()返回任务结果
  30. externalAwaitDone();
  31. }

我们在之前介绍过说Thread.join()会使线程阻塞,而ForkJoinPool.join()会使线程免于阻塞,下面是ForkJoinPool.join()的流程图:
join流程图

RecursiveAction和RecursiveTask

通常情况下,在创建任务的时候我们一般不直接继承ForkJoinTask,而是继承它的子类RecursiveActionRecursiveTask

两个都是ForkJoinTask的子类,RecursiveAction可以看做是无返回值的ForkJoinTask,RecursiveTask是有返回值的ForkJoinTask

此外,两个子类都有执行主要计算的方法compute(),当然,RecursiveAction的compute()返回void,RecursiveTask的compute()有具体的返回值。

18.3.2 ForkJoinPool

ForkJoinPool是用于执行ForkJoinTask任务的执行(线程)池。

ForkJoinPool管理着执行池中的线程和任务队列,此外,执行池是否还接受任务,显示线程的运行状态也是在这里处理。

我们来大致看下ForkJoinPool的源码:

 
  1. @sun.misc.Contended
  2. public class ForkJoinPool extends AbstractExecutorService {
  3. // 任务队列
  4. volatile WorkQueue[] workQueues;
  5.  
  6. // 线程的运行状态
  7. volatile int runState;
  8.  
  9. // 创建ForkJoinWorkerThread的默认工厂,可以通过构造函数重写
  10. public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;
  11.  
  12. // 公用的线程池,其运行状态不受shutdown()和shutdownNow()的影响
  13. static final ForkJoinPool common;
  14.  
  15. // 私有构造方法,没有任何安全检查和参数校验,由makeCommonPool直接调用
  16. // 其他构造方法都是源自于此方法
  17. // parallelism: 并行度,
  18. // 默认调用java.lang.Runtime.availableProcessors() 方法返回可用处理器的数量
  19. private ForkJoinPool(int parallelism,
  20. ForkJoinWorkerThreadFactory factory, // 工作线程工厂
  21. UncaughtExceptionHandler handler, // 拒绝任务的handler
  22. int mode, // 同步模式
  23. String workerNamePrefix) { // 线程名prefix
  24. this.workerNamePrefix = workerNamePrefix;
  25. this.factory = factory;
  26. this.ueh = handler;
  27. this.config = (parallelism & SMASK) | mode;
  28. long np = (long)(-parallelism); // offset ctl counts
  29. this.ctl = ((np << AC_SHIFT) & AC_MASK) | ((np << TC_SHIFT) & TC_MASK);
  30. }
  31.  
  32. }

WorkQueue

双端队列,ForkJoinTask存放在这里。

当工作线程在处理自己的工作队列时,会从队列首取任务来执行(FIFO);如果是窃取其他队列的任务时,窃取的任务位于所属任务队列的队尾(LIFO)。

ForkJoinPool与传统线程池最显著的区别就是它维护了一个工作队列数组(volatile WorkQueue[] workQueues,ForkJoinPool中的每个工作线程都维护着一个工作队列)。

runState

ForkJoinPool的运行状态。SHUTDOWN状态用负数表示,其他用2的幂次表示。

18.4 Fork/Join的使用

上面我们说ForkJoinPool负责管理线程和任务,ForkJoinTask实现fork和join操作,所以要使用Fork/Join框架就离不开这两个类了,只是在实际开发中我们常用ForkJoinTask的子类RecursiveTask 和RecursiveAction来替代ForkJoinTask。

下面我们用一个计算斐波那契数列第n项的例子来看一下Fork/Join的使用:

斐波那契数列数列是一个线性递推数列,从第三项开始,每一项的值都等于前两项之和:

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89······

如果设f(n)为该数列的第n项(n∈N*),那么有:f(n) = f(n-1) + f(n-2)。

 
  1. public class FibonacciTest {
  2.  
  3. class Fibonacci extends RecursiveTask<Integer> {
  4.  
  5. int n;
  6.  
  7. public Fibonacci(int n) {
  8. this.n = n;
  9. }
  10.  
  11. // 主要的实现逻辑都在compute()里
  12. @Override
  13. protected Integer compute() {
  14. // 这里先假设 n >= 0
  15. if (n <= 1) {
  16. return n;
  17. } else {
  18. // f(n-1)
  19. Fibonacci f1 = new Fibonacci(n - 1);
  20. f1.fork();
  21. // f(n-2)
  22. Fibonacci f2 = new Fibonacci(n - 2);
  23. f2.fork();
  24. // f(n) = f(n-1) + f(n-2)
  25. return f1.join() + f2.join();
  26. }
  27. }
  28. }
  29.  
  30. @Test
  31. public void testFib() throws ExecutionException, InterruptedException {
  32. ForkJoinPool forkJoinPool = new ForkJoinPool();
  33. System.out.println("CPU核数:" + Runtime.getRuntime().availableProcessors());
  34. long start = System.currentTimeMillis();
  35. Fibonacci fibonacci = new Fibonacci(40);
  36. Future<Integer> future = forkJoinPool.submit(fibonacci);
  37. System.out.println(future.get());
  38. long end = System.currentTimeMillis();
  39. System.out.println(String.format("耗时:%d millis", end - start));
  40. }
  41.  
  42.  
  43. }

上面例子在本机的输出:

 
  1. CPU核数:4
  2. 计算结果:102334155
  3. 耗时:9490 millis

需要注意的是,上述计算时间复杂度为O(2^n),随着n的增长计算效率会越来越低,这也是上面的例子中n不敢取太大的原因。

此外,也并不是所有的任务都适合Fork/Join框架,比如上面的例子任务划分过于细小反而体现不出效率,下面我们试试用普通的递归来求f(n)的值,看看是不是要比使用Fork/Join快:

 
  1. // 普通递归,复杂度为O(2^n)
  2. public int plainRecursion(int n) {
  3. if (n == 1 || n == 2) {
  4. return 1;
  5. } else {
  6. return plainRecursion(n -1) + plainRecursion(n - 2);
  7. }
  8. }
  9.  
  10. @Test
  11. public void testPlain() {
  12. long start = System.currentTimeMillis();
  13. int result = plainRecursion(40);
  14. long end = System.currentTimeMillis();
  15. System.out.println("计算结果:" + result);
  16. System.out.println(String.format("耗时:%d millis", end -start));
  17. }

普通递归的例子输出:

 
  1. 计算结果:102334155
  2. 耗时:436 millis

通过输出可以很明显的看出来,使用普通递归的效率都要比使用Fork/Join框架要高很多。

这里我们再用另一种思路来计算:

 
  1. // 通过循环来计算,复杂度为O(n)
  2. private int computeFibonacci(int n) {
  3. // 假设n >= 0
  4. if (n <= 1) {
  5. return n;
  6. } else {
  7. int first = 1;
  8. int second = 1;
  9. int third = 0;
  10. for (int i = 3; i <= n; i ++) {
  11. // 第三个数是前两个数之和
  12. third = first + second;
  13. // 前两个数右移
  14. first = second;
  15. second = third;
  16. }
  17. return third;
  18. }
  19. }
  20.  
  21. @Test
  22. public void testComputeFibonacci() {
  23. long start = System.currentTimeMillis();
  24. int result = computeFibonacci(40);
  25. long end = System.currentTimeMillis();
  26. System.out.println("计算结果:" + result);
  27. System.out.println(String.format("耗时:%d millis", end -start));
  28. }

上面例子在笔者所用电脑的输出为:

 
  1. 计算结果:102334155
  2. 耗时:0 millis

这里耗时为0不代表没有耗时,是表明这里计算的耗时几乎可以忽略不计,大家可以在自己的电脑试试,即使是n取大很多量级的数据(注意int溢出的问题)耗时也是很短的,或者可以用System.nanoTime()统计纳秒的时间。

为什么在这里普通的递归或循环效率更快呢?因为Fork/Join是使用多个线程协作来计算的,所以会有线程通信和线程切换的开销。

如果要计算的任务比较简单(比如我们案例中的斐波那契数列),那当然是直接使用单线程会更快一些。但如果要计算的东西比较复杂,计算机又是多核的情况下,就可以充分利用多核CPU来提高计算速度。

另外,Java 8 Stream的并行操作底层就是用到了Fork/Join框架,下一章我们将从源码及案例两方面介绍Java 8 Stream的并行操作。

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值