延遲執行與不可變,系統講解JavaStream數據處理

本文爲掘金社區首發簽約文章,未獲受權禁止轉載。html

最近在公司寫業務的時候,突然想不起來Stream中的累加應該怎麼寫?java

無奈只能面向谷歌編程,花費了我寶貴的三分鐘以後,學會了,很簡單。web

自從我用上JDK8之後,Stream就是我最經常使用的特性,各類流式操做用的飛起,然而此次事之後我突然以爲Stream對我真的很陌生。面試

可能你們都同樣,對最經常使用到的東西,也最容易將其忽略,哪怕你要準備面試估計也確定想不起來要看一下Stream這種東西。編程

不過我既然注意到了,就要從新梳理一遍它,也算是對個人總體知識體系的查漏補缺。數組

花了不少功夫來寫這篇Stream,但願你們和我一塊從新認識並學習一下Stream,瞭解API也好,瞭解內部特性也罷,怕什麼真理無窮,進一步有進一步的歡喜。markdown

在本文中我將Stream的內容分爲如下幾個部分:框架

初看這個導圖你們可能對轉換流操做和終結流操做這兩個名詞有點蒙,其實這是我將Stream中的全部API分紅兩類,每一類起了一個對應的名字(參考自Java8相關書籍,見文末):編程語言

  • 轉換流操做 :例如filter和map方法,將一個Stream轉換成另外一個Stream,返回值都是Stream。svg

  • 終結流操做 :例如count和collect方法,將一個Stream彙總爲咱們須要的結果,返回值都不是Stream。

其中轉換流操做的API我也分了兩類,文中會有詳細例子說明,這裏先看一下定義,有一個大概印象:

  1. 無狀態 :即此方法的執行無需依賴前面方法執行的結果集。

  2. 有狀態 :即此方法的執行須要依賴前面方法執行的結果集。

因爲Stream內容過多,因此我將Stream拆成了上下兩篇,本篇是第一篇,內容翔實,用例簡單且豐富。

第二篇的主題雖然只有一個終結操做,可是終結操做API比較複雜,因此內容也翔實,用例也簡單且豐富,從篇幅上來看二者差很少,敬請期待。


:因爲我本機的電腦是JDK11,並且寫的時候忘了切換到JDK8,因此在用例中大量出現的List.of()在JDK8是沒有的,它等同於JDK8中的Arrays.asList()

:寫做過程當中翻讀了大量Stream源碼和Java8書籍(文末),創做不易,點贊過百,立刻出第二篇。

1. 爲何要使用Stream?

一切還要源於JDK8的發佈,在那個函數式編程語言如火如荼的時代,Java因爲它的臃腫而飽受詬病(強面向對象),社區迫切須要Java能加入函數式語言特色改善這種狀況,終於在2014年Java發佈了JDK8。

在JDK8中,我認爲最大的新特性就是加入了函數式接口和lambda表達式,這兩個特性取自函數式編程。

這兩個特色的加入使Java變得更加簡單與優雅,用函數式對抗函數式,鞏固Java老大哥的地位,簡直是師夷長技以制夷。

而Stream,就是JDK8又依託於上面的兩個特性爲集合類庫作的 一個類庫,它能讓咱們經過lambda表達式更簡明扼要的以流水線的方式去處理集合內的數據,能夠很輕鬆的完成諸如:過濾、分組、收集、歸約這類操做,因此我願將Stream稱爲函數式接口的最佳實踐。

1.1 更清晰的代碼結構

Stream擁有更清晰的代碼結構,爲了更好的講解Stream怎麼就讓代碼變清晰了,這裏假設咱們有一個很是簡單的需求:在一個集合中找到全部大於2的元素

先來看看沒使用Stream以前:

List<Integer> list = List.of(1, 2, 3);
        
        List<Integer> filterList = new ArrayList<>();
        
        for (Integer i : list) {
            if (i > 2) {
                filterList.add(i);
            }
        }
        
        System.out.println(filterList);
複製代碼

上面的代碼很好理解,我就不過多解釋了,其實也還好了,由於咱們的需求比較簡單,若是需求再多點呢?

每多一個要求,那麼if裏面就又要加一個條件了,而咱們開發中每每對象上都有不少字段,那麼條件可能有四五個,最後可能會變成這樣:

