17 張圖帶你秒殺 Synchronized 關鍵字

寫在前面

最近我和我在 BAT 的小夥伴合作,做了一個互聯網大廠八股文在線題庫,這個網站定期更新最近各大廠面試題,歡迎大家關注。

網站鏈接:http://interviewtop.top

引子

小艾和小牛在路上相遇,小艾一臉沮喪。

小牛:小艾小艾,發生甚麼事了?

小艾:別提了,昨天有個面試官問了我好幾個關於 synchronized 關鍵字的問題,沒答上來。

小艾:我後來查了很多資料,有二十多頁的概念說明,也有三十來頁的源碼剖析,看得我頭大。

小牛:你那看的是死知識,不好用,你得聽我的總結。

小艾:看來是有備而來,那您給講講吧。

小牛:那咱們開始!

synchronized 關鍵字引入

我們知道,在多線程程序中往往會出現這麼一個情況:多個線程同時訪問某個線程間的共享變量。來舉個例子吧:

假設銀行存款業務寫了兩個方法,一個是存錢 store() 方法 ,一個是查詢餘額 get() 方法。假設初始客戶小明的賬戶餘額爲 0 元。(PS:這個例子只是個 toy demo,爲了方便大家理解寫的,真實的業務場景不會這樣。)

    // account 客戶在銀行的存款 
    public void store(int money){
        int newAccount=account+money;
        account=newAccount;
    }
    public void get(){
        System.out.print("小明的銀行賬戶餘額:");
        System.out.print(account);
    }

如果小明爲自己存款 1 元,我們期望的線程調用情況如下:

  1. 首先會啓動一個線程調用 store() 方法,爲客戶賬戶餘額增加 1;

  2. 再啓動一個線程調用 get() 方法,輸出客戶的新餘額爲 1。

但實際情況可能由於線程執行的先後順序,出現如圖所示的錯誤:

小明存錢流程

小明:咱家沒錢了

小明會驚奇的以爲自己的錢沒存上。這就是一個典型的由共享數據引發的併發數據衝突問題

解決方式也很簡單,讓併發執行會產生問題的代碼段不併發行了。

如果 store() 方法 執行完,才能執行 get() 方法,而不是像上圖一樣併發執行,自然不會出現這個問題。那如何才能做到呢?

答案就是使用 synchronized 關鍵字。

我們先從直覺上思考一下,如果要實現先執行 store() 方法,再執行 get() 方法的話該怎麼設計。

我們可以設置某個鎖,鎖會有兩種狀態,分別是上鎖解鎖。在 store() 方法執行之前,先觀察這個鎖的狀態,如果是上鎖狀態,就進入阻塞,代碼不運行;

如果這把鎖是解鎖狀態,那就先將這把鎖狀態變爲上鎖,之後接着運行自己的代碼。運行完成之後再將鎖狀態設置爲解鎖。

對於 get() 方法也是如此。

Java 中的 synchronized 關鍵字就是基於這種思想設計的。在 synchronized 關鍵字中,鎖就是一個對象。

synchronized 一共有三種使用方法:

    // account 客戶在銀行的存款 
    public synchronized void store(int money){
        int newAccount=account+money;
        account=newAccount;
    }
    public synchronized void get(){
        System.out.print("小明的銀行賬戶餘額:");
        System.out.print(account);
    }
    public synchronized static void get(){
        ···
    }
    public static void get(){
        synchronized(對象0){
            ···
        }
    }

小艾問:我看了不少參考書還有網上資料,都說 synchronized 的鎖是鎖在對象上的。關於這句話,你能深入講講嗎?

小牛回答道:別急,我先講講 Java 對象在內存中的表示。

Java 對象在內存中的表示

講清 synchronized 關鍵字的原理前需要理清 Java 對象在內存中的表示方法。

Java 對象在內存中的表示

上圖就是一個 Java 對象在內存中的表示。我們可以看到,內存中的對象一般由三部分組成,分別是對象頭、對象實際數據和對齊填充。

