詳解Java多執行緒鎖之synchronized

  • 2019 年 10 月 8 日
  • 筆記

synchronized是Java中解決並發問題的一種最常用的方法,也是最簡單的一種方法。

synchronized的四種使用方式

  1. 修飾程式碼塊:被修飾的程式碼塊稱為同步語句塊,其作用的範圍是大括弧{}括起來的程式碼,作用於調用對象
  2. 修飾方法:被修飾的方法稱為同步方法,其作用的範圍是整個方法,作用於調用對象

    注意:synchronized修飾方法時必須是顯式調用,如果沒有顯式調用,例如子類重寫該方法時沒有顯式加上synchronized,則不會有加鎖效果。

  3. 修飾靜態方法:其作用的範圍是整個靜態方法,作用於所有對象
  4. 修飾類:其作用的範圍是synchronized後面括弧括起來的部分(例如:test.class),作用於所有對象

對象鎖和類鎖是否會互相影響么?

  • 對象鎖:Java的所有對象都含有1個互斥鎖,這個鎖由JVM自動獲取和釋放。執行緒進入synchronized方法的時候獲取該對象的鎖,當然如果已經有執行緒獲取了這個對象的鎖,那麼當前執行緒會等待;synchronized方法正常返回或者拋異常而終止,JVM會自動釋放對象鎖。這裡也體現了用synchronized來加鎖的1個好處,方法拋異常的時候,鎖仍然可以由JVM來自動釋放。

  • 類鎖:對象鎖是用來控制實例方法之間的同步,類鎖是用來控制靜態方法(或靜態變數互斥體)之間的同步。其實類鎖只是一個概念上的東西,並不是真實存在的,它只是用來幫助我們理解鎖定實例方法和靜態方法的區別的。java類可能會有很多個對象,但是只有1個Class對象,也就是說類的不同實例之間共享該類的Class對象。Class對象其實也僅僅是1個java對象,只不過有點特殊而已。由於每個java對象都有1個互斥鎖,而類的靜態方法是需要Class對象。所以所謂的類鎖,不過是Class對象的鎖而已。

類鎖和對象鎖不是同1個東西,一個是類的Class對象的鎖,一個是類的實例的鎖。也就是說:1個執行緒訪問靜態synchronized的時候,允許另一個執行緒訪問對象的實例synchronized方法。反過來也是成立的,因為他們需要的鎖是不同的。

對應的實驗程式碼如下:

@Slf4j  public class SynchronizedExample {        // 修飾一個程式碼塊      public void test1(int j) {          synchronized (this) {              for (int i = 0; i < 10; i++) {                  log.info("test1 {} - {}", j, i);              }          }      }        // 修飾一個方法      public synchronized void test2(int j) {          for (int i = 0; i < 10; i++) {              log.info("test2 {} - {}", j, i);          }      }        // 修飾一個類      public static void test3(int j) {          synchronized (SynchronizedExample.class) {              for (int i = 0; i < 10; i++) {                  log.info("test3 {} - {}", j, i);              }          }      }        // 修飾一個靜態方法      public static synchronized void test4(int j) {          for (int i = 0; i < 10; i++) {              log.info("test4 {} - {}", j, i);          }      }        public static void main(String[] args) {          SynchronizedExample example1 = new SynchronizedExample();          SynchronizedExample example2 = new SynchronizedExample();          ExecutorService executorService = Executors.newCachedThreadPool();          executorService.execute(() -> {              example1.test2(1);          });          executorService.execute(() -> {              example2.test2(2);          });      }  }

在JDK1.6之前,synchronized一直被稱呼為重量級鎖(重量級鎖就是採用互斥量來控制對資源的訪問)。通過反編譯成位元組碼指令可以看到,synchronized會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令。根據虛擬機規範的要求,在執行monitorenter指令時,首先要嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前執行緒已經擁有了那個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將鎖計算器減1,當計數器為0時,鎖就被釋放,然後notify通知所有等待的執行緒。
Java的執行緒是映射到作業系統的原生執行緒上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來幫忙完成,這就需要用戶態和內核態切換,大量的狀態轉換需要耗費很多處理器的時間。

synchronized的優化

在JDK1.6中對鎖的實現引入了大量的優化:

  1. 鎖粗化(Lock Coarsening):將多個連續的鎖擴展成一個範圍更大的鎖,用以減少頻繁互斥同步導致的性能損耗。
  2. 鎖消除(Lock Elimination):JVM即時編譯器在運行時,通過逃逸分析,如果判斷一段程式碼中,堆上的所有數據不會逃逸出去從來被其他執行緒訪問到,就可以去除這個鎖。
  3. 偏向鎖(Biased Locking):目的是消除數據無競爭情況下的同步原語。使用CAS記錄獲取它的執行緒。下一次同一個執行緒進入則偏向該執行緒,無需任何同步操作。
  4. 適應性自旋(Adaptive Spinning):為了避免執行緒頻繁掛起、恢復的狀態切換消耗。執行緒會進入自旋狀態。JDK1.6引入了自適應自旋。自旋時間根據之前鎖自旋時間和執行緒狀態,動態變化,可以能減少自旋的時間。
  5. 輕量級鎖(Lightweight Locking):在沒有多執行緒競爭的情況下避免重量級互斥鎖,只需要依靠一條CAS原子指令就可以完成鎖的獲取及釋放。

在JDK1.6之後,synchronized不再是重量級鎖,鎖的狀態變成以下四種狀態:
無鎖->偏向鎖->輕量級鎖->重量級鎖

鎖的狀態

自適應自旋鎖

大部分時候,共享數據的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒「稍等一下」,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。這項技術就是所謂的自旋鎖。
自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有獲取到鎖,則該執行緒應該被掛起。在JDK1.6中引入了自適應的自旋鎖,自適應意味著自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

所謂自旋,不是獲取不到就阻塞,而是在原地等待一會兒,再次嘗試(當然次數或者時長有限),他是以犧牲CPU為代價來換取內核狀態切換帶來的開銷。藉助於適應性自旋,可以在CPU時間片的損耗和內核狀態的切換開銷之間相對的找到一個平衡,進而能夠提高性能

偏向鎖

大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。當一個執行緒訪問同步塊並獲取鎖時,會在對象頭的鎖記錄里存儲鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的MarkWord里是否存儲著指向當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要再測試一下MarkWord中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前執行緒,如果失敗則進行輕量鎖的升級。

偏向鎖

輕量級鎖

如果說偏向鎖是只允許一個執行緒獲得鎖,那麼輕量級鎖就是允許多個執行緒獲得鎖,但是只允許他們順序拿鎖,不允許出現競爭,也就是拿鎖失敗的情況,輕量級鎖的步驟如下:

  1. 執行緒1在執行同步程式碼塊之前,JVM會先在當前執行緒的棧幀中創建一個空間用來存儲鎖記錄,然後再把對象頭中的MarkWord複製到該鎖記錄中,官方稱之為DisplacedMarkWord。然後執行緒嘗試使用CAS將對象頭中的MarkWord替換為指向鎖記錄的指針。如果成功,則獲得鎖,進入步驟3)。如果失敗執行步驟2)
  2. 執行緒自旋,自旋成功則獲得鎖,進入步驟3)。自旋失敗,則膨脹成為重量級鎖,並把鎖標誌位變為10,執行緒阻塞進入步驟3)
  3. 鎖的持有執行緒執行同步程式碼,執行完CAS替換MarkWord成功釋放鎖,如果CAS成功則流程結束,CAS失敗執行步驟4)
  4. CAS執行失敗說明期間有執行緒嘗試獲得鎖並自旋失敗,輕量級鎖升級為了重量級鎖,此時釋放鎖之後,還要喚醒等待的執行緒

輕量級鎖

重量級鎖

自旋的執行緒在自旋過程中,成功獲得資源(即之前獲的資源的執行緒執行完成並釋放了共享資源),則整個狀態依然處於輕量級鎖的狀態,如果自旋失敗則進入重量級鎖的狀態,這個時候,自旋的執行緒進行阻塞,等待之前執行緒執行完成並喚醒自己,需要從用戶態切換到內核態實現。(當競爭競爭激烈時,執行緒直接進入阻塞狀態。不過在高版本的JVM中不會立刻進入阻塞狀態而是會自旋一小會兒看是否能獲取鎖如果不能則進入阻塞狀態。)

總結

可以簡單總結是如下場景:

  1. 只有一個執行緒進入加鎖區,鎖狀態是偏向鎖
  2. 多個執行緒交替進入加鎖區,鎖狀態可能是輕量級鎖
  3. 多執行緒同時進入加鎖區,鎖狀態可能是重量級鎖

最後,限於筆者經驗水平有限,歡迎讀者就文中的觀點提出寶貴的建議和意見。如果想獲得更多的學習資源或者想和更多的技術愛好者一起交流,可以關注我的公眾號『全菜工程師小輝』後台回復關鍵詞領取學習資料、進入後端技術交流群和程式設計師副業群。同時也可以加入程式設計師副業群Q群:735764906 一起交流。

哎呀,如果我的名片丟了。微信搜索「全菜工程師小輝」,依然可以找到我