阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?

以下文章來源於阿里巴巴中間件 ,作者楚昭

阿里巴巴中間件

Aliware阿里巴巴中間件官方賬號


簡介


在計算機行業有一個定律叫"摩爾定律",在此定律下,計算機的性能突飛猛進,而且價格也隨之越來越便宜, CPU 從單核到了多核,緩存性能也得到了很大提升,尤其是多核 CPU 技術的到來,計算機同一時刻可以處理多個任務。在硬件層面的發展帶來的效率極大提升中,軟件層面的多線程編程已經成為必然趨勢,然而多線程編程就會引入數據安全性問題,有矛必有盾,於是發明了“鎖”來解決線程安全問題。在這篇文章中,總結了 Java 中幾把經典的 JVM 級別的鎖。


synchronized


synchronized 關鍵字是一把經典的鎖,也是我們平時用得最多的。在 JDK1.6 之前, syncronized 是一把重量級的鎖,不過隨著 JDK 的升級,也在對它進行不斷的優化,如今它變得不那麼重了,甚至在某些場景下,它的性能反而優於輕量級鎖。在加了 syncronized 關鍵字的方法、代碼塊中,一次只允許一個線程進入特定代碼段,從而避免多線程同時修改同一數據。


synchronized 鎖有如下幾個特點:


有鎖升級過程


在 JDK1.5 (含)之前, synchronized 的底層實現是重量級的,所以之前一直稱呼它為"重量級鎖",在 JDK1.5 之後,對 synchronized 進行了各種優化,它變得不那麼重了,實現原理就是鎖升級的過程。我們先聊聊 1.5 之後的 synchronized 實現原理是怎樣的。說到 synchronized 加鎖原理,就不得不先說 Java 對象在內存中的佈局, Java 對象內存佈局如下:


阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?


如上圖所示,在創建一個對象後,在 JVM 虛擬機( HotSpot )中,對象在 Java 內存中的存儲佈局 可分為三塊:


對象頭區域此處存儲的信息包括兩部分:


1、對象自身的運行時數據( MarkWord )

存儲 hashCode、GC 分代年齡、鎖類型標記、偏向鎖線程 ID 、 CAS 鎖指向線程 LockRecord 的指針等, synconized 鎖的機制與這個部分( markwork )密切相關,用 markword 中最低的三位代表鎖的狀態,其中一位是偏向鎖位,另外兩位是普通鎖位。

2、對象類型指針( Class Pointer )

對象指向它的類元數據的指針、 JVM 就是通過它來確定是哪個 Class 的實例。


實例數據區域


此處存儲的是對象真正有效的信息,比如對象中所有字段的內容


對其填充區域


JVM 的實現 HostSpot 規定對象的起始地址必須是 8 字節的整數倍,換句話來說,現在 64 位的 OS 往外讀取數據的時候一次性讀取 64bit 整數倍的數據,也就是 8 個字節,所以 HotSpot 為了高效讀取對象,就做了"對齊",如果一個對象實際佔的內存大小不是 8byte 的整數倍時,就"補位"到 8byte 的整數倍。所以對齊填充區域的大小不是固定的。

當線程進入到 synchronized 處嘗試獲取該鎖時, synchronized 鎖升級流程如下:


阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?


如上圖所示, synchronized 鎖升級的順序為:偏向鎖->輕量級鎖->重量級鎖,每一步觸發鎖升級的情況如下:


偏向鎖

在 JDK1.8 中,其實默認是輕量級鎖,但如果設定了
-XX:BiasedLockingStartupDelay = 0 ,那在對一個 Object 做 syncronized 的時候,會立即上一把偏向鎖。當處於偏向鎖狀態時, markwork 會記錄當前線程 ID 。


升級到輕量級鎖

當下一個線程參與到偏向鎖競爭時,會先判斷 markword 中保存的線程 ID 是否與這個線程 ID 相等,如果不相等,會立即撤銷偏向鎖,升級為輕量級鎖。每個線程在自己的線程棧中生成一個 LockRecord ( LR ),然後每個線程通過 CAS (自旋)的操作將鎖對象頭中的 markwork 設置為指向自己的 LR 的指針,哪個線程設置成功,就意味著獲得鎖。關於 synchronized 中此時執行的 CAS 操作是通過 native 的調用 HotSpot 中 bytecodeInterpreter.cpp 文件 C++ 代碼實現的,有興趣的可以繼續深挖。


升級到重量級鎖

如果說競爭加劇(如線程自旋次數或者自旋的線程數超過某閾值, JDK1.6 之後,由 JVM 自己控制該規則),就會升級為重量級鎖。此時就會向操作系統申請資源,線程掛起,進入到操作系統內核態的等待隊列中,等待操作系統調度,然後映射回用戶態。在重量級鎖中,由於需要做內核態到用戶態的轉換,而這個過程中需要消耗較多時間,也就是"重"的原因之一。


