前言
synchronized 這個關鍵字的重要性不言而喻,幾乎可以說是併發、多執行緒必須會問到的關鍵字了。synchronized 會涉及到鎖、升級降級操作、鎖的撤銷、物件頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,物件頭等,為你揭開 synchronized 的面紗。
淺析 synchronized
synchronized
是 Java 併發模組非常重要的關鍵字,它是 Java 內建的一種同步機制,代表了某種內在鎖定的概念,當一個執行緒對某個共享資源
加鎖後,其他想要獲取共享資源的執行緒必須進行等待,synchronized 也具有互斥和排他的語義。
什麼是互斥?我們想必小時候都玩兒過磁鐵,磁鐵會有正負極的概念,同性相斥異性相吸,相斥相當於就是一種互斥的概念,也就是兩者互不相容。
synchronized 也是一種獨佔的關鍵字,但是它這種獨佔的語義更多的是為了增加執行緒安全性,通過獨佔某個資源以達到互斥、排他的目的。
在瞭解了排他和互斥的語義後,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實現。
synchronized 的使用
關於 synchronized 想必你應該都大致瞭解過
- synchronized 修飾例項方法,相當於是對類的例項進行加鎖,進入同步程式碼前需要獲得當前例項的鎖
- synchronized 修飾靜態方法,相當於是對類物件進行加鎖
- synchronized 修飾程式碼塊,相當於是給物件進行加鎖,在進入程式碼塊前需要先獲得物件的鎖
下面我們針對每個用法進行解釋
synchronized 修飾例項方法
synchronized 修飾例項方法,例項方法是屬於類的例項。synchronized 修飾的例項方法相當於是物件鎖。下面是一個 synchronized 修飾例項方法的例子。
public synchronized void method()
{
// ...
}
像如上述 synchronized 修飾的方法就是例項方法,下面我們通過一個完整的例子來認識一下 synchronized 修飾例項方法
public class TSynchronized implements Runnable{
static int i = 0;
public synchronized void increase(){
i++;
System.out.println(Thread.currentThread().getName());
}
@Override
public void run() {
for(int i = 0;i < 1000;i++) {
increase();
}
}
public static void main(String[] args) throws InterruptedException {
TSynchronized tSynchronized = new TSynchronized();
Thread aThread = new Thread(tSynchronized);
Thread bThread = new Thread(tSynchronized);
aThread.start();
bThread.start();
aThread.join();
bThread.join();
System.out.println("i = " + i);
}
}
上面輸出的結果 i = 2000 ,並且每次都會列印當前現成的名字
來解釋一下上面程式碼,程式碼中的 i 是一個靜態變數,靜態變數也是全域性變數,靜態變數儲存在方法區中。increase 方法由 synchronized 關鍵字修飾,但是沒有使用 static 關鍵字修飾,表示 increase 方法是一個例項方法,每次建立一個 TSynchronized 類的同時都會建立一個 increase 方法,increase 方法中只是列印出來了當前訪問的執行緒名稱。Synchronized 類實現了 Runnable 介面,重寫了 run 方法,run 方法裡面就是一個 0 - 1000 的計數器,這個沒什麼好說的。在 main 方法中,new 出了兩個執行緒,分別是 aThread 和 bThread,Thread.join 表示等待這個執行緒處理結束。這段程式碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨佔性。
synchronized 修飾靜態方法
synchronized 修飾靜態方法就是 synchronized 和 static 關鍵字一起使用
public static synchronized void increase(){}
當 synchronized 作用於靜態方法時,表示的就是當前類的鎖,因為靜態方法是屬於類的,它不屬於任何一個例項成員,因此可以通過 class 物件控制併發訪問。
這裡需要注意一點,因為 synchronized 修飾的例項方法是屬於例項物件,而 synchronized 修飾的靜態方法是屬於類物件,所以呼叫 synchronized 的例項方法並不會阻止訪問 synchronized 的靜態方法。
synchronized 修飾程式碼塊
synchronized 除了修飾例項方法和靜態方法外,synchronized 還可用於修飾程式碼塊,程式碼塊可以巢狀在方法體的內部使用。
public void run() {
synchronized(obj){
for(int j = 0;j < 1000;j++){
i++;
}
}
}
上面程式碼中將 obj 作為鎖物件對其加鎖,每次當執行緒進入 synchronized 修飾的程式碼塊時就會要求當前執行緒持有obj 例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待。
synchronized 修飾的程式碼塊,除了可以鎖定物件之外,也可以對當前例項物件鎖、class 物件鎖進行鎖定
// 例項物件鎖
synchronized(this){
for(int j = 0;j < 1000;j++){
i++;
}
}
//class物件鎖
synchronized(TSynchronized.class){
for(int j = 0;j < 1000;j++){
i++;
}
}
synchronized 底層原理
在簡單介紹完 synchronized 之後,我們就來聊一下 synchronized 的底層原理了。
我們或許都有所瞭解(下文會細緻分析),synchronized 的程式碼塊是由一組 monitorenter/monitorexit 指令實現的。而Monitor
物件是實現同步的基本單元。
啥是
Monitor
物件呢?
Monitor 物件
任何物件都關聯了一個管程,管程就是控制物件併發訪問的一種機制。管程
是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對管程的實現。
管程提供了一種排他訪問機制,這種機制也就是 互斥
。互斥保證了在每個時間點上,最多隻有一個執行緒會執行同步方法。
所以你理解了 Monitor 物件其實就是使用管程控制同步訪問的一種物件。
物件記憶體佈局
在 hotspot
虛擬機器中,物件在記憶體中的佈局分為三塊區域:
物件頭(Header)
例項資料(Instance Data)
對齊填充(Padding)
這三塊區域的記憶體分佈如下圖所示
我們來詳細介紹一下上面物件中的內容。
物件頭 Header
物件頭 Header 主要包含 MarkWord 和物件指標 Klass Pointer,如果是陣列的話,還要包含陣列的長度。
在 32 位的虛擬機器中 MarkWord ,Klass Pointer 和陣列長度分別佔用 32 位,也就是 4 位元組。
如果是 64 位虛擬機器的話,MarkWord ,Klass Pointer 和陣列長度分別佔用 64 位,也就是 8 位元組。
在 32 位虛擬機器和 64 位虛擬機器的 Mark Word 所佔用的位元組大小不一樣,32 位虛擬機器的 Mark Word 和 Klass Pointer 分別佔用 32 bits 的位元組,而 64 位虛擬機器的 Mark Word 和 Klass Pointer 佔用了64 bits 的位元組,下面我們以 32 位虛擬機器為例,來看一下其 Mark Word 的位元組具體是如何分配的。
用中文翻譯過來就是
- 無狀態也就是
無鎖
的時候,物件頭開闢 25 bit 的空間用來儲存物件的 hashcode ,4 bit 用於存放分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為 01。 偏向鎖
中劃分更細,還是開闢 25 bit 的空間,其中 23 bit 用來存放執行緒ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標識, 0 表示無鎖,1 表示偏向鎖,鎖的標識位還是 01。輕量級鎖
中直接開闢 30 bit 的空間存放指向棧中鎖記錄的指標,2bit 存放鎖的標誌位,其標誌位為 00。重量級鎖
中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指標,2 bit 存放鎖的標識位,為 11GC標記
開闢 30 bit 的記憶體空間卻沒有佔用,2 bit 空間存放鎖標誌位為 11。
其中無鎖和偏向鎖的鎖標誌位都是 01,只是在前面的 1 bit 區分了這是無鎖狀態還是偏向鎖狀態。
關於為什麼這麼分配的記憶體,我們可以從 OpenJDK
中的markOop.hpp類中的列舉窺出端倪
來解釋一下
- age_bits 就是我們說的分代回收的標識,佔用4位元組
- lock_bits 是鎖的標誌位,佔用2個位元組
- biased_lock_bits 是是否偏向鎖的標識,佔用1個位元組。
- max_hash_bits 是針對無鎖計算的 hashcode 佔用位元組數量,如果是 32 位虛擬機器,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機器,64 - 4 - 2 - 1 = 57 byte,但是會有 25 位元組未使用,所以 64 位的 hashcode 佔用 31 byte。
- hash_bits 是針對 64 位虛擬機器來說,如果最大位元組數大於 31,則取 31,否則取真實的位元組數
- cms_bits 我覺得應該是不是 64 位虛擬機器就佔用 0 byte,是 64 位就佔用 1byte
- epoch_bits 就是 epoch 所佔用的位元組大小,2 位元組。
在上面的虛擬機器物件頭分配表中,我們可以看到有幾種鎖的狀態:無鎖(無狀態),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優化後新增加的,其目的就是為了大大優化鎖的效能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那麼大了。其實從鎖有無鎖定來講,還是隻有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現就是增加了鎖的獲取效能而已,並沒有出現新的鎖。
所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個執行緒持有後,它就會處於鎖定狀態。在 HotSpot 虛擬機器中,monitor 的底層程式碼是由 ObjectMonitor
實現的,其主要資料結構如下(位於 HotSpot 虛擬機器原始碼 ObjectMonitor.hpp 檔案,C++ 實現的)
這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的執行緒都會被封裝稱為 ObjectWaiter
物件。
_Owner 是指向了 ObjectMonitor 物件的執行緒,而 _WaitSet 和 _EntryList 就是用來儲存每個執行緒的列表。
那麼這兩個列表有什麼區別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個列表
當多個執行緒同時訪問某段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的 monitor 之後,就會進入 _Owner 區域,並把 ObjectMonitor 物件的 _Owner 指向為當前執行緒,並使 _count + 1,如果呼叫了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個執行緒會進入到 _WaitSet 列表中等待被喚醒。如果當前執行緒執行完畢後也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復位 _count 的值。
Klass Pointer 表示的是型別指標,也就是物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
你可能不是很理解指標是個什麼概念,你可以簡單理解為指標就是指向某個資料的地址。
例項資料 Instance Data
例項資料部分是物件真正儲存的有效資訊,也是程式碼中定義的各個欄位的位元組大小,比如一個 byte 佔 1 個位元組,一個 int 佔用 4 個位元組。
對齊 Padding
對齊不是必須存在的,它只起到了佔位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求物件的起始地址必須是 8 位元組的整數倍,也就是說物件的位元組大小是 8 的整數倍,不夠的需要使用 Padding 補全。
鎖的升級流程
先來個大體的流程圖來感受一下這個過程,然後下面我們再分開來說
無鎖
無鎖狀態
,無鎖即沒有對資源進行鎖定,所有的執行緒都可以對同一個資源進行訪問,但是隻有一個執行緒能夠成功修改資源。
無鎖的特點就是在迴圈內進行修改操作,執行緒會不斷的嘗試修改共享資源,直到能夠成功修改資源並退出,在此過程中沒有出現衝突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的效能是非常高的。
偏向鎖
HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,還存在鎖由同一執行緒多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是為了解決只有在一個執行緒執行同步時提高效能。
可以從物件頭的分配中看到,偏向鎖要比無鎖多了執行緒ID
和 epoch
,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
- 首先執行緒訪問同步程式碼塊,會通過檢查物件頭 Mark Word 的
鎖標誌位
判斷目前鎖的狀態,如果是 01,說明就是無鎖或者偏向鎖,然後再根據是否偏向鎖
的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執行下一步 - 執行緒使用 CAS 操作來嘗試對物件加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那麼當前執行緒就會獲得物件的偏向鎖,此時會在物件頭的 Mark Word 中記錄當前執行緒 ID 和獲取鎖的時間 epoch 等資訊,然後執行同步程式碼塊。
全域性安全點(Safe Point):全域性安全點的理解會涉及到 C 語言底層的一些知識,這裡簡單理解 SafePoint 是 Java 程式碼中的一個執行緒可能暫停執行的位置。
等到下一次執行緒在進入和退出同步程式碼塊時就不需要進行 CAS
操作進行加鎖和解鎖,只需要簡單判斷一下物件頭的 Mark Word 中是否儲存著指向當前執行緒的執行緒ID,判斷的標誌當然是根據鎖的標誌位來判斷的。如果用流程圖來表示的話就是下面這樣
關閉偏向鎖
偏向鎖在Java 6 和Java 7 裡是預設啟用
的。由於偏向鎖是為了在只有一個執行緒執行同步塊時提高效能,如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false
,那麼程式預設會進入輕量級鎖狀態。
關於 epoch
偏向鎖的物件頭中有一個被稱為 epoch
的值,它作為偏差有效性的時間戳。
輕量級鎖
輕量級鎖
是指當前鎖是偏向鎖的時候,資源被另外的執行緒所訪問,那麼偏向鎖就會升級為輕量級鎖
,其他執行緒會通過自旋
的形式嘗試獲取鎖,不會阻塞,從而提高效能,下面是詳細的獲取過程。
輕量級鎖加鎖過程
- 緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執行下一步
- 如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個執行緒的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操作,撤銷偏向鎖,然後等原持有偏向鎖的執行緒到達
全域性安全點(SafePoint)
時,會暫停原持有偏向鎖的執行緒,然後會檢查原持有偏向鎖的狀態,如果已經退出同步,就會喚醒持有偏向鎖的執行緒,執行下一步 - 檢查物件頭中的 Mark Word 記錄的是否是當前執行緒 ID,如果是,執行同步程式碼,如果不是,執行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)
重量級鎖
重量級鎖其實就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個過程。
重量級鎖的獲取流程
- 接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執行下一步
- 會在原持有偏向鎖的執行緒的棧中分配鎖記錄,將物件頭中的 Mark Word 拷貝到原持有偏向鎖執行緒的記錄中,然後原持有偏向鎖的執行緒獲得輕量級鎖,然後喚醒原持有偏向鎖的執行緒,從安全點處繼續執行,執行完畢後,執行下一步,當前執行緒執行第 4 步
- 執行完畢後,開始輕量級解鎖操作,解鎖需要判斷兩個條件
- 判斷物件頭中的 Mark Word 中鎖記錄指標是否指向當前棧中記錄的指標
- 拷貝在當前執行緒鎖記錄的 Mark Word 資訊是否與物件頭中的 Mark Word 一致。
如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,並喚起等待的執行緒,進行新一輪的鎖競爭。
- 在當前執行緒的棧中分配鎖記錄,拷貝物件頭中的 MarkWord 到當前執行緒的鎖記錄中,執行 CAS 加鎖操作,會把物件頭 Mark Word 中鎖記錄指標指向當前執行緒鎖記錄,如果成功,獲取輕量級鎖,執行同步程式碼,然後執行第3步,如果不成功,執行下一步
- 當前執行緒沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限後還沒有獲取到鎖,那麼輕量級鎖就會升級為
重量級鎖
如果用流程圖表示是這樣的
根據上面對於鎖升級細緻的描述,我們可以總結一下不同鎖的適用範圍和場景。
synchronized 程式碼塊的底層實現
為了便於方便研究,我們把 synchronized 修飾程式碼塊的示例簡單化,如下程式碼所示
public class SynchronizedTest {
private int i;
public void syncTask(){
synchronized (this){
i++;
}
}
}
我們主要關注一下 synchronized 的位元組碼,如下所示
從這段位元組碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令指向同步程式碼塊的結束位置。
那麼為什麼會有兩個 monitorexit 呢?
不知道你注意到下面的異常表了嗎?如果你不知道什麼是異常表,那麼我建議你讀一下這篇文章
看完這篇Exception 和 Error,和麵試官扯皮就沒問題了
synchronized 修飾方法的底層原理
方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用位元組碼來控制,真的是這樣嗎?我們來反編譯一波看看結果
public class SynchronizedTest {
private int i;
public synchronized void syncTask(){
i++;
}
}
這次我們使用 javap -verbose 來輸出詳細的結果
從位元組碼上可以看出,synchronized 修飾的方法並沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標識,該標識指明瞭此方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。這就是 synchronized 鎖在同步程式碼塊上和同步方法上的實現差別。
我自己肝了六本 PDF,全網傳播超過10w+ ,微信搜尋「程式設計師cxuan」關注公眾號後,在後臺回覆 cxuan ,領取全部 PDF,這些 PDF 如下