今天,我們從 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 編譯器執行鎖粗化優化的時候,會進行命令重排到后一個同步塊的臨界區中。
鎖粗化默認是開啟的。如果要關閉這個特性可以在 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 系統自身完成的話,那么接下來的優化就需要通過代碼實現了。
鎖的開銷主要是在爭用鎖上,當多線程對共享資源進行訪問時,會出現線程等待。
即便是使用內存屏障,也會導致沖刷寫緩沖器,清空無效化隊列等開銷。
為了降低這種開銷,通常可以從幾個方面入手,例如:減少線程申請鎖的頻率(減少臨界區)和減少線程持有鎖的時間長度(減小鎖顆粒)以及多線程的設計模式。
減少臨界區的范圍
當共享資源需要被多線程訪問時,會將共享資源或者代碼段放到臨界區中。
如果在代碼書寫中減少臨界區的長度,就可以減少鎖被持有的時間,從而降低鎖被征用的概率,達到減少鎖開銷的目的。
減小鎖的顆粒度
減小鎖的顆粒度可以降低鎖的申請頻率,從而減小鎖被爭用的概率。其中一種常見的方法就是將一個顆粒度較粗的鎖拆分成顆粒度較細的鎖。
拆分鎖的顆粒度
假設有一個類 ServerStatus,里面包含了四個方法:
addUser
addQuery
removeUser
removeQuery
如果分別在每個方法加上 Synchronized。在一個線程訪問其中任意一個方法的時候,將鎖住 ServerStatus,此時其他線程都無法訪問另外三個方法,從而進入等待。
如果只針對每個方法內部操作的對象加鎖,例如:addUser 和 removeUser 方法針對 users 對象加鎖。又例如:addQuery 和 removeQuery 方法針對 queries 對象加鎖。
假設,當一個線程池調用 addUser 方法的時候,只會鎖住 user 對象。另外一個線程是可以執行 addQuery 和 removeQuery 方法的。
并不會因為鎖住整個對象而進入等待。JDK 內置的 ConcurrentHashMap 與 SynchronizedMap 就使用了類似的設計。
針對不同的方法中使用的對象進行鎖定
讀寫鎖
也叫做線程的讀寫模式(Read-Write Lock),其本質是一種多線程設計模式。
將讀取操作和寫入操作分開考慮,在執行讀取操作之前,線程必須獲取讀取的鎖。
在執行寫操作之前,必須獲取寫鎖。當線程執行讀取操作時,共享資源的狀態不會發生變化,其他的線程也可以讀取。但是在讀取時,不可以寫入。
其實,讀寫模式就是將原來共享資源的鎖,轉化成為讀和寫兩把鎖,將其分兩種情況考慮。
如果都是讀操作可以支持多線程同時進行,只有在寫時其他線程才會進入等待。