Java 線程與同步的性能優化

本文探討的主題是,如何挖掘出Java線程和同步設施的最大性能。

1、線程池與ThreadPoolExecutor

1)線程池與ThreadPoolExecutor

線程池的實現可能有所不同,但基本概念與工作方式是一樣的:有一個隊列(或多個),任務被提交到這個隊列中。一定數量的線程去該隊列中獲取任務,然後執行。任務執行完成後,線程會返滬隊列,檢索另一個任務並執行。如果沒有需要執行的任務,則線程等待。

線程池的大小,與線程池的性能密切相關。
線程池有:最小線程數,最大線程數。
最小線程數:核型池大小。線程的創建成本比較高,線程池中會有最小數目的線程,隨時待命執行任務,以提高任務執行效率。
最大線程數:線程需要佔用一定的系統資源,空線線程太多會佔用過多系統資源,反過來會其他線程/進程的運行效率,故需要設置一個最大數量。最大線程數還是一個必要的限流閥,防止一次執行太多線程。

Java API中常用的線程池:ThreadPoolExecutor。

2)最大線程數

最大線程數的設置取決於負載特性與底層硬件,特別的,最優線程數也與每個任務阻塞的頻率有關。
一般來說,最大線程至少設置為CPU的核數。
等於cpu核數:每個線程分配到一個單獨的cpu上執行。但線程的平均執行效率與基準並不成嚴格的線性比。主要原因:1。線程需自身協同&選取執行任務。2。儘管沒有其他用戶級任務,但cpu還需執行其他系統級的任務。
大於cpu核數:如果是CPU密集型任務,CPU為系統性能的瓶頸所在,線程數大於cpu時性能反而會降低,可能剛開始影響不大,但隨着線程數增多,性能會越差。如果是I/O密集型任務,則系統瓶頸未必是cpu,可能是外部資源,此時添加線程會對系統性能產生嚴重影響。
小於cpu核數:此時應用服務器負載小於100%,可以預留剩餘CPU資源去執行非線程池任務的額外任務。
註:基準指的是單線程的執行效率。

設置最優線程數量非常重要的第一步是找到系統真正的瓶頸所在。因為,如果向系統瓶頸出增加負載(大於cpu核數),性能會顯著下降。
當設置線程數大小方面出現問題時,系統很大程度長也會出現問題,所以,充足的測試非常關鍵。

3)最小線程數(minThread)

大部分情況下,開發者會直接了當的將最小線程數與最大線程數設置為同一值。
出發點:
防止系統創建太多線程,以節省系統資源。
設置的值應該確保系統可以處理預期的最大負載。
指定最小線程數的負面影響相當小。線程在線程池創建時分配,還是按需分配,或在預熱階段分配,對性能的影響可以忽略不計。

另一可以調優的地方為:線程的空閑時間。
一般而言,對於線程數為最小值的線程池,新線程一旦創建出來後,應該至少保留幾分鐘,以處理任何負載飆升。應該避免新線程任務執行完後很快就退出,然後短時間內又需要為新的任務而創建新的線程。空閑時間應該以分鐘計,而且至少在10m~30m之間。

存留一些空閑線程,對應用性能影響通常微乎其微。一般而言,線程本身不會佔用太多大量的堆空間。但是線程局部對象所佔用的總的內存量,應該加以限制。

4)線程池任務大小

等待線程池執行的任務,會被存放到一個隊列或列表中,當有空閑線程可以執行任務時,就從隊列中拉取一個。
當隊列中任務數量非常大時,任務就需要等待很長時間,直到前面任務執行完畢,這會導致不均衡。

線程池通常會限制其大小。當達到隊列數限制時,再添加任務就會失敗。(此時可異常報錯or封裝錯誤信息)

5)設置ThreadPoolExecutor的大小

線程池的一般應為是:線程池創建時,準備好最小數目的線程數,當需要執行一個任務時,如果線程都處於忙碌狀態,就會啟動一個新的線程(一直到達到最大線程數),去執行該任務。如果達到最大線程數,任務會被添加到等待隊列,如果已經達到等待隊列無法加入新任務時,則拒絕之。但ThreadPoolExecutor的表現與此標準行為有點不同。

