Java中不同的併發實現的效能比較

Java譯站發表於2015-01-27

Fork/Join框架在不同配置下的表現如何?

正如即將上映的星球大戰那樣,Java 8的並行流也是譭譽參半。並行流(Parallel Stream)的語法糖就像預告片裡的新型光劍一樣令人興奮不已。現在Java中實現併發程式設計存在多種方式,我們希望瞭解這麼做所帶來的效能提升及風險是什麼。從經過260多次測試之後拿到的資料來看,還是增加了不少新的見解的,這裡我們想和大家分享一下。

ExecutorService vs. Fork/Join框架 vs. 並行流

在很久很久以前,在一個遙遠的星球上。。好吧,其實我只是想說,在10年前,Java的併發還只能通過第三方庫來實現。然後Java 5到來了,並引入了java.util.concurrent包,上面帶有深深的Doug Lea的烙印。ExecutorService為我們提供了一種簡單的操作執行緒池的方式。當然了,java.util.concurrent包也在不斷完善,Java 7中還引入了基於ExecutorService執行緒池實現的Fork/Join框架。對很多開發人員來說,Fork/Join框架仍然顯得非常神祕,因此Java 8的stream提供了一種更為方便地使用它的方法。我們來看下這幾種方式有什麼不同之處。

我們來通過兩個任務來進行測試,一個是CPU密集型的,一個是IO密集型的,同樣的功能,分別在4種場景下進行測試。不同實現中執行緒的數量也是一個非常重要的因素,因此這個也是我們測試的目標之一。測試機器共有8個核,因此我們分別使用4,8,16,32個執行緒來進行測試。對每個任務而言,我們還會測試下單執行緒的版本,不過這個在圖中並沒有標出來,因為它的時間要長得多。如果想了解這些測試用例是如何執行的,你可以看一下最後的基礎庫一節。我們開始吧。

給一段580萬行6GB大小的文字建立索引

在本次測試中我們生成了一個超大的文字檔案,並通過相同的方法來建立索引。我們來看下結果如何:

單執行緒執行時間:176,267毫秒,大約3分鐘。 注意,上圖是從20000毫秒開始的。

1. 執行緒過少會浪費CPU,而過多則會增加負載

從圖中第一個容易注意到的就是柱狀圖的形狀——光從這4個資料就能大概瞭解到各個實現的表現是怎樣的了。8個執行緒到16個執行緒這裡有所傾斜,這是因為某些執行緒阻塞在了檔案IO這裡,因此增加執行緒能更好地使用CPU資源。而當加到32個執行緒時,由於增加了額外的開銷,效能又開始會變差。

2. 並行流表現最佳。與直接使用Fork/Join相比要快1秒左右

並行流所提供的可不止是語法糖(這裡指的並不是lambda表示式),而且它的效能也比Fork/Join框架以及ExecutorService要更好。索引完6GB大小的檔案只需要24.33秒。請相信Java,它的效能也能做到很好。

3. 但是。。並行流的表現也是最糟糕的:唯獨它是超過了30秒的

並行流為什麼會影響效能,這裡也給你上了一課。這在本來就執行著多執行緒應用的機器上是有可能的。由於可用的執行緒本身就很少了,直接使用Fork/Join框架要比使用並行流更好一些——兩者的結果相差5秒,大約是18%的效能損耗。

4. 如果涉及到IO操作的話,不要使用預設的執行緒池大小

測試中使用預設執行緒池大小(預設值是機器的CPU核數,在這裡是8)的並行流,跟使用16個執行緒相比要慢上2秒。也就是說使用預設的池大小則要慢了7%。這是由於阻塞的IO執行緒導致的。由於有很多執行緒處於等待狀態,因此引入更多的執行緒能夠更好地利用CPU資源,當其它執行緒在等待排程時不至於讓它們閒著。

如果改變並行流的預設的Fork/Join池的大小?你可以通過一個JVM引數來修改公用的Fork/Join執行緒池的大小:

-Djava.util.concurrent.ForkJoinPool.common.parallelism=16 

(預設情況下,所有的Fork/Join任務都會共用同一個執行緒池,執行緒的數量等於CPU的核數。好處就是當執行緒空閒下來時可以收來處理其它任務。)

