全面吃透JAVA Stream流操作,讓程式碼更加的優雅

架構悟道發表於2022-07-11

全面吃透JAVA Stream流操作,讓程式碼更加的優雅
☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝☝

在JAVA中,涉及到對陣列Collection等集合類中的元素進行操作的時候,通常會通過迴圈的方式進行逐個處理,或者使用Stream的方式進行處理。

例如,現在有這麼一個需求:

從給定句子中返回單詞長度大於5的單詞列表,按長度倒序輸出,最多返回3個

JAVA7及之前的程式碼中,我們會可以照如下的方式進行實現:


/**
 * 【常規方式】
 * 從給定句子中返回單詞長度大於5的單詞列表,按長度倒序輸出,最多返回3個
 *
 * @param sentence 給定的句子,約定非空,且單詞之間僅由一個空格分隔
 * @return 倒序輸出符合條件的單詞列表
 */
public List<String> sortGetTop3LongWords(@NotNull String sentence) {
    // 先切割句子,獲取具體的單詞資訊
    String[] words = sentence.split(" ");
    List<String> wordList = new ArrayList<>();
    // 迴圈判斷單詞的長度,先過濾出符合長度要求的單詞
    for (String word : words) {
        if (word.length() > 5) {
            wordList.add(word);
        }
    }
    // 對符合條件的列表按照長度進行排序
    wordList.sort((o1, o2) -> o2.length() - o1.length());
    // 判斷list結果長度,如果大於3則擷取前三個資料的子list返回
    if (wordList.size() > 3) {
        wordList = wordList.subList(0, 3);
    }
    return wordList;
}

JAVA8及之後的版本中,藉助Stream流,我們可以更加優雅的寫出如下程式碼:


/**
 * 【Stream方式】
 * 從給定句子中返回單詞長度大於5的單詞列表,按長度倒序輸出,最多返回3個
 *
 * @param sentence 給定的句子,約定非空,且單詞之間僅由一個空格分隔
 * @return 倒序輸出符合條件的單詞列表
 */
public List<String> sortGetTop3LongWordsByStream(@NotNull String sentence) {
    return Arrays.stream(sentence.split(" "))
            .filter(word -> word.length() > 5)
            .sorted((o1, o2) -> o2.length() - o1.length())
            .limit(3)
            .collect(Collectors.toList());
}

直觀感受上,Stream的實現方式程式碼更加簡潔、一氣呵成。很多的同學在程式碼中也經常使用Stream流,但是對Stream流的認知往往也是僅限於會一些簡單的filtermapcollect等操作,但JAVA的Stream可以適用的場景與能力遠不止這些。

那麼問題來了:Stream相較於傳統的foreach的方式處理stream,到底有啥優勢

這裡我們可以先擱置這個問題,先整體全面的瞭解下Stream,然後再來討論下這個問題。

筆者結合在團隊中多年的程式碼檢視遇到的情況,結合平時專案編碼實踐經驗,對Stream的核心要點與易混淆用法典型使用場景等進行了詳細的梳理總結,希望可以幫助大家對Stream有個更全面的認知,也可以更加高效的應用到專案開發中去。

Stream初相識

概括講,可以將Stream流操作分為3種型別

  • 建立Stream
  • Stream中間處理
  • 終止Steam

每個Stream管道操作型別都包含若干API方法,先列舉下各個API方法的功能介紹。

  • 開始管道

主要負責新建一個Stream流,或者基於現有的陣列、List、Set、Map等集合型別物件建立出新的Stream流。

API 功能說明
stream() 建立出一個新的stream序列流物件
parallelStream() 建立出一個可並行執行的stream流物件
Stream.of() 通過給定的一系列元素建立一個新的Stream序列流物件

  • 中間管道

負責對Stream進行處理操作,並返回一個新的Stream物件,中間管道操作可以進行疊加

API 功能說明
filter() 按照條件過濾符合要求的元素, 返回新的stream流
map() 將已有元素轉換為另一個物件型別,一對一邏輯,返回新的stream流
flatMap() 將已有元素轉換為另一個物件型別,一對多邏輯,即原來一個元素物件可能會轉換為1個或者多個新型別的元素,返回新的stream流
limit() 僅保留集合前面指定個數的元素,返回新的stream流
skip() 跳過集合前面指定個數的元素,返回新的stream流
concat() 將兩個流的資料合併起來為1個新的流,返回新的stream流
distinct() 對Stream中所有元素進行去重,返回新的stream流
sorted() 對stream中所有的元素按照指定規則進行排序,返回新的stream流
peek() 對stream流中的每個元素進行逐個遍歷處理,返回處理後的stream流

  • 終止管道

