synchronized 鎖的原理

會說話的丶貓發表於2020-08-04

synchronized 的基本認識

  在多執行緒併發程式設計中 synchronized 一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,隨著 Java SE 1.6 對synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中為了減少獲得鎖和釋放鎖帶來的性
能消耗而引入的偏向鎖和輕量級鎖。這塊在後續我們會慢慢展開

synchronized 的基本語法

  synchronized 有三種方式來加鎖,分別是
  1. 修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖
  2. 靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖
  3. 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。不同的修飾型別,代表鎖的控制粒度

synchronized 的應用

  修改前面的案例,使用 synchronized 關鍵字後,可以達到資料安全的效果

public class Demo {
private static int count = 0;

public static void inc() {
synchronized (Demo.class) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}

public static void main(String[] args)
throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> Demo.inc()).start();
}
Thread.sleep(3000);
System.out.println("執行結果" + count);
}
}
 

思考鎖是如何儲存的

  可以思考一下,要實現多執行緒的互斥特性,那這把鎖需要哪些因素?
  1. 鎖需要有一個東西來表示,比如獲得鎖是什麼狀態、無鎖狀態是什麼狀態
  2. 這個狀態需要對多個執行緒共享那麼我們來分析,synchronized 鎖是如何儲存的呢?觀察synchronized 的整個語法發現,synchronized(lock)是基於lock 這個物件的生命週期來控制鎖粒度的,那是不是鎖的
儲存和這個 lock 物件有關係呢?於是我們以物件在 jvm 記憶體中是如何儲存作為切入點,去看看物件裡面有什麼特效能夠實現鎖

物件在記憶體中的佈局

  在 Hotspot 虛擬機器中,物件在記憶體中的儲存佈局,可以分為三個區域:物件頭(Header)、例項資料(Instance Data)、對齊填充(Padding)

 

 

synchronized 鎖的升級

  在分析 markword 時,提到了偏向鎖、輕量級鎖、重量級鎖。在分析這幾種鎖的區別時,我們先來思考一個問題使用鎖能夠實現資料的安全性,但是會帶來效能的下降。不使用鎖能夠基於執行緒並行提升程式效能,
但是卻不能保證執行緒安全性。這兩者之間似乎是沒有辦法達到既能滿足效能也能滿足安全性的要求。hotspot 虛擬機器的作者經過調查發現,大部分情況下,加鎖的程式碼不僅僅不存在多執行緒競爭,而且總是由同一個執行緒
多次獲得。所以基於這樣一個概率,是的 synchronized 在JDK1.6 之後做了一些優化,為了減少獲得鎖和釋放鎖帶來的效能開銷,引入了偏向鎖、輕量級鎖的概念。因此大家會發現在 synchronized 中,鎖存在四種狀態
分別是:無鎖、偏向鎖、輕量級鎖、重量級鎖; 鎖的狀態根據競爭激烈的程度從低到高不斷升級。

偏向鎖的基本原理

  前面說過,大部分情況下,鎖不僅僅不存在多執行緒競爭,而是總是由同一個執行緒多次獲得,為了讓執行緒獲取鎖的代價更低就引入了偏向鎖的概念。怎麼理解偏向鎖呢?當一個執行緒訪問加了同步鎖的程式碼塊時,會在物件頭中存
儲當前執行緒的 ID,後續這個執行緒進入和退出這段加了同步鎖的程式碼塊時,不需要再次加鎖和釋放鎖。而是直接比較物件頭裡面是否儲存了指向當前執行緒的偏向鎖。如果相等表示偏向鎖是偏向於當前執行緒的,就不需要再嘗試獲得鎖了

偏向鎖的獲取和撤銷邏輯

  1. 首先獲取鎖 物件的 Markword,判斷是否處於可偏向狀態。(biased_lock=1、且 ThreadId 為空)
  2. 如果是可偏向狀態,則通過 CAS 操作,把當前執行緒的 ID寫入到 MarkWord
    a) 如果 cas 成功,那麼 markword 就會變成這樣。表示已經獲得了鎖物件的偏向鎖,接著執行同步程式碼塊
    b) 如果 cas 失敗,說明有其他執行緒已經獲得了偏向鎖,這種情況說明當前鎖存在競爭,需要撤銷已獲得偏向
     鎖的執行緒,並且把它持有的鎖升級為輕量級鎖(這個操作需要等到全域性安全點,也就是沒有執行緒在執行字
     節碼)才能執行
  3. 如果是已偏向狀態,需要檢查 markword 中儲存的ThreadID 是否等於當前執行緒的 ThreadID
    a) 如果相等,不需要再次獲得鎖,可直接執行同步程式碼塊
    b) 如果不相等,說明當前鎖偏向於其他執行緒,需要撤銷偏向鎖並升級到輕量級鎖

