1. 程式人生 > 實用技巧 >多執行緒原理(4)-synchronized與鎖

多執行緒原理(4)-synchronized與鎖

yy

這篇文章我們來聊一聊Java多執行緒裡面的“鎖”。

首先需要明確的一點是:Java多執行緒的鎖都是基於物件的,Java中的每一個物件都可以作為一個鎖。

還有一點需要注意的是,我們常聽到的類鎖其實也是物件鎖。

Java類只有一個Class物件(可以有多個例項物件,多個例項共享這個Class物件),而Class物件也是特殊的Java物件。所以我們常說的類鎖,其實就是Class物件的鎖。

9.1 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

關鍵字在方法上,那臨界區就是整個方法內部。而如果是使用synchronized程式碼塊,那臨界區就指的是程式碼塊內部的區域。

9.2 鎖

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

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

無鎖就是沒有對資源進行鎖定,任何執行緒都可以嘗試去修改它,無鎖在這裡不再細講。

幾種鎖會隨著競爭情況逐漸升級,鎖的升級很容易發生,但是鎖降級發生的條件會比較苛刻,鎖降級發生在Stop The World期間,當JVM進入安全點的時候,會檢查是否有閒置的鎖,然後進行降級。

關於鎖降級有兩點說明:

1.不同於大部分文章說鎖不能降級,實際上HotSpot JVM 是支援鎖降級的,文末有連結。

2.上面提到的Stop The World期間,以及安全點,這些知識是屬於JVM的知識範疇,本文不做細講。

下面分別介紹這幾種鎖以及它們之間的升級。

9.2.1 Java物件頭

前面我們提到,Java的鎖都是基於物件的。首先我們來看看一個物件的“鎖”的資訊是存放在什麼地方的。

每個Java物件都有物件頭。如果是非陣列型別,則用2個字寬來儲存物件頭,如果是陣列,則會用3個字寬來儲存物件頭。在32位處理器中,一個字寬是32位;在64位虛擬機器中,一個字寬是64位。物件頭的內容如下表

我們主要來看看Mark Word的格式:

  Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,於是引入了偏向鎖。

偏向鎖會偏向於第一個訪問鎖的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒訪問,則持有偏向鎖的執行緒將永遠不需要觸發同步。也就是說,偏向鎖在資源無競爭情況下消除了同步語句,連CAS操作都不做了,提高了程式的執行效能。

大白話就是對鎖置個變數,如果發現為true,代表資源無競爭,則無需再走各種加鎖/解鎖流程。如果為false,代表存在其他執行緒競爭資源,那麼就會走後面的流程。

實現原理

一個執行緒在第一次進入同步塊時,會在物件頭和棧幀中的鎖記錄裡儲存鎖的偏向的執行緒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. 喚醒被停止的執行緒,將當前鎖升級成輕量級鎖。

所以,如果應用程式裡所有的鎖通常處於競爭狀態,那麼偏向鎖就會是一種累贅,對於這種情況,我們可以一開始就把偏向鎖這個預設功能給關閉。

9.2.3 輕量級鎖

  多個執行緒在不同時段獲取同一把鎖,即不存在鎖競爭的情況,也就沒有執行緒阻塞。針對這種情況,JVM採用輕量級鎖來避免執行緒的阻塞與喚醒

輕量級鎖的加鎖

  JVM會為每個執行緒在當前執行緒的棧幀中建立用於儲存鎖記錄的空間Lock Record,如果一個執行緒獲得鎖的時候發現是輕量級鎖,會把鎖的Mark Word複製到自己的Lock Record中/

  然後執行緒嘗試用CAS(compare and swap)將鎖的Mark Word替換為指向Lock Record的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示Mark Word已經被替換成了其他執行緒的鎖記錄,說明在與其它執行緒競爭鎖,當前執行緒就嘗試使用自旋來獲取鎖。

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

自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該執行緒就一直處在自旋狀態,白白浪費CPU資源。解決這個問題最簡單的辦法就是指定自旋的次數,例如讓其迴圈10次,如果還沒獲取到鎖就進入阻塞狀態。

但是JDK採用了更聰明的方式——適應性自旋,簡單來說就是執行緒如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。

自旋也不是一直進行下去的,如果自旋到一定程度(和JVM、作業系統相關),依然沒有獲取到鎖,稱為自旋失敗,那麼這個執行緒會阻塞。同時這個鎖就會升級成重量級鎖。

輕量級鎖的釋放:

在釋放鎖時,當前執行緒會使用CAS操作將Lock Record的MarWodl的內容複製回鎖的Mark Word裡面。如果沒有發生競爭,那麼這個複製的操作會成功。如果有其他執行緒因為自旋多次導致輕量級鎖升級成了重量級鎖,那麼CAS操作會失敗,此時會釋放鎖並喚醒被阻塞的執行緒。

9.2.4 重量級鎖

重量級鎖依賴於作業系統的互斥量(mutex) 實現的,而作業系統中執行緒間狀態的轉換需要相對比較長的時間,所以重量級鎖效率很低,但被阻塞的執行緒不會消耗CPU。

前面說到,每一個物件都可以當做一個鎖,當多個執行緒同時請求某個物件鎖時,物件鎖會設定幾種狀態用來區分請求的執行緒:

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中去。需要注意的是,當呼叫一個鎖物件的waitnotify方法時,如當前鎖的狀態是偏向鎖或輕量級鎖則會先膨脹成重量級鎖。

9.2.5 總結鎖的升級流程

每一個執行緒在準備獲取共享資源時: 第一步,檢查MarkWord裡面是不是放的自己的ThreadId/初始化的預設值 ,如果是,表示當前執行緒是處於 “偏向鎖” 。

第二步,如果MarkWord不是自己的ThreadId,如果之前的執行緒已經死了,那寫入自己的threadId到鎖的mark word中,仍為“偏向鎖”;如果之前執行緒未死亡,鎖升級,這時候,用CAS來執行切換,新的執行緒根據MarkWord裡面現有的ThreadId,通知之前執行緒暫停,之前執行緒將Markword的內容置為空。

第三步,兩個執行緒都把鎖物件Mark Word複製到自己lock record,接著開始通過CAS操作, 嘗試把指向lock record的指標寫入鎖的markword。

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

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

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

9.2.6 各種鎖的優缺點對比

總結:在 Java 語言中,使用 Synchronized 是能夠實現執行緒同步的,即加鎖,由於是悲觀鎖,在操作同步資源的時候直接先加鎖。為了優化,提高效能,引入了無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態。偏向鎖通過對比 Mark Word的ThreadId 解決加鎖問題,避免執行CAS操作;輕量級鎖通過CAS操作和自旋來解決加鎖問題,避免阻塞和喚醒影響效能;重量級鎖會導致執行緒阻塞。

原文:http://redspider.group:4000/article/02/9.html