1. 程式人生 > 實用技巧 >java併發程式設計系列之原理篇-synchronized與鎖

java併發程式設計系列之原理篇-synchronized與鎖

前言

Java中的鎖都是基於物件的鎖,Java中的每一個物件都可以作為一個鎖,我們常聽到類鎖其實也是物件鎖,因為Java類只有一個class物件(一個Java類可以有多個例項物件,多個例項物件共享這一個Java類)。之所以有鎖的概念,都是因為在多個執行緒在訪問一個共享變數資源時會發生一些不可控制的問題。所以,鎖控制的就是共享資源物件。

鎖的分類

Java 6 為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖“。在Java 6 以前,所有的鎖都是”重量級“鎖。所以在Java 6 及其以後,一個物件其實有四種鎖狀態,它們級別由低到高依次是:

  1. 無鎖狀態
  2. 偏向鎖狀態
  3. 輕量級鎖狀態
  4. 重量級鎖狀態

鎖還有其他的分類,比如自旋鎖樂觀鎖和悲觀鎖共享鎖(讀)和獨享鎖(寫)可重入鎖和不可重入鎖公平鎖和非公平鎖,下面我們來介紹一下這些概念。

  • 自旋鎖 - 它是指當一個執行緒在獲取鎖的時候,如果鎖已經被其他執行緒獲取到了,那麼該執行緒將迴圈等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖菜退出迴圈。比如被synchronized關鍵字修飾的物件鎖就是一個自旋鎖。

  • 樂觀鎖 - 它是假設執行緒在爭搶鎖時不會發生衝突(讀寫衝突),如果發生衝突,則判斷當前鎖的狀態是否已經改變,如果改變,則本次寫操作失效,重新進行讀取最新數值後的寫操作。比如ReentrantReadWriteLock中的readLock就實現了樂觀鎖的機制。
  • 悲觀鎖 - 它是假設執行緒在爭搶鎖時一定會發生衝突(讀寫),所以如果一個執行緒爭搶了鎖在進行讀或寫操作的時候,其他執行緒是不能進行讀或寫操作的。比如synchronized和ReentrantLock都是一種悲觀鎖。

  • 共享鎖(讀) - 它是指一個執行緒在擁有該共享鎖的時候,它可以對共享資源進行讀操作,同時其他執行緒同樣也可以獲得該共享鎖對共享資源進行讀的操作,但是不能對共享資源進行寫的操作。比如ReentrantReadWriteLock.readLock()就是一個共享鎖。
  • 獨享鎖(寫) - 它是指一個執行緒擁有了該獨享鎖的時候,它可以對共享資源進行寫操作,但其他執行緒就不能獲得該獨享鎖對共享資源進行讀或者寫的操作。比如ReentrantReadWriteLock.writeLock()就是一個獨享鎖。

  • 可重入鎖 - 它是指一個執行緒獲取了一個可重入鎖之後,它還可以多次來獲取這個可重入鎖,也就是說可以自由進入同一個鎖所同步的其他的程式碼。比如synchronized和ReentrantLock就是一種可重入鎖。
  • 不可重入鎖 - 它是指一個執行緒獲取了一個不可重入鎖之後,不能再進入該鎖所同步的其他程式碼塊。

  • 公平鎖 - 它是指執行緒獲取鎖的順序是按照先進先出(FIFO)的順序來的。比如ReentrantLock可以通過建構函式傳遞一個true來實現公平鎖。
  • 非公平鎖 - 它是指執行緒獲取鎖的順序有可能因為CPU排程策略的不同而使執行緒獲取鎖的順序不確定。synchronized和ReentrantLock都可以實現非公平鎖。

關於鎖的幾種狀態,是鎖被執行緒佔用之後,由於鎖被多個執行緒爭搶,從而由無狀態鎖向其他狀態進行升級轉換。我們這裡結合synchronized關鍵字進行解釋。

Synchronized關鍵字

Synchronized關鍵字,用來給一段程式碼加上一個鎖,從而實現多個執行緒在訪問同一資源物件鎖的時候進行同步執行(也即序列執行)。它是一個獨享、非公平、悲觀的鎖。它通常有以下幾種使用方法:

// 關鍵字在例項方法上,鎖為當前類的例項
public synchronized void instanceLock() {
    // code
}

// 關鍵字在靜態方法上,鎖為當前Class物件
public static synchronized void classLock() {
    // code
}

