關(guān)于 Java8 的 stream 中的 reduce 操作
該筆記編寫時并未參照官方文檔,所以在一些地方可能會有錯漏。
在之前再次學(xué)習(xí)折疊操作的時候,我曾做了一些筆記,并且使用 js 編寫了一些示例。當時本打算同時也介紹一下 Java8 的 stream 中提供的 reduce 方法(以下簡稱 java8-reduce),但發(fā)現(xiàn)其行為和 js 中的相去甚遠,所以先略過了。Java為了高性能和并發(fā)支持,它的 reduce 方法是經(jīng)過大量優(yōu)化的,也引入了自己獨有的所謂 Combiner,非常具有Java特色。
如果要說 java8-reduce 的最明顯的特點,我認為有兩個——
首先,它非常合適同 Java 的可變集合一起工作(collect 方法),這在其他語言里是見不到的,這樣既符合 java 傳統(tǒng)的編程模式,也能保證足夠的性能,甚至在并發(fā)情況下也能夠應(yīng)付。
然后,它允許并發(fā)的 reduce 操作;java8-reduce 能把集合切分成多個部分,對每個部分并行地執(zhí)行 reduce 操作,并通過所謂的 combiner 函數(shù)兩兩組合,得到最終結(jié)果;但顯然使用并行的 reduce 操作時必須符合特定約束,以保證其能正確執(zhí)行,無關(guān)切分情況或處理器核心數(shù)量。下面挨個點名。
The easiest way
最簡單的 reduce 操作就是結(jié)果值和集合內(nèi)的值同類型的 reduce 了,比如對集合求積,求和,Java 中干這事一樣簡單——
但 Java 未提供 reduceRight 方法,想必是認為其應(yīng)用范圍不廣,事實也確實如此。
Parallel: Where thing goes wrong
上面是串行流,它的行為同其它語言一致,但若是并行流呢?試試下面的代碼——
結(jié)果是什么?如果是串行流的話,結(jié)果是肯定的——7,但這是并行流,我的電腦的結(jié)果是 9。也可以去嘗試一下其它結(jié)果,最后會發(fā)現(xiàn),只要 zero 參數(shù)(java 叫做 identity,這個名字興許能給我們啟發(fā))不為 0,最后并行得到的結(jié)果和串行必然不一樣。
那么,并行流的 reduce 究竟是怎么跑的?
我們知道,串行流的 reduce 可以描述成把集合中各元素通過一個二元操作符相連接,比如上面的求集合的和可以寫成——
但是并行流顯然不是這樣干的,總的來說,并行流的 reduce 操作,會先把流切分成多個部分(具體切分數(shù)目由處理器核心數(shù)量決定),然后對每個部分各自并行執(zhí)行 reduce 操作,然后再兩兩進行 Combine 操作,得到最終結(jié)果,這里的每個切分的部分,可以把它稱作ReduceTask
,比如對[1,2,3,4,5].reduceParr(1, plus)
,它的計算過程可能是這樣的——
將
[1, 2, 3, 4, 5]
切分成四段,[1]
,[2]
,[3]
,[4, 5]
對每一段,并行調(diào)用 reduce 方法,即
[1].reduce(1, plus)
,[2].reduce(1, plus)
……得到2
,3
,4
,10
。對結(jié)果兩兩執(zhí)行 combine 操作,這里的 combine 操作即 acc 操作,即 plus,
combine(combine(2, 3), combine(4, 10))
得到結(jié)果——19。(實踐好像是分了 5 段,因此結(jié)果是 20)
具體流程是我們不應(yīng)該關(guān)心的,我們只需要知道,各個部分會分別 reduce,然后會兩兩 combine,最終得到最終結(jié)果。這里的“兩兩 combine”不是說從左往右依次 combine(這不就又是一個串行的 reduce 嘛),想象一下歸并排序的迭代版,它自底向上,每次 combine 的都是更“大”的值。
對于同類型的 reduce 操作,combine 函數(shù)同 acc 函數(shù)相同,所以[1,2,3].reduce(0, Integer::sum)
也可以描述成[1,2,3].reduce(0, Integer::sum, Integer::sum)
,表示它的合并和積累函數(shù)都是 sum。
非同類型的 reduce
只有在非同類型的 reduce 操作中,combine 才會明確顯示出來——它需要用戶主動去定義,但我們先來看看 Java 特色的 reduce 操作。
非同類型的 reduce 操作有兩個方法可用——
reduce(identity : U, acc : (U, T) => U, combiner : (U, U) => U)
collect(supplier : () => U, acc : (U, T) => (), combiner : (U, U) => ())
前者(reduce)用于累積的類型是不可變值的情況;后者(collect)用于累積的類型是可變值的情況。
reduce 方法
我們先來看看 reduce,下面的操作將集合反轉(zhuǎn)——
在這里,第三個參數(shù)就是所謂的 combiner,這里因為是串行流,所以 combiner 不會被調(diào)用,可以直接返回 null。但 combiner 本身不能為 null,否則會拋空指針異常。
但若是并行流呢?我們憑第一印象,大概會這么寫——
但是這樣是無法生效的,如果試圖對該集合進行輸出,它在輸出時會拋出空指針異常!這說明它的結(jié)構(gòu)被破壞了,我們遇到了線程安全問題!
但為什么會這樣呢?原來,reduce 方法中所使用的 identity,會被每一個 ReduceTask 都共用!并且我們在 acc 函數(shù)中原樣返回了累積值,因此它會被持續(xù)使用下去,如果在 combiner 中試圖判斷a == b
,它也將是 true,因為 a 和 b 是同一個對象!
因此,reduce 方法只適合不可變對象,或者我們可以每次返回值都不改變原值,而是返回一個新的值,新的引用,但這對性能是極大的損耗,只有 string 這樣的不可變類在 reduce 方法上才能得到運用。
collect 方法
這時,使用collect
方法就是一個更好的選擇,我們再看一看 collect 的簽名——
這簽名實際上把所有細節(jié)都描述出來了——我們通過一個 supplier,來讓每一個 ReduceTask 都能拿到不一樣的引用,從而避免共享數(shù)據(jù)問題;acc 和 combiner 都是沒有返回值的,因此顯然我們需要通過修改累積值來完成積累和合并操作。下面使用 collect 來進行一個 count 操作——
That’s it!了解這么多就足夠了。使用原則是,當使用值類型,或不可變類型,如 String,Scala,Kotlin 的不可變集合,基本類型等的時候,使用 reduce 方法;使用引用類型,可變類型的時候,使用 collect 方法。
雖然 combiner,acc,初始值的設(shè)置要遵循的一定的規(guī)律,但給出統(tǒng)一和容易理解的表述并不容易,具體問題具體分析吧。