目录
1.并行和并发
并发是两个任务共享时间段,并行则是两个任务在同一时间发生,比如运行在多核CPU上。如果一个程序要运行两个任务,并且只有一个CPU给他们分配了不同的时间片,那么这就是并发而不是并行。并发和并行的区别如下:
数据并行化是指将数据分成块,为每块数据分配单独的处理单元。当需要在大量数据上执行同样的操作时,数据并行化很管用,它将问题分解为可在多块数据上求解的形式,然后对每块数据执行运算,最后将各数据块上得到的结果汇总,从而获得最终答案。
2.并行化流操作
并行化操作流只需改变一个方法调用即可实现,如果已经有一个Stream对象,调用它的parallel方法就能让其拥有并行操作的能力。如果想从一个集合类创建一个流调用parallelStream就能立即获得一个拥有并行能力的流。
并行化运行基于流的代码是否比串行化运行更快呢?答案是否定的,并行流带来速度提升受输入流的大小,编写代码的方式和核的数量的影响。
3.模拟掷骰子
如果公平地投掷两次骰子,然后将朝上的一面的点数相加,就会得到一个2~12的数字,重复执行N次,计算点数之和出现的概率。我们可以使用下面的代码来并行化模拟掷骰子:
public Map<Integer, Double> parallelDiceRolls() {
double fraction = 1.0 / N;
return IntStream.range(0, N).parallel().mapToObj(twoDiceThrows()).collect(groupingBy(side -> side, summingDouble(n -> fraction)));
}
private IntFunction<Integer> twoDiceThrows() {
return i -> {
ThreadLocalRandom random = ThreadLocalRandom.current();
int firstThrow = random.nextInt(1, 7);
int secondThrow = random.nextInt(1, 7);
return firstThrow + secondThrow;
};
}
public static void main(String[] args) {
ManualDiceRolls rolls = new ManualDiceRolls();
Map<Integer, Double> result = rolls.parallelDiceRolls();
result.entrySet().forEach(System.out::println);
}
上述代码中,我们使用IntStream的range方法创建大小为N的流,然后调用parallel方法使用流的并行化操作,twoDiceThrow函数模拟了连续两次扔骰子事件,返回值是两次点数之和;使用mapToObject方法在流上使用该函数,然后得到需要合并的所有结果的流,使用groupingBy方法将点数一样的结果合并,summingDouble将数字映射为1/N并进行简单相加,最终得到Map<Integer,Double>是点数之和到它们的概率的映射。这是一个很好的并行化案例,并行化能带来速度的提升。
下面的代码给出了手动实现并行化模拟掷骰子的代码,可以看出,大多数代码都在处理调度和等待线程池中的某项任务完成。而使用并行化流时,这些都不用程序员手动管理。
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
/**
* 公平的掷骰子
*
* @author: xuzongxin
* @date: 2019/4/21 23:07
* @description:
*/
public class ManualDiceRolls {
private static final int N = 1000000000;
private final double fraction;
private final Map<Integer, Double> results;
private final int numberOfThreads;
private final ExecutorService executor;
private final int workPerThread;
public ManualDiceRolls() {
this.fraction = 1.0 / N;
this.results = new ConcurrentHashMap<>();
this.numberOfThreads = Runtime.getRuntime().availableProcessors();
this.executor = Executors.newFixedThreadPool(numberOfThreads);
this.workPerThread = N / numberOfThreads;
}
public void simulateDiceRoles() {
List<Future<?>> futures = submitJobs();
awaitCompletion(futures);
printResults();
}
private void printResults() {
results.entrySet().forEach(System.out::println);
}
private List<Future<?>> submitJobs() {
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < numberOfThreads; i++) {
futures.add(executor.submit(makeJob()));
}
return futures;
}
private Runnable makeJob() {
return () -> {
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int i = 0; i < workPerThread; i++) {
int entry = twoDiceThrows(random);
accumulateResult(entry);
}
};
}
/**
* compute相当于put,用于存放新的值
*
* @param entry
*/
private void accumulateResult(int entry) {
results.compute(entry, (key, previous) -> previous == null ? fraction : previous + fraction);
}
private int twoDiceThrows(ThreadLocalRandom random) {
int firstThrow = random.nextInt(1, 7);
int secondThrow = random.nextInt(1, 7);
return firstThrow + secondThrow;
}
private void awaitCompletion(List<Future<?>> futures) {
futures.forEach(future -> {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
});
executor.shutdown();
}
public static void main(String[] args) {
ManualDiceRolls rolls = new ManualDiceRolls();
rolls.simulateDiceRoles();
}
}
执行的结果为:
2=0.02777869699056155
3=0.05555046997594372
4=0.08331533196132952
5=0.1111279239466902
6=0.13889180693207653
7=0.1666550339174632
8=0.13889050193207722
9=0.11112350594669253
10=0.08334383096131452
11=0.05554903997594447
12=0.0277738569905641
注意:流框架在并行化处理时,要避免持有锁,流框架会在需要的时候,自己处理同步操作,因此程序员没有必要为自己的数据结构加锁。我们可以使用parallel方法将流转换为并行流,也可以使用sequential方法生成串行流,但是在对流求值时,不能同时处于两种模式,要么是并行的,要么是串行的。
4.性能
影响并行流性能的主要因素有5个,依次分析如下:
- 数据大小:输入数据的大小会影响并行化处理对性能的提升,只有处理的数据足够多,每个数据处理花费的时间足够长时,并行化处理才有意义。
- 源数据结构:每个管道的操作都基于一些初始数据源, 通常是集合。 将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。
- 装箱:处理基本类型比处理装箱类型要快。
- 核的数量:极端情况下, 只有一个核, 因此完全没必要并行化。
- 单元处理开销:比如数据大小, 这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长, 并行操作带来的性能提升越明显
在底层,并行流还是使用了fork/join框架,fork递归式地分解问题,然后每段并行执行,最终由join合并结果,返回最后的值。看如下的并行求和操作代码:
private int addIntegers(List<Integer> values) {
return values.parallelStream().mapToInt(i -> i).sum();
}
下图形象地展示了上述代码所示的操作:
假设并行流将我们的工作分解开,在一个四核的机器上并行执行。
- 数据被分成四块。
- 计算工作在每个线程里并行执行。这包括将每个Integer对象映射成为int值,然后在每个线程里面将1/4的数字相加,理想情况下,我们希望在这里花的时间越多越好,因为这里是并行操作的最佳场所。
- 然后合并结果。
根据问题的分解方式,初始的数据源的特性变得尤为重要,它影响了分解的性能。根据性能的好坏,将核心类库提供的通用数据结构分成以下3组:
- 性能好:ArrayList、数组或IntStream.range,这些数据结构支持随机读取,能轻易地被任意分解。
- 性能一般:HashSet、TreeSet,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。
- 性能差:有些数据结构难以分解,可能要花O(N)的时间复杂度来分解问题,其中包括LinkedList,对半分解太难。还有Streams.iterate和BufferedReader.lines,它们长度未知,因此很难预测该在哪里分解。
在讨论流中单独操作每一块的种类时,可以分成两种不同的操作:无状态和有状态的。无状态操作整个过程中不必维护状态,有状态操作则有维护状态所需的开销和限制。如果能避开有状态,选用无状态操作,就能获得更好的并行性能。无状态操作包括map、filter和flatMap,有状态操作包括sorted、distinct和limit。
5.总结
- 数据并行化是把工作拆分,同时在多核CPU上执行的方式。
- 如果使用流编写代码,可以通过parallel或者parallelStream方法实现数据并行化操作。
- 影响性能的五要素是:数据大小、源数据结构、值是否装箱、可用的CPU核数量,以及处理每个元素所花的时间。