// 關鍵字在程式碼塊上,鎖為括號裡面的物件
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

被synchronized關鍵字修飾的程式碼塊屬於臨界區,也就是說同一時刻只能有一個執行緒來執行臨界區的程式碼。關於類鎖和類的物件鎖有以下兩種等價寫法:
類的物件鎖寫法

// 關鍵字在例項方法上,鎖為當前例項
public synchronized void instanceLock() {
    // code
}

// 關鍵字在程式碼塊上,鎖為括號裡面的物件
public void blockLock() {
    synchronized (this) {
        // code
    }
}

類鎖的寫法

// 關鍵字在例項方法上,鎖為當前例項
public static synchronized void instanceLock() {
    // code
}

// 關鍵字在程式碼塊上,鎖為括號裡面的物件
public void blockLock() {
    synchronized (this.getClass()) {
        // code
    }
}

鎖的優化

在使用鎖的時候,JVM會對包含鎖的程式碼進行鎖優化,鎖優化包括兩種:鎖消除鎖粗化

  1. 鎖消除

鎖消除可以通過JVM的引數來設定,-XX:+DoEscapeAnalysis -XX:+EliminateLocks。當這樣設定後,當某一個本方法只被一個執行緒重複呼叫,不存在產生安全問題時,JIT會觸發優化,在方法被連續呼叫時預設去掉多餘的鎖。比如某一方法使用StringBuffer的append方法進行連續呼叫時,則會消除中間呼叫方法裡的synchronized。

  1. 鎖粗化

鎖粗化和鎖消除類似,將程式碼中使用多個同一個物件鎖的synchronized關鍵字進行合併,只使用一個。

synchronized關鍵字的原理(鎖狀態的升級過程)

我們知道,當沒有執行緒來爭奪鎖的時候,這個鎖就是一個無狀態鎖,這個時候任何執行緒都可以去嘗試來爭奪該鎖並對共享資源進行修改。而隨著多個執行緒競爭情況的升級,鎖的狀態也會隨著升級。同時,鎖也會發生降級,但它的發生條件是比較苛刻的,它發生在JVM進行Stop-The-World期間,當JVM進入安全點的時候,會檢查是否有閒置的鎖,然後進行降級。這裡我們不過多去討論鎖降級。

java物件頭

由於Java的鎖都是基於物件的,所以我們要來看一看物件的“鎖資訊”被存放在哪裡。我們都知道,Java中的每個例項化物件都存放在堆記憶體中,除了存放該物件的成員變數的資料以外,還有一個物件頭。如果是非陣列型別,則用2個字寬來儲存物件頭,如果是陣列,則會用3個字寬來儲存物件頭。在32位處理器中,一個字寬是32位,64位處理器中,一個字寬是64位。物件頭的內容如下:

長度 內容 說明
32/64bit Mark Word 儲存物件的hashCode或鎖資訊等
32/64bit Class Metadata Address 儲存到物件型別資料的指標
32/64bit Array length 陣列的長度(如果是陣列)

從上表中我們可以看出物件的鎖資訊存放在Mark Word欄位中,那麼我們來看一下具體的Mark Word裡邊的內容:

鎖狀態 29 bit 或 61 bit 1 bit 是否是偏向鎖? 2 bit 鎖標誌位
無鎖 物件的hashcode(25bit)和分代年齡(4bit) 0 01
偏向鎖 執行緒ID(23bit)、Epoch時間戳(2bit)、分代年齡(4bit) 1 01
輕量級鎖 指向棧中鎖記錄的指標 此時這一位不用於表示偏向鎖 00
重量級鎖 指向互斥量(重量級鎖)的指標 此時這一位不用於表示偏向鎖 10
GC標記 ----- 此時這一位不用於表示偏向鎖 11

可以看到,當物件狀態為偏向鎖時,Mark Word儲存的是偏向的執行緒ID;當狀態為輕量級鎖時,Mark Word儲存的是指向執行緒棧中鎖記錄的指標;當狀態為重量級鎖時,Mark Word為指向堆中的monitor物件的指標
現在我們知道了物件的鎖資訊被存放在物件頭的Mark Word裡邊,那麼我們來看一下這個物件鎖是怎麼一步一步升級的。