或者,你還可以用下這個小技巧,用一個自定義的Fork/Join池來執行並行流。它會覆蓋掉預設的公用的Fork/Join池並讓你能夠使用自己配置好的執行緒池。手段有點卑劣。測試中我們使用的是公用的執行緒池。

5. 單執行緒的效能跟最快的結果相比要慢7.25倍

併發能夠提升7.25倍的效能,考慮到機器是8核的,也就是說接近是8倍的提升!還差的那點應該是消耗線上程的開銷上了。不僅如此,即便是測試中表現最差的並行版本,也就是4個執行緒的並行流實現(30.23秒),也比單執行緒的版本(176.27秒)要快5.8倍。

如果不考慮IO的話呢?比如判斷某個數是否是素數

對這次測試而言,我們將去除掉IO的部分,來測試下判斷一個大整數是否是素數要花多長時間。這個數有多大?19位,1,530,692,068,127,007,263,換句話說,一百五十三萬零六百九十二兆零六百八十一億兩千萬七千二百六十三。好吧,讓我透透氣先。我們也沒有做任何的優化,而是直接運算到它的平方根,為此我們還檢查了所有的偶數,儘管這個大數並不能被2整除,這只是為了讓運算的時間更久一些。先劇透一下:這的確是一個素數。每個實現運算的次數也都是一樣的。

下面是測試的結果:

單執行緒執行時間:118,127毫秒,大約2分鐘 注意,上圖是從20000毫秒開始的

1. 8個執行緒與16個執行緒相差不大

和IO測試中不同,這裡並沒有IO呼叫,因此8個執行緒和16個執行緒的差別並不大,Fork/Join的版本例外。由於它的反常表現,我們還多執行了好幾組測試以確保得到的結果是正確的,但事實表明,結果仍是一樣。希望你能在下方的評論一欄說一下你對這個的看法。

2. 不同實現的最好結果都很接近

我們看到,不同的實現版本最快的結果都是一樣的,大約是28秒左右。不管實現的方法如何,結果都大同小異。但這並不意味著使用哪種方法都一樣。請看下面這點。

3. 並行流的執行緒處理開銷要優於其它實現

這點非常有意思。在本次測試中,我們發現,並行流的16個執行緒的再次勝出。不止如此,在這次測試中,不管執行緒數是多少,並行流的表現都是最好的。

4. 單執行緒的版本比最快的結果要慢4.2倍

除此之外,在執行計算密集型任務時,並行版本的優勢要比帶有IO的測試要減少了2倍。由於這是個CPU密集型的測試,這個結果倒也說得過去,不像前面那個測試中那樣,減少CPU的等待IO的時間能獲得額外的收益。

結論

之前我也建議過大家讀一下原始碼,瞭解下何時應該使用並行流,並且在Java中進行併發程式設計時,不要武斷地下結論。最好的檢驗方式就是在演示環境中多跑跑類似的測試用例。需要特別注意的因素包括你所執行的硬體環境 (以及測試的硬體環境),還有應用程式的匯流排程數。包括公用Fork/Join的執行緒池以及團隊中其它開發人員所寫的程式碼中包含的執行緒。在你編寫自己的併發邏輯前,最好先檢查下上述這些情況,對你的應用程式有一個整體的瞭解。

基礎庫

我們是在EC2的c3.2xlarge例項上執行的本次測試,它有8個vCPU核以及15GB的記憶體。vCPU是因為這裡用到了超執行緒技術,因此實際上只有4個物理核,但每個核模擬成了兩個。對作業系統的排程器而言,認為我們一共有8個核。為了儘可能的公平,每個實現都執行了10遍,並選擇了第2次到第9次的平均執行時間。也就是一共執行了260次!處理時長也非常重要。我們所選擇的任務的執行時間都會超過20秒,因此時間差異能很容易看出來,而不太受外部因素的影響。

最後

原始的測試結果在這裡,程式碼放在Github上。歡迎進行修改,並告訴我們你的測試結果。如果發現了什麼我們這裡沒有講到的有意思的新的見解或者現象,歡迎告訴我們,我們很希望能把它們追加到本文中。

相關文章