JAVA多線程優化居然有這麼多思路,你知道幾個?

java識堂 發佈 2020-01-02T05:42:26+00:00

眾所周知,Java的鎖分為兩種:一種是內部鎖,它用 Synchronized 關鍵字來修飾,由 JVM 負責管理,並且不會出現鎖泄漏的情況。其中,讀寫鎖只是多線程設計模式中的一種,如果有興趣可以擴展閱讀其他的設計模式,協助進行多線程開發。

今天,我們從 Java 內部鎖優化,代碼中的鎖優化,以及線程池優化幾個方面展開討論。

Java 內部鎖優化


當使用 Java 多線程訪問共享資源的時候,會出現競態的現象。即隨著時間的變化,多線程「寫」共享資源的最終結果會有所不同。


為了解決這個問題,讓多線程「寫」資源的時候有先後順序,引入了鎖的概念。每次一個線程只能持有一個鎖進行寫操作,其他的線程等待該線程釋放鎖以後才能進行後續操作。


從這個角度來看,鎖的使用在 Java 多線程編程中是相當重要的,那麼是如何對鎖進行優化?


眾所周知,Java 的鎖分為兩種:

  • 一種是內部鎖,它用 Synchronized 關鍵字來修飾,由 JVM 負責管理,並且不會出現鎖泄漏的情況。
  • 另外一種是顯示鎖。


這裡重點討論的是內部鎖優化。內部鎖的優化方式由 Java 內部機制完成,雖然不需要程式設計師直接參與,但了解它對理解多線程優化原理有很大幫助。

這部分的優化主要包括四部分:

  • 鎖消除
  • 鎖粗化
  • 偏向鎖
  • 適應鎖


鎖消除(Lock Elision),JIT 編譯器對內部鎖的優化。在介紹其原理之前先說說,逃逸和逃逸分析。

逃逸是指在方法之內創建的對象,除了在方法體之內被引用之外,還在方法體之外被其他變量引用。


也就是,在方法體之外引用方法內的對象。在方法執行完畢之後,方法中創建的對象應該被 GC 回收,但由於該對象被其他變量引用,導致 GC 無法回收。


這個無法回收的對象稱為「逃逸」對象。Java 中的逃逸分析,就是對這種對象的分析。


回到鎖消除,Java JIT 會通過逃逸分析的方式,去分析加鎖的代碼段/共享資源,他們是否被一個或者多個線程使用,或者等待被使用。


如果通過分析證實,只被一個線程訪問,在編譯這個代碼段的時候就不生成 Synchronized 關鍵字,僅僅生成代碼對應的機器碼。

換句話說,即便開發人員對代碼段/共享資源加上了 Synchronized(鎖),只要 JIT 發現這個代碼段/共享資源只被一個線程訪問,也會把這個 Synchronized(鎖)去掉。從而避免競態,提高訪問資源的效率。

鎖消除示意圖


作為開發人員來說,只需要在代碼層面去考慮是否用 Synchronized(鎖)。


說白了,就是感覺這段代碼有可能出現競態,那麼就使用 Synchronized(鎖),至於這個鎖是否真的會使用,則由 Java JIT 編譯器來決定。


鎖粗化(Lock Coarsening) ,是 JIT 編譯器對內部鎖具體實現的優化。假設有幾個在程序上相鄰的同步塊(代碼段/共享資源)上,每個同步塊使用的是同一個鎖實例。


那麼 JIT 會在編譯的時候將這些同步塊合併成一個大同步塊,並且使用同一個鎖實例。這樣避免一個線程反覆申請/釋放鎖。

鎖粗化示意圖


如上圖存在三塊代碼段,分割成三個臨界區,JIT 會將其合併為一個臨界區,用一個鎖對其進行訪問控制。


即使在臨界區的空隙中,有其他的線程可以獲取鎖信息,JIT 編譯器執行鎖粗化優化的時候,會進行命令重排到後一個同步塊的臨界區中。


鎖粗化默認是開啟的。如果要關閉這個特性可以在 Java 程序的啟動命令行中添加虛擬機參數「-XX:-EliminateLocks」。


偏向鎖(Biased Locking),顧名思義,它會偏向於第一個訪問鎖的線程。如果在接下來的運行中,該鎖沒有被其他線程訪問,則持有偏向鎖的線程不會觸發同步。


相反,在運行過程中,遇到了其他線程搶占鎖,則持有偏向鎖的線程會被掛起,JVM 會消除掛起線程的偏向鎖。