List<Integer> list = List.of(1, 2, 3);

        List<Integer> filterList = new ArrayList<>();

        for (Integer i : list) {
            if (i > 2 && i < 10 && (i % 2 == 0)) {
                filterList.add(i);
            }
        }

        System.out.println(filterList);
複製代碼

if裏面塞了不少條件,看起來就變得亂糟糟了,其實這也還好,最要命的是項目中每每有不少相似的需求,它們之間的區別只是某個條件不同,那麼你就須要複製一大坨代碼,改吧改吧就上線了,這就致使代碼裏有大量重複的代碼。

若是你Stream,一切都會變得清晰易懂:

List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製代碼

這段代碼你只須要關注咱們最關注的東西:篩選條件就夠了,filter這個方法名能讓你清楚的知道它是個過濾條件,collect這個方法名也能看出來它是一個收集器,將最終結果收集到一個List裏面去。

同時你可能發現了,爲何上面的代碼中不用寫循環?

由於Stream會幫助咱們進行隱式的循環,這被稱爲:內部迭代,與之對應的就是咱們常見的外部迭代了。

因此就算你不寫循環,它也會進行一遍循環。

1.2 沒必要關心變量狀態

Stream在設計之初就被設計爲不可變的,它的不可變有兩重含義:

  1. 因爲每次Stream操做都會生成一個新的Stream,因此Stream是不可變的,就像String。

  2. 在Stream中只保存原集合的引用,因此在進行一些會修改元素的操做時,是經過原元素生成一份新的新元素,因此Stream 的任何操做都不會影響到原對象。

第一個含義能夠幫助咱們進行鏈式調用,實際上咱們使用Stream的過程當中每每會使用鏈式調用,而第二個含義則是函數式編程中的一大特色:不修改狀態。

不管對Stream作怎麼樣的操做,它最終都不會影響到原集合,它的返回值也是在原集合的基礎上進行計算得來的。

因此在Stream中咱們沒必要關心操做原對象集合帶來的種種反作用,用就完了。

關於函數式編程能夠查閱阮一峯的函數式編程初探

1.3 延遲執行與優化

Stream只在遇到終結操做的時候纔會執行,好比:

List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println);
複製代碼

這麼一段代碼是不會執行的,peek方法能夠看做是forEach,這裏我用它來打印Stream中的元素。

由於filter方法和peek方法都是轉換流方法,因此不會觸發執行。

若是咱們在後面加入一個count方法就能正常執行:

List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println)
                .count();
複製代碼

count方法是一個終結操做,用於計算出Stream中有多少個元素,它的返回值是一個long型。

Stream的這種沒有終結操做就不會執行的特性被稱爲延遲執行

與此同時,Stream還會對API中的無狀態方法進行名爲循環合併的優化,具體例子詳見第三節。

2. 建立Stream

爲了文章的完整性,我思來想去仍是加上了建立Stream這一節,這一節主要介紹一些建立Stream的經常使用方式,Stream的建立通常能夠分爲兩種狀況:

  1. 使用Steam接口建立

  2. 經過集合類庫建立

同時還會講一講Stream的並行流與鏈接,都是建立Stream,卻具備不一樣的特色。

2.1 經過Stream接口建立

Stream做爲一個接口,它在接口中定義了定義了幾個靜態方法爲咱們提供建立Stream的API:

public static<T> Stream<T> of(T... values) {
        return Arrays.stream(values);
    }

複製代碼

首先是of方法,它提供了一個泛型可變參數,爲咱們建立了帶有泛型的Stream流,同時在若是你的參數是基本類型的狀況下會使用自動包裝對基本類型進行包裝:

Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<Double> doubleStream = Stream.of(1.1d, 2.2d, 3.3d);

        Stream<String> stringStream = Stream.of("1", "2", "3");
複製代碼

固然,你也能夠直接建立一個空的Stream,只須要調用另外一個靜態方法——empty(),它的泛型是一個Object:

Stream<Object> empty = Stream.empty();
複製代碼

以上都是咱們讓咱們易於理解的建立方式,還有一種方式能夠建立一個無限制元素數量的Stream——generate():