可重入

synchronized 擁有強制原子性的內部鎖機制,是一把可重入鎖。因此,在一個線程使用 synchronized 方法是調用該對象另一個 synchronized 方法,即一個線程得到一個對象鎖後再次請求該對象鎖,是永遠可以拿到鎖的。在 Java 中線程獲得對象鎖的操作是以線程為單位的,而不是以調用為單位的。synchronized 鎖的對象頭的 markwork 中會記錄該鎖的線程持有者和計數器,當一個線程請求成功後, JVM 會記下持有鎖的線程,並將計數器計為1。此時其他線程請求該鎖,則必須等待。而該持有鎖的線程如果再次請求這個鎖,就可以再次拿到這個鎖,同時計數器會遞增。當線程退出一個 synchronized 方法/塊時,計數器會遞減,如果計數器為 0 則釋放該鎖鎖。


悲觀鎖(互斥鎖、排他鎖)

synchronized 是一把悲觀鎖(獨佔鎖),當前線程如果獲取到鎖,會導致其它所有需要鎖的線程等待,一直等待持有鎖的線程釋放鎖才繼續進行鎖的爭搶。


ReentrantLock

ReentrantLock 從字面可以看出是一把可重入鎖,這點和 synchronized 一樣,但實現原理也與 syncronized 有很大差別,它是基於經典的 AQS(AbstractQueueSyncronized) 實現的, AQS 是基於 volitale 和 CAS 實現的,其中 AQS 中維護一個 valitale 類型的變量 state 來做一個可重入鎖的重入次數,加鎖和釋放鎖也是圍繞這個變量來進行的。ReentrantLock 也提供了一些 synchronized 沒有的特點,因此比 synchronized 好用。


AQS模型如下圖:

阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?


ReentrantLock 有如下特點:


1、可重入

ReentrantLock 和 syncronized 關鍵字一樣,都是可重入鎖,不過兩者實現原理稍有差別, RetrantLock 利用 AQS 的的 state 狀態來判斷資源是否已鎖,同一線程重入加鎖, state 的狀態 +1 ; 同一線程重入解鎖, state 狀態 -1 (解鎖必須為當前獨佔線程,否則異常); 當 state 為 0 時解鎖成功。


2、需要手動加鎖、解鎖

synchronized 關鍵字是自動進行加鎖、解鎖的,而 ReentrantLock 需要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成,來手動加鎖、解鎖。


3、支持設置鎖的超時時間

synchronized 關鍵字無法設置鎖的超時時間,如果一個獲得鎖的線程內部發生死鎖,那麼其他線程就會一直進入阻塞狀態,而 ReentrantLock 提供 tryLock 方法,允許設置線程獲取鎖的超時時間,如果超時,則跳過,不進行任何操作,避免死鎖的發生。


4、支持公平/非公平鎖

synchronized 關鍵字是一種非公平鎖,先搶到鎖的線程先執行。而 ReentrantLock 的構造方法中允許設置 true/false 來實現公平、非公平鎖,如果設置為 true ,則線程獲取鎖要遵循"先來後到"的規則,每次都會構造一個線程 Node ,然後到雙向鏈表的"尾巴"後面排隊,等待前面的 Node 釋放鎖資源。


5、可中斷鎖

ReentrantLock 中的 lockInterruptibly() 方法使得線程可以在被阻塞時響應中斷,比如一個線程 t1 通過 lockInterruptibly() 方法獲取到一個可重入鎖,並執行一個長時間的任務,另一個線程通過 interrupt() 方法就可以立刻打斷 t1 線程的執行,來獲取t1持有的那個可重入鎖。而通過 ReentrantLock 的 lock() 方法或者 Synchronized 持有鎖的線程是不會響應其他線程的 interrupt() 方法的,直到該方法主動釋放鎖之後才會響應 interrupt() 方法。


ReentrantReadWriteLock

ReentrantReadWriteLock (讀寫鎖)其實是兩把鎖,一把是 WriteLock (寫鎖),一把是讀鎖, ReadLock 。讀寫鎖的規則是:讀讀不互斥、讀寫互斥、寫寫互斥。在一些實際的場景中,讀操作的頻率遠遠高於寫操作,如果直接用一般的鎖進行併發控制的話,就會讀讀互斥、讀寫互斥、寫寫互斥,效率低下,讀寫鎖的產生就是為了優化這種場景的操作效率。一般情況下獨佔鎖的效率低來源於高併發下對臨界區的激烈競爭導致線程上下文切換。因此當併發不是很高的情況下,讀寫鎖由於需要額外維護讀鎖的狀態,可能還不如獨佔鎖的效率高,因此需要根據實際情況選擇使用。


