第06篇 並行流和fork-join框架

爲了讓我們的程序運行的更加高效,CPU的使用效率更高,我們可以通過讓程序並行執行的方式讓所有的CPU都忙碌起來,從而提供程序執行的效率。

有兩種方式來實現並行:java8的fork-join框架、java8中的並行流(底層依然是fork-join框架)。
這裏我們以計算n以內數字的和爲例進行改進,也讓我們能夠很好的看到效果。

首先,我們定義要求和的最大數爲:Long max = 1000000000L;

一、並行流

(一)經典for循環

首先我們使用經典的for循環,串行進行遍歷求和:

 @Test
 public void serial() {
     long sum = 0;
     long start = System.currentTimeMillis();
     for (long i = 0; i <= max; i++) {
         sum += i;
     }
     long end = System.currentTimeMillis();
     System.out.println(String.format("for循環串行計算,sum:%d,總共耗時爲:%d", sum, (end - start)));
 }

效果如下:

for循環串行計算,sum:500000000500000000,總共耗時爲:363

(二)Stream求和

接下來,我們使用stream來遍歷求和,代碼如下:

@Test
public void java8Stream() {
    long start = System.currentTimeMillis();
    Long sum = Stream.iterate(1L, i -> i + 1)
            .limit(max)
            .reduce(0L, Long::sum);
    long end = System.currentTimeMillis();
    System.out.println(String.format("java8流式計算,sum:%d,總共耗時爲:%d", sum, (end - start)));
}

效果如下:
java8流式計算,sum:500000000500000000,總共耗時爲:11660

我們會發現,這種方式比for循環慢了很多,產生的原因主要如下:

  1. Stream本身也是串行的;
  2. 在進行計算的時候,我們使用的Stream流會使用包裝類,在計算的時候要進行拆箱和裝箱過程,會消耗大量的時間。

(三)Stream並行求和

我們可以通過把流轉換成並行流來進行計算

 @Test
 public void java8Parallel() {
     long start = System.currentTimeMillis();
     long sum = Stream.iterate(1L, i -> i + 1)
             .limit(max)
             .parallel() //獲取並行流
             .reduce(0L, Long::sum);
     long end = System.currentTimeMillis();
     System.out.println(String.format("Java8並行流計算,sum:%d,總共耗時爲:%d", sum, (end - start)));
 }

效果:
在這裏插入圖片描述
可以看到,直接發生了內存溢出,產生原因如下:

  1. 和上面一樣,包裝類會對相率產生極大的影響;
  2. fork-join框架底層需要使用Spliterator(後續講解)對迭代器進行切割,進一步出現了問題;

(四)去掉拆裝箱的Stream並行

在使用的時候,我們應當儘量避免包裝類的轉換,所以,我們可以使用LongStream 來獲取數據,這樣的話,就避免了不必要的拆箱和裝箱。其他的場景下,我們也需要注意這一點。

@Test
public void java8ParallelWtihoutPackage() {
    long start = System.currentTimeMillis();
    long sum = LongStream.rangeClosed(0, max)
            .parallel() //獲取並行流
            .sum();
    long end = System.currentTimeMillis();
    System.out.println(String.format("Java8並行流計算,去掉裝箱拆箱,sum:%d,總共耗時爲:%d", sum, (end - start)));
}

效果如下:
Java8並行流計算,去掉裝箱拆箱,sum:500000000500000000,總共耗時爲:252

並行流的獲取

上面演示了串行到並行流的演進過程,接下來,我們給出常用的並行流獲取方式:

  1. 獲取流的使用,調用parallelStream()方法代替之前的stream()方法。如:Collection.parallelStream、Arrays.parallelStream 等待;
  2. 可以把普通的Stream轉換成並行流,這朱啊喲是通過parallel()方法實現;
  3. 相反的,我們也可以把並行流轉換成普通的流,方法爲:sequential

配置並行流使用的線程池

  • 並行流內部使用了默認的ForkJoinPool它默認的 線程數量就是你的處理器數量,這個值是由Runtime.getRuntime().available- Processors()得到的。
  • 可 以 通 過 系 統 屬 性 java.util.concurrent.ForkJoinPool.common. parallelism來改變線程池大小,如下所示:
    System.setProperty(“java.util.concurrent.ForkJoinPool.common.parallelism”,“12”);
  • 這是一個全局設置,因此它將影響代碼中所有的並行流。反過來說,目前還無法專爲某個 並行流指定這個值。一般而言,讓ForkJoinPool的大小等於處理器數量是個不錯的默認值, 除非你有很好的理由,否則我們強烈建議你不要修改它。

並行流原理

並行流的Stream在內部分成了幾塊。因此可以對不同的塊獨立並行進行歸納操作。最後,同一個歸納操作會將各個子流的部分歸納結果合併起來,得到整個原始流的歸納結果
在這裏插入圖片描述

並行流使用原則

  1. 如果有疑問,測量。把順序流轉成並行流輕而易舉,但卻不一定是好事,所以一定要進行測量。
  2. 留意裝箱。自動裝箱和拆箱操作會大大降低性能。Java 8中有原始類型流(IntStream、 LongStream、DoubleStream)來避免這種操作,但凡有可能都應該用這些流。
  3. 有些操作本身在並行流上的性能就比順序流差。特別是limit和findFirst等依賴於元素順序的操作,它們在並行流上執行的代價非常大。例如,findAny會比findFirst性能好,因爲它不一定要按順序來執行。你總是可以調用unordered方法來把有序流變成無序流。那麼,如果你需要流中的n個元素而不是專門要前n個的話,對無序並行流調用 limit可能會比單個有序流(比如數據源是一個List)更高效。
  4. 還要考慮流的操作流水線的總計算成本。設N是要處理的元素的總數,Q是一個元素通過 流水線的大致處理成本,則N*Q就是這個對成本的一個粗略的定性估計。Q值較高就意味 着使用並行流時性能好的可能性比較大
  5. 對於較小的數據量,選擇並行流幾乎從來都不是一個好的決定。並行處理少數幾個元素 的好處還抵不上並行化造成的額外開銷。
  6. 要考慮流背後的數據結構是否易於分解。例如,ArrayList的拆分效率比LinkedList 高得多,因爲前者用不着遍歷就可以平均拆分,而後者則必須遍歷。另外,用range工廠方法創建的原始類型流也可以快速分解。
  7. 流自身的特點,以及流水線中的中間操作修改流的方式,都可能會改變分解過程的性能。例如,一個SIZED流可以分成大小相等的兩部分,這樣每個部分都可以比較高效地並行處理,但篩選操作可能丟棄的元素個數卻無法預測,導致流本身的大小未知。
  8. 還要考慮終端操作中合併步驟的代價是大是小(例如Collector中的combiner方法)。

流的數據源和可分解性

在這裏插入圖片描述
需要注意的是:並行流的底層,依然採用的是fork-join框架。

二、fork-join框架

分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任 務的結果合併起來生成整體結果。它是ExecutorService接口的一個實現,它把子任務分配給 線程池(稱爲ForkJoinPool)中的工作線程。
使用fork-join框架來實現並行的步驟如下:

(一)RecursiveTask

要把任務提交到這個池,必須創建RecursiveTask的一個子類,其中R是並行化任務(以 及所有子任務)產生的結果類型,或者如果任務不返回結果,則是RecursiveAction類型(當 然它可能會更新其他非局部機構)。要定義RecursiveTask,只需實現它唯一的抽象方法 compute: protected abstract R compute();

這個方法同時定義了將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成 單個子任務結果的邏輯。正由於此,這個方法的實現類似於下面的僞代碼:

if (任務足夠小或不可分) { 順序計算該任務
} else {
     將任務分成兩個子任務
     遞歸調用本方法,拆分每個子任務,等待所有子任務完成
     合併每個子任務的結果
}

(二)fork-join過程

在這裏插入圖片描述

(三)使用示例

  1. 定義自己的RecursiveTask
package com.firewolf.java8.s005.parallasync;

import java.util.concurrent.RecursiveTask;

/**
 * Java7中的並行計算
 * 定義一個用於拆分和合並的計算類
 * 這個類需要繼承RecursiveAction(沒有返回值)或者是RecursiveTask(有返回值)
 *
 * @author liuxing
 */
public class ForkCalculater extends RecursiveTask<Long> {
    private static final long serialVersionUID = -6790744108691400188L;
    private long start;
    private long end;

    private long boundary = 10000;