public static<T> Stream<T> generate(Supplier<? extends T> s) {
        Objects.requireNonNull(s);
        return StreamSupport.stream(
                new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
    }
複製代碼

從方法參數上來看,它接受一個函數式接口——Supplier做爲參數,這個函數式接口是用來建立對象的接口,你能夠將其類比爲對象的建立工廠,Stream將今後工廠中建立的對象放入Stream中:

Stream<String> generate = Stream.generate(() -> "Supplier");

        Stream<Integer> generateInteger = Stream.generate(() -> 123);
複製代碼

我這裏是爲了方便直接使用Lamdba構造了一個Supplier對象,你也能夠直接傳入一個Supplier對象,它會經過Supplier接口的get() 方法來構造對象。

2.2 經過集合類庫進行建立

相較於上面一種來講,第二種方式更較爲經常使用,咱們經常對集合就行Stream流操做而非手動構建一個Stream:

Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();
        
        Stream<String> stringStreamList = List.of("1", "2", "3").stream(); 
複製代碼

在Java8中,集合的頂層接口Collection被加入了一個新的接口默認方法——stream(),經過這個方法咱們能夠方便的對全部集合子類進行建立Stream的操做:

Stream<Integer> listStream = List.of(1, 2, 3).stream();
        
        Stream<Integer> setStream = Set.of(1, 2, 3).stream();
複製代碼

經過查閱源碼,能夠發先 stream() 方法本質上仍是經過調用一個Stream工具類來建立Stream:

default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
複製代碼

2.3 建立並行流

在以上的示例中全部的Stream都是串行流,在某些場景下,爲了最大化壓榨多核CPU的性能,咱們可使用並行流,它經過JDK7中引入的fork/join框架來執行並行操做,咱們能夠經過以下方式建立並行流:

Stream<Integer> integerParallelStream = Stream.of(1, 2, 3).parallel();

        Stream<String> stringParallelStream = Stream.of("1", "2", "3").parallel();

        Stream<Integer> integerParallelStreamList = List.of(1, 2, 3).parallelStream();

        Stream<String> stringParallelStreamList = List.of("1", "2", "3").parallelStream();
複製代碼

是的,在Stream的靜態方法中沒有直接建立並行流的方法,咱們須要在構造Stream後再調用一次parallel()方法才能建立並行流,由於調用parallel()方法並不會從新建立一個並行流對象,而是在原有的Stream對象上面設置了一個並行參數。

固然,咱們還能夠看到,Collection接口中能夠直接建立並行流,只須要調用與stream() 對應的parallelStream()方法,就像我剛纔講到的,他們之間其實只有參數的不一樣:

default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }
複製代碼

不過通常狀況下咱們並不須要用到並行流,在Stream中元素不過千的狀況下性能並不會有太大提高,由於將元素分散到不一樣的CPU進行計算也是有成本的。

並行的好處是充分利用多核CPU的性能,可是使用中每每要對數據進行分割,而後分散到各個CPU上去處理,若是咱們使用的數據是數組結構則能夠很輕易的進行分割,可是若是是鏈表結構的數據或者Hash結構的數據則分割起來很明顯不如數組結構方便。

因此只有當Stream中元素過萬甚至更大時,選用並行流才能帶給你更明顯的性能提高。

最後,當你有一個並行流的時候,你也能夠經過sequential() 將其方便的轉換成串行流:

Stream.of(1, 2, 3).parallel().sequential();
複製代碼

2.4 鏈接Stream

若是你在兩處構造了兩個Stream,在使用的時候但願組合在一塊兒使用,可使用concat():

Stream<Integer> concat = Stream
                .concat(Stream.of(1, 2, 3), Stream.of(4, 5, 6));
複製代碼

若是是兩種不一樣的泛型流進行組合,自動推斷會自動的推斷出兩種類型相同的父類:

Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<String> stringStream = Stream.of("1", "2", "3");

        Stream<? extends Serializable> stream = Stream.concat(integerStream, stringStream);
複製代碼

3. Stream轉換操做之無狀態方法

無狀態方法:即此方法的執行無需依賴前面方法執行的結果集。

在Stream中無狀態的API咱們經常使用的大概有如下三個:

  1. map()方法:此方法的參數是一個Function對象,它可使你對集合中的元素作自定義操做,並保留操做後的元素。

  2. filter()方法:此方法的參數是一個Predicate對象,Predicate的執行結果是一個Boolean類型,因此此方法只保留返回值爲true的元素,正如其名咱們可使用此方法作一些篩選操做。

  3. flatMap()方法:此方法和map()方法同樣參數是一個Function對象,可是此Function的返回值要求是一個Stream,該方法能夠將多個Stream中的元素聚合在一塊兒進行返回。