根據所選擇的任務隊列類型不同,TreadPoolExecutor 決定啟動一個新線程也不同:

  • synchronousQueue:線程池的表現與標準行為相同,不同的是,這個隊列不能保存等待任務。當線程數達到最大數目時,添加任務會被拒絕。適用於管理只有少量任務的情況;該類文檔建議將最大線程數設置為一個非常大的值(適用於CPU密集型,其他情況不適用)。
  • 無界隊列:LinkedBlockingQueue,因為沒有大小限制,所有不會拒絕任何任務。此時,線程池會按照最小數目創建線程,最大線程數無用。如果兩個值相同,則這與固定線程數的傳統線程池相似。大線程數就起作用了。(如果是任務積壓,加入更多線程非常明智;如果已經是CPU密集型任務,加入更多資源是錯誤的)
  • 有界隊列:ArrayBlockingQueue,線程池創建最小數目的線程,當一個任務進來,如果線程都處於忙綠狀態,該任務被添加到緩存隊列,直到緩存隊列已經滿了;而此時又有新任務進來時,才會啟動一個新線程。這裡不會因為隊列滿了而拒絕任務。
    有界隊列的理念是:大部分時間使用核心線程,即使有適量的任務在隊列中等待運行。此時,隊列就可用作第一個節流閥。如果任務請求繼續變多,第二個節流閥是最

6)最佳實踐

線程初始化成本很高,線程池是的系統上的線程數容易控制。
線程池必須仔細調優。盲目向池中添加新線程,在某些情況下對性能反而不利。
使用線程池,在嘗試獲得更好的性能時,使用KISS原則:Keep it Simple,Stupid。可以將線程池最大線程數和最小線程數設置為相同,在保存等待任務方面,如果適合使用無界隊列,則選擇LinkedBlockingQueue;如果適合使用有界隊列,則選擇ArrayBlockingQueue。

2、ForkJoinPol

1)定義

ForkJoinPool 與 ThreadPoolExecutor類一樣,也實現了Executor和ExecutorService接口。
ForkJoinPool在內部使用一個無界任務隊列,供構造器中所指定數目的線程來運行,如果沒有指定線程數,則默認為該機器的CPU數。
ForkJoinPool是為了配合採用分治算法的使用而設計:任務可以遞歸的分解為子集。這些子集可以並行處理,然後每個子集的結果被歸併到一個結果中。經典例子:快速排序。

2)分治算法

分治算法的重點:算法會創建大量的任務,而這些任務只有相對較少的幾個線程來管理。
ForkJoinPool允許其中的線程創建新任務,之後掛起當前任務,任務被掛起後,線程可以執行其他等待的任務(父任務必須等待子任務完成)。
fork()和join()方法是關鍵,這些方法你使用來一系列內部的,從屬於每個線程的隊列來操縱任務,並將線程從執行一個任務切換到執行另一個。

3)ForkJoinPool vs ThreadPoolExecutor

儘管分治技術非常的強大,但是濫用也可能會導致行性能變糟糕。
如,把數組劃分為多個斷,使用ThreadPoolExecutor讓多個線程掃描數組,也是非常容易的。
測試中,ThreadPoolExecutor完全不需要GC,而每個ForkJoinPool測試花費1~2秒在GC上。對於性能差異而言,這一點所佔比重很大,但是這個並非故事的全部:創建和管理任務對象的開銷也會傷害ForkJoinnPool的性能。

執行某些任務所花的時間比其他任務長,這種情況叫做不均衡。
一般而言,如果任務是均衡的,使用分段的ThreadPoolExecutor性能更高;而如果任務是不均衡的,則使用ForkJoinPool性能更好。

應該花寫心思去定,算法中遞歸任務什麼時候結束最為合適。創建太對任務會降低性能,但如果創建任務太少,任務所需執行時間又長短不一,也會降低性能。

4)Java8 自動化並性

Java8引入了自動化並行特定種類代碼的能力,這種並行化就依賴於ForkJoinPool類的使用。
Java8為這個類加入了一個新特性:一個公共的池,可供任何沒有顯示指定給某個特定池的ForkJoinTask使用。這個公共池是ForkJoinPool類的一個static元素,其大小默認設置為目標機器上的處理器數。

Stream流的forEach()方法將為數據列表中的每個元素創建一個任務,每個任務都會由公共的ForkJoinTask池處理。
設置ForkJoinTask池的大小和設置其他任務線程池同樣重要,如果想確保CPU可供作其他任務使用,可以考慮減小公共線程池的線程數;如果公共線程池中的任務會阻塞等待I/O或其他數據,可以考慮增大線程數。
通過-Djava.util.concurrent.ForkJoinPool.common.parallelism=N來設定。

