實現synchronized
的基礎有兩個:Java 物件頭和 Monitor。
在虛擬機器規範中定義了物件在記憶體中的佈局,主要由以下 3 部分組成:
- 物件頭
- 例項資料
- 對齊填充
而synchronized
的實現就藏在物件頭中。物件頭中由兩個比較重要的部分組成:
-
Mark Word:預設儲存物件的 hashCode,分代年齡,鎖型別,鎖標誌位等資訊,是實現輕量級鎖和偏向鎖的關鍵
-
Class Metadata Address:型別指標指向物件的類後設資料,JVM 通過這個指標確定該物件是哪個類的資料
下圖是在 32 位機器上的 Mark Word 的組成示意圖。在 Java6 之前,synchronized
的實現是依靠重量級鎖來實現的,鎖標誌位是10
。在 Java6後,對synchronized
進行了優化,增加了輕量級鎖和偏向鎖
我們先來說一下重量級鎖:重量級鎖中存放的是指向重量級鎖的指標。在 Java 中,每個物件都存在著一個 Monitor 與之關聯。當執行緒持有一個物件的 Monitor 後,Monitor便處於鎖定狀態。在 Hotspot 中,Monitor 是由`ObjectMonitor`來實現的。
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
複製程式碼
上面是ObjectMonitor
類的初始化程式碼,可以看到有_WaitSet
和_EntryList
,對應於 Java 中的等待池和鎖池
_owner
表示的是持有objectMonitor
的執行緒。當多個執行緒同時訪問一個物件的同步程式碼時,首先會進入到_EntryList
裡,當執行緒獲取到物件的Monitor
後,就把Monitor
中的owner
設定位當前執行緒,同時Monitor
中的_count
變數+1- 若持有
Monitor
的執行緒執行完畢或者呼叫wait()
方法,則會釋放持有的Monitor
,owner
會被設定為NULL
,並將 count-1。當前執行緒會進入到_WaitSet
,等待被喚醒Monitor
物件存在於每個物件的物件頭中,synchronized
便是通過持有Monitor
實現鎖的,這也是為什麼 Java 中任意物件可以作為鎖的原因。
下面是從位元組碼層面來分析synchronized
關鍵字
public class SyncBlockAndMethod {
public void syncTask() {
//同步程式碼塊
synchronized (this) {
System.out.println("Hello");
}
}
//同步方法
public void syncMethod() {
System.out.println("Hello Again");
}
}
複製程式碼
在類中定義了同步程式碼塊和同步方法,使用javac
編譯為位元組碼
javac SyncBlockAndMethod.java
複製程式碼
然後使用javap
檢視編譯生成的位元組碼
javap -verbose SyncBlockAndMethod
複製程式碼
首先來看與synchronized
修飾同步程式碼塊有關的位元組碼
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String Hello
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
...
...
...
複製程式碼
從位元組碼中可知,synchronized
同步語句塊的實現主要對應於monitorenter
和monitorexit
指令。monitorenter
是同步程式碼塊的入口,表示在這裡獲取鎖。monitorexit
是同步程式碼塊的出口,表示在這裡釋放鎖。
細心的同學可以看到位元組碼中有一個monitorenter
指令,卻有兩個monitorexit
指令。第一個monitorexit
指令是和monitorenter
指令對應的。
而為了保證在同步程式碼塊中丟擲異常時依然能夠釋放鎖,編譯器會自動產生一個異常處理器,在同步程式碼塊中丟擲異常時,會在這個異常處理器裡面釋放鎖,對應於位元組碼中的第二個monitorexit
指令。
下面來看與synchronized
修飾方法有關的位元組碼
public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String Hello Again
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 13: 0
line 15: 8
複製程式碼
上面沒有看到monitorenter
和monitorexit
指令,因為在synchronized
修飾方法時,monitor
的實現是隱式的,在方法的flags
中新增了ACC_SYNCHRONIZED
標誌位,通過這種方式來實現synchronized
方法。當一個執行緒進入synchronized
方法時會獲取monitor
物件,在方法體中丟擲異常或者執行完成時會釋放monitor
。
什麼是重入:
從互斥鎖的設計上來說,當一個執行緒檢視操作一個由其他執行緒持有的物件鎖的臨界資源時,將會處於阻塞狀態。但是當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入,是可以請求成功的。
舉個例子:一個執行緒在獲得鎖進入synchronized
方法時,在方法內部又呼叫了該物件另一個synchronized
方法,是可以呼叫成功的。
在早期版本中,synchronized
屬於重量鎖,Monitor
依賴於底層作業系統的Mutex Lock
實現
執行緒獲得鎖之後會切換,而執行緒之間的切換需要用使用者態轉換到和心態,開銷較大
在 Java6 以後,在 JVM(Hospot) 層面對synchronized
做了較大的優化,synchronized
效能得到了很大的提升,包括如下:
- Adaptive Spinning(自適應自旋鎖)
- Lock Eliminate(鎖消除)
- Lock Coarsening(鎖粗化)
- Lightweight Locking(輕量級鎖)
- Biased Locking(偏向鎖) 這些優化都是為了線上程之間更高效地共享資料以及解決競爭問題,從而提高程式的執行效率
自旋鎖
在許多情況下,共享資料的鎖定狀態只會持續很短一段時間,為了這一點點的時間去切換執行緒並不值得,在多核 CPU 的情況下,完全可以讓另一個沒有獲取到鎖的執行緒通過執行忙迴圈等待鎖的釋放,而不是切換執行緒讓出 CPU。這就是自旋鎖,在 Java4 就引入了,只是預設是關閉的,到了 Java6 之後才變為預設開啟自旋鎖。
如果其他執行緒佔有鎖的時間非常短,那麼自旋很快就能獲取到鎖,自旋鎖的效能會很好;但是如果鎖被其他執行緒長期佔有,那麼效能上的開銷就會比較大,因為自旋就是迴圈,如果迴圈時間較長,那麼就會白白消耗 CPU 的資源。因此在 自旋一定次數後仍然沒有獲取到鎖,那麼就使用傳統的方式掛起並切換執行緒,可以使用preBlockSpin
引數設定自旋次數。但是要確定不同場景下自旋次數是比較困難的,因此出現了自適應自旋鎖
自適應自旋鎖
在自適應自旋鎖中,自旋的次數不再固定,而是由上一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定的。如果在一個鎖物件上,剛剛有一個執行緒通過自旋成功獲取到鎖,並且持有鎖的執行緒正在執行黃總,JVM 會認為通過自旋成功獲取到鎖的可能性很大,在下次其他執行緒獲取鎖時會增加自旋的次數。反之如果一個鎖很少可以通過自旋成功獲取,那麼在之後獲取鎖時,將跳過自旋過程,避免浪費處理器資源。
鎖消除
鎖消除也是一種鎖優化,在進行 JIT 編譯時,JVM 對執行上下文進行掃描,去除不可能存在競爭的鎖,可以節省一些沒有意義的請求鎖的時間,提升程式的效能。在下面的例子中,StringBuffer 是執行緒安全的,append() 方法帶有synchronized
關鍵字修飾,但是由於 sb 物件只會在add()方法內被呼叫,屬於區域性變數,不可能b被其他執行緒引用,因此 sb 物件屬於不可能共享的資源,JVM 會自動消除 sb.append() 方法的鎖
public class StringBufferWithoutSync {
public void add(String str1, String str2) {
StringBuffer sb = new StringBuffer();
//StringBuffer 是執行緒安全的,append() 方法帶有synchronized關鍵字修飾。
//但是由於 sb 物件只會在add()方法內被呼叫,屬於區域性變數,不可能b被其他執行緒引用
//因此 sb 物件屬於不可能共享的資源,JVM 會自動消除 sb.append() 方法的鎖
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
for (int i = 0; i < 100; i++) {
withoutSync.add("a", "b");
}
}
}
複製程式碼
鎖粗化
通過擴大加鎖的範圍,避免反覆加鎖和釋放鎖
public class CoarseSync {
public static String copyString100Times(String target) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
//由於 append 方法是 synchronized 的。每次 append 都會去申請鎖,JVM 檢測到在迴圈中的加鎖和釋放鎖操作,
// 比較耗時,就會將鎖粗化到迴圈外部,只加一次鎖,這樣就提高了效能
sb.append(target);
}
return sb.toString();
}
}
複製程式碼
在上面程式碼中,由於 append 方法是 synchronized 的。每次 append 都會去申請鎖,JVM 檢測到在迴圈中的加鎖和釋放鎖操作,比較耗時,就會將鎖粗化到迴圈外部,只加一次鎖,這樣就提高了效能。
synchronized 的四種狀態
無鎖、偏向鎖、輕量級鎖、重量級鎖。 會隨著競爭情況逐漸升級,鎖膨脹的方向為:無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖。
在特定情況下還會進行鎖降級,當 JVM 執行到安全點(Safe Point)時,會檢查是否有空閒的Monitor
,並嘗試將其進行降級。
無鎖就對應上面的鎖消除,重量鎖就是Monitor
,下面重點說一下偏向鎖和輕量級鎖。
偏向鎖的出現是為了減少同一執行緒獲取鎖的代價。 在大多數情況下,鎖不存在多執行緒競爭,總是由同一執行緒多次獲得。為了減少同一執行緒獲取鎖的代價,引入了偏向鎖。
偏向鎖的核心思想是:如果一個執行緒獲得了鎖,那麼鎖進入偏向模式,此時 Mark Word 的結構也變為偏向鎖結構,當該執行緒再次清秋鎖時,無需再做任何同步操作,即獲取鎖的過程只需要檢查 Mark Word 的鎖標記位,以及當前執行緒 ID 等於 Mark Word 的 ThreadID即可,這樣就省去了大量有關鎖申請的操作。 當一個執行緒訪問同步塊並獲取到鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒 ID,以後該執行緒再進入和推出同步塊時,不需要進行 CAS 操作來加鎖和解鎖,從而提高了程式的效能。 對於沒有鎖競爭的場合,偏向鎖有很好的優化效果。但是對於鎖競爭比較激烈的多執行緒場合,偏向鎖就失去作用了,這時偏向鎖就會升級為輕量級鎖。
輕量級鎖
輕量級鎖是由偏向鎖升級來的,偏向鎖執行在一個執行緒進入同步塊的情況下,當第二個執行緒加入鎖競爭的時候,偏向鎖就會升級為輕量級鎖。 輕量級鎖適用的場景是執行緒交替執行同步塊的情況,若存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。
- 線上程進入同步程式碼塊時,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為"01"狀態),虛擬機器首先在噹噹前執行緒的棧幀中建立一個名為瑣記錄(Lock Record)的空間,用於儲存鎖物件目前的 Mark Word 的拷貝,官方稱之為 Displaced Mark Word,這時執行緒堆疊與物件頭的狀態如圖所示:
- 把物件頭中的 Mark Word 拷貝到棧幀的瑣記錄中
- 拷貝成功後,虛擬機器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標,並將 Lock Record 裡的 owner 指標指向物件的 Mark Word,如果更次呢成功,則執行步驟 4,否則執行步驟 5
- 如果步驟 3 中的更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件 Mark Word 的鎖標誌位設定為"00",表示該物件處於輕量鎖狀態。這時候執行緒棧幀與物件頭的狀態如圖所示:
5. 如果步驟 3 的更新操作失敗了,虛擬機器會首先檢查物件的 Mark Word 的是否指向當前執行緒的棧幀,如果時就說明當前執行緒已經擁有了這個物件的鎖,可以直接進入同步塊繼續執行。否則說明多個執行緒在競爭鎖,輕量級鎖膨脹為重量級鎖,鎖標誌的狀態變為"10",Mark Word 中儲存的就變為指向重量級鎖的指標,當前執行緒便嘗試使用自旋來獲取鎖,而後面等待鎖的執行緒要進入阻塞狀態。
下面再來講講輕量級鎖解鎖的過程。
解鎖的過程:
- 通過 CAS 操作嘗試把執行緒棧幀中複製的Displaced Mark Word 替換到物件當前的 Mark Word 中
- 如果替換成功,整個同步過程就完成了
- 如果替換失敗,說明由其他執行緒嘗試獲取該鎖(此時鎖已經膨脹為重量級鎖),那就要在釋放鎖的同時,喚醒被掛起的執行緒 這裡需要說明一下為什麼把棧幀中的 Displaced Mark Word 成功替換到物件的 Mark Word 中,就是解鎖成功了?這裡需要從鎖的記憶體語義來理解
鎖的記憶體語義
- 當執行緒釋放鎖時,Java 記憶體模型會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中
- 當執行緒獲取鎖時,Java 記憶體模型會把該執行緒對應的本地記憶體置為無效,從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數
也就是說執行緒 A 釋放一個鎖,實際上是執行緒 A 向接下來將要獲取這個鎖的某個執行緒發出訊息,這個訊息就是執行緒 A 對共享變數所做的修改。 執行緒 B 獲取某個鎖,實際上是執行緒 B 接收之前佔有這個鎖的執行緒發出的訊息,這個訊息就是在釋放鎖之前對共享變數所做的修改。
鎖 | 優點 | 缺點 | 使用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要 CAS 操作,沒有額外的效能消耗,和執行非同步方法相比僅存在納秒級的差別 | 如果執行緒之間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 只有一個執行緒訪問同步程式碼塊或者同步方法的場景 |
輕量級鎖 | 競爭的執行緒不會阻塞,提高了相應速度 | 如果執行緒長時間搶佔不到鎖,自旋會消耗 CPU 效能 | 執行緒交替執行方法同步塊或者同步方法的場景 |
重量級鎖 | 執行緒競爭不使用自旋,不會消耗 CPU | 執行緒阻塞,響應時間緩慢,在多執行緒下,頻繁地獲取和釋放鎖,會帶來巨大的效能消耗 | 追求吞吐量,同步程式碼塊或者同步方法執行時間較長的場景 |
- 參考:輕量級鎖加鎖&解鎖過程
原文來源於部落格:zhangxiann.com/archives/sy…