</h4>
正如即将上映的星球大戰那樣,java 8的并行流也是毀譽參半。并行流(parallel stream)的文法糖就像預告片裡的新型光劍一樣令人興奮不已。現在java中實作并發程式設計存在多種方式,我們希望了解這麼做所帶來的性能提升及風險是什麼。從經過260多次測試之後拿到的資料來看,還是增加了不少新的見解的,這裡我們想和大家分享一下。
在很久很久以前,在一個遙遠的星球上。。好吧,其實我隻是想說,在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個線程來進行測試。對每個任務而言,我們還會測試下單線程的版本,不過這個在圖中并沒有标出來,因為它的時間要長得多。如果想了解這些測試用例是如何運作的,你可以看一下最後的基礎庫一節。我們開始吧。
在本次測試中我們生成了一個超大的文本檔案,并通過相同的方法來建立索引。我們來看下結果如何:
單線程執行時間:176,267毫秒,大約3分鐘。 注意,上圖是從20000毫秒開始的。
從圖中第一個容易注意到的就是柱狀圖的形狀——光從這4個資料就能大概了解到各個實作的表現是怎樣的了。8個線程到16個線程這裡有所傾斜,這是因為某些線程阻塞在了檔案io這裡,是以增加線程能更好地使用cpu資源。而當加到32個線程時,由于增加了額外的開銷,性能又開始會變差。
并行流所提供的可不止是文法糖(這裡指的并不是lambda表達式),而且它的性能也比fork/join架構以及executorservice要更好。索引完6gb大小的檔案隻需要24.33秒。請相信java,它的性能也能做到很好。
并行流為什麼會影響性能,這裡也給你上了一課。這在本來就運作着多線程應用的機器上是有可能的。由于可用的線程本身就很少了,直接使用fork/join架構要比使用并行流更好一些——兩者的結果相差5秒,大約是18%的性能損耗。
測試中使用預設線程池大小(預設值是機器的cpu核數,在這裡是8)的并行流,跟使用16個線程相比要慢上2秒。也就是說使用預設的池大小則要慢了7%。這是由于阻塞的io線程導緻的。由于有很多線程處于等待狀态,是以引入更多的線程能夠更好地利用cpu資源,當其它線程在等待排程時不至于讓它們閑着。
如果改變并行流的預設的fork/join池的大小?你可以通過一個jvm參數來修改公用的fork/join線程池的大小:
(預設情況下,所有的fork/join任務都會共用同一個線程池,線程的數量等于cpu的核數。好處就是當線程空閑下來時可以收來處理其它任務。)
或者,你還可以用下這個小技巧,用一個自定義的fork/join池來運作并行流。它會覆寫掉預設的公用的fork/join池并讓你能夠使用自己配置好的線程池。手段有點卑劣。測試中我們使用的是公用的線程池。
并發能夠提升7.25倍的性能,考慮到機器是8核的,也就是說接近是8倍的提升!還差的那點應該是消耗線上程的開銷上了。不僅如此,即便是測試中表現最差的并行版本,也就是4個線程的并行流實作(30.23秒),也比單線程的版本(176.27秒)要快5.8倍。
對這次測試而言,我們将去除掉io的部分,來測試下判斷一個大整數是否是素數要花多長時間。這個數有多大?19位,1,530,692,068,127,007,263,換句話說,一百五十三萬零六百九十二兆零六百八十一億兩千萬七千二百六十三。好吧,讓我透透氣先。我們也沒有做任何的優化,而是直接運算到它的平方根,為此我們還檢查了所有的偶數,盡管這個大數并不能被2整除,這隻是為了讓運算的時間更久一些。先劇透一下:這的确是一個素數。每個實作運算的次數也都是一樣的。
下面是測試的結果:
單線程執行時間:118,127毫秒,大約2分鐘 注意,上圖是從20000毫秒開始的
和io測試中不同,這裡并沒有io調用,是以8個線程和16個線程的差别并不大,fork/join的版本例外。由于它的反常表現,我們還多運作了好幾組測試以確定得到的結果是正确的,但事實表明,結果仍是一樣。希望你能在下方的評論一欄說一下你對這個的看法。
我們看到,不同的實作版本最快的結果都是一樣的,大約是28秒左右。不管實作的方法如何,結果都大同小異。但這并不意味着使用哪種方法都一樣。請看下面這點。
這點非常有意思。在本次測試中,我們發現,并行流的16個線程的再次勝出。不止如此,在這次測試中,不管線程數是多少,并行流的表現都是最好的。
除此之外,在運作計算密集型任務時,并行版本的優勢要比帶有io的測試要減少了2倍。由于這是個cpu密集型的測試,這個結果倒也說得過去,不像前面那個測試中那樣,減少cpu的等待io的時間能獲得額外的收益。
之前我也建議過大家讀一下源碼,了解下何時應該使用并行流,并且在java中進行并發程式設計時,不要武斷地下結論。最好的檢驗方式就是在示範環境中多跑跑類似的測試用例。需要特别注意的因素包括你所運作的硬體環境 (以及測試的硬體環境),還有應用程式的總線程數。包括公用fork/join的線程池以及團隊中其它開發人員所寫的代碼中包含的線程。在你編寫自己的并發邏輯前,最好先檢查下上述這些情況,對你的應用程式有一個整體的了解。
我們是在ec2的c3.2xlarge執行個體上運作的本次測試,它有8個vcpu核以及15gb的記憶體。vcpu是因為這裡用到了超線程技術,是以實際上隻有4個實體核,但每個核模拟成了兩個。對作業系統的排程器而言,認為我們一共有8個核。為了盡可能的公平,每個實作都運作了10遍,并選擇了第2次到第9次的平均運作時間。也就是一共運作了260次!處理時長也非常重要。我們所選擇的任務的運作時間都會超過20秒,是以時間差異能很容易看出來,而不太受外部因素的影響。
原始的測試結果在這裡,代碼放在github上。歡迎進行修改,并告訴我們你的測試結果。如果發現了什麼我們這裡沒有講到的有意思的新的見解或者現象,歡迎告訴我們,我們很希望能把它們追加到本文中。