3、線程同步

1)同步的代價

(1)同步與可伸縮性

加速比公式Speedup ,Amdahl定律
1
加速比 = ——————– (P:程序並行運行部分耗時, N:所用到的總線程數)
( 1 – P ) + P / N
假定每個線程總有CPU可用,隨着P值的降低,引入多線程所帶來的性能優勢也會隨之下降。
限制串行塊中的代碼量之所以如此重要,原因就在於此。提供x的CPU,本來希望提升x倍的性能,但在P != 1時,實際提升倍數 < x.

(2)鎖定對象的開銷

* 獲取同步鎖的開銷

無鎖競爭時,synchronized關鍵字和CAS指令之間有輕微差別。此時,synchronized鎖被稱為非膨脹鎖(uninflated) ,獲取非膨脹鎖的開銷在幾百納秒的數量級;而CAS指令損失更小。
有鎖競爭時,多個線程存在競爭的條件下,開銷會更高。此時,synchronized修飾的同步鎖會變為膨脹鎖,成本隨線程數的增多而增加,但每個線程的成本是固定的;而使用CAS指令時,開銷是無法預測的,隨着線程數增加,重試次數也會增加。

* volatile關鍵字,寄存器刷新

Java特有的,依賴於Java內存模型JMM。
同步的目的是保護對內存中值(變量)的訪問,變量可能會臨時保存在寄存器中,這比直接在主內存中訪問更高效。
寄存器值對其他線程是不可見的,當前線程修改來寄存器中的某個值,必須在某個時機把寄存器中的值刷新到主內存中,以便能其他線程可以看到這個值。而寄存器值必須刷新的時機,就是由線程同步控制的。

實際的語言會非常複雜,簡單的理解是,當一個線程離開某個同步塊時,必須將任何修改過的值刷新到主內存中。這意味着進入該同步快的其他線程將能看到最新修改的值。
類似的,基於CAS的保護確保操作期間修改的變量被刷新到主內存中年,標記為volatile的變量,無論什麼時候被修改來,總會在主內存中更新。
寄存器刷新的影響也和程序運行所在的處理器種類有關,有大量供線程使用的寄存器的處理器與較簡單的處理器相比,將需要更多的刷新。

2)避免同步

如果同步可以避免,那加鎖的損失就不會影響應用的性能。
兩種方式:

* 每個線程使用哪個不同的對象。
* 使用基於CAS的替代方案。

通常情況下,在比較基於CAS的設施和傳統同步是,可以使用以下指導原則:
如果訪問的是不存在競爭的資源,你那麼基於CAS的保護也稍快與傳統的同步(雖然你完全不使用保護會更快)。
如果訪問的資源存在輕度或適度的競爭,那麼基於CAS的保護也快於傳統的同步(而且往往快的更多)。
隨着訪問資源的競爭越來越劇烈,在某一時刻,傳統的同步就會成為更高效的選擇。在實踐中,這隻會出現在運行着大量線程的非常大型的機器上。
當被保護的值有多個讀取,但不會被寫入時,基於CAS的保護不會受競爭的影響。

3)偽共享

(1)偽共享怎樣造成的

再多線程程序中,偽共享問題過去相當隱蔽,但是隨着多核機器成為標配,很多同步性能問題更明顯的浮出水面來。偽共享就是一個越來越重要的問題。

偽共享之所以會出現,跟CPU處理其高速緩存的方式有關。考慮一個簡單類中的數據:
public class DataHolder { private volatile long l1; private volatile long l2; private volatile long l3; private volatile long l4; }

這裡的每個long值都保存在毗鄰的內存位置中,當程序要操作其中一個long值時(如l2),一大塊內存會被加載到當前所用的某個CPU核上,
當另一個線程要操作另外一個long值時(比如l3),則會加載同樣一段內存到另一個和的緩存行中(cache line)。

大多數情況下,像這樣呢的加載是有意義的,如果程序訪問的對象中的某個特定實例變量,則很有可能訪問鄰接的實例變量。如果這寫實例變量都被加載到當前核的高速緩存中,內存訪問就非常快,這是很大的性能優勢。
這個模式的缺點是,當程序更新本地緩存中的某一個值時,當前的核必須通知其他所有核,這個內存被修改了。其他內存必須作廢其緩存行,並重新從內存中加載。