先來看看一個map()方法的示例:

Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> mapStream = integerStreamList.map(i -> i * 10);
複製代碼

咱們擁有一個List,想要對其中的每一個元素進行乘10 的操做,就能夠採用如上寫法,其中的i是對List中元素的變量名, 後面的邏輯則是要對此元素進行的操做,以一種很是簡潔明瞭的方式傳入一段代碼邏輯執行,這段代碼最後會返回一個包含操做結果的新Stream。

這裏爲了更好的幫助你們理解,我畫了一個簡圖:


接下來是filter()方法示例:

Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> filterStream = integerStreamList.filter(i -> i >= 20);
複製代碼

在這段代碼中會執行i >= 20 這段邏輯,而後將返回值爲true的結果保存在一個新的Stream中並返回。

這裏我也有一個簡單的圖示:


flatMap() 方法的描述在上文我已經描述過,可是有點過於抽象,我在學習此方法中也是搜索了不少示例纔有了較好的理解。

根據官方文檔的說法,此方法是爲了進行一對多元素的平展操做:

List<Order> orders = List.of(new Order(), new Order());

        Stream<Item> itemStream = orders.stream()
                .flatMap(order -> order.getItemList().stream());
複製代碼

這裏我經過一個訂單示例來講明此方法,咱們的每一個訂單中都包含了一個商品List,若是我想要將兩個訂單中全部商品List組成一個新的商品List,就須要用到flatMap()方法。

在上面的代碼示例中能夠看到每一個訂單都返回了一個商品List的Stream,咱們在本例中只有兩個訂單,因此也就是最終會返回兩個商品List的Stream,flatMap()方法的做用就是將這兩個Stream中元素提取出來而後放到一個新的Stream中。

老規矩,放一個簡單的圖示來講明:

圖例中我使用青色表明Stream,在最終的輸出中能夠看到flatMap()將兩個流變成了一個流進行輸出,這在某些場景中很是有用,好比我上面的訂單例子。


還有一個很不經常使用的無狀態方法peek()

Stream<T> peek(Consumer<? super T> action);
複製代碼

peek方法接受一個Consumer對象作參數,這是一個無返回值的參數,咱們能夠經過peek方法作些打印元素之類的操做:

Stream<Integer> peekStream = integerStreamList.peek(i -> System.out.println(i));

複製代碼

然而若是你不太熟悉的話,不建議使用,某些狀況下它並不會生效,好比:

List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .peek(System.out::println)
                .count();
複製代碼

API文檔上面也註明了此方法是用於Debug,經過個人經驗,只有當Stream最終須要從新生產元素時,peek纔會執行。

上面的例子中,count只須要返回元素個數,因此peek沒有執行,若是換成collect方法就會執行。

或者若是Stream中存在過濾方法如filter方法和match相關方法,它也會執行。

3.1 基礎類型Stream

上一節提到了三個Stream中最經常使用的三個無狀態方法,在Stream的無狀態方法中還有幾個和map()與flatMap()對應的方法,它們分別是:

  1. mapToInt

  2. mapToLong

  3. mapToDouble

  4. flatMapToInt

  5. flatMapToLong

  6. flatMapToDouble

這六個方法首先從方法名中就能夠看出來,它們只是在map()或者flatMap()的基礎上對返回值進行轉換操做,按理說不必單拎出來作成一個方法,實際上它們的關鍵在於返回值:

  1. mapToInt返回值爲IntStream

  2. mapToLong返回值爲LongStream

  3. mapToDouble返回值爲DoubleStream

  4. flatMapToInt返回值爲IntStream

  5. flatMapToLong返回值爲LongStream

  6. flatMapToDouble返回值爲DoubleStream

在JDK5中爲了使Java更加的面向對象,引入了包裝類的概念,八大基礎數據類型都對應着一個包裝類,這使你在使用基礎類型時能夠無感的進行自動拆箱/裝箱,也就是自動使用包裝類的轉換方法。

好比,在最前文的示例中,我用了這樣一個例子:

Stream<Integer> integerStream = Stream.of(1, 2, 3);
複製代碼

我在建立Stream中使用了基本數據類型參數,其泛型則被自動包裝成了Integer,可是咱們有時可能忽略自動拆裝箱也是有代價的,若是咱們想在使用Stream中忽略這個代價則可使用Stream中轉爲基礎數據類型設計的Stream:

  1. IntStream:對應 基礎數據類型中的int、short、char、boolean

  2. LongStream:對應基礎數據類型中的long

  3. DoubleStream:對應基礎數據類型中的double和float

