此為過去的舊文,2014 年 4 月 20 日初次發表於 logdown。
除了 Pipe 的設計外,Stream 另外讓我好奇的二個特色:lazy evaluation 和 parallel stream。lazy evaluation 可以想成是延後運算到真正必要的時候,而 parallel stream 則是將 Pipe 以平行運算的方式進行,最重要的是這二個特色都是針對 JVM 最佳化過的,應該比我們自己寫來的更有效率。
當看到 lazy evaluation 我最先想到的是,應該對載入大型檔案有幫助,例如:減少記憶體使用量,但我沒把握這想法是否正確,所以設計了一個實驗試試看我的想法。首先,設計一個 FileSearchStrategy
介面,可以輸入檔案(目錄)、關鍵字和結果收集器 (SearchResultCollector
),每個實作可以用不同的方式從檔案中搜尋關鍵字,並將結果 <檔案名稱、行數、該行內容> 存放到收集器中。
由於 Java 的 File
可能指向一個檔案或目錄,因此 AbstractSearchStrategy
實作 FileSearchStrategy
的 search(File, String, SearchResultCollector)
,以遞迴的方式走訪每一層目錄,並留一個 hook method 讓繼承者提供實際掃描檔案的實作。
基礎架構完成後,第一個預設實作 DefaultSearchStrategy
是在還沒有 Stream API 之前,針對文字檔案常用的演算法:逐行掃描。這個實作的結果即是實驗的基準值。
接著 AllLinesSearchStrategy
的實作,使用在Java 7推出的 NIO 2 (New I/O 2) 套件所提供的 Files
的 readAllLines(Path)
函式,事實上,在 Java Doc 的說明中,這個函式只適合用在簡單的案例,並不適合用在大檔案上,因此,這個實作應該會得到實驗中最差的結果。
為了方便後續平行處理的實驗,StreamSearchStrategy
的實作,將實際 Pipe 的運算組成放到另一個函式:scanStream(Stream, String, SearchResultCollector)
中,然後使用 BufferedReader
的 lines()
函式取得 Stream
物件進行運算。為了取得行號,Pipe 的第一個 intermediate operation 使用 map(Function)
,將字串轉成同時帶有行號與字串內容的物件 (使用 KeywordSearchResult
只是簡化實作),然後再用 filter(Predicate)
過濾掉不要的物件。
為了取得行號,所以 StreamSearchStrategy
的 scanStream(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 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)
似乎是致命傷。
先前 AbstractSearchStrategy
使用的是傳統 foreach 方式走訪所有的檔案,如果使用 Stream的parallel 會有幫助嗎?還是更糟?所以,將 AbstractSearchStrategy
的 search(File, String, SearchResultCollector)
改成如下,再次執行測試。
原先期望透過 parallel stream 的方式加快執行速度,但從 Table 4 看來,幾乎沒有加速,反而還帶來了反效果:記憶體使用量暴增。令人意外的是 Stream v2 的記憶體使用量比 Stream 還多,不知該怎麼解釋。
似乎只要和 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 要好。
該是結論的時候了,首先,lazy evaluation 的效益必須是在 pipe 的組合上有最佳化過的,若組合的不好反而更糟糕,且在 I/O 上幫助似乎也不大。parallel stream 要能發揮效果必須看資料的來源類型,I/O 類型或是存取上有競爭現象的資料較難發揮出效益,但若是在記憶體當中的資料,彼此無存取競爭 (不用使用 lock) 的現象,那 parallel stream 的效果就相當明顯,不過要注意的是 parallel stream 也會使記憶體的使用量增加,使用上也要小心。