爲什麼需要流?
集合API是Java應用中最重要的部分之一。幾乎每一個Java應用都會使用集合處理數據。儘管集合非常重要,但是再Java應用中集合的處理在很多方面仍然不是令人很滿意。
第一,許多其他編程語言或者庫都會以聲明式的方式來處理數據。
比如SQL,從一個表中查詢數據,根據條件就行過濾,且可以根據特定的格式來分組數據元素。這裏我們不去過多討論查詢的內部實現。可以看到這種方式讓我們代碼很容易被理解。可惜,在Java中我們並沒有類似的操作,我們必須使用控制流實現數據底層的數據處理。
第二,如何真正高效地處理大數據量的集合?理想情況下,如果需要加快數據處理,可能會利用多核的架構。但是寫併發程序很難且容易出錯。這兩個原因就是Streams API的設計初衷。新的API中新增一個抽象接口Stream,可以讓我們能夠聲明式地處理數據。此外,流可以讓我們重複利用多核架構而不必處理底層的構造例如線程,鎖,條件變量及異變量。舉個例子,假如你需要過濾(查詢指定的客戶的費用清單,根據費用清單的總額排序,獲取最終的清單編號)一個費用清單列表。
現在使用Streams API,可以使用下列代碼來描述:
List<Integer> ids
= invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.sorted(comparingDouble(Invoice::getAmount))
.map(Invoice::getId)
.collect(Collectors.toList());
什麼是流?
那麼什麼是流?簡單地說,你可以把它當做支持像數據庫那樣操作的”優質迭代器“。專業點來說,就是一組支持聚合操作的源元素序列。解析一下專業名稱:
- 元素序列(Sequence of elements)
一個流提供一個接口生成一個指定數據類型的數據序列。然而,流並不會存儲數據而是隻進行計算。 - 源(Source)
流消費的是一些來自於集合、數組或者I/O資源等提供的數據源。 - 聚合操作(Aggregate operations)
流支持類似數據庫的操作和常見的函數式編程語言的操作。例如,filter,map, reduce,findFirst,allMatch,sorted等。
此外,流操作還有兩個不同於集合的根本性特性。 - 管道化
許多的流操作都會將自己返回。這將允許多個操作被鏈接起來形成一個更大的管道。這個針對惰性,短路,迴路融合進行了優化組合。 - 內部迭代
相較於集合的外部迭代,流操作將迭代邏輯進行了隱藏。
流操作
接口java.util.stream.Stream定義了許多方法,可以分爲兩類:
- 操作例如filter,sorted和map可以連接在一起形成一個管道。
- 操作例如collect**,findFirst和allMatch會終結管道並且返回一個結果。
能夠被鏈接的操作叫做中間操作(intermediate operations),他們的操作結果是一個流所以能夠連接在一起,中間操作具備惰性切可以被優化組合。能夠終結流管道的操作叫做終端操作(terminal operations),他們會輸出像List,Integer甚至是void等數據類型。
過濾(Filtering)
這裏有幾種方式用來過濾數據流
- filter
使用Predicate對象作爲參數並且返回包含匹配斷言的所有元素的數據流。 - distinct
返回一個不重複的數據流(根據流中元素的equals方法實現) - limit
返回特定大小數量的數據流 - skip
返回一個跳過前N個元素的數據流。
List<Invoice> expensiveInvoices
= invoices.stream()
.filter(inv -> inv.getAmount() > 10_000)
.limit(5)
.collect(Collectors.toList());
匹配(Matching)
一個常用的數據處理模式,判斷某些元素是否匹配指定的屬性值。
你可以用anyMatch,allMatch和noneMatch操作來實現。他們都是採用predicate爲參數且返回一個boolean值。
舉個例子,你可以使用allMatch來檢測費用清單流中的包含大於1000的數據元素。
boolean expensive =
invoices.stream()
.allMatch(inv -> inv.getAmount() > 1_000);
查找(Finding)
此外,這個Stream接口還提供了findFirst和findAny操作從流中來返回亂序的元素。
這些操作能夠和其他流操作例如過濾等一起協同工作。
findFirst和findAny操作都會返回一個Optional對象。
Optional<Invoice> =
invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.findAny();
映射(Mapping)
流還支持map操作,這個操作把Function對象作爲參數把流中的元素轉換成其他的數據類型。這個功能應用於每一個元素,映射成新的數據元素。
舉個例子,你可能想根據流中的數據元素抽取一些額外的信息。下面的代碼會從一個費用清單列表返回一個的費用清單ID的列表。
List<Integer> ids
= invoices.stream()
.map(Invoice::getId)
.collect(Collectors.toList());
累計(Reducing)
另一種常用方式就是從源數據中組合出一個新的單一值。例如,計算費用清單中的最大總額或者計算費用清單的總額。
這個時候可能會用到reduce方法,這個方法可以重複地應用每一步操作直到最終結果返回。
看一下使用for循環來彙總
int sum = 0;
for (int x : numbers) {
sum += x;
}
列表中每一個數據元素都使用加號來生成一個新的值。根本上就是累計列表中數字成爲一個數字。
這段代碼有兩個重要參數,一個是初始值爲0的sum變量,一個是進行組合操作的加號。
使用reduce方法,你也可以彙總流中的數據元素。
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
這個reduce方法用兩個參數:
初始值。這兒是0
BinaryOperator<T>組合兩個元素且返回一個值。reduce方法本質上就是抽取出重複的程序。其他的查詢例如“計算乘積”或“計算最大值”都可以使用reduce方法。
int product = numbers.stream().reduce(1, (a, b) -> a * b);
int max = numbers.stream().reduce(Integer.MIN_VALUE, Integer::max);
收集器(Collectors)
我們之前看到的操作都是返回另外一個流(中間操作)或者一個值(例如一個布爾值、整形值,或者Optional對象)的操作(終端操作)。
collect方法也是一個終端操作。它能夠將流中的元素計算成一個彙總的結果。
collect的參數類型是java.util.stream.Collector。一個Collector對象描述瞭如何將一個流中元素計算成一個最終值。
工廠方法Collectors.toList()返回一個將流中元素計算成一個List的Collector對象。還有其他類似的構建採集器變量都可以在類Collectors中看到。
你可以使用方法Collectors.groupingBy來根據客戶信息分組費用清單:
Map<Customer, List<Invoice>> customerToInvoices
= invoices.stream().collect(
Collectors.groupingBy(Invoice::getCustomer));
組合在一起
下面這段是舊風格的Java代碼。
List<Invoice> oracleAndTrainingInvoices = new ArrayList<>();
List<Integer> ids = new ArrayList<>();
List<Integer> firstFiveIds = new ArrayList<>();
for(Invoice inv: invoices) {
if(inv.getCustomer() == Customer.ORACLE) {
if(inv.getTitle().contains("Training")) {
oracleAndTrainingInvoices.add(inv);
}
}
}
Collections.sort(oracleAndTrainingInvoices, new Comparator<Invoice>() {
@Override
public int compare(Invoice inv1, Invoice inv2) {
return Double.compare(inv1.getAmount(), inv2.getAmount());
}
});
for(Invoice inv: oracleAndTrainingInvoices) {
ids.add(inv.getId());
}
for(int i = 0; i < 5; i++) {
firstFiveIds.add(ids.get(i));
}
現在我們一步一步地使用Streams API來重構。
首先,我們應該使用一箇中間容器來存儲哪些包含Customer.ORACLE的客戶和"Training"標題的費用清單。
這個地方我麼使用filter操作:
Stream<Invoice> oracleAndTrainingInvoices
= invoices.stream()
.filter(inv -> inv.getCustomer() == Customer.ORACLE)
.filter(inv -> inv.getTitle().contains("Training"));
其次,我們需要根據費用清單的總額進行排序。我們可以一起使用方法Comparator.comparing和方法sorted:
Stream<Invoice> sortedInvoices
= oracleAndTrainingInvoices.sorted(comparingDouble(Invoice::getAmount));
然後,我們需要提取IDs,這兒使用map操作:
Stream<Integer> ids
= sortedInvoices.map(Invoice::getId);
最後,我們可能只對前5名的工單剛興趣。我們使用limit操作來完成這個功能。那麼我們把這些代碼整理一下並且使用collect方法。
最終代碼像這樣:
List<Integer> firstFiveIds
= invoices.stream()
.filter(inv -> inv.getCustomer() == Customer.ORACLE)
.filter(inv -> inv.getTitle().contains("Training"))
.sorted(comparingDouble(Invoice::getAmount))
.map(Invoice::getId)
.limit(5)
.collect(Collectors.toList());
從新舊兩種風格的代碼可以看到,在舊風格中的代碼中,每一個變量被存儲後都會在下一步操作中使用。而使用Streams API那些本地變量沒有了。
並行流(Parallel Streams)
這個Streams API支持簡單數據並行化。換句話說,你可以在不用考慮底層細節實現的情況下要求併發執行流管道。後臺執行時,Streams API會使用Fork/Join框架來利用計算機多核架構處理數據。而我們需要做的就是將stream()替換成parallelStream()。舉個例子,我們需要過濾一些總額比較大的一些費用清單:
List<Invoice> expensiveInvoices
= invoices.parallelStream()
.filter(inv -> inv.getAmount() > 10_000)
.collect(Collectors.toList());
還有一種方式就是使用parallel方法來轉換stream流成並行流。
Stream<Invoice> expensiveInvoices
= invoices.stream()
.filter(inv -> inv.getAmount() > 10_000);
List<Invoice> result
= expensiveInvoices.parallel()
.collect(Collectors.toList());
然而,並行流並不總是一種很好的解決方案。這兒有幾個因素需要考慮以實現性能最大化。
- 易分割性(Splittability)
並行流的內部實現依賴於分割源數據的複雜度。數據結構如數組就比較好分割,但是像LinkedList或者文件就比較麻煩。 - 每個元素的運行成本(Cost per element)
計算一個流中的每一個元素時間越長,那麼並行化的好處越多。 - 裝箱(Boxing)
如果可以的儘可能地使用基本數據類型代替對象數據類型,基本數據類型佔用更小的內存和緩存。 - 數據量(Size)
數據量越大,並行化性能越好。 - 核心數量(Number of cores)
理論上講,計算機核心越多,越能進行並行化。
實際上,我建議你通過代碼基準檢測和代碼邏輯視圖化來考慮性能提升。Java Microbenchmark Harness (JMH)是一款由Oracle提供的非常流行的框架,它能夠幫助你。如果你不考慮這些,那麼你隨意的從普通流切換到並行流可能會導致性能更差。
總結
- 一個流是一組支持聚合操作的源數據生成的元素序列。
- 流操作分爲兩種類型:中間操作和終端操作。
- 中間操作可以連接在一起形成一個管道。
- 中間操作包括filter,map,distinct和sorted。
- 終端操作處理流管道且返回一個結果。
- 終端操作包括allMatch,collect和forEach。
- 採集器用來匯聚流中元素成一個彙總結果,相關容器例如List和Map。
- 一個流管道可以並行執行。
- 在使用並行流來提升代碼性能時,需要考慮多個因素。包括易分割性,每個元素的運行成本,裝箱,數據量和計算機可用的核心數。