我們知道,在多執行緒程式中往往會出現這麼一個情況:多個執行緒同時訪問某個執行緒間的共享變數。來舉個例子吧:
假設銀行存款業務寫了兩個方法,一個是存錢 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 元,我們期望的執行緒呼叫情況如下:
首先會啟動一個執行緒呼叫 store() 方法,為客戶賬戶餘額增加 1;
再啟動一個執行緒呼叫 get() 方法,輸出客戶的新餘額為 1。
但實際情況可能由於執行緒執行的先後順序,出現如圖所示的錯誤:
小明會驚奇的以為自己的錢沒存上。這就是一個典型的由共享資料引發的併發資料衝突問題。
解決方式也很簡單,讓併發執行會產生問題的程式碼段不併發行了。
如果 store() 方法 執行完,才能執行 get() 方法,而不是像上圖一樣併發執行,自然不會出現這個問題。那如何才能做到呢?
答案就是使用 synchronized 關鍵字。
我們先從直覺上思考一下,如果要實現先執行 store() 方法,再執行 get() 方法的話該怎麼設計。
我們可以設定某個鎖,鎖會有兩種狀態,分別是上鎖和解鎖。在 store() 方法執行之前,先觀察這個鎖的狀態,如果是上鎖狀態,就進入阻塞,程式碼不執行;
如果這把鎖是解鎖狀態,那就先將這把鎖狀態變為上鎖,之後接著執行自己的程式碼。執行完成之後再將鎖狀態設定為解鎖。
對於 get() 方法也是如此。
Java 中的 synchronized 關鍵字就是基於這種思想設計的。在 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); }
直接修飾某個靜態方法。在這種情況下進行多執行緒併發訪問時,如果其他執行緒也是呼叫屬於同一類的被 synchronized 修飾的靜態方法,就會被阻塞。相當於把鎖資訊記錄在這個方法對應的類上。
public synchronized static void get(){ ··· }
修飾程式碼塊。如果此時有別的執行緒也想訪問某個被synchronized(物件0)修飾的同步程式碼塊時,也會被阻塞。
public static void get(){ synchronized(物件0){ ··· } }
A問:我看了不少參考書還有網上資料,都說 synchronized 的鎖是鎖在物件上的。關於這句話,你能深入講講嗎?
B回答道:別急,我先講講 Java 物件在記憶體中的表示。
Java 物件在記憶體中的表示講清 synchronized 關鍵字的原理前需要理清 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 欄位分為四個部分:
物件的 hashCode ;
物件的分代年齡,這部分用於對物件的垃圾回收;
是否為偏向鎖位,1代表是,0代表不是;
鎖標誌位,這裡是 01。
synchronized關鍵字的實現原理講完了 Java 物件在記憶體中的表示,我們下一步來講講 synchronized 關鍵字的實現原理。
從前文中我們可以看到, synchronized 關鍵字有兩種修飾方法
直接作為關鍵字修飾在方法上,將整個方法作為同步程式碼塊: public synchronized static void `get()`{ ··· }
修飾在同步程式碼塊上。
public static void `get()`{ synchronized(物件0){ ··· } }
針對這兩種情況,Java 編譯時的處理方法並不相同。
對於第一種情況,編譯器會為其自動生成了一個 ACC_SYNCHRONIZED 關鍵字用來標識。
在 JVM 進行方法呼叫時,當發現呼叫的方法被 ACC_SYNCHRONIZED 修飾,則會先嚐試獲得鎖。
對於第二種情況,編譯時在程式碼塊開始前生成對應的1個 monitorenter 指令,代表同步塊進入。2個 monitorexit 指令,代表同步塊退出。
這兩種方法底層都需要一個 reference 型別的引數,指明要鎖定和解鎖的物件。
如果 synchronized 明確指定了物件引數,那就是該物件。
如果沒有明確指定,那就根據修飾的方法是例項方法還是類方法,取對應的物件例項或類物件(Java 中類也是一種特殊的物件)作為鎖物件。
每個物件維護著一個記錄著被鎖次數的計數器。當一個執行緒執行 monitorenter,該計數器自增從 0 變為 1;
當一個執行緒執行 monitorexit,計數器再自減。當計數器為 0 的時候,說明物件的鎖已經釋放。
A問:為什麼會有兩個 monitorexit 指令呢?
B答:正常退出,得用一個 monitorexit 吧,如果中間出現異常,鎖會一直無法釋放。所以編譯器會為同步程式碼塊添加了一個隱式的 try-finally 異常處理,在 finally 中會呼叫 monitorexit命令最終釋放鎖。
重量級鎖
A問:那麼問題來了,之前你說鎖的資訊是記錄在物件的 Mark Word 中的,那現在冒出來的 monitor 又是什麼呢?
B答:我們先來看一下重量級鎖對應物件的 Mark Word。
在 Java 的早期版本中,synchronized 鎖屬於重量級鎖,此時物件的 Mark Word 如圖所示。
我們可以看到,該物件頭的 Mark Word 分為兩個部分。第一部分是指向重量級鎖的指標,第二部分是鎖標記位。
而這裡所說的指向重量級鎖的指標就是 monitor。
英文詞典翻譯 monitor 是監視器。Java 中每個物件會對應一個監視器。
這個監視器其實也就是監控鎖有沒有釋放,釋放的話會通知下一個等待鎖的執行緒去獲取。
monitor 的成員變數比較多,我們可以這樣理解:
我們可以將 monitor 簡單理解成兩部分,第一部分表示當前佔用鎖的執行緒,第二部分是等待這把鎖的執行緒佇列。
如果當前佔用鎖的執行緒把鎖釋放了,那就需要線上程佇列中喚醒下一個等待鎖的執行緒。
但是阻塞或喚醒一個執行緒需要依賴底層的作業系統來實現,Java 的執行緒是對映到作業系統的原生執行緒之上的。
而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個狀態轉換需要花費很多的處理器時間,甚至可能比使用者程式碼執行的時間還要長。
由於這種效率太低,Java 後期做了改進,我再來詳細講一講。
CAS演算法在講其他改進之前,我們先來聊聊 CAS 演算法。CAS 演算法全稱為 Compare And Swap。
顧名思義,該演算法涉及到了兩個操作,比較(Compare)和交換(Swap)。
怎麼理解這個操作呢?我們來看下圖:
我們知道,在對共享變數進行多執行緒操作的時候,難免會出現執行緒安全問題。
對該問題的一種解決策略就是對該變數加鎖,保證該變數在某個時間段只能被一個執行緒操作。
但是這種方式的系統開銷比較大。因此開發人員提出了一種新的演算法,就是大名鼎鼎的 CAS 演算法。
CAS 演算法的思路如下:
該演算法認為執行緒之間對變數的操作進行競爭的情況比較少。
演算法的核心是對當前讀取變數值 E 和記憶體中的變數舊值 V 進行比較。
如果相等,就代表其他執行緒沒有對該變數進行修改,就將變數值更新為新值 N。
如果不等,就認為在讀取值 E 到比較階段,有其他執行緒對變數進行過修改,不進行任何操作。
當執行緒執行 CAS 演算法時,該執行過程是原子操作,原子操作的含義就是執行緒開始跑這個函式後,執行過程中不會被別的程式打斷。
我們來看看實際上 Java 語言中如何使用這個 CAS 演算法,這裡我們以 AtomicInteger 類中的 compareAndSwapInt() 方法舉例:
public final native boolean compareAndSwapInt(Object var1, long var2, int var3, int var4)
可以看到,該函式原型接受四個引數:
第一個引數是一個 AtomicInteger 物件。
第二個引數是該 AtomicInteger 物件對應的成員變數在記憶體中的地址。
第三個引數是上圖中說的執行緒之前讀取的值 P。
第四個引數是上圖中說的執行緒計算的新值 V。
偏向鎖JDK 1.6 中提出了偏向鎖的概念。該鎖提出的原因是,開發者發現多數情況下鎖並不存在競爭,一把鎖往往是由同一個執行緒獲得的。
如果是這種情況,不斷的加鎖解鎖是沒有必要的。
那麼能不能讓 JVM 直接負責在這種情況下加解鎖的事情,不讓作業系統插手呢?
因此開發者設計了偏向鎖。偏向鎖在獲取資源的時候,會在資源物件上記錄該物件是否偏向該執行緒。
偏向鎖並不會主動釋放,這樣每次偏向鎖進入的時候都會判斷該資源是否是偏向自己的,如果是偏向自己的則不需要進行額外的操作,直接可以進入同步操作。
下圖表示偏向鎖的 Mark Word結構:
可以看到,偏向鎖對應的 Mark Word 包含該偏向鎖對應的執行緒 ID、偏向鎖的時間戳和物件分代年齡。
偏向鎖的申請流程
我們再來看一下偏向鎖的申請流程:
首先需要判斷物件的 Mark Word 是否屬於偏向模式,如果不屬於,那就進入輕量級鎖判斷邏輯。否則繼續下一步判斷;
判斷目前請求鎖的執行緒 ID 是否和偏向鎖本身記錄的執行緒 ID 一致。如果一致,繼續下一步的判斷,如果不一致,跳轉到步驟4;
判斷是否需要重偏向,重偏向邏輯在後面一節批次重偏向和批次撤銷會說明。如果不用的話,直接獲得偏向鎖;
利用 CAS 演算法將物件的 Mark Word 進行更改,使執行緒 ID 部分換成本執行緒 ID。如果更換成功,則重偏向完成,獲得偏向鎖。如果失敗,則說明有多執行緒競爭,升級為輕量級鎖。
值得注意的是,在執行完同步程式碼後,執行緒不會主動去修改物件的 Mark Word,讓它重回無鎖狀態。
所以一般執行完 synchronized 語句後,如果是偏向鎖的狀態的話,執行緒對鎖的釋放操作可能是什麼都不做。
匿名偏向鎖
在 JVM 開啟偏向鎖模式下,如果一個物件被新建,在四秒後,該物件的物件頭就會被置為偏向鎖。
一般來說,當一個執行緒獲取了一把偏向鎖時,會在物件頭和棧幀中的鎖記錄裡不僅說明目前是偏向鎖狀態,也會儲存鎖偏向的執行緒 ID。
在 JVM 四秒自動建立偏向鎖的情況下,執行緒 ID 為0。
由於這種情況下的偏向鎖不是由某個執行緒求得生成的,這種情況下的偏向鎖也稱為匿名偏向鎖。
批次重偏向和批次撤銷
在生產者消費者模式下,生產者執行緒負責物件的建立,消費者執行緒負責對生產出來的物件進行使用。
當生產者執行緒建立了大量物件並執行加偏向鎖的同步操作,消費者對物件使用之後,會產生大量偏向鎖執行和偏向鎖撤銷的問題。
Russell K和 Detlefs D在他們的文章提出了批次重偏向和批次撤銷的過程。
在上圖情景下,他們探討了能不能直接將偏向的執行緒換成消費者的執行緒。
替換不是一件容易事,需要在 JVM 的眾多執行緒中找到類似上文情景的執行緒。
他們最後提出的解決方法是:
以類為單位,為每個類維護一個偏向鎖撤銷計數器,每一次該類的物件發生偏向撤銷操作時,該計數器計數 +1,當這個計數值達到重偏向閾值時,JVM 就認為該類可能不適合正常邏輯,適合批次重偏向邏輯。這就是對應上圖流程圖裡的是否需要重偏向過程。
以生產者消費者為例,生產者生產同一型別的物件給消費者,然後消費者對這些物件都需要執行偏向鎖撤銷,當撤銷過程過多時就會觸發上文規則,JVM 就注意到這個類了。
具體規則是:
每個類物件會有一個對應的 epoch 欄位,每個處於偏向鎖狀態物件的 Mark Word 中也有該欄位,其初始值為建立該物件時,類物件中的 epoch 的值。每次發生批次重偏向時,就將類物件的 epoch 欄位 +1,得到新的值 epoch_new。遍歷 JVM 中所有執行緒的棧,找到該類物件,將其 epoch 欄位改為新值。根據執行緒棧的資訊判斷出該執行緒是否鎖定了該物件,將現在偏向鎖還在被使用的物件賦新值 epoch_new。下次有執行緒想獲得鎖時,如果發現當前物件的 epoch 值和類的 epoch 不相等,不會執行撤銷操作,而是直接透過 CAS 操作將其 Mark Word 的 Thread ID 改成當前執行緒 ID。批次撤銷相對於批次重偏向好理解得多,JVM 也會統計重偏向的次數。
假設該類計數器計數繼續增加,當其達到批次撤銷的閾值後(預設40),JVM 就認為該類的使用場景存在多執行緒競爭,會標記該類為不可偏向,之後對於該類的鎖升級為輕量級鎖。
輕量級鎖輕量級鎖的設計初衷在於併發程式開發者的經驗“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”。
所以它的設計出發點也線上程競爭情況較少的情況下。我們先來看一下輕量級鎖的 Mark Word 佈局。
如果當前物件是輕量級鎖狀態,物件的 Mark Word 如下圖所示。
我們可以看到,該物件頭Mark Word分為兩個部分。第一部分是指向棧中的鎖記錄的指標,第二部分是鎖標記位,針對輕量級鎖該標記位為 00。
A問:那這指向棧中的鎖記錄的指標是什麼意思呢?
B答:這得結合輕量級鎖的上鎖步驟來慢慢講。
如果當前這個物件的鎖標誌位為 01(即無鎖狀態或者輕量級鎖狀態),執行緒在執行同步塊之前,JVM 會先在當前的執行緒的棧幀中建立一個 Lock Record,包括一個用於儲存物件頭中的 Mark Word 以及一個指向物件的指標。
然後 JVM 會利用 CAS 演算法對這個物件的 Mark Word 進行修改。如果修改成功,那該執行緒就擁有了這個物件的鎖。我們來看一下如果上圖的執行緒執行 CAS 演算法成功的結果。
當然 CAS 也會有失敗的情況。如果 CAS 失敗,那就說明同時執行 CAS 操作的執行緒可不止一個了, Mark Word 也做了更改。
首先虛擬機器會檢查物件的 Mark Word 欄位指向棧中的鎖記錄的指標是否指向當前執行緒的棧幀。如果是,那就說明可能出現了類似 synchronized 中套 synchronized 情況:
synchronized (物件0) { synchronized (物件0) { ··· }}
當然這種情況下當前執行緒已經擁有這個物件的鎖,可以直接進入同步程式碼塊執行。
否則說明鎖被其他執行緒搶佔了,該鎖還需要升級為重量級鎖。
和偏向鎖不同的是,執行完同步程式碼塊後,需要執行輕量級鎖的解鎖過程。解鎖過程如下:
透過 CAS 操作嘗試把執行緒棧幀中複製的 Mark Word 物件替換當前物件的 Mark Word。如果 CAS 演算法成功,整個同步過程就完成了。如果 CAS 演算法失敗,則說明存在競爭,鎖升級為重量級鎖。我們來總結一下輕量級鎖升級過程吧:
總結這次我們瞭解了 synchronized 底層實現原理和對應的鎖升級過程。最後我們再透過這張流程圖來回顧一下 synchronized 鎖升級過程吧。