Java Stream的并行實現

作者: 一字馬胡
轉載標志 【2017-11-03】

更新日志

日期 更新內容 備注
2017-11-03 添加轉載標志 持續更新

并行與并發

關于并發與并行,需要弄清楚的是,并行關注于多個任務同時進行,而并發則通過調度來不停的切換多個任務執行,而實質上多個任務不是同時執的。并發,英文單詞為:Concurrent。并行的英文單詞為:parallel。如果想對并發和并行有一個比較直觀的認識,可以參考下面這張圖片:

并行與并發

Fork/Join 框架與 Java Stream API

Fork/Join框架屬于并行框架,關于Fork/Join框架的一些內容,可以參考這篇文章:Java Fork/Join并行框架。簡單來說,Fork/Join框架可以將大的任務切分為足夠小的任務,然后將小任務分配給不同的線程來執行,而線程之間通過工作竊取算法來協調資源,提前昨晚任務的線程可以去“竊取”其他還沒有做完任務的線程的任務,而每一個線程都會持有一個雙端隊列,里面存儲著分配給自己的任務,Fork/Join框架在實現上,為了防止線程之間的競爭,線程在消費分配給自己的任務時,是從隊列頭取任務的,而“竊取”線程則從隊列尾部取任務。
Fork/Join框架通過fork方法來分割大任務,通過使用join來獲取小任務的結果,然后組合成大任務的結果。關于Fork/Join任務模型,可以參考下面的圖片:

Fork/Join的任務模型

關于Java Stream API的相關內容,可以參考該文章:Java Streams API。

Stream在實現上使用了Fork/Join框架來實現并發,所以使用Stream我們可以在不知不覺間就使得我們的程序跑得飛快,究其原因就是Stream使用了Fork/Join并發框架來處理任務,當然,你需要顯示的指定Stream為parallel,否則Stream默認都是串行流。比如對于Collection,你可以使用parallelStream來轉換為一個并發流,或者使用stream方法轉換為串行流,然后使用parallel操作使得串行流變為并發流。本文的重點是剖析Stream是如何使用Fork/Join來做并發的。

Stream的并發實現細節

在了解了Fork/Join并發框架和Java Stream之后,首要的問題就是:Stream是如何使用Fork/Join框架來做到并發的?其實對于使用者來說,了解Stream就是通過Fork/Join框架來做的就好了,但是如果想要深入了解一下Fork/Join框架的實踐,以及Java Stream的設計方法,那么去讀一下實現的源碼還是很有必要的,下文中的分析僅代表個人觀點!

需要注意的一點是,Java Stream的操作分為兩類,也可以分為三類,具體的細節可以參考該文章:Java Streams API。一個簡單的判斷一個操作是否是Terminal操作還是Intermediate操作的方法是,如果操作返回的是一個新的Stream,那么就是一個Intermediate操作,否則就是一個Terminal操作。

  • Intermediate:一個流可以后面跟隨零個或多個 intermediate 操作。其目的主要是打開流,做出某種程度的數據操作,然后返回一個新的流,交給下一個操作使用。這類操作都是惰性化的(lazy),就是說,僅僅調用到這類方法,并沒有真正開始流的遍歷。

  • Terminal:一個流只能有一個 terminal 操作,當這個操作執行后,流就被使用“光”了,無法再被操作。所以這必定是流的最后一個操作。Terminal 操作的執行,才會真正開始流的遍歷,并且會生成一個結果,或者一個 side effect。

  • 還有一種操作被稱為 short-circuiting。用以指:

    • 對于一個 intermediate 操作,如果它接受的是一個無限大(infinite/unbounded)的 Stream,但返回一個 有限的新 Stream。
    • 對于一個 terminal 操作,如果它接受的是一個無限大的 Stream,但能在有限的時間計算出結果。

Java Stream對四種類型的Terminal操作使用了Fork/Join實現了并發操作,下面的圖片展示了這四種操作類型:

支持并行的四種Stream操作

我們首先來走一遍Stream操作的執行路徑,下面的代碼是我們想要做的操作流,下文會根據該代碼示例來跟蹤Stream的執行路徑:

        Stream.of(1,2,3,4)
                .parallel()
                .map(n -> n*2)
                .collect(Collectors.toCollection(ArrayList::new));

解釋一下,上面的代碼想要實現的功能是將(1,2,3,4)這四個數字每一個都變為其自身的兩倍,然后收集這些元素到一個ArrayList中返回。這是一個非常簡單的功能,下面是上面的操作流的執行路徑:


    step 1:
    
    public static<T> Stream<T> of(T... values) {
        return Arrays.stream(values);
    }
    
    step 2:
    
        public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
        Objects.requireNonNull(mapper);
        return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                     StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
            @Override
            Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
                return new Sink.ChainedReference<P_OUT, R>(sink) {
                    @Override
                    public void accept(P_OUT u) {
                        downstream.accept(mapper.apply(u));
                    }
                };
            }
        };
    }
    
    step 3:
    
        public final <R, A> R collect(Collector<? super P_OUT, A, R> collector) {
            ...
            container = evaluate(ReduceOps.makeRef(collector));
            ...
    }
    
    step 4:
    
        final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
        assert getOutputShape() == terminalOp.inputShape();
        if (linkedOrConsumed)
            throw new IllegalStateException(MSG_STREAM_LINKED);
        linkedOrConsumed = true;

        return isParallel()
               ? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
               : terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
    }
    
    step 5:
    
    使用Fork/Join框架執行操作。
    