換句話說,偏向鎖只能在單個線程反覆持有該鎖的時候起效。其目的是,為了避免相同線程獲取同一個鎖時,產生的線程切換,以及同步操作。

從實現機制上講, 每個偏向鎖都關聯一個計數器和一個占有線程。最開始沒有線程占有的時候,計數器為 0,鎖被認為是 unheld 狀態。


當有線程請求 unheld 鎖時,JVM 記錄鎖的擁有者,並把鎖的請求計數加 1。


如果同一線程再次請求鎖時,計數器就會增加 1,當線程退出 Syncronized 時,計數器減 1,當計數器為 0 時,鎖被釋放。


為了完成上述實現,鎖對象中有個 ThreadId 欄位。第一次獲取鎖之前,該欄位是空的。持有鎖的線程,會將自身的 ThreadId 寫入到鎖的 ThreadId 中。


下次有線程獲取鎖時,先檢查自身 ThreadId 是否和偏向鎖保存的 ThreadId 一致。

如果一致,則認為當前線程已經獲取了鎖,不需再次獲取鎖。偏向鎖默認是開啟的。


如果要關閉這個特性,可以在 Java 程序的啟動命令行中添加虛擬機參數「-XX:-UseBiasedLocks」。


適應鎖(Adaptive Locking):當一個線程持申請鎖時,該鎖正在被其他線程持有。


那麼申請鎖的線程會進入等待,等待的線程會被暫停,暫停的線程會產生上下文切換。


由於上下文切換是比較消耗系統資源的,所以這種暫停線程的方式比較適合線程處理時間較長的情況。


前面一個線程執行的時間較長,才能彌補後面等待線程上下文切換的消耗。如果說線程執行較短,那麼也可以採取忙等(Busy Wait)的狀態。


這種方式不會暫停線程,通過代碼中的 while 循環檢查鎖是否被釋放,一旦釋放就持有鎖的執行權。


這種方式雖然不會帶來上下文的切換,但是會消耗 CPU 的資源。為了綜合較長和較短兩種線程等待模式,JVM 會根據運行過程中收集到的信息來判斷,鎖持有時間是較長時間或者較短時間。然後再採取線程暫停或忙等的策略。

Java 代碼中如何進行鎖優化


前面講了 Java 系統是如何針對內部鎖進行優化的。如果說內部鎖的優化是 Java 系統自身完成的話,那麼接下來的優化就需要通過代碼實現了。


鎖的開銷主要是在爭用鎖上,當多線程對共享資源進行訪問時,會出現線程等待。


即便是使用內存屏障,也會導致沖刷寫緩衝器,清空無效化隊列等開銷。


為了降低這種開銷,通常可以從幾個方面入手,例如:減少線程申請鎖的頻率(減少臨界區)和減少線程持有鎖的時間長度(減小鎖顆粒)以及多線程的設計模式。


減少臨界區的範圍


當共享資源需要被多線程訪問時,會將共享資源或者代碼段放到臨界區中。

如果在代碼書寫中減少臨界區的長度,就可以減少鎖被持有的時間,從而降低鎖被徵用的機率,達到減少鎖開銷的目的。

減少臨界區示例圖


如上圖,儘量避免對一個方法進行加鎖同步,可以只針對方法中的需要同步資源/變量進行同步。其他的代碼段不放到 Synchronzied 中,減少臨界區的範圍。

減小鎖的顆粒度

減小鎖的顆粒度可以降低鎖的申請頻率,從而減小鎖被爭用的機率。其中一種常見的方法就是將一個顆粒度較粗的鎖拆分成顆粒度較細的鎖。

拆分鎖的顆粒度

假設有一個類 ServerStatus,裡面包含了四個方法:

  • addUser
  • addQuery
  • removeUser
  • removeQuery

如果分別在每個方法加上 Synchronized。在一個線程訪問其中任意一個方法的時候,將鎖住 ServerStatus,此時其他線程都無法訪問另外三個方法,從而進入等待。

如果只針對每個方法內部操作的對象加鎖,例如:addUser 和 removeUser 方法針對 users 對象加鎖。又例如:addQuery 和 removeQuery 方法針對 queries 對象加鎖。


假設,當一個線程池調用 addUser 方法的時候,只會鎖住 user 對象。另外一個線程是可以執行 addQuery 和 removeQuery 方法的。


並不會因為鎖住整個對象而進入等待。JDK 內置的 ConcurrentHashMap 與 SynchronizedMap 就使用了類似的設計。

針對不同的方法中使用的對象進行鎖定

讀寫鎖

