脑图概览
CPU 密集型任务 和 IO 密集型任务
在聊 Future 以及 Fork/Join 之前我想先聊聊我们所运行的任务。 一般性来说,我们的所运行的任务在 CPU 级别 被分为两类:CPU 密集型 和 IO 密集型。
CPU 密集型
CPU 密集型任务 也叫做 计算密集型任务 。意思为 硬盘及内存性能要比 CPU 好很多。大多数时间是 CPU Loading 100% ,CPU 要读写硬盘内存时所花费的时间很短,但计算时间很长。
在我们的程序系统中,绝大多数时间耗费在 计算、逻辑判断等 CPU 动作上的程序我们称之为 CPU bound。例如计算圆周率千位万位, 视频高清解码,或者可以简单理解为以算法为核心的程序,该程序中涉及大量的复杂算法操作。
CPU bound 程序一般 CPU 占用率很高,而程序 I/O 几乎不怎么耗时。
对于 CPU 密集型 的程序来说线程数一般推荐设置为:
线程数 = CPU 核心数 + 1 (超线程)
为什么要这么做?
CPU 密集型任务主要消耗 CPU 资源,主要靠 CPU 的运算能力。
虽然计算密集型任务也可通过多任务来完成,但创建过多的线程会导致 频繁的线程切换
那我们都知道对于 Java 这种使用内核线程模型在线程切换时需要在 内核态和用户态来回切换 其花费的时间就越高,CPU 计算效率就越低,所以为了避免线程切换带来的不必要的时间损耗,对于 CPU 密集型任务来说 线程数 = CPU 核数 。
为什么要 + 1 呢 ? 一是 : 当下很多 CPU 支持超线程技术。 二是 : 可能某个线程出于某种原因阻塞或者挂掉可以进行补位。 当然这个多出来的线程可能带来的线程切换也需要你在实际应用中进行衡量。
记住技术是死的,思维是活的,活学活用,没有最好的解决方案,只有最适合的解决方案
IO 密集型
相信大家看了上面的 CPU 密集型 基本可以推测出 IO 密集型的意思了。
那么 IO 密集型的意思是 系统 CPU 的性能相对于 硬盘及内存的数据读写来说要好很多,大部分的耗时集中在 IO 数据的读取上,我们称之为 IO bound。常见的我们的 Web 应用网络的请求,文件的读取,数据库的 CRUD 等操作 都是 IO 密集型的。
这类程序的特点是一般性能达到极限时 CPU Loading 也不是很高。这可能是任务本身存在大量的 IO 操作,而业务逻辑上的 pipeline (这里 pipeline 可以先简单理解为处理流程) 做的不太好,没有充分利用处理器的能力。
对于 IO 密集型 的程序来说 这里给出一个经验公式:
线程数 = ( 1 + 线程等待时间 / 线程 CPU 时间 )* CPU 核数 * CPU 使用率
或者简单粗暴的方式。 我们假设 线程等待时间和计算时间比值为 1 且 CPU 利用率为 100% 则有以下公式:
线程数 = 2 * CPU 核数
为什么要这么做:
IO 密集型任务需要我们充分利用 CPU 所以我们需要更多的线程来使用我们的 CPU 去处理更多的任务
如何理解 线程等待时间/线程 CPU 使用时间 这个计算。其意味着 我们线程等待时间越长 我们 CPU 空闲的时间越长 所以我们需要更多的线程来利用 CPU 空闲的资源。而 线程在 CPU 计算上花费的时间越长, 那么 CPU 空闲的时间就少一些 ,我们就不需要更多的线程来占用资源了,反而可能弄巧成拙增加了线程切换的开销
那么在实际开发中是 2 * CPU 核数 ,还是需要精细估算,就要评估你的业务需求了。
Fork Join 框架 (CPU 密集型任务处理)
有了前面的铺垫,我们来聊聊 Fork Join 框架(Java 1.7 开始提供),Fork Join 框架及 ForkJoinPool 如标题,是适合于 CPU 密集型任务处理的。其核心思想就是分治,分而治之。
Fork :翻译为 分叉、分支。可以理解为将大任务切分为小任务。 Join :翻译为 加入、合并。可以理解为将小任务合并。
题外话:这玩意感觉和 map(映射) reduce(规约) 思想上有异曲同工之妙。 因为本篇重点不在于 MapReduce 就不深聊了,哪天咱独立一篇细说。 嘿嘿。
举个简单的例子,比如我想把 10 万个数相加,那么我可以考虑拆分出 10 个子任务,每个任务加 1 万个数字最后汇总,这样我的计算效率一下就提升了 10 倍。
不得不说的 Future 接口 及其实现 FutureTask 类
我们来看一下 AbstractExecutorService 的 submit() 方法(也就是我们常用的线程池 submit 方法)他们都是有返回值的,Future 。
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Runnable task, T result) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task, result);
execute(ftask);
return ftask;
}
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
RunnableFuture<T> ftask = newTaskFor(task);
execute(ftask);
return ftask;
}
不论你传入的是 Runnable 还是 Callable 都会通过 newTaskFor() 方法 封装为 RunnableFuture 返回。 带你再看一眼 newTaskFor() 方法
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new FutureTask<T>(runnable, value);
}
protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
return new FutureTask<T>(callable);
}
最终会 new FutureTask 类,那么实际上返回的是 FutureTask。
一个可行的臃肿任务优化思路,异步阻塞:
1 将大任务中可以并行处理的任务抽离出来
2 通过 Callable 获取返回值提交给线程池处理 3 FutureTask 接收任务 4 在主线程的一个合适的时间通过 FutureTask 的 get() 方法阻塞式的获取结果。 5 汇总你的结果
Talk is cheap Show me the Code 不是伪代码的伪代码:
@Test
public void testFutureTask() throws ExecutionException, InterruptedException, TimeoutException {
//获取当前可用 CPU 核数
int cpuNum = Runtime.getRuntime().availableProcessors();
System.out.println("当前系统可用 CPU 核数为:"+cpuNum);
//创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(cpuNum);
//创建任务,拆分 30 万 自加任务 为 3 个
FutureTask<Integer> futureTask1 = new FutureTask<>(() -> {
System.out.println(Thread.currentThread().getName());
int sum=0;
for (int i=0;i<=100;i++){
Thread.sleep(1);
sum+=i;
}
return sum;
});
FutureTask<Integer> futureTask2 = new FutureTask<>(() -> {
System.out.println(Thread.currentThread().getName());
int sum=0;
for (int i=101;i<=200;i++){
Thread.sleep(1);
sum+=i;
}
return sum;
});
FutureTask<Integer> futureTask3 = new FutureTask<>(() -> {
System.out.println(Thread.currentThread().getName());
int sum=0;
for (int i=201;i<=300;i++){
Thread.sleep(1);
sum+=i;
}
return sum;
});
//任务提交给线程池中线程并行执行
executorService.submit(futureTask1);
executorService.submit(futureTask2);
executorService.submit(futureTask3);
//这个时候主线程可以为所欲为做其他事情
//1. 抽会儿烟
//2. 喝个酒
//3. 顺便烫个头
//4. 诶我还能睡会儿 Thread.sleep();
//活都干完了我看看任务有没有完成,完成直接获取,没完成阻塞等待
// 主线程阻塞获取结果,这里注意,一般获取是要设置超时时间的,避免卡死
int result1 = futureTask1.get(3, TimeUnit.SECONDS);
int result2 = futureTask2.get(3, TimeUnit.SECONDS);
int result3 = futureTask3.get(3, TimeUnit.SECONDS);
//45150
System.out.println("正确答案为:45150 计算结果为:"+(result1+result2+result3));
单线程比对方法:
@Test
public void sum30w() throws InterruptedException {
long sum=0;
for (int i=0;i<=300;i++){
Thread.sleep(1);
sum+=i;
}
System.out.println("正确答案为:45150 计算结果为:"+sum);
}
从运行结果来看 多线程使用 FutureTask 可以使我整个任务可以在 300 ms 内完成 单线程却远超 300 ms 由此 分而知之 + 并行优化 重要性可想而知
这里要注意一点 在考虑优化你代码的时候 如果你代码本身的逻辑就很简单,运行速度已经很快了就没有必要分治了,因为本身来说使用线程在线程创建及汇总的过程中性能一定是有所消耗的,可能最后你分治多线程优化后还跑的更慢了,这也是我们常说的避免过度优化
先认识一下 ForkJoinPool
- ForkJoinPool 是对 ExecuterService 的补充
- ForkJoinPool 主要用于实现“分而治之”算法。
- ForkJoinPool 合适于计算密集型任务。
结语
OK 我康了康文章长度,已经够长了。所谓贪多嚼不烂,我怕写多了大家都看不下去了(其实就是懒了,不想写了哈哈),所以关于 ForkJoinPool 用法以及 FutureTask 源码级别的逻辑剖析我们放到后文再说。
喜欢本文的同学,不妨点个赞,加个收藏,加个关注,如果能聊聊你的见解就更好了。
愿灯火阑珊处我们可以在相遇 ,我是妄语,我是搁浅,我们下篇文章再见~
公众号中会提供音频版本!!! 欢迎关注 :