偏向鎖的撤銷

  偏向鎖的撤銷並不是把物件恢復到無鎖可偏向狀態(因為偏向鎖並不存在鎖釋放的概念),而是在獲取偏向鎖的過程中,發現 cas 失敗也就是存線上程競爭時,直接把被偏向的鎖物件升級到被加了輕量級鎖的狀態。
對原持有偏向鎖的執行緒進行撤銷時,原獲得偏向鎖的執行緒
  有兩種情況:
  1. 原獲得偏向鎖的執行緒如果已經退出了臨界區,也就是同步程式碼塊執行完了,那麼這個時候會把物件頭設定成無鎖狀態並且爭搶鎖的執行緒可以基於 CAS 重新偏向但前執行緒
  2. 如果原獲得偏向鎖的執行緒的同步程式碼塊還沒執行完,處於臨界區之內,這個時候會把原獲得偏向鎖的執行緒升級為輕量級鎖後繼續執行同步程式碼塊
  在我們的應用開發中,絕大部分情況下一定會存在 2 個以上的執行緒競爭,那麼如果開啟偏向鎖,反而會提升獲取鎖的資源消耗。所以可以通過 jvm 引數UseBiasedLocking 來設定開啟或關閉偏向鎖

 

輕量級鎖的基本原理

輕量級鎖的加鎖和解鎖邏輯

  鎖升級為輕量級鎖之後,物件的 Markword 也會進行相應的的變化。升級為輕量級鎖的過程:
  1. 執行緒在自己的棧楨中建立鎖記錄 LockRecord。
  2. 將鎖物件的物件頭中的MarkWord複製到執行緒的剛剛建立的鎖記錄中。
  3. 將鎖記錄中的 Owner 指標指向鎖物件。
  4. 將鎖物件的物件頭的 MarkWord替換為指向鎖記錄的指標。

自旋鎖

  輕量級鎖在加鎖過程中,用到了自旋鎖所謂自旋,就是指當有另外一個執行緒來競爭鎖時,這個執行緒會在原地迴圈等待,而不是把該執行緒給阻塞,直到那個
獲得鎖的執行緒釋放鎖之後,這個執行緒就可以馬上獲得鎖的。注意,鎖在原地迴圈的時候,是會消耗 cpu 的,就相當於在執行一個啥也沒有的 for 迴圈。
所以,輕量級鎖適用於那些同步程式碼塊執行的很快的場景,這樣,執行緒原地等待很短的時間就能夠獲得鎖了。自旋鎖的使用,其實也是有一定的概率背景,在大部分同
步程式碼塊執行的時間都是很短的。所以通過看似無異議的迴圈反而能提升鎖的效能。但是自旋必須要有一定的條件控制,否則如果一個執行緒執行同步程式碼塊的時間很長,那麼這個執行緒不斷的迴圈反而
會消耗 CPU 資源。預設情況下自旋的次數是 10 次,可以通過 preBlockSpin 來修改在 JDK1.6 之後,引入了自適應自旋鎖,自適應意味著自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自
旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相
對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費處理器資源

輕量級鎖的解鎖

  輕量級鎖的鎖釋放邏輯其實就是獲得鎖的逆向邏輯,通過CAS 操作把執行緒棧幀中的 LockRecord 替換回到鎖物件的MarkWord 中,如果成功表示沒有競爭。如果失敗,表示
當前鎖存在競爭,那麼輕量級鎖就會膨脹成為重量級鎖

 

重量級鎖的基本原理

  當輕量級鎖膨脹到重量級鎖之後,意味著執行緒只能被掛起阻塞來等待被喚醒了。

重量級鎖的 monitor

  加了同步程式碼塊以後,在位元組碼中會看到一個monitorenter 和 monitorexit。每一個 JAVA 物件都會與一個監視器 monitor 關聯,我們可以把它理解成為一把鎖,當一個執行緒想要執行一段被
synchronized 修飾的同步方法或者程式碼塊時,該執行緒得先獲取到 synchronized 修飾的物件對應的 monitor。monitorenter 表示去獲得一個物件監視器。monitorexit 表示釋放 monitor 監視器的所有權,使得其他被阻塞的執行緒
可以嘗試去獲得這個監視器monitor 依賴作業系統的 MutexLock(互斥鎖)來實現的, 執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能