偏向鎖

Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,於是引入了偏向鎖
偏向鎖會偏向於第一個訪問鎖的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒訪問,則持有偏向鎖的執行緒將永遠不需要觸發同步。也就是說,偏向鎖在資源無競爭情況下消除了同步語句,連CAS操作都不做了,提高了程式的執行效能

一個執行緒在第一次進入同步塊時,會在物件頭和棧幀中的鎖記錄裡儲存鎖的偏向的執行緒ID。當下次該執行緒進入這個同步塊時,會去檢查鎖的Mark Word裡面是不是放的自己的執行緒ID。
如果是,表明該執行緒已經獲得了鎖,以後該執行緒在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖 ;如果不是,就代表有另一個執行緒來競爭這個偏向鎖。這個時候會嘗試使用CAS來替換Mark Word裡面的執行緒ID為新執行緒的ID,這個時候要分兩種情況:

  • 成功,表示之前的執行緒不存在了, Mark Word裡面的執行緒ID為新執行緒的ID,鎖不會升級,仍然為偏向鎖;
  • 失敗,表示之前的執行緒仍然存在,那麼暫停之前的執行緒,設定偏向鎖標識為0,並設定鎖標誌位為00,升級為輕量級鎖,會按照輕量級鎖的方式進行競爭鎖。

CAS: Compare and Swap
比較並設定。用於在硬體層面上提供原子性操作。在 Intel 處理器中,比較並交換通過指令cmpxchg實現。 比較是否和給定的數值一致,如果一致則修改,不一致則不修改。

執行緒競爭偏向鎖的過程如下:

圖中涉及到了lock record指標指向當前堆疊中的最近一個lock record,是輕量級鎖按照先來先服務的模式進行了輕量級鎖的加鎖。
撤銷偏向鎖
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時, 持有偏向鎖的執行緒才會釋放鎖。偏向鎖升級成輕量級鎖時,會暫停擁有偏向鎖的執行緒,重置偏向鎖標識,這個過程看起來容易,實則開銷還是很大的,大概的過程如下:

  1. 在一個安全點(在這個時間點上沒有位元組碼正在執行)停止擁有鎖的執行緒。
  2. 遍歷執行緒棧,如果存在鎖記錄的話,需要修復鎖記錄和Mark Word,使其變成無鎖狀態。
  3. 喚醒被停止的執行緒,將當前鎖升級成輕量級鎖。
    所以,如果應用程式裡所有的鎖通常處於競爭狀態,那麼偏向鎖就會是一種累贅,對於這種情況,我們可以一開始就把偏向鎖這個預設功能給關閉:
-XX:UseBiasedLocking=false

下面這個經典的圖總結了偏向鎖的獲得和撤銷:

輕量級鎖

JVM會為每個執行緒在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,我們稱為Displaced Mark Word。如果一個執行緒獲得鎖的時候發現是輕量級鎖,會把鎖的Mark Word複製到自己的Displaced Mark Word裡面。
然後執行緒嘗試用CAS將鎖的Mark Word中用來指向棧中鎖記錄的位置替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,那麼當前執行緒的棧幀中儲存著鎖的記錄,另外棧中還有一個用來指向當前物件的物件頭Mark Word的一個owner儲存空間,用來說明當前引用的是哪個物件鎖。如果失敗,表示Mark Word已經被替換成了其他執行緒的鎖記錄,說明在與其它執行緒競爭鎖,當前執行緒就嘗試使用自旋來獲取鎖。

自旋:不斷嘗試去獲取鎖,一般用迴圈來實現。

自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該執行緒就一直處在自旋狀態,白白浪費CPU資源。解決這個問題最簡單的辦法就是指定自旋的次數,例如讓其迴圈10次,如果還沒獲取到鎖就進入阻塞狀態。
但是JDK採用了更聰明的方式——適應性自旋,簡單來說就是執行緒如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。
自旋也不是一直進行下去的,如果自旋到一定程度(和JVM、作業系統相關),依然沒有獲取到鎖,稱為自旋失敗,那麼這個執行緒會阻塞。同時這個鎖就會升級成重量級鎖。
輕量級鎖的釋放
在釋放鎖時,當前執行緒會使用CAS操作將Displaced Mark Word的內容複製回鎖的Mark Word裡面。如果沒有發生競爭,那麼這個複製的操作會成功。如果有其他執行緒因為自旋多次導致輕量級鎖升級成了重量級鎖,那麼CAS操作會失敗,此時會釋放鎖並喚醒被阻塞的執行緒。
請參考下邊的加鎖和解鎖流程圖:

重量級鎖

當一個物件鎖成為了重量級鎖之後,它的Mark Word中就有一個位置用來指向一個用來監視執行緒狀態的物件監視器,它是通過互斥量來實現的。重量級鎖依賴於作業系統的互斥量(mutex) 實現的,而作業系統中執行緒間狀態的轉換需要相對比較長的時間,所以重量級鎖效率很低,但被阻塞的執行緒不會消耗CPU。
前面說到,每一個物件都可以當做一個鎖,當多個執行緒同時請求某個物件鎖時,物件鎖會使用一個物件監視器Monitor來監視該物件。這個Monitor中設定幾種狀態用來區分請求的執行緒:

  • Contention List:所有請求鎖的執行緒將被首先放置到該競爭佇列
  • Entry List:Contention List中那些有資格成為候選人的執行緒被移到Entry List
  • Wait Set:那些呼叫wait方法被阻塞的執行緒被放置到Wait Set
  • OnDeck:任何時刻最多隻能有一個執行緒正在競爭鎖,該執行緒稱為OnDeck
  • Owner:獲得鎖的執行緒稱為Owner
  • !Owner:釋放鎖的執行緒
    當一個執行緒嘗試獲得鎖時,如果該鎖已經被佔用,則會將該執行緒封裝成一個ObjectWaiter物件插入到Contention List的佇列的隊首,然後呼叫park函式掛起當前執行緒。
    當執行緒釋放鎖時,會從Contention List或EntryList中挑選一個執行緒喚醒,被選中的執行緒叫做Heir presumptive即假定繼承人,假定繼承人被喚醒後會嘗試獲得鎖,但synchronized是非公平的,所以假定繼承人不一定能獲得鎖。這是因為對於重量級鎖,執行緒先自旋嘗試獲得鎖,這樣做的目的是為了減少執行作業系統同步操作帶來的開銷。如果自旋不成功再進入等待佇列。這對那些已經在等待佇列中的執行緒來說,稍微顯得不公平,還有一個不公平的地方是自旋執行緒可能會搶佔了Ready執行緒的鎖。
    如果執行緒獲得鎖後呼叫Object.wait方法,則會將執行緒加入到WaitSet中,當被Object.notify喚醒後,會將執行緒從WaitSet移動到Contention List或EntryList中去。需要注意的是,當呼叫一個鎖物件的wait或notify方法時,如當前鎖的狀態是偏向鎖或輕量級鎖則會先膨脹成重量級鎖

鎖升級的步驟總結

每一個執行緒在準備獲取共享資源時:

第一步,檢查MarkWord裡面是不是放的自己的ThreadId ,如果是,表示當前執行緒是處於 “偏向鎖” 。

第二步,如果MarkWord不是自己的ThreadId,鎖升級,這時候,用CAS來執行切換,新的執行緒根據MarkWord裡面現有的ThreadId,通知之前執行緒暫停,之前執行緒將Markword的內容置為空。

第三步,兩個執行緒都把鎖物件的HashCode複製到自己新建的用於儲存鎖的記錄空間,接著開始通過CAS操作, 把鎖物件的MarKword的內容修改為自己新建的記錄空間的地址的方式競爭MarkWord。

第四步,第三步中成功執行CAS的獲得資源,失敗的則進入自旋 。

第五步,自旋的執行緒在自旋過程中,成功獲得資源(即之前獲的資源的執行緒執行完成並釋放了共享資源),則整個狀態依然處於 輕量級鎖的狀態,如果自旋失敗 。

第六步,進入重量級鎖的狀態,這個時候,自旋的執行緒進行阻塞,等待之前執行緒執行完成並喚醒自己。

不同等級的鎖優缺點對比

下表來自《Java併發程式設計的藝術》:

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗。 適用於只有一個執行緒訪問同步塊場景。
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度。 如果始終得不到鎖競爭的執行緒使用自旋會消耗CPU。 追求響應時間。同步塊執行速度非常快。
重量級鎖 執行緒競爭不使用自旋,不會消耗CPU。 執行緒阻塞,響應時間緩慢。 追求吞吐量。同步塊執行時間較長。

參考資料

深入淺出Java多執行緒
《Java併發程式設計的藝術》