爲了讓我們的程序運行的更加高效,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循環慢了很多,產生的原因主要如下:
- Stream本身也是串行的;
- 在進行計算的時候,我們使用的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)));
}
效果:
可以看到,直接發生了內存溢出,產生原因如下:
- 和上面一樣,包裝類會對相率產生極大的影響;
- 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
並行流的獲取
上面演示了串行到並行流的演進過程,接下來,我們給出常用的並行流獲取方式:
- 獲取流的使用,調用parallelStream()方法代替之前的stream()方法。如:Collection.parallelStream、Arrays.parallelStream 等待;
- 可以把普通的Stream轉換成並行流,這朱啊喲是通過
parallel()
方法實現; - 相反的,我們也可以把並行流轉換成普通的流,方法爲:
sequential
配置並行流使用的線程池
- 並行流內部使用了默認的ForkJoinPool它默認的 線程數量就是你的處理器數量,這個值是由Runtime.getRuntime().available- Processors()得到的。
- 可 以 通 過 系 統 屬 性 java.util.concurrent.ForkJoinPool.common. parallelism來改變線程池大小,如下所示:
System.setProperty(“java.util.concurrent.ForkJoinPool.common.parallelism”,“12”); - 這是一個全局設置,因此它將影響代碼中所有的並行流。反過來說,目前還無法專爲某個 並行流指定這個值。一般而言,讓ForkJoinPool的大小等於處理器數量是個不錯的默認值, 除非你有很好的理由,否則我們強烈建議你不要修改它。
並行流原理
並行流的Stream在內部分成了幾塊。因此可以對不同的塊獨立並行進行歸納操作。最後,同一個歸納操作會將各個子流的部分歸納結果合併起來,得到整個原始流的歸納結果
並行流使用原則
- 如果有疑問,測量。把順序流轉成並行流輕而易舉,但卻不一定是好事,所以一定要進行測量。
- 留意裝箱。自動裝箱和拆箱操作會大大降低性能。Java 8中有原始類型流(IntStream、 LongStream、DoubleStream)來避免這種操作,但凡有可能都應該用這些流。
- 有些操作本身在並行流上的性能就比順序流差。特別是limit和findFirst等依賴於元素順序的操作,它們在並行流上執行的代價非常大。例如,findAny會比findFirst性能好,因爲它不一定要按順序來執行。你總是可以調用unordered方法來把有序流變成無序流。那麼,如果你需要流中的n個元素而不是專門要前n個的話,對無序並行流調用 limit可能會比單個有序流(比如數據源是一個List)更高效。
- 還要考慮流的操作流水線的總計算成本。設N是要處理的元素的總數,Q是一個元素通過 流水線的大致處理成本,則N*Q就是這個對成本的一個粗略的定性估計。Q值較高就意味 着使用並行流時性能好的可能性比較大
- 對於較小的數據量,選擇並行流幾乎從來都不是一個好的決定。並行處理少數幾個元素 的好處還抵不上並行化造成的額外開銷。
- 要考慮流背後的數據結構是否易於分解。例如,ArrayList的拆分效率比LinkedList 高得多,因爲前者用不着遍歷就可以平均拆分,而後者則必須遍歷。另外,用range工廠方法創建的原始類型流也可以快速分解。
- 流自身的特點,以及流水線中的中間操作修改流的方式,都可能會改變分解過程的性能。例如,一個SIZED流可以分成大小相等的兩部分,這樣每個部分都可以比較高效地並行處理,但篩選操作可能丟棄的元素個數卻無法預測,導致流本身的大小未知。
- 還要考慮終端操作中合併步驟的代價是大是小(例如Collector中的combiner方法)。
流的數據源和可分解性
需要注意的是:並行流的底層,依然採用的是fork-join框架。
二、fork-join框架
分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任 務的結果合併起來生成整體結果。它是ExecutorService接口的一個實現,它把子任務分配給 線程池(稱爲ForkJoinPool)中的工作線程。
使用fork-join框架來實現並行的步驟如下:
(一)RecursiveTask
要把任務提交到這個池,必須創建RecursiveTask的一個子類,其中R是並行化任務(以 及所有子任務)產生的結果類型,或者如果任務不返回結果,則是RecursiveAction類型(當 然它可能會更新其他非局部機構)。要定義RecursiveTask,只需實現它唯一的抽象方法 compute: protected abstract R compute();
這個方法同時定義了將任務拆分成子任務的邏輯,以及無法再拆分或不方便再拆分時,生成 單個子任務結果的邏輯。正由於此,這個方法的實現類似於下面的僞代碼:
if (任務足夠小或不可分) { 順序計算該任務
} else {
將任務分成兩個子任務
遞歸調用本方法,拆分每個子任務,等待所有子任務完成
合併每個子任務的結果
}
(二)fork-join過程
(三)使用示例
- 定義自己的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;
}
}
}
- 使用
@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使用建議
- 對一個任務調用join方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,因爲每個子任務都必須等待另一個子任務完成才能啓動。
- 不應該在RecursiveTask內部使用ForkJoinPool的invoke方法。相反,你應該始終直接調用compute或fork方法,只有順序代碼才應該用invoke來啓動並行計算。
- 對子任務調用fork方法可以把它排進ForkJoinPool。同時對左邊和右邊的子任務調用它似乎很自然,但這樣做的效率要比直接對其中一個調用compute低。這樣做你可以爲其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。
- 和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計算快。
三、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());
}
這次的計算結果,就正確了