Java并行


前言

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)可以用基本类流就用(IntStreamLongStreamDoubleStream
(3)有些操作在顺序流上性能更好,如limitfindFirst等方法依赖于顺序,而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定义了并行流如何拆分要遍历的数据

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值