    public ForkCalculater(long start, long end) {
        super();
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        long length = end - start;
        if (length >= boundary) { //進行任務劃分
            long middle = (start + end) / 2;

            ForkCalculater left = new ForkCalculater(start, middle);
            left.fork(); //利用另一個ForkJoinPool線程異步執行新創建的子任務

            ForkCalculater right = new ForkCalculater(middle + 1, end);
            Long rightResult = right.compute(); //同步執行右邊的,這樣可以減少提交到線程池中的任務,當然,調用join也是可以的

            Long leftResult = left.join(); // 同步等在左邊的結果

            return leftResult + rightResult;
        } else {// 不能再劃分的時候,進行計算
            long sum = 0;
            for (long i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        }
    }

}
  1. 使用
@Test
public void fork_join() {
    ForkJoinPool pool = new ForkJoinPool();
    ForkJoinTask<Long> t = new ForkCalculater(0, max);
    long start = System.currentTimeMillis();
    Long sum = pool.invoke(t);
    long end = System.currentTimeMillis();
    System.out.println(String.format("fork-join計算框架,sum:%d,總共耗時爲:%d", sum, (end - start)));
}

效果:
fork-join計算框架,sum:500000000500000000,總共耗時爲:279

我們可以看到,效率也是非常的高
而問題在於,代碼寫起來太過麻煩,主要是RecursiveTask的編寫,比較痛苦

(四)工作原理

fork-join採用了一種“工作竊取”的技術來提供計算的效率,具體如下:
理想情況下,劃分並行任務時, 應該讓每個任務都用完全相同的時間完成,讓所有的CPU內核都同樣繁忙。不幸的是,實際中,每 個子任務所花的時間可能天差地別,要麼是因爲劃分策略效率低,要麼是有不可預知的原因,比如 磁盤訪問慢,或是需要和外部服務協調執行。

分支/合併框架工程用一種稱爲工作竊取(work stealing)的技術來解決這個問題。在實際應 用中,這意味着這些任務差不多被平均分配到ForkJoinPool中的所有線程上。每個線程都爲分 配給它的任務保存一個雙向鏈式隊列,每完成一個任務,就會從隊列頭上取出下一個任務開始執 行。基於前面所述的原因,某個線程可能早早完成了分配給它的所有任務,也就是它的隊列已經 空了,而其他的線程還很忙。這時,這個線程並沒有閒下來,而是隨機選了一個別的線程,從隊 列的尾巴上“偷走”一個任務。這個過程一直繼續下去,直到所有的任務都執行完畢,所有的隊 列都清空。這就是爲什麼要劃成許多小任務而不是少數幾個大任務,這有助於更好地在工作線程 之間平衡負載。

(五)fork-join使用建議

  1. 對一個任務調用join方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,因爲每個子任務都必須等待另一個子任務完成才能啓動。
  2. 不應該在RecursiveTask內部使用ForkJoinPool的invoke方法。相反,你應該始終直接調用compute或fork方法,只有順序代碼才應該用invoke來啓動並行計算。
  3. 對子任務調用fork方法可以把它排進ForkJoinPool。同時對左邊和右邊的子任務調用它似乎很自然,但這樣做的效率要比直接對其中一個調用compute低。這樣做你可以爲其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。
  4. 和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計算快。

三、Spliterator

Spliterator是Java 8中加入的另一個新接口;這個名字代表“可分迭代器”(splitable iterator)。和Iterator一樣,Spliterator也用於遍歷數據源中的元素,但它是爲了並行執行 而設計的
Stream的並行計算,就是依賴了Spliterator來自動的對流進行了拆分。
通常情況下,我們不需要自己實現,當然如果需要實現的話,我們需要去實現Spliterator接口。

(一)Spliterator接口

這個接口定義的幾個方法如下:
boolean tryAdvance(Consumer<? super T> action);:類似於普通的 Iterator,因爲它會按順序一個一個使用Spliterator中的元素,並且如果還有其他元素要遍 歷就返回true
Spliterator<T> trySplit();:專爲Spliterator接口設計的,因爲它可以把一些元素劃出去分 給第二個Spliterator(由該方法返回),讓它們兩個並行處理,需要注意的是, 這裏僅僅返回劃分出來的那一部分。
long estimateSize();:估計還剩下多少元素要遍歷,因爲即使不那麼確切,能快速算出來是一個值 也有助於讓拆分均勻一點
int characteristics();:返回這個Spliterator的特性集合,可選值如下:
在這裏插入圖片描述
如果有多個特點,就加起來

(二)拆分過程

將Stream拆分成多個部分的算法是一個遞歸過程。第一步是對第一個 Spliterator調用trySplit,生成第二個Spliterator。第二步對這兩個Spliterator調用 trysplit,這樣總共就有了四個Spliterator。這個框架不斷對Spliterator調用trySplit 直到它返回null,表明它處理的數據結構不能再分割,如圖所示:
在這裏插入圖片描述

(三)自定義Spliterator示例

這裏以統計字符串中單詞的數量來示例
字符串內容爲:
private final String CONTENTS = "Nel mezzo del cammin di nostra vita i ritrovai in una selva oscura ché la dritta via era smarrita";

爲了演示效果,這裏沒有使用字符串的方法。

1. 普通for循環完成統計

@Test
public void forWordCounter() {
    int counter = 0;
    boolean lastSpace = true;
    for (char c : CONTENTS.toCharArray()) {
        if (Character.isWhitespace(c)) {
            lastSpace = true;
        } else {
            if (lastSpace)
                counter++;
            lastSpace = false;
        }
    }
    System.out.println(counter);
}

結果是19個

2. 使用Stream計算

由於每個字符傳入後,需要返回單詞的數量已經是否是空格,所以需要頂一個對象來實現

package com.firewolf.java8.s005.parallasync;

/**
 * 單詞統計器
 */
public class WordCounter {