顧名思義,通過終止管道操作之後,Stream流將會結束,最後可能會執行某些邏輯處理,或者是按照要求返回某些執行後的結果資料。

API 功能說明
count() 返回stream處理後最終的元素個數
max() 返回stream處理後的元素最大值
min() 返回stream處理後的元素最小值
findFirst() 找到第一個符合條件的元素時則終止流處理
findAny() 找到任何一個符合條件的元素時則退出流處理,這個對於序列流時與findFirst相同,對於並行流時比較高效,任何分片中找到都會終止後續計算邏輯
anyMatch() 返回一個boolean值,類似於isContains(),用於判斷是否有符合條件的元素
allMatch() 返回一個boolean值,用於判斷是否所有元素都符合條件
noneMatch() 返回一個boolean值, 用於判斷是否所有元素都不符合條件
collect() 將流轉換為指定的型別,通過Collectors進行指定
toArray() 將流轉換為陣列
iterator() 將流轉換為Iterator物件
foreach() 無返回值,對元素進行逐個遍歷,然後執行給定的處理邏輯

Stream方法使用

map與flatMap

mapflatMap都是用於轉換已有的元素為其它元素,區別點在於:

  • map 必須是一對一的,即每個元素都只能轉換為1個新的元素
  • flatMap 可以是一對多的,即每個元素都可以轉換為1個或者多個新的元素

比如:有一個字串ID列表,現在需要將其轉為User物件列表。可以使用map來實現:


/**
 * 演示map的用途:一對一轉換
 */
public void stringToIntMap() {
    List<String> ids = Arrays.asList("205","105","308","469","627","193","111");
    // 使用流操作
    List<Integer> results = ids.stream()
            .map(s -> Integer.valueOf(s))
            .collect(Collectors.toList());
    System.out.println(results);
}

執行之後,會發現每一個元素都被轉換為對應新的元素,但是前後總元素個數是一致的:


[User{id='205'}, 
 User{id='105'},
 User{id='308'}, 
 User{id='469'}, 
 User{id='627'}, 
 User{id='193'}, 
 User{id='111'}]

再比如:現有一個句子列表,需要將句子中每個單詞都提取出來得到一個所有單詞列表。這種情況用map就搞不定了,需要flatMap上場了:


public void stringToIntFlatmap() {
    List<String> sentences = Arrays.asList("hello world","Jia Gou Wu Dao");
    // 使用流操作
    List<String> results = sentences.stream()
            .flatMap(sentence -> Arrays.stream(sentence.split(" ")))
            .collect(Collectors.toList());
    System.out.println(results);
}

執行結果如下,可以看到結果列表中元素個數是比原始列表元素個數要多的:


[hello, world, Jia, Gou, Wu, Dao]

這裡需要補充一句,flatMap操作的時候其實是先每個元素處理並返回一個新的Stream,然後將多個Stream展開合併為了一個完整的新的Stream,如下:

peek和foreach方法

peekforeach,都可以用於對元素進行遍歷然後逐個的進行處理。

但根據前面的介紹,peek屬於中間方法,而foreach屬於終止方法。這也就意味著peek只能作為管道中途的一個處理步驟,而沒法直接執行得到結果,其後面必須還要有其它終止操作的時候才會被執行;而foreach作為無返回值的終止方法,則可以直接執行相關操作。


public void testPeekAndforeach() {
    List<String> sentences = Arrays.asList("hello world","Jia Gou Wu Dao");
    // 演示點1: 僅peek操作,最終不會執行
    System.out.println("----before peek----");
    sentences.stream().peek(sentence -> System.out.println(sentence));
    System.out.println("----after peek----");
    // 演示點2: 僅foreach操作,最終會執行
    System.out.println("----before foreach----");
    sentences.stream().forEach(sentence -> System.out.println(sentence));
    System.out.println("----after foreach----");
    // 演示點3: peek操作後面增加終止操作,peek會執行
    System.out.println("----before peek and count----");
    sentences.stream().peek(sentence -> System.out.println(sentence)).count();
    System.out.println("----after peek and count----");
}

輸出結果可以看出,peek獨自呼叫時並沒有被執行、但peek後面加上終止操作之後便可以被執行,而foreach可以直接被執行:


----before peek----
----after peek----
----before foreach----
hello world
Jia Gou Wu Dao
----after foreach----
----before peek and count----
hello world
Jia Gou Wu Dao
----after peek and count----


filter、sorted、distinct、limit

這幾個都是常用的Stream的中間操作方法,具體的方法的含義在上面的表格裡面有說明。具體使用的時候,可以根據需要選擇一個或者多個進行組合使用,或者同時使用多個相同方法的組合


public void testGetTargetUsers() {
    List<String> ids = Arrays.asList("205","10","308","49","627","193","111", "193");
    // 使用流操作
    List<Dept> results = ids.stream()
            .filter(s -> s.length() > 2)
            .distinct()
            .map(Integer::valueOf)
            .sorted(Comparator.comparingInt(o -> o))
            .limit(3)
            .map(id -> new Dept(id))
            .collect(Collectors.toList());
    System.out.println(results);
}

上面的程式碼片段的處理邏輯很清晰:

  1. 使用filter過濾掉不符合條件的資料
  2. 通過distinct對存量元素進行去重操作
  3. 通過map操作將字串轉成整數型別
  4. 藉助sorted指定按照數字大小正序排列
  5. 使用limit擷取排在前3位的元素
  6. 又一次使用map將id轉為Dept物件型別
  7. 使用collect終止操作將最終處理後的資料收集到list中

輸出結果:

[Dept{id=111},  Dept{id=193},  Dept{id=205}]

簡單結果終止方法

按照前面介紹的,終止方法裡面像countmaxminfindAnyfindFirstanyMatchallMatchnonneMatch等方法,均屬於這裡說的簡單結果終止方法。所謂簡單,指的是其結果形式是數字、布林值或者Optional物件值等。


public void testSimpleStopOptions() {
    List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
    // 統計stream操作後剩餘的元素個數
    System.out.println(ids.stream().filter(s -> s.length() > 2).count());
    // 判斷是否有元素值等於205
    System.out.println(ids.stream().filter(s -> s.length() > 2).anyMatch("205"::equals));
    // findFirst操作
    ids.stream().filter(s -> s.length() > 2)
            .findFirst()
            .ifPresent(s -> System.out.println("findFirst:" + s));
}

執行後結果為:


6
true
findFirst:205

避坑提醒

這裡需要補充提醒下,一旦一個Stream被執行了終止操作之後,後續便不可以再讀這個流執行其他的操作了,否則會報錯,看下面示例:


public void testHandleStreamAfterClosed() {
    List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
    Stream<String> stream = ids.stream().filter(s -> s.length() > 2);
    // 統計stream操作後剩餘的元素個數
    System.out.println(stream.count());
    System.out.println("-----下面會報錯-----");
    // 判斷是否有元素值等於205
    try {
        System.out.println(stream.anyMatch("205"::equals));
    } catch (Exception e) {
        e.printStackTrace();
    }
    System.out.println("-----上面會報錯-----");
}

執行的時候,結果如下:


6
-----下面會報錯-----
java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
	at java.util.stream.ReferencePipeline.anyMatch(ReferencePipeline.java:449)
	at com.veezean.skills.stream.StreamService.testHandleStreamAfterClosed(StreamService.java:153)
	at com.veezean.skills.stream.StreamService.main(StreamService.java:176)
-----上面會報錯-----

因為stream已經被執行count()終止方法了,所以對stream再執行anyMatch方法的時候,就會報錯stream has already been operated upon or closed,這一點在使用的時候需要特別注意。

結果收集終止方法

因為Stream主要用於對集合資料的處理場景,所以除了上面幾種獲取簡單結果的終止方法之外,更多的場景是獲取一個集合類的結果物件,比如List、Set或者HashMap等。

這裡就需要collect方法出場了,它可以支援生成如下型別的結果資料:

  • 一個集合類,比如List、Set或者HashMap等
  • StringBuilder物件,支援將多個字串進行拼接處理並輸出拼接後結果
  • 一個可以記錄個數或者計算總和的物件(資料批量運算統計

生成集合

應該算是collect最常被使用到的一個場景了:


public void testCollectStopOptions() {
    List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(23));
    // collect成list
    List<Dept> collectList = ids.stream().filter(dept -> dept.getId() > 20)
            .collect(Collectors.toList());
    System.out.println("collectList:" + collectList);
    // collect成Set
    Set<Dept> collectSet = ids.stream().filter(dept -> dept.getId() > 20)
            .collect(Collectors.toSet());
    System.out.println("collectSet:" + collectSet);
    // collect成HashMap,key為id,value為Dept物件
    Map<Integer, Dept> collectMap = ids.stream().filter(dept -> dept.getId() > 20)
            .collect(Collectors.toMap(Dept::getId, dept -> dept));
    System.out.println("collectMap:" + collectMap);
}