上面的五個步驟是經過一些省略的,需要注意的一點是,intermediate類型的操作僅僅將操作加到一個upstream里面,具體的原文描述如下:


Construct a new Stream by appending a stateless intermediate operation to an existing stream.

比如上面我們的操作中的map操作,實際上只是將操作加到一個intermediate鏈條上面,不會立刻執行。重點是第五步,Stream是如何使用Fork/Join來實現并發的。evaluate這個方法至關重要,在方法里面會分開處理,對于設置了并發標志的操作流,會使用Fork/Join來并發執行操作任務,而對于沒有打開并發標志的操作流,則串行執行操作。

Fork/Join框架的核心方法是一個叫做compute的方法,下面分析一個forEach操作如何通過Fork/Join框架來實現并發,通過追蹤代碼,可以發現forEach的并發版本其實是一個交由一個ForEachTask對象來做,而ForEachTask類中實現了compute方法:

// Similar to AbstractTask but doesn't need to track child tasks
        public void compute() {
            Spliterator<S> rightSplit = spliterator, leftSplit;
            long sizeEstimate = rightSplit.estimateSize(), sizeThreshold;
            if ((sizeThreshold = targetSize) == 0L)
                targetSize = sizeThreshold = AbstractTask.suggestTargetSize(sizeEstimate);
            boolean isShortCircuit = StreamOpFlag.SHORT_CIRCUIT.isKnown(helper.getStreamAndOpFlags());
            boolean forkRight = false;
            Sink<S> taskSink = sink;
            ForEachTask<S, T> task = this;
            while (!isShortCircuit || !taskSink.cancellationRequested()) {
                if (sizeEstimate <= sizeThreshold ||
                    (leftSplit = rightSplit.trySplit()) == null) {
                    task.helper.copyInto(taskSink, rightSplit);
                    break;
                }
                ForEachTask<S, T> leftTask = new ForEachTask<>(task, leftSplit);
                task.addToPendingCount(1);
                ForEachTask<S, T> taskToFork;
                if (forkRight) {
                    forkRight = false;
                    rightSplit = leftSplit;
                    taskToFork = task;
                    task = leftTask;
                }
                else {
                    forkRight = true;
                    taskToFork = leftTask;
                }
                taskToFork.fork();
                sizeEstimate = rightSplit.estimateSize();
            }
            task.spliterator = null;
            task.propagateCompletion();
        }
    }

在上面的代碼中將大任務拆成成了小任務,那哪里收集了這些小任務呢?看下面的代碼:

        @Override
        public <S> Void evaluateParallel(PipelineHelper<T> helper,
                                         Spliterator<S> spliterator) {
            if (ordered)
                new ForEachOrderedTask<>(helper, spliterator, this).invoke();
            else
                new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
            return null;
        }

可以看到調用了invoke方法,而對invoke的描述如下:

     * Commences performing this task, awaits its completion if
     * necessary, and returns its result, or throws an (unchecked)
     * {@code RuntimeException} or {@code Error} if the underlying
     * computation did so.

不是說Fork/Join框架嘛?那有了fork為什么沒有join而是invoke呢?下面是對join方法的描述:


     * Returns the result of the computation when it {@link #isDone is
     * done}.  This method differs from {@link #get()} in that
     * abnormal completion results in {@code RuntimeException} or
     * {@code Error}, not {@code ExecutionException}, and that
     * interrupts of the calling thread do <em>not</em> cause the
     * method to abruptly return by throwing {@code
     * InterruptedException}.
     

根據join的描述,我們知道還可以使用get方法來獲取結果,但是get方法會拋出異常而join和invoke方法都不會拋出異常,而是將異常報告給ForkJoinTask,讓ForkJoinTask來拋出異常。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,763評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,238評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,823評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,604評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,339評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,713評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,712評論 3 445
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,893評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,448評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,201評論 3 357
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,397評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,944評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,631評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,033評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,321評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,128評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,347評論 2 377

推薦閱讀更多精彩內容

  • 轉自: Java 8 中的 Streams API 詳解 為什么需要 Stream Stream 作為 Java ...
    普度眾生的面癱青年閱讀 2,928評論 0 11
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,818評論 18 139
  • 為什么需要 Stream Stream 作為 Java 8 的一大亮點,它與 java.io 包里的 InputS...
    鐵鋼0閱讀 143評論 0 0
  • 本文采用實例驅動的方式,對JAVA8的stream API進行一個深入的介紹。雖然JAVA8中的stream AP...
    浮梁翁閱讀 25,843評論 3 50
  • 伴隨著一場淋漓的大雨,呼吸著新鮮的空氣,開始了小歐的第一天的正課。最開始是入學儀式,陽光挺拔的男生、端莊美...
    王延旭閱讀 560評論 0 2