結果並非如此:當一個特定線程在修改了某個volatile值時,其他每個線程的緩存行都會作廢,內存值必須重新加載。
嚴格來講,偽共享未必會涉及同步(或volatile)變量:不論何時,CPU緩存中有任何數據被寫入了,其他保存了同樣範圍數據的緩存都必須作為。然而,切記Java內存模型要求數據只在同步原語(包括CAS和volatile構造)結束時必須寫入主內存。

(2)如何監測?

目前沒有清晰完整的答案,兩個可能的方案:

  • 目標處理器廠商提供的用於診斷偽共享的工具。
  • 需要一些直覺和實驗去監測偽共享。

(3)如何糾正

  • 涉及的變量避免頻繁的寫入。對於頻繁地修改volatile變量或者退出同步代碼塊,偽共享的影響才很大。
  • 填充相關的變量,以免被加載到相同的緩存行中。

4、JVM線程調優

1)調節線程棧大小

在內存比較稀缺的機器上,可以減少線程棧大小。
每個線程都有一個原生棧,操作系統用她來保存該線程的調用棧信息。一般而言,32位的JVM有128k的棧,64位的JVM有256k的棧就足夠來。如果設置的過小,當某個線程的調用棧非常大時,會拋出StackOverflowError。
通過 -Xss=N,設置線程棧大小。

2)偏向鎖

當鎖被徵用時,JVM(和操作系統)可以選擇如何分配鎖。鎖可以被公平的授予,每個線程以輪轉調度方式(round-robin)獲得鎖。還有一種方案,即鎖可以偏向於對它訪問最為頻繁的線程。

偏向鎖的背後理論依據是,如果一個線程最近用到了某個鎖,那麼線程下一次執行由同一把鎖保護的代碼所需的數據可能仍然保存在處理器的緩存中。如果個給這個線程優先獲得這把鎖的泉流,緩存命中率可能就會增加。如果實現了這點,性能會有所改進。

但是因為偏向鎖也需要一些薄記信息,故優勢性能可能會更糟糕。
特別是,使用了某個線程池的應用(包括大部分應用服務器),在偏向鎖生效的情況下,性能會更糟糕。在那種編程模型下,不同的線程有同等的機會訪問爭用的鎖。對於類應用,使用 -XX:-UseBiasedLocking選項禁用偏向鎖,會稍稍改進性能。偏向鎖默認是開啟的。

3)自旋鎖

在處理同步鎖的競爭問題時,JVM有兩種選擇。對於想要獲取鎖而陷入阻塞的線程,可以讓他進入忙循環(自旋),執行一些指令然後在次檢查這個鎖。也可以把這個線程放入一個隊列,在鎖可用時通知它(使得CPU可供其他線程使用呢)。

如果多個線程競爭的鎖的被持有時間較短,那忙循環方式快的多;如果被持有時間較長,則更適合線程等待通知的方式。

JVM會在這兩種情況間尋求合理的平衡,自動調整將線程移交到待通知隊列中之前的自旋時間。
如果想影響JVM處理自旋的方式,唯一合理的方式就是讓同步代碼塊儘可能短;這樣可以限制與程序功能沒有直接關係的自旋的量,也降低了進入通知隊列的機會。

4)線程優先級

操作系統會為機器上運行的每個線程計算一個「當前「(current)優先級。當前優先級會考慮Java指派的優先級(開發者定義的),但還會考慮其他許多因素,其中最重要的一個是:自線程上次運行到現在所持續時間。這可以保證所有線程都有機會在某個時間點運行,不論優先級高低,沒有線程會一直處於「飢餓「狀態。這兩個因素之間的平衡會隨操作系統的不同而有所差異。
但是,不管哪種情況,都不能依賴線程的優先級來影響其性能。如果某些任務比其他任務更重要,就必須使用應用層邏輯來劃分優先級。
在某種程度上,通過將任務指派給不同的線程池,並修改那些池的大小來解決。

5、監控線程與鎖

在做性能分析時,總的要注意亮點:總線程數和線程花在等待鎖或其他資源上的時間。至於如何監控就要藉助相應的工具來實現了,這部分內容暫不講解。