在這些接口中均可以和上文的例子同樣經過of方法構造Stream,且不會自動拆裝箱。

因此上文中提到的那六個方法實際上就是將普通流轉換成這種基礎類型流,在咱們須要的時候能夠擁有更高的效率。

基礎類型流在API方面擁有Stream同樣的API,因此在使用方面只要明白了Stream,基礎類型流也都是同樣的。

:IntStream、LongStream和DoubleStream都是接口,但並不是繼承自Stream接口。

3.2 無狀態方法的循環合併

說完無狀態的這幾個方法咱們來看一個前文中的例子:

List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製代碼

在這個例子中我用了三次filter方法,那麼你們以爲Stream會循環三次進行過濾嗎?

若是換掉其中一個filter爲map,你們以爲會循環幾回?

List<Integer> list = List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製代碼

從咱們的直覺來看,須要先使用map方法對全部元素作處理,而後再使用filter方法作過濾,因此須要執行三次循環。

但回顧無狀態方法的定義,你能夠發現其餘這三個條件能夠放在一個循環裏面作,由於filter只依賴map的計算結果,而沒必要依賴map執行完後的結果集,因此只要保證先操做map再操做filter,它們就能夠在一次循環內完成,這種優化方式被稱爲循環合併

全部的無狀態方法均可以放在同一個循環內執行,它們也能夠方便的使用並行流在多個CPU上執行。

4. Stream轉換操做之有狀態方法

前面說完了無狀態方法,有狀態方法就比較簡單了,只看名字就能夠知道它的做用:

方法名 方法結果
distinct() 元素去重。
sorted() 元素排序,重載的兩個方法,須要的時候能夠傳入一個排序對象。
limit(long maxSize) 傳入一個數字,表明只取前X個元素。
skip(long n) 傳入一個數字,表明跳過X個元素,取後面的元素。
takeWhile(Predicate predicate) JDK9新增,傳入一個斷言參數當第一次斷言爲false時中止,返回前面斷言爲true的元素。
dropWhile(Predicate predicate) JDK9新增,傳入一個斷言參數當第一次斷言爲false時中止,刪除前面斷言爲true的元素。

以上就是全部的有狀態方法,它們的方法執行都必須依賴前面方法執行的結果集才能執行,好比排序方法就須要依賴前面方法的結果集才能進行排序。

同時limit方法和takeWhile是兩個短路操做方法,這意味效率更高,由於可能內部循環尚未走完時就已經選出了咱們想要的元素。

因此有狀態的方法不像無狀態方法那樣能夠在一個循環內執行,每一個有狀態方法都要經歷一個單獨的內部循環,因此編寫代碼時的順序會影響到程序的執行結果以及性能,但願各位讀者在開發過程當中注意。

5. 總結

本文主要是對Stream作了一個概覽,並講述了Stream的兩大特色:

  1. 不可變:不影響原集合,每次調用都返回一個新的Stream。

  2. 延遲執行:在遇到終結操做以前,Stream不會執行。

同時也將Stream的API分紅了轉換操做和終結操做兩類,並講解了全部經常使用的轉換操做,下一章的主要內容將是終結操做。

在看Stream源碼的過程當中發現了一個有趣的事情,在ReferencePipeline類中(Stream的實現類),它的方法順序從上往下正好是:無狀態方法 → 有狀態方法 → 聚合方法。

好了,學完本篇後,我想你們對Stream的總體已經很清晰了,同時對轉換操做的API應該也已經掌握了,畢竟也很少😂,Java8還有不少強大的特性,咱們下次接着聊~


同時,本文在寫做過程當中也參考瞭如下書籍:

這三本書都很是好,第一本是Java核心技術的做者寫的,若是你想全面的瞭解JDK8的升級能夠看這本。

第二本能夠說是一個小冊子,只有一百多頁很短,主要講了一些函數式的思想。

若是你只能看一本,那麼我這裏推薦第三本,豆瓣評分高達9.2,內容和質量都當屬上乘。


最後,創做不易,若是對你們有所幫助,但願你們點贊支持,有什麼問題也能夠在評論區裏討論😄~

相關文章
相關標籤/搜索