對象頭包含 Mark Word、Class Pointer 和 Length 三部分。

  • Mark Word 記錄了對象關於鎖的信息,垃圾回收信息等。

  • Class Pointer 用於指向對象對應的 Class 對象(其對應的元數據對象)的內存地址。

  • Length 只適用於對象是數組時,它保存了該數組的長度信息。

對象實際數據包括了對象的所有成員變量,其大小由各個成員變量的大小決定。

對齊填充表示最後一部分的填充字節位,這部分不包含有用信息。

我們剛纔講的鎖 synchronized 鎖使用的就是對象頭的 Mark Word 字段中的一部分。

Mark Word 中的某些字段發生變化,就可以代表鎖不同的狀態。

由於鎖的信息是記錄在對象裏的,有的開發者也往往會說鎖住對象這種表述。

無鎖狀態的 Mark Word

這裏我們以無鎖狀態的 Mark Word 字段舉例:

如果當前對象是無鎖狀態,對象的 Mark Word 如圖所示。

無鎖狀態的 Mark Word 字段

我們可以看到,該對象頭的 Mark Word 字段分爲四個部分:

  1. 對象的 hashCode ;

  2. 對象的分代年齡,這部分用於對對象的垃圾回收;

  3. 是否爲偏向鎖位,1 代表是,0 代表不是;

  4. 鎖標誌位,這裏是 01。

synchronized 關鍵字的實現原理

講完了 Java 對象在內存中的表示,我們下一步來講講 synchronized 關鍵字的實現原理。

從前文中我們可以看到, synchronized 關鍵字有兩種修飾方法

  1. 直接作爲關鍵字修飾在方法上,將整個方法作爲同步代碼塊:
    public synchronized static void `get()`{
        ···
    }
  1. 修飾在同步代碼塊上
    public static void `get()`{
        synchronized(對象0){
            ···
        }
    }

針對這兩種情況,Java 編譯時的處理方法並不相同。

對於第一種情況,編譯器會爲其自動生成了一個 ACC_SYNCHRONIZED 關鍵字用來標識。

在 JVM 進行方法調用時,當發現調用的方法被 ACC_SYNCHRONIZED 修飾,則會先嚐試獲得鎖。

對於第二種情況,編譯時在代碼塊開始前生成對應的 1 個 monitorenter 指令,代表同步塊進入。2 個 monitorexit 指令,代表同步塊退出。

這兩種方法底層都需要一個 reference 類型的參數,指明要鎖定和解鎖的對象。

如果 synchronized 明確指定了對象參數,那就是該對象。

如果沒有明確指定, 那就根據修飾的方法是實例方法還是類方法,取對應的對象實例或類對象(Java 中類也是一種特殊的對象)作爲鎖對象。

確定鎖定和解鎖的對象

每個對象維護着一個記錄着被鎖次數的計數器。當一個線程執行 monitorenter,該計數器自增從 0 變爲 1;

當一個線程執行 monitorexit,計數器再自減。當計數器爲 0 的時候,說明對象的鎖已經釋放。

小艾問:爲什麼會有兩個 monitorexit 指令呢?

小牛答:正常退出,得用一個 monitorexit 吧,如果中間出現異常,鎖會一直無法釋放。所以編譯器會爲同步代碼塊添加了一個隱式的 try-finally 異常處理,在 finally 中會調用 monitorexit 命令最終釋放鎖。

重量級鎖

小艾問:那麼問題來了,之前你說鎖的信息是記錄在對象的 Mark Word 中的,那現在冒出來的 monitor 又是什麼呢?

小牛答:我們先來看一下重量級鎖對應對象的 Mark Word。

在 Java 的早期版本中,synchronized 鎖屬於重量級鎖,此時對象的 Mark Word 如圖所示。

重量級鎖的 Mark Word 字段

我們可以看到,該對象頭的 Mark Word 分爲兩個部分。第一部分是指向重量級鎖的指針,第二部分是鎖標記位。

而這裏所說的指向重量級鎖的指針就是 monitor

英文詞典翻譯 monitor 是監視器。Java 中每個對象會對應一個監視器。

這個監視器其實也就是監控鎖有沒有釋放,釋放的話會通知下一個等待鎖的線程去獲取。

monitor 的成員變量比較多,我們可以這樣理解:

monitor 結構

我們可以將 monitor 簡單理解成兩部分,第一部分表示當前佔用鎖的線程,第二部分是等待這把鎖的線程隊列

如果當前佔用鎖的線程把鎖釋放了,那就需要在線程隊列中喚醒下一個等待鎖的線程。

但是阻塞或喚醒一個線程需要依賴底層的操作系統來實現,Java 的線程是映射到操作系統的原生線程之上的。

而操作系統實現線程之間的切換需要從用戶態轉換到核心態,這個狀態轉換需要花費很多的處理器時間,甚至可能比用戶代碼執行的時間還要長。

由於這種效率太低,Java 後期做了改進,我再來詳細講一講。

CAS 算法

在講其他改進之前,我們先來聊聊 CAS 算法。CAS 算法全稱爲 Compare And Swap。

顧名思義,該算法涉及到了兩個操作,比較(Compare)和交換(Swap)。

怎麼理解這個操作呢?我們來看下圖:

CAS 算法

我們知道,在對共享變量進行多線程操作的時候,難免會出現線程安全問題。

對該問題的一種解決策略就是對該變量加鎖,保證該變量在某個時間段只能被一個線程操作。

但是這種方式的系統開銷比較大。因此開發人員提出了一種新的算法,就是大名鼎鼎的 CAS 算法。

CAS 算法的思路如下:

  1. 該算法認爲線程之間對變量的操作進行競爭的情況比較少。

  2. 算法的核心是對當前讀取變量值 E 和內存中的變量舊值 V 進行比較。

  3. 如果相等,就代表其他線程沒有對該變量進行修改,就將變量值更新爲新值 N

  4. 如果不等,就認爲在讀取值 E 到比較階段,有其他線程對變量進行過修改,不進行任何操作。

當線程運行 CAS 算法時,該運行過程是原子操作,原子操作的含義就是線程開始跑這個函數後,運行過程中不會被別的程序打斷。

我們來看看實際上 Java 語言中如何使用這個 CAS 算法,這裏我們以 AtomicInteger 類中的 compareAndSwapInt() 方法舉例:

public final native boolean compareAndSwapInt
(Object var1, long var2, int var3, int var4)

可以看到,該函數原型接受四個參數:

  1. 第一個參數是一個 AtomicInteger 對象。

  2. 第二個參數是該 AtomicInteger 對象對應的成員變量在內存中的地址。

  3. 第三個參數是上圖中說的線程之前讀取的值 P

  4. 第四個參數是上圖中說的線程計算的新值 V

偏向鎖

JDK 1.6 中提出了偏向鎖的概念。該鎖提出的原因是,開發者發現多數情況下鎖並不存在競爭,一把鎖往往是由同一個線程獲得的。

如果是這種情況,不斷的加鎖解鎖是沒有必要的。

那麼能不能讓 JVM 直接負責在這種情況下加解鎖的事情,不讓操作系統插手呢?

因此開發者設計了偏向鎖。偏向鎖在獲取資源的時候,會在資源對象上記錄該對象是否偏向該線程。

偏向鎖並不會主動釋放,這樣每次偏向鎖進入的時候都會判斷該資源是否是偏向自己的,如果是偏向自己的則不需要進行額外的操作,直接可以進入同步操作。

下圖表示偏向鎖的 Mark Word 結構:

偏向鎖的 Mark Word 字段

可以看到,偏向鎖對應的 Mark Word 包含該偏向鎖對應的線程 ID、偏向鎖的時間戳和對象分代年齡。

偏向鎖的申請流程

我們再來看一下偏向鎖的申請流程:

  1. 首先需要判斷對象的 Mark Word 是否屬於偏向模式,如果不屬於,那就進入輕量級鎖判斷邏輯。否則繼續下一步判斷;

  2. 判斷目前請求鎖的線程 ID 是否和偏向鎖本身記錄的線程 ID 一致。如果一致,繼續下一步的判斷,如果不一致,跳轉到步驟 4;

  3. 判斷是否需要重偏向,重偏向邏輯在後面一節批量重偏向和批量撤銷會說明。如果不用的話,直接獲得偏向鎖;

  4. 利用 CAS 算法將對象的 Mark Word 進行更改,使線程 ID 部分換成本線程 ID。如果更換成功,則重偏向完成,獲得偏向鎖。如果失敗,則說明有多線程競爭,升級爲輕量級鎖。

偏向鎖的申請流程

值得注意的是,在執行完同步代碼後,線程不會主動去修改對象的 Mark Word,讓它重回無鎖狀態。

所以一般執行完 synchronized 語句後,如果是偏向鎖的狀態的話,線程對鎖的釋放操作可能是什麼都不做。

匿名偏向鎖

在 JVM 開啓偏向鎖模式下,如果一個對象被新建,在四秒後,該對象的對象頭就會被置爲偏向鎖。

一般來說,當一個線程獲取了一把偏向鎖時,會在對象頭和棧幀中的鎖記錄裏不僅說明目前是偏向鎖狀態,也會存儲鎖偏向的線程 ID。

在 JVM 四秒自動創建偏向鎖的情況下,線程 ID 爲 0。

由於這種情況下的偏向鎖不是由某個線程求得生成的,這種情況下的偏向鎖也稱爲匿名偏向鎖。

批量重偏向和批量撤銷

生產者消費者模式下,生產者線程負責對象的創建,消費者線程負責對生產出來的對象進行使用。

當生產者線程創建了大量對象並執行加偏向鎖的同步操作,消費者對對象使用之後,會產生大量偏向鎖執行和偏向鎖撤銷的問題。

大量偏向鎖執行和偏向鎖撤銷的問題

Russell K 和 Detlefs D 在他們的文章提出了批量重偏向和批量撤銷的過程。

在上圖情景下,他們探討了能不能直接將偏向的線程換成消費者的線程。

替換不是一件容易事,需要在 JVM 的衆多線程中找到類似上文情景的線程。

他們最後提出的解決方法是:

以類爲單位,爲每個類維護一個偏向鎖撤銷計數器,每一次該類的對象發生偏向撤銷操作時,該計數器計數 +1,當這個計數值達到重偏向閾值時,JVM 就認爲該類可能不適合正常邏輯,適合批量重偏向邏輯。這就是對應上圖流程圖裏的是否需要重偏向過程。

以生產者消費者爲例,生產者生產同一類型的對象給消費者,然後消費者對這些對象都需要執行偏向鎖撤銷,當撤銷過程過多時就會觸發上文規則,JVM 就注意到這個類了。

批量重偏向和批量撤銷

具體規則是:

  1. 每個類對象會有一個對應的 epoch 字段,每個處於偏向鎖狀態對象的 Mark Word 中也有該字段,其初始值爲創建該對象時,類對象中的 epoch 的值。

  2. 每次發生批量重偏向時,就將類對象的 epoch 字段 +1,得到新的值 epoch_new

  3. 遍歷 JVM 中所有線程的棧,找到該類對象,將其 epoch 字段改爲新值。根據線程棧的信息判斷出該線程是否鎖定了該對象,將現在偏向鎖還在被使用的對象賦新值 epoch_new

  4. 下次有線程想獲得鎖時,如果發現當前對象的 epoch 值和類的 epoch 不相等,不會執行撤銷操作,而是直接通過 CAS 操作將其 Mark Word 的 Thread ID 改成當前線程 ID。

批量撤銷相對於批量重偏向好理解得多,JVM 也會統計重偏向的次數。

假設該類計數器計數繼續增加,當其達到批量撤銷的閾值後(默認 40),JVM 就認爲該類的使用場景存在多線程競爭,會標記該類爲不可偏向,之後對於該類的鎖升級爲輕量級鎖。

輕量級鎖

輕量級鎖的設計初衷在於併發程序開發者的經驗 “對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”。

所以它的設計出發點也在線程競爭情況較少的情況下。我們先來看一下輕量級鎖的 Mark Word 佈局。