ReentrantReadWriteLock 的原理也是基於 AQS 進行實現的,與 ReentrantLock 的差別在於 ReentrantReadWriteLock 鎖擁有共享鎖、排他鎖屬性。讀寫鎖中的加鎖、釋放鎖也是基於 Sync (繼承於 AQS ),並且主要使用 AQS 中的 state 和 node 中的 waitState 變量進行實現的。實現讀寫鎖與實現普通互斥鎖的主要區別在於需要分別記錄讀鎖狀態及寫鎖狀態,並且等待隊列中需要區別處理兩種加鎖操作。ReentrantReadWriteLock 中將 AQS 中的 int 類型的 state 分為高 16 位與第 16 位分別記錄讀鎖和寫鎖的狀態,如下圖所示:

阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?


WriteLock(寫鎖)是悲觀鎖(排他鎖、互斥鎖)


通過計算 state&((1<<16)-1) ,將 state 的高 16 位全部抹去,因此 state 的低位記錄著寫鎖的重入計數。

獲取寫鎖源碼:

<code>         

public

void

lock

()

{ sync.acquire(

1

); }/<code>

其中“tryAcquire”方法在NonfairSync(公平鎖)中和FairSync(非公平鎖)中都有各自的實現

<code>*     * Acquires 

in

exclusive mode, ignoring interrupts. Implemented *

by

