學完本章你將能夠:
分辨命令式與宣告式風格,說明 Stream 的三段結構與惰性求值特性。
用多種方式建立 Stream,並熟練運用 |filter|、|map|、 |flatMap| 等中間操作。
正確選用終端操作,並掌握 |reduce| 的三種形式。
用 |Collectors| 完成分組、分割與各式彙總。
使用基本型別流避免自動裝箱成本。
判斷何時該用平行流,並避開共享可變狀態的陷阱。
上一章我們學會把「行為」當成值傳遞。Stream API 正是這個能力的舞台:它讓我們用 宣告式的方式描述「要對資料做什麼」,而不必陷入「怎麼一步步做」的迴圈細節。 Stream 不是一種新的資料結構,而是一條「資料處理的管線」——資料從來源流入, 經過一連串轉換,最後匯集成結果。
先強調一個常見誤解:Stream 與集合(Collection)是不同的東西。集合關心的是 「如何有效率地儲存與存取元素」,Stream 關心的是「如何宣告式地計算」。Stream 不儲存資料,也不會修改來源,它只是描述一段計算。
命令式與宣告式的對比
假設需求是:「從一串交易金額中,找出所有大於 1000 的金額,乘以稅率後加總」。 先看傳統的命令式寫法——我們得親手管理迴圈、暫存變數與控制流程:
import java.util.List;
/** * 命令式風格:明確告訴電腦「每一步怎麼做」。 * 迴圈、條件、累加器全都要自己維護。 */ public class ImperativeStyle
public static void main(String[] args)
List<Integer> amounts = List.of(500, 1500, 800, 2000, 1200);
double total = 0; // 手動維護的累加器 for (int amount : amounts) // 手動走訪 if (amount > 1000) // 手動過濾 total += amount * 1.05; // 手動轉換與累加 System.out.println("命令式結果:" + total);
命令式結果:4935.0
換成 Stream 的宣告式寫法,我們只描述「做什麼」,把「怎麼做」交給函式庫:
import java.util.List;
/** * 宣告式風格:像在描述需求,讀起來近乎自然語言。 * 過濾、轉換、加總各是一個獨立、可讀的步驟。 */ public class DeclarativeStyle
public static void main(String[] args)
List<Integer> amounts = List.of(500, 1500, 800, 2000, 1200);
double total = amounts.stream() // 來源 .filter(a -> a > 1000) // 中間操作:過濾 .mapToDouble(a -> a * 1.05) // 中間操作:轉換 .sum(); // 終端操作:加總 System.out.println("宣告式結果:" + total);
宣告式結果:4935.0
兩者結果相同,但宣告式寫法把「意圖」直接攤在眼前,沒有樣板迴圈干擾閱讀,日後 要平行化也只需一個字(稍後會看到)。
Stream 的結構與特性
三段式結構
每條 Stream 管線都由三部分組成:
來源(source):集合、陣列、產生器等。
中間操作(intermediate operation):如 |filter|、 |map|,每個都回傳一個新的 Stream,因此可以鏈式串接。
終端操作(terminal operation):如 |collect|、 |sum|,觸發整條管線真正執行並產出結果。
兩大特性:惰性與一次性
Stream 有兩個必須牢記的特性。第一是惰性求值:中間操作不會立即執行, 它們只是在「登記」要做的事,直到遇到終端操作才一次啟動。第二是只能消費 一次:一條 Stream 被終端操作消費後就作廢,再次使用會拋出例外。
Stream 只能消費一次。以下程式會在第二次終端操作時拋出 |IllegalStateException: stream has already been operated upon or closed|:
|Stream<String> s = list.stream();|
|s.count();|(第一次終端操作,正常)
|s.forEach(...);|(第二次,拋例外)
正確做法是每次都從來源重新呼叫 |list.stream()| 取得新的 Stream。
建立 Stream
Stream 的來源相當多元。下面示範最常用的幾種建立方式:
import java.util.Arrays; import java.util.List; import java.util.stream.IntStream; import java.util.stream.Stream;
/** * 彙整六種建立 Stream 的方式。 * 無限流(iterate/generate)務必用 limit 收斂,否則永不終止。 */ public class CreateStream
public static void main(String[] args)
// 1. 從集合 List<String> list = List.of("a", "b", "c"); System.out.println("集合:" + list.stream().count());
// 2. Stream.of 直接列舉元素 System.out.println("of:" + Stream.of(1, 2, 3).count());
// 3. iterate:從種子反覆套用函式(無限,需 limit) Stream.iterate(1, n -> n * 2).limit(5) .forEach(n -> System.out.print(n + " ")); System.out.println();
// 4. generate:反覆呼叫 Supplier(無限,需 limit) Stream.generate(() -> "x").limit(3) .forEach(System.out::print); System.out.println();
// 5. IntStream.range:產生整數區間 [1, 5) System.out.println("range 總和:" + IntStream.range(1, 5).sum());
// 6. Arrays.stream:從陣列 int[] arr = 10, 20, 30; System.out.println("陣列平均:" + Arrays.stream(arr).average().orElse(0));
集合:3 of:3 1 2 4 8 16 xxx range 總和:10 陣列平均:20.0
中間操作
常用中間操作總覽
中間操作最常用的有:|filter|(過濾)、|map|(一對一轉換)、 |flatMap|(一對多後攤平)、|sorted|(排序)、|distinct| (去重)、|limit|/|skip|(截取)、|peek|(窺視)。以下用 一個 |flatMap| 的經典場景示範:把「巢狀清單」攤平成單層。
import java.util.List;
/** * flatMap 把「每個元素展開成一條子流」再串接成單一條流。 * 常用於攤平巢狀集合,或把句子拆成單字。 */ public class FlatMapDemo
public static void main(String[] args)
List<List<Integer>> nested = List.of( List.of(1, 2, 3), List.of(4, 5), List.of(6));
// map 會得到 Stream<List<Integer>>;flatMap 才能攤平成 Integer List<Integer> flat = nested.stream() .flatMap(List::stream) // 每個子清單展開成流再串接 .toList(); // Java 16 起的簡便收集 System.out.println("攤平後:" + flat);
// 把句子清單拆成不重複的單字 List<String> sentences = List.of("java is fun", "fun is good"); List<String> words = sentences.stream() .flatMap(s -> java.util.Arrays.stream(s.split(" "))) .distinct() // 去除重複 .sorted() // 自然排序 .toList(); System.out.println("單字:" + words);
攤平後:[1, 2, 3, 4, 5, 6] 單字:[fun, good, is, java]
惰性求值的證明
前面說中間操作是惰性的,用 |peek| 就能親眼驗證。|peek| 讓我們窺視 每個流經的元素。觀察下面輸出的順序,會發現處理是「垂直」而非「水平」的: 一個元素一路走完整條管線,下一個元素才開始,而不是所有元素先做完 |filter| 再一起做 |map|。
import java.util.stream.Stream;
/** * 用 peek 觀察元素流經管線的順序,驗證惰性求值。 * 每個元素「一條龍」走完 filter 與 map,才輪到下一個元素。 */ public class LazyEvaluation public static void main(String[] args) Stream.of("apple", "banana", "avocado", "cherry") .peek(s -> System.out.println("進入:" + s)) .filter(s -> s.startsWith("a")) // 只留 a 開頭 .peek(s -> System.out.println(" 通過過濾:" + s)) .map(String::toUpperCase) // 轉大寫 .forEach(s -> System.out.println(" 輸出:" + s));
進入:apple 通過過濾:apple 輸出:APPLE 進入:banana 進入:avocado 通過過濾:avocado 輸出:AVOCADO 進入:cherry
惰性求值不只是實作細節,它帶來實質好處。第一,可以支援無限流(配合 |limit| 只取需要的部分)。第二,能做「短路」最佳化:如 |findFirst|、|anyMatch| 一旦找到答案就立刻停止,不會白跑剩餘元素。 第三,多個中間操作會「融合」成單次走訪,而非每個操作各走一遍整個資料集。
終端操作
常見終端操作
終端操作觸發計算並產出結果。除了前面用過的 |forEach|、|count|、 |sum|,還有比對類的 |anyMatch|/|allMatch|/|noneMatch|、 搜尋類的 |findFirst|/|findAny|、極值類的 |min|/|max|, 以及收集類的 |toList|。
import java.util.List;
/** * 示範比對、搜尋、極值等終端操作。 * 比對與搜尋操作具有「短路」特性,找到即停。 */ public class TerminalOps
public static void main(String[] args)
List<Integer> nums = List.of(3, 7, 2, 9, 4, 6);
// 比對:是否全部大於 0 / 是否有任一大於 8 / 是否沒有負數 System.out.println("全部為正?" + nums.stream().allMatch(n -> n > 0)); System.out.println("有 >8 的?" + nums.stream().anyMatch(n -> n > 8)); System.out.println("無負數?" + nums.stream().noneMatch(n -> n < 0));
// 搜尋:第一個大於 5 的元素(Optional,可能不存在) System.out.println("第一個 >5:" + nums.stream().filter(n -> n > 5).findFirst().orElse(-1));
// 極值:需提供 Comparator System.out.println("最大值:" + nums.stream().max(Integer::compareTo).orElseThrow());
全部為正?true 有 >8 的?true 無負數?true 第一個 >5:7 最大值:9
reduce 的三種形式
|reduce| 是最通用的彙總操作,把一串元素「摺疊」成單一結果。它有三種形式, 理解它們的差異是掌握 Stream 的分水嶺:
import java.util.List; import java.util.Optional;
/** * reduce 的三種多載: * (1) 無初值 → 回傳 Optional(流可能為空)。 * (2) 有初值 → 回傳同型別結果。 * (3) 有初值與合併器 → 支援型別轉換與平行合併。 */ public class ReduceDemo
public static void main(String[] args)
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// 形式一:無初值,結果可能不存在,故為 Optional Optional<Integer> sum1 = nums.stream().reduce((a, b) -> a + b); System.out.println("形式一總和:" + sum1.orElse(0));
// 形式二:有初值 0,一定有結果 int sum2 = nums.stream().reduce(0, Integer::sum); System.out.println("形式二總和:" + sum2);
// 形式三:初值為 0,累加器把字串長度加總;合併器供平行時合併 List<String> words = List.of("java", "stream", "api"); int totalLen = words.stream() .reduce(0, (acc, s) -> acc + s.length(), Integer::sum); System.out.println("字元總長:" + totalLen);
形式一總和:15 形式二總和:15 字元總長:13
collect 與 Collectors 深入
|collect| 搭配 |Collectors| 工具類,是把 Stream 匯集成集合或彙總值 的主力。基本的有 |toList|、|toSet|、|toMap|,字串用 |joining|,數值用 |counting|、|summingInt|、 |averagingInt|。
import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors;
/** * 示範 toMap 的重複鍵處理與 joining 的三參數版本。 */ public class CollectorsBasics
public static void main(String[] args)
List<String> names = List.of("Alice", "Bob", "Amy", "Ben");
// joining 三參數:分隔符、前綴、後綴 String joined = names.stream() .collect(Collectors.joining(", ", "[", "]")); System.out.println("串接:" + joined);
// toMap:以首字母為鍵;遇重複鍵用合併函式(保留較長者) Map<Character, String> byInitial = names.stream() .collect(Collectors.toMap( s -> s.charAt(0), // 鍵 Function.identity(), // 值 (a, b) -> a.length() >= b.length() ? a : b)); System.out.println("依首字母:" + byInitial);
串接:[Alice, Bob, Amy, Ben] 依首字母:A=Alice, B=Bob
使用 |Collectors.toMap| 時,若兩個元素產生相同的鍵,又沒有提供 合併函式(第三個參數),會在執行期拋出 |IllegalStateException| (訊息為 Duplicate key)。凡是鍵不保證唯一的場景,都要記得提供合併函式來決定 如何取捨。
分組與分割
|groupingBy| 依某個鍵把元素分組成 |Map|,還能搭配「下游收集器」 對每組再做彙總,甚至多層巢狀分組。|partitioningBy| 是特例,依布林條件把 元素分成 |true| 與 |false| 兩組。
import java.util.List; import java.util.Map; import java.util.stream.Collectors;
/** * groupingBy 的三種用法:單層分組、雙參數(下游收集器)、多層巢狀。 */ public class GroupingDemo
record Product(String category, String name, int price)
public static void main(String[] args)
List<Product> items = List.of( new Product("食品", "麵包", 40), new Product("食品", "牛奶", 60), new Product("文具", "鉛筆", 15), new Product("文具", "筆記本", 55));
// 單層分組:類別 → 該類別的商品清單 Map<String, List<Product>> byCat = items.stream() .collect(Collectors.groupingBy(Product::category)); System.out.println("各類別商品數:"); byCat.forEach((k, v) -> System.out.println(" " + k + ":" + v.size()));
// 雙參數:類別 → 該類別平均價格(下游收集器) Map<String, Double> avgByCat = items.stream() .collect(Collectors.groupingBy(Product::category, Collectors.averagingInt(Product::price))); System.out.println("各類別平均價:" + avgByCat);
// 多層巢狀:類別 → (是否高價) → 名稱清單 Map<String, Map<Boolean, List<String>>> nested = items.stream() .collect(Collectors.groupingBy(Product::category, Collectors.groupingBy(p -> p.price() >= 50, Collectors.mapping(Product::name, Collectors.toList())))); System.out.println("巢狀分組:" + nested);
各類別商品數: 食品:2 文具:2 各類別平均價:食品=50.0, 文具=35.0 巢狀分組:食品=true=[牛奶], false=[麵包], 文具=true=[筆記本], false=[鉛筆]
基本型別流
一般 |Stream<Integer>| 在每次運算時都要在 |int| 與 |Integer| 之間裝箱、拆箱,資料量大時成本可觀。|IntStream|、|LongStream|、 |DoubleStream| 直接操作基本型別,並提供 |sum|、|average|、 |summaryStatistics| 等便利方法。用 |mapToInt| 可從物件流轉成 |IntStream|,用 |boxed| 則能轉回物件流。
import java.util.List; import java.util.IntSummaryStatistics;
/** * 用 mapToInt 轉成 IntStream,一次取得多項統計,全程免裝箱。 */ public class PrimitiveStream
public static void main(String[] args)
List<String> words = List.of("java", "stream", "api", "lambda");
// mapToInt 把物件流轉為 IntStream(此處取每個字的長度) IntSummaryStatistics stats = words.stream() .mapToInt(String::length) // Stream<String> → IntStream .summaryStatistics(); // 一次算出多項統計
System.out.println("數量:" + stats.getCount()); System.out.println("總和:" + stats.getSum()); System.out.println("平均:" + stats.getAverage()); System.out.println("最長:" + stats.getMax());
數量:4 總和:19 平均:4.75 最長:6
平行流
把 |stream()| 換成 |parallelStream()|,或在既有流上呼叫 |parallel()|,Stream 就會利用多核心平行處理。它底層使用共用的 |ForkJoinPool| 把資料切段、分頭運算、再合併結果。聽起來很美好,但平行化 是把雙面刃。
平行流的適用條件
平行流要真正變快,需同時滿足幾個條件:資料量夠大(否則切分與合併的開銷超過 效益)、每個操作是無狀態且獨立的、不存取共享的可變狀態、資料來源易於切分 (陣列、|ArrayList| 佳,|LinkedList| 差)。
平行流最危險的錯誤是存取共享可變狀態。下面的程式想用平行流把元素加進 一個普通 |ArrayList|,但 |ArrayList| 並非執行緒安全,多個執行緒同時 |add| 會造成資料遺失、錯亂,甚至拋出例外——而且問題時有時無,極難除錯。
import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream;
/** * 反面教材:平行流寫入非執行緒安全的 ArrayList。 * 預期 1000 筆,實際往往少於 1000(甚至拋例外),結果不可靠。 */ public class ParallelPitfall
public static void main(String[] args)
List<Integer> unsafe = new ArrayList<>(); // 非執行緒安全
// 錯誤:多執行緒同時對同一個 List 做 add IntStream.range(0, 1000).parallel() .forEach(unsafe::add); // 危險的副作用 System.out.println("錯誤寫法筆數(應為1000):" + unsafe.size());
// 正解:用 collect 讓框架安全地合併各段結果 List<Integer> safe = IntStream.range(0, 1000).parallel() .boxed() .toList(); System.out.println("正確寫法筆數:" + safe.size());
錯誤寫法筆數(應為1000):968 // 每次執行可能不同,甚至拋出例外 正確寫法筆數:1000
正確的平行化原則是:不要在 |forEach| 裡製造副作用,改用 |collect| 或 |reduce| 這類為平行合併而設計的操作。它們會讓每個執行 緒各自累積局部結果,最後由框架安全地合併,天生就是執行緒安全的。
綜合實戰:員工資料多維度分析
最後用一個貫穿式範例,展示 Stream 在真實資料分析中的威力。給定一組員工資料, 我們一口氣回答多個查詢:各部門人數、各部門平均薪資、全公司最高薪者、以及把 研發部的姓名串成一行。
import java.util.List; import java.util.Map; import java.util.stream.Collectors;
/** * 以一份員工資料集,示範 Stream 一站式完成多種分析查詢。 */ public class EmployeeAnalytics
record Employee(String name, String dept, int salary)
public static void main(String[] args)
List<Employee> staff = List.of( new Employee("Alice", "研發", 92000), new Employee("Bob", "研發", 88000), new Employee("Cathy", "行銷", 76000), new Employee("David", "行銷", 81000), new Employee("Eva", "業務", 95000));
// 查詢一:各部門人數 Map<String, Long> countByDept = staff.stream() .collect(Collectors.groupingBy( Employee::dept, Collectors.counting())); System.out.println("各部門人數:" + countByDept);
// 查詢二:各部門平均薪資 Map<String, Double> avgByDept = staff.stream() .collect(Collectors.groupingBy(Employee::dept, Collectors.averagingInt(Employee::salary))); System.out.println("各部門均薪:" + avgByDept);
// 查詢三:全公司最高薪者 String topEarner = staff.stream() .max((a, b) -> Integer.compare(a.salary(), b.salary())) .map(Employee::name) .orElse("無"); System.out.println("最高薪:" + topEarner);
// 查詢四:研發部姓名串成一行 String rdNames = staff.stream() .filter(e -> e.dept().equals("研發")) .map(Employee::name) .collect(Collectors.joining("、")); System.out.println("研發成員:" + rdNames);
各部門人數:研發=2, 行銷=2, 業務=1 各部門均薪:研發=90000.0, 行銷=78500.0, 業務=95000.0 最高薪:Eva 研發成員:Alice、Bob
短短數行就完成了過去需要層層迴圈與暫存變數的分析。這正是 Stream 的價值:讓 資料處理的程式碼讀起來就像在描述問題本身。
本章小結
Stream 是「資料處理管線」而非資料結構,強調宣告式描述「做什麼」。
管線由來源、中間操作、終端操作三段組成;中間操作回傳新 Stream 可鏈式 串接,終端操作觸發執行。
Stream 惰性求值(遇終端操作才啟動、支援短路與操作融合),且只能消費 一次。
常用中間操作:|filter|、|map|、|flatMap|、 |sorted|、|distinct|、|limit|/|skip|、 |peek|。
|reduce| 有無初值、有初值、有合併器三種形式;|collect| 搭配 |Collectors| 完成匯集與彙總。
|groupingBy| 可單層、可接下游收集器、可多層巢狀; |partitioningBy| 依布林條件二分。
基本型別流(|IntStream| 等)避免自動裝箱,並提供 |sum|、 |summaryStatistics| 等便利方法。
平行流適用於大資料、無狀態、無共享可變狀態的場景;切勿在 |forEach| 製造副作用,應改用 |collect|/|reduce|。
本章練習
觀念題:為什麼 Stream 被終端操作消費後就不能再用?請說明惰性求值帶來 的三項好處。 (提示:想想無限流、短路、操作融合。)
觀念題:|map| 與 |flatMap| 有何不同?在什麼情況下必須用 |flatMap|? (提示:想想回傳值是「一個元素」還是「一條子流」。)
實作題:給定 |List<String>| 的句子清單,用 Stream 統計所有出現 過的不重複單字數量(忽略大小寫)。 (提示:|flatMap| 拆字、|map| 轉小寫、|distinct|。)
實作題:用 |IntStream| 計算 1 到 100 之間所有質數的總和。 (提示:|IntStream.rangeClosed| 搭配 |filter| 判斷質數。)
實作題:給定員工資料(姓名、部門、薪資),用 |groupingBy| 求出 「每個部門薪資最高的員工姓名」。 (提示:下游收集器用 |Collectors.maxBy| 再 |collectingAndThen| 取出。)
實作題:寫一個平行流版本的總和計算,並用 |reduce| 確保結果正確; 說明為何這裡用 |reduce| 不會有共享可變狀態問題。 (提示:|reduce| 每段各自累積局部結果再合併。)