1. 程式人生 > 其它 >10.多執行緒詳解

10.多執行緒詳解

鎖的記憶體語義

synchronized的底層是使用作業系統的mutex lock實現的。

  • 記憶體可見性:同步快的可見性是由“如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值”、“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store和write操作)”這兩條規則獲得的。

  • 操作原子性:持有同一個鎖的兩個同步塊只能序列地進入

鎖的記憶體語義:

  • 當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中

  • 當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數

鎖釋放和鎖獲取的記憶體語義:

  • 執行緒A釋放一個鎖,實質上是執行緒A向接下來將要獲取這個鎖的某個執行緒發出了(執行緒A對共享變數所做修改的)訊息。

  • 執行緒B獲取一個鎖,實質上是執行緒B接收了之前某個執行緒發出的(在釋放這個鎖之前對共享變數所做修改的)訊息。

  • 執行緒A釋放鎖,隨後執行緒B獲取這個鎖,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息

 

 

synchronized鎖

synchronized用的鎖是存在Java物件頭裡的。

JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步。程式碼塊同步是使用monitorenter和monitorexit指令實現的,monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit是插入到方法結束處和異常處。任何物件都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。

根據虛擬機器規範的要求,在執行monitorenter指令時,首先要去嘗試獲取物件的鎖,如果這個物件沒被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1;相應地,在執行monitorexit指令時會將鎖計數器減1,當計數器被減到0時,鎖就釋放了。如果獲取物件鎖失敗了,那當前執行緒就要阻塞等待,直到物件鎖被另一個執行緒釋放為止。

注意兩點:

1、synchronized同步快對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題;

2、同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。

Mutex Lock

監視器鎖(Monitor)本質是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的。每個物件都對應於一個可稱為" 互斥鎖" 的標記,這個標記用來保證在任一時刻,只能有一個執行緒訪問該物件。

互斥鎖:用於保護臨界區,確保同一時間只有一個執行緒訪問資料。對共享資源的訪問,先對互斥量進行加鎖,如果互斥量已經上鎖,呼叫執行緒會阻塞,直到互斥量被解鎖。在完成了對共享資源的訪問後,要對互斥量進行解鎖。

mutex的工作方式:

 

  • 1) 申請mutex

  • 2) 如果成功,則持有該mutex

  • 3) 如果失敗,則進行spin自旋. spin的過程就是線上等待mutex, 不斷髮起mutex gets, 直到獲得mutex或者達到spin_count限制為止

  • 4) 依據工作模式的不同選擇yiled還是sleep

  • 5) 若達到sleep限制或者被主動喚醒或者完成yield, 則重複1)~4)步,直到獲得為止

由於Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一條執行緒,都需要作業系統來幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間。所以synchronized是Java語言中的一個重量級操作。在JDK1.6中,虛擬機器進行了一些優化,譬如在通知作業系統阻塞執行緒之前加入一段自旋等待過程,避免頻繁地切入到核心態中:

synchronized與java.util.concurrent包中的ReentrantLock相比,由於JDK1.6中加入了針對鎖的優化措施(見後面),使得synchronized與ReentrantLock的效能基本持平。ReentrantLock只是提供了synchronized更豐富的功能,而不一定有更優的效能,所以在synchronized能實現需求的情況下,優先考慮使用synchronized來進行同步。

Java物件頭

 

在執行期間,Mark Word裡儲存的資料會隨著鎖標誌位的變化而變化,以32位的JDK為例:

鎖優化

偏向鎖、輕量級鎖、重量級鎖

Synchronized是通過物件內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的。而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。因此,這種依賴於作業系統Mutex Lock所實現的鎖我們稱之為“重量級鎖”。

Java SE 1.6為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖”:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖可以升級但不能降級。

偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得。偏向鎖是為了在只有一個執行緒執行同步塊時提高效能。

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖。引入偏向鎖是為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令(由於一旦出現多執行緒競爭的情況就必須撤銷偏向鎖,所以偏向鎖的撤銷操作的效能損耗必須小於節省下來的CAS原子指令的效能消耗)。

偏向鎖獲取過程:

  • (1)訪問Mark Word中偏向鎖的標識是否設定成1,鎖標誌位是否為01——確認為可偏向狀態。

  • (2)如果為可偏向狀態,則測試執行緒ID是否指向當前執行緒,如果是,進入步驟(5),否則進入步驟(3)。

  • (3)如果執行緒ID並未指向當前執行緒,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中執行緒ID設定為當前執行緒ID,然後執行(5);如果競爭失敗,執行(4)。

  • (4)如果CAS獲取偏向鎖失敗,則表示有競爭(CAS獲取偏向鎖失敗說明至少有過其他執行緒曾經獲得過偏向鎖,因為執行緒不會主動去釋放偏向鎖)。當到達全域性安全點(safepoint)時,會首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著(因為可能持有偏向鎖的執行緒已經執行完畢,但是該執行緒並不會主動去釋放偏向鎖),如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態(標誌位為“01”),然後重新偏向新的執行緒;如果執行緒仍然活著,撤銷偏向鎖後升級到輕量級鎖狀態(標誌位為“00”),此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖。

  • (5)執行同步程式碼。

偏向鎖的釋放過程:

如上步驟(4)。偏向鎖使用了一種等到競爭出現才釋放偏向鎖的機制:偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位為“01”)或輕量級鎖(標誌位為“00”)的狀態。

關閉偏向鎖:

偏向鎖在Java 6和Java 7裡是預設啟用的。由於偏向鎖是為了在只有一個執行緒執行同步塊時提高效能,如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。

輕量級鎖

輕量級鎖是為了在執行緒近乎交替執行同步塊時提高效能。

輕量級鎖的加鎖過程:

  • (1)在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。這時候執行緒堆疊與物件頭的狀態如下圖所示。

  • (2)拷貝物件頭中的Mark Word複製到鎖記錄中。

  • (3)拷貝成功後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,並將Lock record裡的owner指標指向object mark word。如果更新成功,則執行步驟(3),否則執行步驟(4)。

  • (4)如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如下圖所示。

  • (5)如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,若當前只有一個等待執行緒,則可通過自旋稍微等待一下,可能另一個執行緒很快就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。 

輕量級鎖的解鎖過程:

  • (1)通過CAS操作嘗試把執行緒中複製的Displaced Mark Word物件替換當前的Mark Word。

  • (2)如果替換成功,整個同步過程就完成了。

  • (3)如果替換失敗,說明有其他執行緒嘗試過獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的執行緒。

重量級鎖

如上輕量級鎖的加鎖過程步驟(5),輕量級鎖所適應的場景是執行緒近乎交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。Mark Word的鎖標記位更新為10,Mark Word指向互斥量(重量級鎖)

Synchronized的重量級鎖是通過物件內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的作業系統的Mutex Lock(互斥鎖)來實現的。而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。

(具體見前面的mutex lock)

偏向鎖、輕量級鎖、重量級鎖之間轉換 

 

偏向所鎖,輕量級鎖都是樂觀鎖,重量級鎖是悲觀鎖。

  • 一個物件剛開始例項化的時候,沒有任何執行緒來訪問它的時候。它是可偏向的,意味著,它現在認為只可能有一個執行緒來訪問它,所以當第一個執行緒來訪問它的時候,它會偏向這個執行緒,此時,物件持有偏向鎖。偏向第一個執行緒,這個執行緒在修改物件頭成為偏向鎖的時候使用CAS操作,並將物件頭中的ThreadID改成自己的ID,之後再次訪問這個物件時,只需要對比ID,不需要再使用CAS在進行操作。

  •  一旦有第二個執行緒訪問這個物件,因為偏向鎖不會主動釋放,所以第二個執行緒可以看到物件時偏向狀態,這時表明在這個物件上已經存在競爭了。檢查原來持有該物件鎖的執行緒是否依然存活,如果掛了,則可以將物件變為無鎖狀態,然後重新偏向新的執行緒。如果原來的執行緒依然存活,則馬上執行那個執行緒的操作棧,檢查該物件的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖,(偏向鎖就是這個時候升級為輕量級鎖的),此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖;如果不存在使用了,則可以將物件回覆成無鎖狀態,然後重新偏向。

  • 輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個執行緒對於同一個鎖的操作都會錯開,或者說稍微等待一下(自旋),另一個執行緒就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止CPU空轉。

其他鎖優化

 

鎖消除

鎖消除即刪除不必要的加鎖操作。虛擬機器即時編輯器在執行時,對一些“程式碼上要求同步,但是被檢測到不可能存在共享資料競爭”的鎖進行消除。

根據程式碼逃逸技術,如果判斷到一段程式碼中,堆上的資料不會逃逸出當前執行緒,那麼可以認為這段程式碼是執行緒安全的,不必要加鎖。

看下面這段程式:

雖然StringBuffer的append是一個同步方法,但是這段程式中的StringBuffer屬於一個區域性變數,並且不會從該方法中逃逸出去(即StringBuffer sb的引用沒有傳遞到該方法外,不可能被其他執行緒拿到該引用),所以其實這過程是執行緒安全的,可以將鎖消除。

鎖粗化

如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,那即使沒有出現執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

如果虛擬機器檢測到有一串零碎的操作都是對同一物件的加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部。

舉個例子:

 

這裡每次呼叫stringBuffer.append方法都需要加鎖和解鎖,如果虛擬機器檢測到有一系列連串的對同一個物件加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次append方法時進行加鎖,最後一次append方法結束後進行解鎖。

自旋鎖與自適應自旋鎖

  • 引入自旋鎖的原因:互斥同步對效能最大的影響是阻塞的實現,因為掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的併發效能帶來很大的壓力。同時虛擬機器的開發團隊也注意到在許多應用上面,共享資料的鎖定狀態只會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒執行緒是非常不值得的。

  • 自旋鎖:讓該執行緒執行一段無意義的忙迴圈(自旋)等待一段時間,不會被立即掛起(自旋不放棄處理器額執行時間),看持有鎖的執行緒是否會很快釋放鎖。自旋鎖在JDK 1.4.2中引入,預設關閉,但是可以使用-XX:+UseSpinning開開啟;在JDK1.6中預設開啟。

  • 自旋鎖的缺點:自旋等待不能替代阻塞,雖然它可以避免執行緒切換帶來的開銷,但是它佔用了處理器的時間。如果持有鎖的執行緒很快就釋放了鎖,那麼自旋的效率就非常好;反之,自旋的執行緒就會白白消耗掉處理器的資源,它不會做任何有意義的工作,這樣反而會帶來效能上的浪費。所以說,自旋等待的時間(自旋的次數)必須要有一個限度,例如讓其迴圈10次,如果自旋超過了定義的時間仍然沒有獲取到鎖,則應該被掛起(進入阻塞狀態)。通過引數-XX:PreBlockSpin可以調整自旋次數,預設的自旋次數為10。

  • 自適應的自旋鎖:JDK1.6引入自適應的自旋鎖,自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定:如果在同一個鎖的物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。簡單來說,就是執行緒如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。

  • 自旋鎖使用場景:從輕量級鎖獲取的流程中我們知道,當執行緒在獲取輕量級鎖的過程中執行CAS操作失敗時,是要通過自旋來獲取重量級鎖的。(見前面“輕量級鎖”)

 

總結

    • synchronized特點:保證記憶體可見性、操作原子性

    • synchronized影響效能的原因

      • 1、加鎖解鎖操作需要額外操作; 

      • 2、互斥同步對效能最大的影響是阻塞的實現,因為阻塞涉及到的掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成(使用者態與核心態的切換的效能代價是比較大的)

    • synchronized鎖:物件頭中的Mark Word根據鎖標誌位的不同而被複用

      • 偏向鎖:在只有一個執行緒執行同步塊時提高效能。Mark Word儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單比較ThreadID。特點:只有等到執行緒競爭出現才釋放偏向鎖,持有偏向鎖的執行緒不會主動釋放偏向鎖。之後的執行緒競爭偏向鎖,會先檢查持有偏向鎖的執行緒是否存活,如果不存貨,則物件變為無鎖狀態,重新偏向;如果仍存活,則偏向鎖升級為輕量級鎖,此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖

      • 輕量級鎖:在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,嘗試拷貝鎖物件目前的Mark Word到棧幀的Lock Record,若拷貝成功:虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,並將Lock record裡的owner指標指向物件的Mark Word。若拷貝失敗:若當前只有一個等待執行緒,則可通過自旋稍微等待一下,可能持有輕量級鎖的執行緒很快就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖

      • 重量級鎖:指向互斥量(mutex),底層通過作業系統的mutex lock實現。等待鎖的執行緒會被阻塞,由於Linux下Java執行緒與作業系統核心態執行緒一一對映,所以涉及到使用者態和核心態的切換、作業系統核心態中的執行緒的阻塞和恢復。

    • 來源 https://www.cnblogs.com/g012/p/15504571.html