invoking at least once { #tryAcquire}, * returning on success. Otherwise the thread

is

queued, possibly * repeatedly blocking and unblocking, invoking { * #tryAcquire} until success. This method can be used * to implement method { Lock#lock}. * * arg the acquire argument. This value

is

conveyed to * { #tryAcquire} but

is

otherwise uninterpreted and * can represent anything you like. */

public

final

void acquire(int arg) {

if

(!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

protected

final

boolean tryAcquire(int acquires) { /<code>

1、如果讀寫鎖的計數不為0,且持有鎖的線程不是當前線程,則返回false

<code>  * 

1

.

If

read

count

nonzero or write

count

nonzero * and owner

is

a different thread, fail. */<code>

2、如果持有鎖的計數不為0且計數總數超過限定的最大值,也返回false

<code>*

2

If

 

count

 would saturate, fail. (

This

 can only             *    happen 

if

 

count

 

is

 already nonzero.)             */<code>

3、如果該鎖是可重入或該線程在隊列中的策略是允許它嘗試搶鎖,那麼該線程就能獲取鎖 *

<code>Otherwise, 

this

 thread 

is

 eligible 

for

 lock 

if

             *    it 

is

 either a reentrant acquire 

or

             *    queue policy allows it. If so, update state             *    

and

 set owner.             *

/            Thread current = Thread.currentThread();            /

/獲取讀寫鎖的狀態            int c = getState();            /

/獲取該寫鎖重入的次數            int w = exclusiveCount(c);            /

/如果讀寫鎖狀態不為0,說明已經有其他線程獲取了讀鎖或寫鎖            if (c != 0) {                /

/如果寫鎖重入次數為0,說明有線程獲取到讀鎖,根據“讀寫鎖互斥”原則,返回false                /

/或者如果寫鎖重入次數不為0,且獲取寫鎖的線程不是當前線程,根據"寫鎖獨佔"原則,返回false                /

/ (Note: if c != 0 and w == 0 then shared count != 0)                if (w == 0 || current != getExclusiveOwnerThread())                    return false;               /

/如果寫鎖可重入次數超過最大次數(65535),則拋異常                if (w + exclusiveCount(acquires) > MAX_COUNT)                    throw new Error("Maximum lock count exceeded");                /

/到這裡說明該線程是重入寫鎖,更新重入寫鎖的計數(+1),返回true                /

/ Reentrant acquire                setState(c + acquires);                return true;            }            /

/如果讀寫鎖狀態為0,說明讀鎖和寫鎖都沒有被獲取,會走下面兩個分支:           /

/如果要阻塞或者執行CAS操作更新讀寫鎖的狀態失敗,則返回false            /

/如果不需要阻塞且CAS操作成功,則當前線程成功拿到鎖,設置鎖的owner為當前線程,返回

true

            

if

 (writerShouldBlock() ||                !compareAndSetState(c, c + acquires))                

return

 

false

;            setExclusiveOwnerThread(current);            

return

 

true

;        }/<code>

釋放寫鎖源碼:

<code>  

protected

final

boolean

tryRelease

(

int

releases)

{ /<code>


ReadLock(讀鎖)是共享鎖(樂觀鎖)


通過計算 state>>>16 進行無符號補 0 ,右移 16 位,因此 state 的高位記錄著寫鎖的重入計數.


讀鎖獲取鎖的過程比寫鎖稍微複雜些,首先判斷寫鎖是否為 0 並且當前線程不佔有獨佔鎖,直接返回;否則,判斷讀線程是否需要被阻塞並且讀鎖數量是否小於最大值並且比較設置狀態成功,若當前沒有讀鎖,則設置第一個讀線程 firstReader 和 firstReaderHoldCount ;若當前線程線程為第一個讀線程,則增加 firstReaderHoldCount ;否則,將設置當前線程對應的 HoldCounter 對象的值,更新成功後會在 firstReaderHoldCount 中 readHolds ( ThreadLocal 類型的)的本線程副本中記錄當前線程重入數,這是為了實現 JDK1.6 中加入的 getReadHoldCount ()方法的,這個方法能獲取當前線程重入共享鎖的次數( state 中記錄的是多個線程的總重入次數),加入了這個方法讓代碼複雜了不少,但是其原理還是很簡單的:如果當前只有一個線程的話,還不需要動用 ThreadLocal ,直接往 firstReaderHoldCount 這個成員變量裡存重入數,當有第二個線程來的時候,就要動用 ThreadLocal 變量 readHolds 了,每個線程擁有自己的副本,用來保存自己的重入數。

獲取讀鎖源碼:




<code>         

public

void

lock

()

{ sync.acquireShared(

1

); }

public

final

void

acquireShared

(

int

arg)

{

if

(tryAcquireShared(arg)

0

) doAcquireShared(arg); }

protected

final

int

tryAcquireShared

(

int

unused)

{ /<code>


釋放讀鎖源碼:


<code>

public

final

boolean

releaseShared

(

int

arg)

{

if

(tryReleaseShared(arg)) {/<code>

通過分析可以看出:

在線程持有讀鎖的情況下,該線程不能取得寫鎖(因為獲取寫鎖的時候,如果發現當前的讀鎖被佔用,就馬上獲取失敗,不管讀鎖是不是被當前線程持有)。


在線程持有寫鎖的情況下,該線程可以繼續獲取讀鎖(獲取讀鎖時如果發現寫鎖被佔用,只有寫鎖沒有被當前線程佔用的情況才會獲取失敗)。


LongAdder


在高併發的情況下,我們對一個 Integer 類型的整數直接進行 i++ 的時候,無法保證操作的原子性,會出現線程安全的問題。為此我們會用 juc 下的 AtomicInteger ,它是一個提供原子操作的 Interger 類,內部也是通過 CAS 實現線程安全的。但當大量線程同時去訪問時,就會因為大量線程執行 CAS 操作失敗而進行空旋轉,導致 CPU 資源消耗過多,而且執行效率也不高。Doug Lea 大神應該也不滿意,於是在 JDK1.8 中對 CAS 進行了優化,提供了 LongAdder ,它是基於了 CAS 分段鎖的思想實現的。


線程去讀寫一個 LongAdder 類型的變量時,流程如下:


阿里大佬告訴你Java 的幾把 JVM 級鎖,你都搞懂了嗎?


LongAdder 也是基於 Unsafe 提供的 CAS 操作 +valitale 去實現的。在 LongAdder 的父類 Striped64 中維護著一個 base 變量和一個 cell 數組,當多個線程操作一個變量的時候,先會在這個 base 變量上進行 cas 操作,當它發現線程增多的時候,就會使用 cell 數組。比如當 base 將要更新的時候發現線程增多(也就是調用 casBase 方法更新 base 值失敗),那麼它會自動使用 cell 數組,每一個線程對應於一個 cell ,在每一個線程中對該 cell 進行 cas 操作,這樣就可以將單一 value 的更新壓力分擔到多個 value 中去,降低單個 value 的 “熱度”,同時也減少了大量線程的空轉,提高併發效率,分散併發壓力。這種分段鎖需要額外維護一個內存空間 cells ,不過在高併發場景下,這點成本幾乎可以忽略。分段鎖是一種優秀的優化思想, juc 中提供的的 ConcurrentHashMap 也是基於分段鎖保證讀寫操作的線程安全。


作者信息:

夏傑 ,花名楚昭,現就職於阿里巴巴企業智能事業部 BUC&ACL&SSO 團隊,面向阿里巴巴經濟體提供人員賬號的權限管控、應用數據安全訪問治理,並通過現有的技術沉澱與領域模型,致力於打造 To B、To G 領域的應用信息化架構的基礎設施 SAAS 產品 MOZI 。


分享到:


相關文章: