Java 8 初探 - Lazy Evaluation & Parallel Stream

閱讀時間約 8 分鐘
此為過去的舊文,2014 年 4 月 20 日初次發表於 logdown。

除了 Pipe 的設計外,Stream 另外讓我好奇的二個特色:lazy evaluation parallel stream。lazy evaluation 可以想成是延後運算到真正必要的時候,而 parallel stream 則是將 Pipe 以平行運算的方式進行,最重要的是這二個特色都是針對 JVM 最佳化過的,應該比我們自己寫來的更有效率。

當看到 lazy evaluation 我最先想到的是,應該對載入大型檔案有幫助,例如:減少記憶體使用量,但我沒把握這想法是否正確,所以設計了一個實驗試試看我的想法。首先,設計一個 FileSearchStrategy 介面,可以輸入檔案(目錄)、關鍵字和結果收集器 (SearchResultCollector),每個實作可以用不同的方式從檔案中搜尋關鍵字,並將結果 <檔案名稱、行數、該行內容> 存放到收集器中。

由於 Java 的 File 可能指向一個檔案或目錄,因此 AbstractSearchStrategy 實作 FileSearchStrategysearch(File, String, SearchResultCollector),以遞迴的方式走訪每一層目錄,並留一個 hook method 讓繼承者提供實際掃描檔案的實作。

基礎架構完成後,第一個預設實作 DefaultSearchStrategy 是在還沒有 Stream API 之前,針對文字檔案常用的演算法:逐行掃描。這個實作的結果即是實驗的基準值。

接著 AllLinesSearchStrategy 的實作,使用在Java 7推出的 NIO 2 (New I/O 2) 套件所提供的 FilesreadAllLines(Path)函式,事實上,在 Java Doc 的說明中,這個函式只適合用在簡單的案例,並不適合用在大檔案上,因此,這個實作應該會得到實驗中最差的結果。

為了方便後續平行處理的實驗,StreamSearchStrategy 的實作,將實際 Pipe 的運算組成放到另一個函式:scanStream(Stream, String, SearchResultCollector)中,然後使用 BufferedReaderlines() 函式取得 Stream 物件進行運算。為了取得行號,Pipe 的第一個 intermediate operation 使用 map(Function),將字串轉成同時帶有行號與字串內容的物件 (使用 KeywordSearchResult 只是簡化實作),然後再用 filter(Predicate) 過濾掉不要的物件。

為了取得行號,所以 StreamSearchStrategyscanStream(Stream, String, SearchResultCollector) 中 Pipe 組成是先用 map(Function) 再用 filter(Predicate)。若忽略行號,改成 StreamSearchStrategyV2,先使用 filter(Predicate) 再使用 map(Function),對結果會有影響嗎?

目前系統記憶體越來越大,作業系統常會將檔案內容快取在系統記憶體中,當第二次讀取相同檔案時,速度可以加速許多,但對實驗來說,這會是個影響數據的關鍵,為了避免讀到快取的檔案內容,實驗準備了三個資料夾,每個資料夾放入 Table 1 所述相同的檔案結構,以 A 子資料夾為例,一個檔案有 100k 行 (筆) 資料,大小約 3.62 MB,這樣的檔案有 10 個檔案,B、C 等子資料夾依此類推,G 子資料夾則是將 A~F 子資料夾複製一份放入,120 個檔案合計 4.45 GB。


Table 1 - Test data

Table 1 - Test data


Table 2 - Test environment

Table 2 - Test environment


當然,這是否可行要看系統記憶體的多寡,實驗的環境如 Table 2 所列,而測試方法如下,每種 strategy 依序掃描三個資料夾,當第二個 strategy 開始掃描第一個資料夾時,因其他兩個資料夾在前一個 strategy 載入,總量有 8.9 GB,應該要超過系統記憶體,第一個資料夾內的內容應該已不在快取中。實驗使用一個 MemoryUsageMonitor 的物件,定期監控JVM的記憶體使用量,並記錄每次掃描的峰值。


好啦!該是公布測試結果 (Table 3) 的時候了,All Lines 果然如預期般使用最多的記憶體(超過1GB),所花費的時間也是最長的,多了 20 秒左右,但 Default、Stream 和 Stream v2 之間的差異不大,就執行時間上,三者的差距大約3秒,而記憶體的使用量 Stream v2 和 Default 幾乎是一樣,但 Stream 一開始的 map(Function) 似乎是致命傷。

Table 3 - Test result of four strategies

Table 3 - Test result of four strategies


先前 AbstractSearchStrategy 使用的是傳統 foreach 方式走訪所有的檔案,如果使用 Stream的parallel 會有幫助嗎?還是更糟?所以,將 AbstractSearchStrategysearch(File, String, SearchResultCollector) 改成如下,再次執行測試。

原先期望透過 parallel stream 的方式加快執行速度,但從 Table 4 看來,幾乎沒有加速,反而還帶來了反效果:記憶體使用量暴增。令人意外的是 Stream v2 的記憶體使用量比 Stream 還多,不知該怎麼解釋。


Table 4 - Test result of four strategies with parallel directories traversal

Table 4 - Test result of four strategies with parallel directories traversal


似乎只要和 I/O 扯上關係,即使用平行運算的方式也沒有減少太多的執行時間,有時候反而還更慢,那如果資料不在硬碟的檔案裡,都已經在記憶體中,那效果會如何?因此,設計了第三個實驗:100k ~ 6400k 筆資料存放在 ArrayList 中,然後根據參數使用 parallelStream()stream() 作為 scanStream(Stream, String, SearchResultCollector) 的輸入。

實驗結果列於 Table 5,100k 一欄,不論是 Stream 或 Stream v2 哪個先執行都會得到不理想的數據,可能是程式冷啟動所引起,所以忽略 100k 欄的數值。從 200k 開始,不論使用 Stream 或 Stream v2,都可看到當使用 parallelStream() 時,執行時間有明顯的減少,在 Stream v2 的 6400k 一欄,節省了 136 ms。而且整體來說,Stream v2 也明顯比 Stream 要好。


Table 5 - The execution time (ms) with parallel stream

Table 5 - The execution time (ms) with parallel stream


該是結論的時候了,首先,lazy evaluation 的效益必須是在 pipe 的組合上有最佳化過的,若組合的不好反而更糟糕,且在 I/O 上幫助似乎也不大。parallel stream 要能發揮效果必須看資料的來源類型,I/O 類型或是存取上有競爭現象的資料較難發揮出效益,但若是在記憶體當中的資料,彼此無存取競爭 (不用使用 lock) 的現象,那 parallel stream 的效果就相當明顯,不過要注意的是 parallel stream 也會使記憶體的使用量增加,使用上也要小心。

    52會員
    102內容數
    這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
    留言0
    查看全部
    發表第一個留言支持創作者!
    Spirit的沙龍 的其他內容
    書摘《程式設計守則》
    閱讀時間約 25 分鐘
    書摘《從 0 到 1》
    閱讀時間約 17 分鐘
    書摘《高品質微服務》
    閱讀時間約 11 分鐘
    Java 8 初探 - Lambda
    閱讀時間約 6 分鐘
    Java 8 初探 - Closure
    閱讀時間約 5 分鐘
    Java 8 初探 - Stream
    閱讀時間約 7 分鐘