重量級鎖的加鎖的基本流程

  任意執行緒對 Object(Object 由 synchronized 保護)的訪問,首先要獲得 Object 的監視器。如果獲取失敗,執行緒進入同步佇列,執行緒狀態變為 BLOCKED。當訪問 Object 的
前驅(獲得了鎖的執行緒)釋放了鎖,則該釋放操作喚醒阻塞在同步佇列中的執行緒,使其重新嘗試對監視器的獲取。

回顧執行緒的競爭機制

  再來回顧一下執行緒的競爭機制對於鎖升級這塊的一些基本流程。方便大家更好的理解加入有這樣一個同步程式碼塊,存在 Thread#1、Thread#2 等多個執行緒
  synchronized (lock) {
    // do something
  }
  情況一:只有 Thread#1 會進入臨界區;
  情況二:Thread#1 和 Thread#2 交替進入臨界區,競爭不激烈;
  情況三:Thread#1/Thread#2/Thread3… 同時進入臨界區,競爭激烈

偏向鎖

  此時當 Thread#1 進入臨界區時,JVM 會將 lockObject 的物件頭 Mark Word 的鎖標誌位設為“01”,同時會用 CAS 操作把 Thread#1 的執行緒 ID 記錄到 Mark Word 中,此時進
入偏向模式。所謂“偏向”,指的是這個鎖會偏向於 Thread#1,若接下來沒有其他執行緒進入臨界區,則 Thread#1 再出入臨界區無需再執行任何同步操作。也就是說,若只有
Thread#1 會進入臨界區,實際上只有 Thread#1 初次進入臨界區時需要執行 CAS 操作,以後再出入臨界區都不會有同步操作帶來的開銷。

輕量級鎖

  偏向鎖的場景太過於理想化,更多的時候是 Thread#2 也會嘗試進入臨界區, 如果 Thread#2 也進入臨界區但是Thread#1 還沒有執行完同步程式碼塊時,會暫停 Thread#1並且升
級到輕量級鎖。Thread#2 通過自旋再次嘗試以輕量級鎖的方式來獲取鎖

重量級鎖

  如果 Thread#1 和 Thread#2 正常交替執行,那麼輕量級鎖基本能夠滿足鎖的需求。但是如果 Thread#1 和 Thread#2同時進入臨界區,那麼輕量級鎖就會膨脹為重量級鎖,意
味著 Thread#1 執行緒獲得了重量級鎖的情況下,Thread#2就會被阻塞

Synchronized 結合 Java Object 物件中的wait,notify,notifyAll

  前面我們在講 synchronized 的時候,發現被阻塞的執行緒什麼時候被喚醒,取決於獲得鎖的執行緒什麼時候執行完同步程式碼塊並且釋放鎖。那怎麼做到顯示控制呢?我們就需要
借 助 一 個 信 號 機 制 : 在 Object 對 象 中 , 提 供 了wait/notify/notifyall,可以用於控制執行緒的狀態

wait/notify/notifyall 基本概念

  wait:表示持有物件鎖的執行緒 A 準備釋放物件鎖許可權,釋放 cpu 資源並進入等待狀態。
  notify:表示持有物件鎖的執行緒 A 準備釋放物件鎖許可權,通知 jvm 喚 醒 某 個 競 爭 該 對 象 鎖 的 線 程 X 。 線 程 Asynchronized 程式碼執行結束並且釋放了鎖之後,執行緒 X 直
     接獲得物件鎖許可權,其他競爭執行緒繼續等待(即使執行緒 X 同步完畢,釋放物件鎖,其他競爭執行緒仍然等待,直至有新的 notify ,notifyAll 被呼叫)。
  notifyAll:notifyall 和 notify 的區別在於,notifyAll 會喚醒所有競爭同一個物件鎖的所有執行緒,當已經獲得鎖的執行緒A 釋放鎖之後,所有被喚醒的執行緒都有可能獲得物件鎖許可權
  需要注意的是:三個方法都必須在 synchronized 同步關鍵字 所 限 定 的 作 用 域 中 調 用 , 否 則 會 報 錯java.lang.IllegalMonitorStateException ,意思是因為沒有同步,所以
執行緒對物件鎖的狀態是不確定的,不能呼叫這些方法。另外,通過同步機制來確保執行緒從 wait 方法返回時能夠感知到感知到 notify 執行緒對變數做出的修改

相關文章