Java 8 Streams

爲什麼需要流?

集合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)
    流支持類似數據庫的操作和常見的函數式編程語言的操作。例如,filtermapreducefindFirstallMatchsorted等。
    此外,流操作還有兩個不同於集合的根本性特性。
  • 管道化
    許多的流操作都會將自己返回。這將允許多個操作被鏈接起來形成一個更大的管道。這個針對惰性,短路,迴路融合進行了優化組合。
  • 內部迭代
    相較於集合的外部迭代,流操作將迭代邏輯進行了隱藏。

流操作

接口java.util.stream.Stream定義了許多方法,可以分爲兩類:

  • 操作例如filtersortedmap可以連接在一起形成一個管道。
  • 操作例如collect**,findFirstallMatch會終結管道並且返回一個結果。

能夠被鏈接的操作叫做中間操作(intermediate operations),他們的操作結果是一個流所以能夠連接在一起,中間操作具備惰性切可以被優化組合。能夠終結流管道的操作叫做終端操作(terminal operations),他們會輸出像ListInteger甚至是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)

一個常用的數據處理模式,判斷某些元素是否匹配指定的屬性值。
你可以用anyMatchallMatchnoneMatch操作來實現。他們都是採用predicate爲參數且返回一個boolean值。
舉個例子,你可以使用allMatch來檢測費用清單流中的包含大於1000的數據元素。

boolean expensive =
	invoices.stream()
			.allMatch(inv -> inv.getAmount() > 1_000);

查找(Finding)

此外,這個Stream接口還提供了findFirstfindAny操作從流中來返回亂序的元素。
這些操作能夠和其他流操作例如過濾等一起協同工作。
findFirstfindAny操作都會返回一個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。
  • 一個流管道可以並行執行。
  • 在使用並行流來提升代碼性能時,需要考慮多個因素。包括易分割性,每個元素的運行成本,裝箱,數據量和計算機可用的核心數。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章