    private int counter; //單詞數量
    private boolean isWhitespace; //是否是空格

    public WordCounter(int counter, boolean isWhitespace) {
        this.counter = counter;
        this.isWhitespace = isWhitespace;
    }

    /**
     * 累積函數,對每一個字符進行處理
     * @param c 要被處理的字符
     * @return
     */
    public WordCounter accumulate(Character c) {
        if (Character.isWhitespace(c)) { // 當前傳入的字符爲空
            return new WordCounter(this.counter, true);
        } else { // 當前傳入的字符不爲空,那麼如果上一個字符爲空,數量就要+1了,
            return isWhitespace ? new WordCounter(this.counter + 1, false) : new WordCounter(this.counter, false);
        }
    }

    /**
     * 合併函數,把兩個結果合併成一個結果
     * @param wc 另外一個結果
     * @return 合併後的結果
     */
    public WordCounter combiner(WordCounter wc) {
        return new WordCounter(wc.counter + this.counter, wc.isWhitespace);
    }

    /**
     * 返回當前統計的單詞數量
     * @return 單詞數量
     */
    public int getCounter() {
        return this.counter;
    }

}

這裏面還同時定義了累計函數和合並函數

接下來,進行計算

/**
* 通過流來統計單詞個數
*/
@Test
public void streamWordCounter() {
   Stream<Character> charStream = transStr2CharStream();
   countWords(charStream);
}
   /**
* 通過流統計單詞數量
* @param stream
*/
private void countWords(Stream<Character> stream) {
   WordCounter reduce = stream.reduce(new WordCounter(0, true), WordCounter::accumulate, WordCounter::combiner);
   System.out.println(reduce.getCounter());
}

這個結果也沒什麼問題。

3. 使用並行流求單詞數量

@Test
public void parallStramWordCounter(){
   Stream<Character> charStream = transStr2CharStream();
   countWords(charStream.parallel());
}

/**
* 把字符串轉換成流
*
* @return
*/
private Stream<Character> transStr2CharStream() {
   Stream<Character> charStream = IntStream.range(0, CONTENTS.length()).mapToObj(CONTENTS::charAt);
   return charStream;
}

得到的結果爲30,是錯誤的,原因是底層進行拆分的時候,把單詞給拆開了,爲了解決這個問題,我們需要定義自己的Spliterator

4. 自定義Spliterator

自定義Spliterator如下:

package com.firewolf.java8.s005.parallasync;

import java.util.Spliterator;
import java.util.function.Consumer;

public class WCSpliterator implements Spliterator<Character> {

    private String str; // 要被處理的字符串
    private int curentIndex = 0; // 當前處理的字符的下標

    public WCSpliterator(String str) {
        this.str = str;
    }

    /**
     * 普通的迭代
     *
     * @param action
     * @return
     */
    @Override
    public boolean tryAdvance(Consumer<? super Character> action) {
        action.accept(str.charAt(curentIndex++));
        return curentIndex < str.length();
    }

    /**
     * 拆分出來的迭代器
     *
     * @return
     */
    //注意,返回的是拆分出來的這一部分
    @Override
    public Spliterator<Character> trySplit() {
        int currentLenght = str.length() - curentIndex;

        //長度小於10之後不再拆分,直接順序處理,所以返回null
        if (currentLenght < 10) {
            return null;
        }
        for (int splitPos = currentLenght / 2 + curentIndex; splitPos < str.length(); splitPos++) {
            if (Character.isWhitespace(str.charAt(splitPos))) {
                Spliterator<Character> spliterator = new WCSpliterator(str.substring(curentIndex, splitPos));
                curentIndex = splitPos;
                return spliterator;
            }
        }
        return null;
    }

    //估算剩餘長度
    @Override
    public long estimateSize() {
        return str.length() - curentIndex;
    }

    /**
     * 返回這個Spliterator的特點
     * ORDERED:順序的(也就是String中各個Character的次序)
     * SIZED: estimatedSize方法的返回值是精確的
     * SUBSIZED: trySplit方法創建的其他Spliterator也有確切大小
     * NONNULL: String中不能有爲null的Character
     * IMMUTABLE:在解析String時不能再添加Character,因爲String本身是一個不可變類
     *
     * @return
     */
    @Override
    public int characteristics() {
        return ORDERED + SIZED + SUBSIZED + NONNULL + IMMUTABLE;
    }
}

計算代碼:

@Test
public void parallSteamWCBySelfSpliterater(){
    Spliterator<Character> spliterator = new WCSpliterator(CONTENTS);
    Stream<Character> stream = StreamSupport.stream(spliterator, true);
    countWords(stream.parallel());
}

這次的計算結果,就正確了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章