Java 執行緒與同步的效能優化

xuxh120發表於2020-07-19

本文探討的主題是,如何挖掘出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、監控執行緒與鎖

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

相關文章