文章目录
前言
Java并行处理,参考《Java实战》第7章,筛选了我想记的内容
一、并行流
1.parallel方法
对于顺序流调用parallel()
方法,可以将流转换成并行流
public long parallelSum(long n) {
return Stream.iterate(1L, i -> i+1) // 迭代
.limit(n)
.parallel() // 并行流
.reduce(0L, Long::sum) // 归约
并行流的内部使用了默认的ForkJoinPool线程池
当然,我们也可以用sequential()
把并行流转化成顺序流
stream.parallel()
.filter(...)
.sequential()
.map(...)
.parallel()
.reduce()
2.正确使用并行流
错误使用并行流而产生错误的首要原因,就是使用算法改变了某些共享状态。
当改变多个线程共享的对象的可变状态时,会得到错误的结果。
使用并行流应该注意什么?
(1)有时候并行流并不比顺序流快
(2)可以用基本类流就用(IntStream
,LongStream
,DoubleStream
)
(3)有些操作在顺序流上性能更好,如limit
,findFirst
等方法依赖于顺序,而findAny
不用
(4)考虑成本,如果一个元素通过流水线的成本较高,使用并行流更好的可能性越大
(5)对于数据较少的情况可以不考虑并行流
(6)考虑流背后的数据结构是否易于分解,例如ArrayList
的拆分效率比LinkedList
高很多,因为前者不用遍历就可平均拆分
(7)考虑流水线中间操作修改流的操作,可能会改变性能
(8)考虑终端操作中合并步骤的代价是大是小
数据源 | 可分解性 |
---|---|
ArrayList | 极佳 |
LinkedList | 差 |
IntStream.range | 极佳 |
Stream.iterate | 差 |
HashSet | 好 |
TreeSet | 好 |
二、分支/合并框架
分支/合并框架是以递归的方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(ForkJoinPool)中的工作线程。
1.使用RecursiveTask
// 源码的一部分
public abstract class RecursiveTask<V> extends ForkJoinTask<V> {
V result;
protected abstract V compute();
}
V:结果的类型,如果不返回结果,就使用RecursiveAction
compute
:要定义RecursiveTask
,需要实现其抽象方法compute
//compute的伪代码
if (任务足够小或不可分) {
顺序计算该任务
}else {
将该任务分成两个子任务
递归调用本方法,拆分每个子任务,等待所有子任务完成
合并每个子任务的结果
}
这就是分支算法的并行版本而已
也就先分支,再合并,最后得到结果
示例:
// 使用分支/合并框架计算和
public class ForkJoinSumCalculator extends RecursiveTask<Long> {
private final long[] numbers;
private final int start;
private final int end;
private final long THRESHOLD = 10_000; // 任务分解为子任务的阈值大小
public ForkJoinSumCalculator(long[] numbers){ // 公共构造函数用于创建主任务
this(numbers, 0, numbers.length);
}
private ForkJoinSumCalculator(long[] numbers, int start, int end){ // 私有构造函数用于递归为主任务创建子任务
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute(){
int length = end - start;
if (length <= THRESHOLD){
return computeSequentially(); // 如果子任务分至最小,执行sum计算
}else {
ForkJoinSumCalculator leftTask = // 前半部分的子任务
new ForkJoinSumCalculator(numbers, start, start + length / 2);
ForkJoinSumCalculator rightTask = //后半部分的子任务
new ForkJoinSumCalculator(numbers, start + length / 2, end);
leftTask.fork(); // 调用线程线程执行
rightTask.fork();
Long rightResult = rightTask.join(); // 获取子任务结果
Long leftResult = leftTask.join();
return leftResult + rightResult; // 合并结果
}
}
private long computeSequentially(){
long sum = 0;
for(int i = start;i < end;i++){
sum += numbers[i];
}
return sum;
}
}
使用该方法:
public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1,n).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
return new ForkJoinPool().invoke(task); // 获取结果
}
2.正确使用分支/合并框架
(1)对一个任务调用join()
会阻塞调用方,所以应在两个任务都执行后再调用join()
(2)不应该在RecursiveTask
内部使用ForkJoinPook.invoke()
,你应该使用fork()
或compute()
,只有顺序代码才可用invoke来启动并行计算
(3)fork()
将子任务排进ForkJoinPook
,但这样效率比直接调用compute()
低,不过这样可以为为其中一个子任务重用同一线程,避免在线程池中多分配一个任务造成的开销
(4)调试分支并行框架比较困难,不能通过debug追踪
(5)和并行流一样,在多核处理器上使用分支/合并框架并不一定比顺序计算快,因为分支合并的过程也有时间开销
3.工作窃取
可以了解一下
将子任务划分地越小越好是一种错觉,因为每个子任务所花的时间并不相同,划分策略也会影响效率。
分支/合并框架用一种叫工作窃取的方法解决这个问题,每个线程都为分配给它的任务保存一个双项链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执行。
例如某个线程早早地完成了任务,它的队列已经空了,这时它随机选了一个其他的线程,从该线程队列的尾巴上“偷走”一个任务,直到所有线程所有任务结束。
通过这种方法,将任务划分小,有助于在线程间平衡负载。
三.Spliterator
当我们使用并行流时,会对流进行拆分,Java8提供了一个新接口Spliterator
,名字叫“可分迭代器”,来进行自动拆分
public interface Spliterator<T> {
boolean tryAdvance(Consumer<? super T> action);
Spliterator<TG> trySplit();
long estimateSize();
int characteristics();
// 还有其他方法,可以去看源码
}
tryAdvance
: 顺序使用元素,如果还有其他元素要遍历就返回True
trySplit
:把一些元素划分出去给第二个Spliterator,让它们并行处理
estimateSize
:估计还剩下多少元素要遍历
characteristics
:声明Spliterator的特性
特性 | 含义 |
---|---|
ORDERED | 元素的顺序(如List),在遍历划粉时会遵循这一顺序 |
DISTINCT | 对于遍历过的x和y,x.equals(y)返回false |
SORTED | 遍历过的元素按照一个预定义的顺序排序 |
SIZED | 如果由一个已知大小的数据源建立,estimatedSize()返回的是准确值 |
NON-NULL | 遍历的元素不会为null |
IMMUTABLE | 数据源不能修改,在遍历时不能进行增删改操作 |
CONCURRENT | 数据源可被其他线程同时修改而无须同步 |
SUBSIZED | 该迭代器和它拆分出来的迭代器都是SIZED |
1.拆分过程
当trySplit
返回null时,表示数据结构不能再拆分了
2.实现自己的Spliterator
使用parallel时,流是进行自动随机拆分的,而我们可以通过改写Spliterator自己定义怎么拆分
实现一个迭代统计String中单词数的方法:
public int countWords(String s){
int count = 0;
boolean lastSpace = true;
for(char c : s.toCharArray()){
if (Character.isWhitespace(c)){ // 如果是空格
lastSpace = true;
}else{
if(lastSpace) count++; // 上一个字符是空格,而当前不是,单词计数+1
lastSpace = false;
}
}
return count;
}
用函数式风格重写
// 将String转化为一个字符流
Stream<Character> charStream = IntStream.range(0, SENTENCE.length())
.mapToObj(SENTENCE::charAt);
然后用归约的方法计算结果
cahrStream.reduce(........);
在计算时,会有两个变量:counter和lastSpace,为了保存这两变量的状态,使用类来封装
书上是这么写的:
class WordCounter {
private final int counter;
private final boolean lastSpace;
public WordCounter(int counter, boolean lastSpace){this.counter = counter;this.lastSpace = lastspace;}
// 计数
public WordCounter accumulate(Character c) {
if (Character.isWhitespace(c)){
return lastSpace ?
this :
new WordCounter(counter, true);
}else{
return lastSpace?
new WordCounter(counter + 1, false):
this;
}
}
// 合并两个WordCounter,只要合并counter
public WordCounter combine(WordCounter wordCounter) {
return new WordCounter(counter + wordCounter.counter, wordCounter.lastSpcae);
}
// 获取结果
public int getCounter() {
return counter ;
}
}
使用:
public static int countWords(Stream<Character> stream){
WordCounter wordCounter = stream.reduce(new WordCounter(0, true),
WordCounter::accumulate,
WordCounter::combine);
return wordCounter.getCounter();
}
接下来,让它并行工作:
countWords(charStream.parallel());
结果是错误的,因为这里对字符串的字符流进行了并行读取,结果在字符串任意位置拆分,有时一个词被分为了两个词,对结果造成了影响
所以使用Spliterator来规定字符串怎么划分:
public class WordCounterSpliterator implements Spliterator<Character> {
private final String string;
private int currentChar = 0;
public WordCounterSpliterator(String string) {
this.string = string;
}
@Override
public boolean tryAdvance(Consumer<? super Character> action){
action.accept(string.charAt(currentChar++));
return currentChar < string.length();
}
@Override
public Spliterator<Character> trySplit(){
int currentSize = string.length() - currentChar;
if(currentSize < 10) {
return null;
}
for(int splitPos = currentSize/2 + currentChar;splitPos < string.length();splitPos++){
if (Character.isWhitespace(string.charAt(splitPos))){
Spliterator<Character> spliterator = new WordCounterSpliterator(string.substring(currentChar, splitPos));
currentChar = splitPos;
return spliterator;
}
}
return null;
}
@Override
public long estimateSize(){
return string.length() - currentChar;
}
@Override
public int characteristics(){
return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;
}
}
然后使用:
countWords(StreamSupport.stream(new WordCounterSpliterator(s), true));
小结
(1)并行不一定更好,要看元素数量和数据结构
(2)分支/合并矿建
(3)Spliterator定义了并行流如何拆分要遍历的数据