結果如下:


collectList:[Dept{id=22}, Dept{id=23}]
collectSet:[Dept{id=23}, Dept{id=22}]
collectMap:{22=Dept{id=22}, 23=Dept{id=23}}

生成拼接字串

將一個List或者陣列中的值拼接到一個字串裡並以逗號分隔開,這個場景相信大家都不陌生吧?

如果通過for迴圈和StringBuilder去迴圈拼接,還得考慮下最後一個逗號如何處理的問題,很繁瑣:


public void testForJoinStrings() {
    List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
    StringBuilder builder = new StringBuilder();
    for (String id : ids) {
        builder.append(id).append(',');
    }
    // 去掉末尾多拼接的逗號
    builder.deleteCharAt(builder.length() - 1);
    System.out.println("拼接後:" + builder.toString());
}

但是現在有了Stream,使用collect可以輕而易舉的實現:


public void testCollectJoinStrings() {
    List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
    String joinResult = ids.stream().collect(Collectors.joining(","));
    System.out.println("拼接後:" + joinResult);
}

兩種方式都可以得到完全相同的結果,但Stream的方式更優雅:

拼接後:205,10,308,49,627,193,111,193

資料批量數學運算

還有一種場景,實際使用的時候可能會比較少,就是使用collect生成數字資料的總和資訊,也可以瞭解下實現方式:


public void testNumberCalculate() {
    List<Integer> ids = Arrays.asList(10, 20, 30, 40, 50);
    // 計算平均值
    Double average = ids.stream().collect(Collectors.averagingInt(value -> value));
    System.out.println("平均值:" + average);
    // 資料統計資訊
    IntSummaryStatistics summary = ids.stream().collect(Collectors.summarizingInt(value -> value));
    System.out.println("資料統計資訊: " + summary);
}

上面的例子中,使用collect方法來對list中元素值進行數學運算,結果如下:


平均值:30.0
總和: IntSummaryStatistics{count=5, sum=150, min=10, average=30.000000, max=50}

並行Stream

機制說明

使用並行流,可以有效利用計算機的多CPU硬體,提升邏輯的執行速度。並行流通過將一整個stream劃分為多個片段,然後對各個分片流並行執行處理邏輯,最後將各個分片流的執行結果彙總為一個整體流。

約束與限制

並行流類似於多執行緒在並行處理,所以與多執行緒場景相關的一些問題同樣會存在,比如死鎖等問題,所以在並行流終止執行的函式邏輯,必須要保證執行緒安全

回答最初的問題

到這裡,關於JAVA Stream的相關概念與用法介紹,基本就講完了。我們再把焦點切回本文剛開始時提及的一個問題:

Stream相較於傳統的foreach的方式處理stream,到底有啥優勢

根據前面的介紹,我們應該可以得出如下幾點答案:

  • 程式碼更簡潔、偏宣告式的編碼風格,更容易體現出程式碼的邏輯意圖
  • 邏輯間解耦,一個stream中間處理邏輯,無需關注上游與下游的內容,只需要按約定實現自身邏輯即可
  • 並行流場景效率會比迭代器逐個迴圈更高
  • 函式式介面,延遲執行的特性,中間管道操作不管有多少步驟都不會立即執行,只有遇到終止操作的時候才會開始執行,可以避免一些中間不必要的操作消耗

當然了,Stream也不全是優點,在有些方面也有其弊端:

  • 程式碼調測debug不便
  • 程式設計師從歷史寫法切換到Stream時,需要一定的適應時間

總結

好啦,關於JAVA Stream的理解要點與使用技能的闡述就先到這裡啦。那通過上面的介紹,各位小夥伴們是否已經躍躍欲試了呢?快去專案中使用體驗下吧!當然啦,如果有疑問,也歡迎找我一起探討探討咯。

此外

  • 關於Stream中collect的分組、分片等進階操作,以及對並行流的深入探討,因為涉及內容比較多且相對獨立,我會在後續的文件中展開專門介紹下,如果有興趣的話,可以點個關注、避免迷路。

  • 關於本文中涉及的演示程式碼的完整示例,我已經整理並提交到github中,如果您有需要,可以自取:https://github.com/veezean/JavaBasicSkills

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點個關注,也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。

相關文章