如果當前對象是輕量級鎖狀態,對象的 Mark Word 如下圖所示。

輕量級鎖 Mark Word 字段

我們可以看到,該對象頭 Mark Word 分爲兩個部分。第一部分是指向棧中的鎖記錄的指針,第二部分是鎖標記位,針對輕量級鎖該標記位爲 00。

小艾問:那這指向棧中的鎖記錄的指針是什麼意思呢?

小牛答:這得結合輕量級鎖的上鎖步驟來慢慢講。

如果當前這個對象的鎖標誌位爲 01(即無鎖狀態或者輕量級鎖狀態),線程在執行同步塊之前,JVM 會先在當前的線程的棧幀中創建一個 Lock Record,包括一個用於存儲對象頭中的 Mark Word 以及一個指向對象的指針。

Lock Record

然後 JVM 會利用 CAS 算法對這個對象的 Mark Word 進行修改。如果修改成功,那該線程就擁有了這個對象的鎖。我們來看一下如果上圖的線程執行 CAS 算法成功的結果。

執行 CAS 算法

當然 CAS 也會有失敗的情況。如果 CAS 失敗,那就說明同時執行 CAS 操作的線程可不止一個了, Mark Word 也做了更改。

首先虛擬機會檢查對象的 Mark Word 字段指向棧中的鎖記錄的指針是否指向當前線程的棧幀。如果是,那就說明可能出現了類似 synchronized 中套 synchronized 情況:

synchronized (對象0) {
    synchronized (對象0) {
        ···
    }
}

當然這種情況下當前線程已經擁有這個對象的鎖,可以直接進入同步代碼塊執行。

否則說明鎖被其他線程搶佔了,該鎖還需要升級爲重量級鎖。

和偏向鎖不同的是,執行完同步代碼塊後,需要執行輕量級鎖的解鎖過程。解鎖過程如下:

  1. 通過 CAS 操作嘗試把線程棧幀中複製的 Mark Word 對象替換當前對象的 Mark Word。

  2. 如果 CAS 算法成功,整個同步過程就完成了。

  3. 如果 CAS 算法失敗,則說明存在競爭,鎖升級爲重量級鎖。

我們來總結一下輕量級鎖升級過程吧:

輕量級鎖的升級過程

總結

這次我們瞭解了 synchronized 底層實現原理和對應的鎖升級過程。最後我們再通過這張流程圖來回顧一下 synchronized 鎖升級過程吧。

鎖申請完整流程

參考

  1. 實現 Java 虛擬機:JVM 故障診斷與性能優化

  2. 深入理解 java 虛擬機 JVM 高級特性與最佳實踐

  3. Russell K , Detlefs D . Eliminating synchronization-related atomic operations with biased locking and bulk rebiasing[C]// Acm Sigplan Conference on Object-oriented Programming Systems. ACM, 2006.

  4. Dice D , Moir M S , Scherer Iii W N . Quickly reacquirable locks: US 2010.

  5. https://github.com/farmerjohngit/myblog/issues/12

  6. https://www.itqiankun.com/article/bias-lightweight-synchronized-lock

  7. https://www.itqiankun.com/article/bias-lock-epoch-effect

  8. https://www.hollischuang.com/archives/1883

  9. http://www.ideabuffer.cn/2017/05/06/Java%E5%AF%B9%E8%B1%A1%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80/

  10. http://www.ideabuffer.cn/2017/04/21/java-%E4%B8%AD%E7%9A%84%E9%94%81-%E5%81%8F%E5%90%91%E9%94%81%E3%80%81%E8%BD%BB%E9%87%8F%E7%BA%A7%E9%94%81%E3%80%81%E8%87%AA%E6%97%8B%E9%94%81%E3%80%81%E9%87%8D%E9%87%8F%E7%BA%A7%E9%94%81/

  11. https://blog.csdn.net/zhao_miao/article/details/84500771

感謝各位少俠閱讀,我們將會爲大家帶來更多精彩原創文章

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/djttOpikVWdD2dYMI9MsOA