延遲執行與不可變,系統講解JavaStream數據處理
最近在公司寫業務的時候,忽然想不起來Stream
中的累加應該怎麼寫?
無奈只能面向谷歌編程,花費了我寶貴的三分鐘之後,學會了,很簡單。
自從我用上JDK8以後,Stream就是我最常用的特性,各種流式操作用的飛起,然而這次事以後我忽然覺得Stream對我真的很陌生。
可能大家都一樣,對最常用到的東西,也最容易將其忽略
,哪怕你要準備面試估計也肯定想不起來要看一下Stream這種東西。
不過我既然注意到了,就要重新梳理一遍它,也算是對我的整體知識體系的查漏補缺。
花了很多功夫來寫這篇Stream,希望大家和我一塊重新認識並學習一下Stream,瞭解API也好,瞭解內部特性也罷,怕什麼真理無窮,進一步有進一步的歡喜。
在本文中我將Stream的內容分為以下幾個部分:
初看這個導圖大家可能對轉換流操作和終結流操作這兩個名詞有點蒙,其實這是我將Stream中的所有API分成兩類,每一類起了一個對應的名字(參考自Java8相關書籍,見文末):
-
轉換流操作 :例如filter和map方法,將一個Stream轉換成另一個Stream,返回值都是Stream。
-
終結流操作 :例如count和collect方法,將一個Stream彙總為我們需要的結果,返回值都不是Stream。
其中轉換流操作的API我也分了兩類,文中會有詳細例子説明,這裏先看一下定義,有一個大概印象:
-
無狀態 :即此方法的執行無需依賴前面方法執行的結果集。
-
有狀態 :即此方法的執行需要依賴前面方法執行的結果集。
由於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在設計之初就被設計為不可變的
,它的不可變有兩重含義:
-
由於每次Stream操作都會生成一個新的Stream,所以Stream是不可變的,就像String。
-
在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的創建一般可以分為兩種情況:
-
使用Steam接口創建
-
通過集合類庫創建
同時還會講一講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我們常用的大概有以下三個:
-
map()
方法:此方法的參數是一個Function對象,它可以使你對集合中的元素做自定義操作,並保留操作後的元素。 -
filter()
方法:此方法的參數是一個Predicate對象,Predicate的執行結果是一個Boolean類型,所以此方法只保留返回值為true的元素,正如其名我們可以使用此方法做一些篩選操作。 -
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()對應的方法,它們分別是:
-
mapToInt
-
mapToLong
-
mapToDouble
-
flatMapToInt
-
flatMapToLong
-
flatMapToDouble
這六個方法首先從方法名中就可以看出來,它們只是在map()或者flatMap()的基礎上對返回值進行轉換操作,按理説沒必要單拎出來做成一個方法,實際上它們的關鍵在於返回值:
-
mapToInt返回值為IntStream
-
mapToLong返回值為LongStream
-
mapToDouble返回值為DoubleStream
-
flatMapToInt返回值為IntStream
-
flatMapToLong返回值為LongStream
-
flatMapToDouble返回值為DoubleStream
在JDK5中為了使Java更加的面向對象,引入了包裝類的概念,八大基礎數據類型都對應着一個包裝類,這使你在使用基礎類型時可以無感的進行自動拆箱/裝箱,也就是自動使用包裝類的轉換方法。
比如,在最前文的示例中,我用了這樣一個例子:
Stream<Integer> integerStream = Stream.of(1, 2, 3);
複製代碼
我在創建Stream中使用了基本數據類型參數,其泛型則被自動包裝成了Integer,但是我們有時可能忽略自動拆裝箱也是有代價的,如果我們想在使用Stream中忽略這個代價則可以使用Stream中轉為基礎數據類型設計的Stream:
-
IntStream:對應 基礎數據類型中的int、short、char、boolean
-
LongStream:對應基礎數據類型中的long
-
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的兩大特點:
-
不可變
:不影響原集合,每次調用都返回一個新的Stream。 -
延遲執行
:在遇到終結操作之前,Stream不會執行。
同時也將Stream的API分成了轉換操作和終結操作兩類,並講解了所有常用的轉換操作,下一章的主要內容將是終結操作。
在看Stream源碼的過程中發現了一個有趣的事情,在ReferencePipeline
類中(Stream的實現類),它的方法順序從上往下正好是:無狀態方法 → 有狀態方法 → 聚合方法。
好了,學完本篇後,我想大家對Stream的整體已經很清晰了,同時對轉換操作的API應該也已經掌握了,畢竟也不多😂,Java8還有很多強大的特性,我們下次接着聊~
最後,創作不易,如果對大家有所幫助,希望大家點贊支持,有什麼問題也可以在評論區裏討論😄~
- 遵循Promises/A 規範,深入分析Promise實現細節 | 通過872測試樣例
- 80 行代碼實現簡易 RxJS
- 前後端分離項目,如何解決跨域問題?
- springboot中攔截並替換token來簡化身份驗證
- 15 行代碼在 wangEditor v5 使用數學公式
- Java線程池必知必會
- EdgeDB 架構簡析
- TS 類型體操:圖解一個複雜高級類型
- 基於babel的埋點工具簡單實現及思考
- 使用craco對cra項目進行構建優化
- Netty核心概念之ChannelHandler&Pipeline&ChannelHandlerContext
- 理解python異步編程與簡單實現asyncio
- Mycat 作為代理服務端的小知識點
- 一文吃透 React Expiration Time
- 前端模塊化詳解
- Java必備主流技術流程圖
- 【建議使用】告別if,Java超好用參數校驗工具類
- MySQL模糊查詢再也不用like %了
- Java 8 的Stream流那麼強大,你知道它的原理嗎
- Vue SEO的四種方案