也叫做線程的讀寫模式(Read-Write Lock),其本質是一種多線程設計模式。


將讀取操作和寫入操作分開考慮,在執行讀取操作之前,線程必須獲取讀取的鎖。

在執行寫操作之前,必須獲取寫鎖。當線程執行讀取操作時,共享資源的狀態不會發生變化,其他的線程也可以讀取。但是在讀取時,不可以寫入。

其實,讀寫模式就是將原來共享資源的鎖,轉化成為讀和寫兩把鎖,將其分兩種情況考慮。


如果都是讀操作可以支持多線程同時進行,只有在寫時其他線程才會進入等待。

Reader 線程正在讀取,Writer 線程正在等待

Writer 線程正在寫入,Reader 線程正在等待

讀寫鎖類圖


說完了讀寫鎖的基本原理,再來看看參與的角色:

  • Reader(讀者),對 SharedResource 角色執行 Read 操作。
  • Writer(寫者),對 SharedResource 角色執行 Write 操作。
  • SharedResource(共享資源),表示對 Reader 和 Writer 兩者共享的資源。
  • ReadWriteLock(讀寫鎖),提供了 SharedResource 角色實現 Read 操作和 Write 操作時所需的鎖。針對 Read 操作提供 readLock 和 readUnlock,對 Write 操作提供 writeLock 和 writeUnlock。


特別需要注意的是,在這裡需要解決讀寫衝突的問題。當線程 A 獲取讀鎖時,如果有線程 B 正在執行寫操作,線程 A 需要等待,否則會引起 read-write conflict(讀寫衝突)。

如果線程 B 正在執行讀操作,線程 A 不需要等待,因為 read-read 不會引起 conflict(衝突)。


當線程 A 要獲取寫入鎖時,線程 B 正在執行寫操作,線程 A 需要等待,否則會引起 write-write conflict(寫寫衝突)。


如果線程 B 正在執行讀操作,則線程 A 需要等待,否則會引起 read-write conflict(讀寫衝突)。

讀寫鎖衝突示例圖

上面基本把讀寫鎖的基本原理說完了,接下來通過一些代碼片段來看看它是如何實現的。

我們通過 Data 類 SharedResource,ReaderThread 和 WriterThread 來實現 Reader 和 Writer,ReadWriteLock 類來實現讀寫鎖。


首先來看 ReaderThread 和 WriterThread,它們的實現相對簡單。僅僅調用 Data 類中的 Read 和 Write 方法來實現讀寫操作。

ReaderThread 對 Reader 的實現

WriterThread 對 Writer 的實現


接下來就是 ReadWriteLock 類,它實現了讀寫鎖的具體功能。其中的幾個變量用來控制訪問線程和寫入優先級:

  • readingReaders:正在讀取共享資源的線程個數,整型。
  • waitingWriters:正在等待寫入共享資源的線程個數,整型。
  • writingWriters:正在寫入共享資源的線程個數,整型。
  • preferWriter:寫入優先級標示,布爾型,為 true 表示寫入優先;為 false 表示讀取優先。


裡面包含了四個方法,分別是:

  • readLock
  • readUnlock
  • writeLock
  • writeUnlock


顧名思義,分別對應讀鎖定,讀解鎖,寫鎖定,寫解鎖的操作。兩兩組合以後一共四種方法。

ReadWriteLock 示例圖

在 ReadWriteLock 定義的四種方法中,各自完成不同的任務:

  • readLock,讀鎖。線程在讀的時候,檢查是否有寫線程在執行,如果有就需要等待。同時還會觀察,在寫入優先的時候,是否有等待寫入的線程。如果存在也需要等待,等待寫入操作的線程完成再執行。如果以上條件都沒有滿足,那麼進行讀操作,並將讀取線程數 +1。
  • readUnlock,讀解鎖。線程在讀操作完成以後,將讀取線程數 -1。通知其他等待線程執行。
  • writeLock,寫鎖。先將寫等待線程數 +1。如果發現有正在讀的線程或者有正寫的線程,那麼進入等待。否則,進行寫操作,並將正在寫操作線程數 +1。
  • writeUnlock,寫解鎖。線程在寫操作完成以後,將寫線程數 -1。通知其他等待線程執行。


最後,來看共享資源的類:Data。它主要承載讀寫的方法。需要注意的是在做讀/寫的前後,需要加上對應的鎖。

例如:在做讀操作(doRead)之前需要加上 readLock(讀鎖),在完成讀操作以後釋放讀鎖(readUnlock)。


