记得在上个月,微博上有一则热议得新闻:小学数学老师布置做业,要求“数一亿粒米”。算法
网友大多数是以吐槽的态度去看待这件事,也有人指出能用估算的方法,这是一道考察发散思惟的题目。数组
一开始我也以为这个题目很荒唐,彷佛是不可能完成的任务。但这个细细想来值得玩味,我在思考一个问题:若是从计算机的角度去看,如何才能最快速地数一亿粒米呢?bash
首先咱们先将问题简单地抽象一下:并发
做为有煮饭经验的我来讲,米中是存在一些杂质的,因此数米应该不只仅是单纯的数数,其中还有一个判断是米仍是杂质的过程。ide
那么能够将其视做一个长度为L的数组(L大于一亿),这个数组是随机生成的,可是知足数组的每一个元素是一个整型类型的数字(0或1)。约定:元素若是为1,则视做有效的“米”;若是为0,则视做无效的“杂质”。函数
为了更快地完成计算,并行的效率应该是比串行来得高。优化
那么咱们将一我的视做一个工做线程,全家一块儿数米的情景能够视做并发状况。ui
有了以上的铺垫,接下来就是最核心的问题,如何才能最快地数一亿粒米。我不妨假设如下的几种情景:this
今天刚上小学四年级的小季放学回家,妈妈正在作饭,爸爸正在沙发上刷公众号「字节流」。spa
小季说:“妈妈,今天老师布置了一项做业,要数一亿粒米。”
妈妈:“找你爸去。”
爸爸:“?”
因而爸爸一我的开始数米,开启一个循环,遍历整个数组进行计算。
如下是单线程执行的代码。
首先定义一个计算接口:
public interface Counter {
long count(double[] riceArray);
}
复制代码
爸爸循环数米:
public class FatherCounter implements Counter {
@Override
public long count(double[] riceArray) {
long total = 0;
for (double i : riceArray){
if (i == 1)
total += 1;
if (total >= 1e8)
break
}
return total;
}
}
复制代码
主函数:
public static void main(String[] args) {
long length = (long) 1.2e8;
double[] riceArray = createArray(length);
Counter counter = new FatherCounter();
long startTime = System.currentTimeMillis();
long value = counter.count(riceArray);
long endTime = System.currentTimeMillis();
System.out.println("消耗时间(毫秒):" + (endTime - startTime));
}
复制代码
最后的运算结果:
消耗时间(毫秒):190
复制代码
我运行了屡次,最后的消耗时间都在190ms左右。这个单线程循环计算平平无奇,没有什么值得深究的地方。因为大量的计算机资源都在闲置,我猜想,这确定不是最优的解法。
爸爸一我的数了一会,以为本身一我的数米实在是太慢了,家里有这么多人,为何不你们一块儿分摊一点任务呢?每一个人数一部分,最后再合并。
因而小季全家总动员,一块儿来完成做业。
除去三大姑八大姨,如今到场的有爸爸、妈妈、哥哥、姐姐、爷爷、奶奶、外公、外婆八位主要家庭成员(8个CPU的计算机)。
小季说:既然要数1亿粒米,那么就大家每人数12500000粒米,而后再合并一块儿吧!
爸爸说:崽子,别想偷懒,我刚刚数过了,如今换你去,我来给大家分配任务。(主线程)
你们说干就干,各自埋头工做起来。
如下是使用ExecutorService方式的代码:
仍是同一个接口:
public interface Counter {
long count(double[] riceArray);
}
复制代码
建立一个新的实现类:
public class FamilyCounter implements Counter{
private int familyMember;
private ExecutorService pool;
public FamilyCounter() {
this.familyMember = 8;
this.pool = Executors.newFixedThreadPool(this.familyMember);
}
private static class CounterRiceTask implements Callable<Long>{
private double[] riceArray;
private int from;
private int to;
public CounterRiceTask(double[] riceArray, int from, int to) {
this.riceArray = riceArray;
this.from = from;
this.to = to;
}
@Override
public Long call() throws Exception {
long total = 0;
for (int i = from; i<= to; i++){
if (riceArray[i] == 1)
total += 1;
if (total >= 0.125e8)
break;
}
return total;
}
}
@Override
public long count(double[] riceArray) {
long total = 0;
List<Future<Long>> results = new ArrayList<>();
int part = riceArray.length / familyMember;
for (int i = 0; i < familyMember; i++){
results.add(pool.submit(new CounterRiceTask(riceArray, i * part, (i + 1) * part)));
}
for (Future<Long> j : results){
try {
total += j.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException ignore) {
}
}
return total;
}
}
复制代码
主函数依旧是原来的配方:
public static void main(String[] args) {
long length = (long) 1.2e8;
double[] riceArray = createArray(length);
// Counter counter = new FatherCounter();
Counter counter = new FamilyCounter();
long startTime = System.currentTimeMillis();
long total = counter.count(riceArray);
long endTime = System.currentTimeMillis();
System.out.println("消耗时间(毫秒):" + (endTime - startTime));
System.out.println(total);
}
复制代码
最终输出:
消耗时间(毫秒):46
复制代码
我运行了屡次,结果都在46ms左右,说明这个结果具备通常性。那么有一个问题来了,既然一我的数米花费了190ms,那么照理来讲8我的同时工做,最终应该只须要190/8=23ms呀,为何结果是46ms?
由于线程池、线程的建立以及结果的合并计算都是须要消耗时间的(由于个人计算机是8核,因此这里应该不存在线程切换带来的消耗)
假如小季请来更多的亲戚,可以以更快的速度数完一亿粒米吗?我猜不能够,反而会拔苗助长。我将线程池的核心线程数调至16,再次运行,输出结果为:
消耗时间(毫秒):62
复制代码
可见线程以前的切换消耗了必定的资源,因此不少状况下并不是“人多好办事”,人多所带来的团队协调等问题,可能会下降整个团队的工做效率。
到这里,小季已经颇为满意,毕竟计算时间从一开始的190ms,优化到如今的46ms,效率提高了四倍之多。可是爸爸眉头一锁,发现事情并无这么简单,以他常年看公众号「字节流」的经验来看,此事还有蹊跷。
在以前你们埋头数米的过程当中,爸爸做为任务的分配者,也在观察着你们。
他发现,爷爷奶奶因为年纪大了,数米速度彻底比不上眼疾手快的哥哥姐姐。哥哥姐姐完成本身的任务就出去玩了,最后只剩爷爷奶奶还在工做。年轻人竟然不为老人分忧,成何体统!
小季(心里OS):爸爸,好像只有你一直在玩。
因而,爸爸在想能不能有一个算法,当线程池中的某个线程完成本身工做队列中的任务后,并非直接挂起,而是能帮助其余线程。
有了,这不就是work-stealing算法吗?爸爸决定试试ForkJoinPool。
什么是工做窃取算法(work-stealing)呢?当咱们须要完成一个很庞大的任务时(好比这里的数一亿粒米),咱们能够将这个大任务分割为一些互不相关的子任务,为了减小线程间的竞争,将其放在线程的独立工做队列中。当某个线程完成本身工做队列中的任务时,能够从头部窃取其余线程的工做队列中的任务(双端队列,线程自己是从队列尾部获取任务处理,这样进一步避免了线程的竞争)就像下图:
如何划分子任务呢?Fork/Pool采用递归的形式,先将整个数组一分为二,分为left和right,而后对left和right进行相同的操做,直到数组的长度到达一个咱们设定的阈值(这个阈值十分重要,能够影响程序的效率,假设为1000),而后对这个长度的数组进行计算,返回计算结果。上层的任务收到下层任务完成的消息后,开始执行,以此传递,直到任务所有完成。
如下是使用ForkJoinPool方式的代码:
public class TogetherCounter implements Counter {
private int familyMember;
private ForkJoinPool pool;
private static final int THRESHOLD = 3000;
public TogetherCounter() {
this.familyMember = 8;
this.pool = new ForkJoinPool(this.familyMember);
}
private static class CounterRiceTask extends RecursiveTask<Long> {
private double[] riceArray;
private int from;
private int to;
public CounterRiceTask(double[] riceArray, int from, int to) {
this.riceArray = riceArray;
this.from = from;
this.to = to;
}
@Override
protected Long compute() {
long total = 0;
if (to - from <= THRESHOLD){
for(int i = from; i < to; i++){
if (riceArray[1] == 1)
total += 1;
}
return total;
}else {
int mid = (from + to) /2;
CounterRiceTask left = new CounterRiceTask(riceArray, from, mid);
left.fork();
CounterRiceTask right = new CounterRiceTask(riceArray, mid + 1, to);
right.fork();
return left.join() + right.join();
}
}
}
@Override
public long count(double[] riceArray) {
return pool.invoke(new CounterRiceTask(riceArray, 0, riceArray.length - 1));
}
}
复制代码
当我把阈值设置在7000-8000的时候,计算时间缩短到了惊人的15ms,效率又提高了3倍之多!
消耗时间(毫秒):15
复制代码
获得这个结果,爸爸十分满意。此时小季却疑惑了,一样是并行,为何效率相差这么大呢?
爸爸摸着小季的头,说道:这个仍是须要看具体的场景。并非全部状况下,ForkJoinPool都比ExecutorService出色。
ForkJoinPool主要使用了分治法的思想。
它有两个最大的特色:
可以将一个大型任务分割成小任务,并以先进后出的规则(LIFO)来执行,在有些并发中,当任务须要按照必定的顺序来执行时,ForkJoin将发挥其能力。ExecutorService是没法作到的,由于ExecutorService不能决定任务的执行顺序。
ForkJoinPool的偷窃算法,可以在应对任务量不均衡的状况下,或者任务完成存在快慢的状况下,使闲置的线程去帮助正在工做的线程,保证资源的利用率,而且减小线程间的竞争。
爸爸喝了口咖啡,继续说道:在JDK8中,ForkJoinPool添加了一个通用线程池,这个线程池用来处理那些没有被显式提交到任何线程池的任务。这也是为何Arrays.sort()快排速度很是快的缘由,由于引入了自动并行化(Automatic Parallelization)。
小季如有所思:爸爸,我彻底听不懂啊,我仍是只是个四年级的孩子。
爸爸责备道:四年级不早了!人家的孩子一岁就读paper了,哎,不过智力低也怪不了你,毕竟是我生的。有空去关注一下「字节流」这个公众号吧,里面写得比较浅显一些,适合你这种刚入门的。