又例如:在做寫操作(doWrite)之前需要加上 writeLock(寫鎖),在完成寫操作以後釋放寫鎖(writeUnlock)。

共享資源類 Data 示例圖

上面的幾個類已經介紹完了,如果需要測試可以通過調用 ReaderThread 和 WriterThread 來完成調試。

讀寫鎖測試


線程池優化

前面兩部分談到多線程對內部鎖的優化,以及代碼中對鎖的優化。是從減少競態的角度來優化程序的。


如果從提高線程執行效率,來對多線程程序進行優化,自然讓人聯想到了線程池技術。


基本概念與原理

Java 線程池會生成一個隊列,要執行的任務會被提交到這個隊列中。有一定數量的線程會在隊列中取任務,然後執行。


任務執行完畢以後,線程會返回任務隊列,等待其他任務並執行。線程池中有一定數量的線程隨時待命。

由於生成和維持這些線程是需要耗費資源了,維持太多或者太少的線程都會對系統運行效率造成影響,因此對線程池優化是有意義的。

在做線程池調優之前,先介紹一下線程的幾個基本參數,以及線程池運行的原理:

  • corePoolSize,線程池的基本大小,無論是否有任務需要執行,線程池中線程的個數。只有在工作隊列占滿的情況下,才會創建超出這個數量的線程。
  • maximumPoolSize,線程池中允許存在的最大線程數。
  • poolSize,線程池中線程的數量。


當提交任務需要流程池處理時,會經過以下判斷:

  • 線程池中的線程數還沒有達到基本大小,也就是 poolSize<corePoolSize 時。新增一個線程處理任務,即使線程池中存在空閒的線程。
  • 線程池中的線程數大於或等於基本大小,也就是 poolSize>=corePoolSize,並且任務隊列未滿時,將任務提交到阻塞隊列排隊等候處理。
  • 如果當前線程池的線程數大於或等於基本大小,也就是 poolSize>=corePoolSize 且任務隊列占滿時,需要分兩種情況考慮。①當 poolSize<maximumPoolSize,新增線程來處理任務;②當 poolSize=maximumPoolSize,線程池的處理能力達到極限,因此拒絕新增加的任務。


線程池容量配置


從上面線程池原理可以看出,corePoolSize 設置是整個線程池中最關鍵的參數。


如果設置太小會導致線程池的吞吐量不足,因為新提交的任務需要排隊或者被拒絕處理;設置太大可能會耗盡計算機的 CPU 和內存資源。


那麼如何配置合理的線程池大小呢?如果將被處理的任務分為,CPU 密集型任務和 IO 密集型任務。前者需要更多 CPU 的運算操作,後者需要更多的 IO 操作。


CPU 密集型任務應配置儘可能小的線程,如配置 CPU 個數 +1 的線程數,IO 密集型任務應配置儘可能多的線程,因為 IO 操作不占用 CPU,不要讓 CPU 閒下來,應加大線程數量,如配置兩倍 CPU 個數 +1。


CPU 的數字是一個假設,實際環境中需要進行測試,這裡給大家一個思路。

若任務對其他系統資源有依賴,如任務依賴資料庫返回的結果(IO 操作)。其等待時間越長,CPU 空閒時間就越長,那麼線程數量應該越大,才能更好的利用 CPU。


因此在 IO 優化中發現一個估算公式:

最佳線程數目=((線程等待時間+線程 CPU 時間)/線程 CPU 時間 )* CPU 數目。


將公式進一步化簡,得到:

最佳線程數目= (線程等待時間與線程 CPU 時間之比+1)* CPU 數目。


因此得到結論:線程等待時間所占比例越高,需要越多線程。線程 CPU 時間所占比例越高,需要越少線程。


從另外一個角度驗證上面對 IO 密集型(線程等待時間占比高)和 CPU 密集型(CPU 時間占比高)設置線程池大小的想法。


總結


Java 多線程開發優化有兩個思路:

  • 針對鎖的優化
  • 線程池優化

我們從內部鎖優化原理入手,分別介紹了鎖消除,鎖粗化,偏向鎖,適應鎖,都是以 Java 系統本身來做優化的,作為程式設計師需要了解其實現原理。


針對 Java 代碼中鎖的優化,我們又提出了,減少臨界區範圍,減小鎖的顆粒度,讀寫鎖(設計模式)等方法。

其中,讀寫鎖只是多線程設計模式中的一種,如果有興趣可以擴展閱讀其他的設計模式,協助進行多線程開發。最後針對線程池實現原理,提出了設置線程池大小的思路。


關鍵字: