前言
上一篇文章介紹了多執行緒的概念及synchronized
的使用方法《synchronized的使用(一)》,但是僅僅會用還是不夠的,只有瞭解其底層實現才能在開發過程中運籌帷幄,所以本篇探討synchronized
的實現原理及鎖升級(膨脹)的過程。
synchronized實現原理
synchronized
是依賴於JVM
來實現同步的,在同步方法和程式碼塊的原理有點區別。
同步程式碼塊
我們在程式碼塊加上synchronized
關鍵字
public void synSay() {
synchronized (object) {
System.out.println("synSay----" + Thread.currentThread().getName());
}
}
複製程式碼
編譯之後,我們利用反編譯命令javap -v xxx.class
檢視對應的位元組碼,這裡為了減少篇幅,我就只貼上對應的方法的位元組碼。
public void synSay();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: getfield #2 // Field object:Ljava/lang/String;
4: dup
5: astore_1
6: monitorenter
7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
10: new #4 // class java/lang/StringBuilder
13: dup
14: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
17: ldc #6 // String synSay----
19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokestatic #8 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
25: invokevirtual #9 // Method java/lang/Thread.getName:()Ljava/lang/String;
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
37: aload_1
38: monitorexit
39: goto 47
42: astore_2
43: aload_1
44: monitorexit
45: aload_2
46: athrow
47: return
Exception table:
from to target type
7 39 42 any
42 45 42 any
LineNumberTable:
line 21: 0
line 22: 7
line 23: 37
line 24: 47
LocalVariableTable:
Start Length Slot Name Signature
0 48 0 this Lcn/T1;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 42
locals = [ class cn/T1, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
複製程式碼
可以發現synchronized
同步程式碼塊是通過加monitorenter
和monitorexit
指令實現的。
每個物件都有個**監視器鎖(monitor) **,當monitor
被佔用的時候就代表物件處於鎖定狀態,而monitorenter
指令的作用就是獲取monitor
的所有權,monitorexit
的作用是釋放monitor
的所有權,這兩者的工作流程如下:
monitorenter:
- 如果
monitor
的進入數為0,則執行緒進入到monitor
,然後將進入數設定為1
,該執行緒稱為monitor
的所有者。 - 如果是執行緒已經擁有此
monitor
(即monitor
進入數不為0),然後該執行緒又重新進入monitor
,則將monitor
的進入數+1
,這個即為鎖的重入。 - 如果其他執行緒已經佔用了
monitor
,則該執行緒進入到阻塞狀態,知道monitor
的進入數為0,該執行緒再去重新嘗試獲取monitor
的所有權。
monitorexit:執行該指令的執行緒必須是monitor
的所有者,指令執行時,monitor
進入數-1
,如果-1
後進入數為0
,那麼執行緒退出monitor
,不再是這個monitor
的所有者。這個時候其它阻塞的執行緒可以嘗試獲取monitor
的所有權。
同步方法
在方法上加上synchronized
關鍵字
synchronized public void synSay() {
System.out.println("synSay----" + Thread.currentThread().getName());
}
複製程式碼
編譯之後,我們利用反編譯命令javap -v xxx.class
檢視對應的位元組碼,這裡為了減少篇幅,我就只貼上對應的方法的位元組碼。
public synchronized void synSay();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #5 // String synSay----
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: invokestatic #7 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
18: invokevirtual #8 // Method java/lang/Thread.getName:()Ljava/lang/String;
21: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: return
LineNumberTable:
line 20: 0
line 21: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 this Lcn/T1;
複製程式碼
從位元組碼上看,加有synchronized
關鍵字的方法,常量池中比普通的方法多了個ACC_SYNCHRONIZED
標識,JVM
就是根據這個標識來實現方法的同步。
當呼叫方法的時候,呼叫指令會檢查方法是否有ACC_SYNCHRONIZED
標識,有的話執行緒需要先獲取monitor
,獲取成功才能繼續執行方法,方法執行完畢之後,執行緒再釋放monitor
,同一個monitor
同一時刻只能被一個執行緒擁有。
兩種同步方式區別
synchronized
同步程式碼塊的時候通過加入位元組碼monitorenter
和monitorexit
指令來實現monitor
的獲取和釋放,也就是需要JVM通過位元組碼顯式的去獲取和釋放monitor實現同步,而synchronized同步方法的時候,沒有使用這兩個指令,而是檢查方法的ACC_SYNCHRONIZED
標誌是否被設定,如果設定了則執行緒需要先去獲取monitor,執行完畢了執行緒再釋放monitor,也就是不需要JVM去顯式的實現。
這兩個同步方式實際都是通過獲取monitor和釋放monitor來實現同步的,而monitor的實現依賴於底層作業系統的mutex
互斥原語,而作業系統實現執行緒之間的切換的時候需要從使用者態轉到核心態,這個轉成過程開銷比較大。
執行緒獲取、釋放monitor
的過程如下:
執行緒嘗試獲取monitor
的所有權,如果獲取失敗說明monitor
被其他執行緒佔用,則將執行緒加入到的同步佇列中,等待其他執行緒釋放monitor
,當其他執行緒釋放monitor
後,有可能剛好有執行緒來獲取monitor
的所有權,那麼系統會將monitor
的所有權給這個執行緒,而不會去喚醒同步佇列的第一個節點去獲取,所以synchronized
是非公平鎖。如果執行緒獲取monitor
成功則進入到monitor
中,並且將其進入數+1
。
關於什麼是公平鎖、非公平鎖可以參考一下美團技術團隊寫的《不可不說的Java“鎖”事》
到這裡我們也清楚了synchronized
的語義底層是通過一個monitor
的物件完成,其實wait
、notiyf
和notifyAll
等方法也是依賴於monitor
物件來完成的,這也就是為什麼需要在同步方法或者同步程式碼塊中呼叫的原因(需要先獲取物件的鎖,才能執行),否則會丟擲java.lang.IllegalMonitorStateException
的異常
Java物件的組成
我們知道了執行緒要訪問同步方法、程式碼塊的時候,首先需要取得鎖,在退出或者丟擲異常的時候又必須釋放鎖,那麼鎖到底是什麼?又儲存在哪裡?
為了解開這個疑問,我們需要進入Java虛擬機器(JVM) 的世界。在HotSpot
虛擬機器中,Java
物件在記憶體中儲存的佈局可以分為3
塊區域:物件頭、例項資料、對齊填充。synchronized使用的鎖物件儲存在物件頭中
物件頭
物件頭的資料長度在32
位和64
位(未開啟壓縮指標)的虛擬機器中分別為32bit
和64bit
。物件頭由以下三個部分組成:
- Mark Word:記錄了物件和鎖的有關資訊,儲存物件自身的執行時資料,如雜湊碼(HashCode)、
GC
分代年齡、鎖標誌位、執行緒持有的鎖、偏向執行緒ID
、偏向時間戳、物件分代年齡等。注意這個Mark Word結構並不是固定的,它會隨著鎖狀態標誌的變化而變化,而且裡面的資料也會隨著鎖狀態標誌的變化而變化,這樣做的目的是為了節省空間。 - 型別指標:指向物件的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
- 陣列長度:這個屬性只有陣列物件才有,儲存著陣列物件的長度。
在32
位虛擬機器下,Mark Word
的結構和資料可能為以下5
種中的一種。
在64
位虛擬機器下,Mark Word
的結構和資料可能為以下2
種中的一種。
這裡重點注意是否偏向鎖和鎖標誌位,這兩個標識和synchronized
的鎖膨脹息息相關。
例項資料
儲存著物件的實際資料,也就是我們在程式中定義的各種型別的欄位內容。
對齊填充
HotSpot
虛擬機器的對齊方式為8
位元組對齊,即一個物件必須為8
位元組的整數倍,如果不是,則通過這個對齊填充來佔位填充。
synchronized鎖膨脹過程
上文介紹的 "synchronized
實現原理" 實際是synchronized實現重量級鎖的原理,那麼上文頻繁提到monitor
物件和物件又存在什麼關係呢,或者說monitor
物件儲存在物件的哪個地方呢?
在物件的物件頭中,當鎖的狀態為重量級鎖的時候,它的指標即指向monitor
物件,如圖:
monitor
物件?答案:是的。這也是
JVM
對synchronized
的優化,我們知道重量級鎖的實現是基於底層作業系統的mutex
互斥原語的,這個開銷是很大的。所以JVM
對synchronized
做了優化,JVM
先利用物件頭實現鎖的功能,如果執行緒的競爭過大則會將鎖升級(膨脹)為重量級鎖,也就是使用monitor
物件。當然JVM
對鎖的優化不僅僅只有這個,還有引入適應性自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等。
那麼鎖的是怎麼進行膨脹的或者依據什麼來膨脹,這也就是本篇需要介紹的重點,首先我們需要了解幾個概念。
鎖的優化
自旋鎖和自適應性自旋鎖
自旋:當有個執行緒A
去請求某個鎖的時候,這個鎖正在被其它執行緒佔用,但是執行緒A
並不會馬上進入阻塞狀態,而是迴圈請求鎖(自旋)。這樣做的目的是因為很多時候持有鎖的執行緒會很快釋放鎖的,執行緒A
可以嘗試一直請求鎖,沒必要被掛起放棄CPU
時間片,因為執行緒被掛起然後到喚醒這個過程開銷很大,當然如果執行緒A
自旋指定的時間還沒有獲得鎖,仍然會被掛起。
自適應性自旋:自適應性自旋是自旋的升級、優化,自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態決定。例如執行緒如果自旋成功了,那麼下次自旋的次數會增多,因為JVM
認為既然上次成功了,那麼這次自旋也很有可能成功,那麼它會允許自旋的次數更多。反之,如果對於某個鎖,自旋很少成功,那麼在以後獲取這個鎖的時候,自旋的次數會變少甚至忽略,避免浪費處理器資源。有了自適應性自旋,隨著程式執行和效能監控資訊的不斷完善,JVM
對程式鎖的狀況預測就會變得越來越準確,JVM
也就變得越來越聰明。
鎖消除
鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。
鎖粗化
在使用鎖的時候,需要讓同步塊的作用範圍儘可能小,這樣做的目的是為了使需要同步的運算元量儘可能小,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。
輕量級鎖
所謂輕量級鎖是相對於使用底層作業系統mutex
互斥原語實現同步的重量級鎖而言的,因為輕量級鎖同步的實現是基於物件頭的Mark Word。那麼輕量級鎖是怎麼使用物件頭來實現同步的呢,我們看看具體實現過程。
獲取鎖過程:
- 線上程進入同步方法、同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為"01"狀態,是否為偏向鎖為"0"),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Recored)的空間,用於儲存鎖物件目前的Mark Word的拷貝(官方把這份拷貝加了個Displaced字首,即Displaced Mark Word)。
- 將物件頭的
Mark Word
拷貝到執行緒的鎖記錄(Lock Recored)中。 - 拷貝成功後,虛擬機器將使用
CAS
操作嘗試將物件的Mark Word
更新為指向Lock Record
的指標。如果這個更新成功了,則執行步驟4
,否則執行步驟5
。 - 更新成功,這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位將轉變為"00",即表示此物件處於輕量級鎖的狀態。。
- 更新失敗,虛擬機器首先會檢查物件的
Mark Word
是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其其它執行緒搶佔了。進行自旋執行步驟3
,如果自旋結束仍然沒有獲得鎖,輕量級鎖就需要膨脹為重量級鎖,鎖標誌位狀態值變為"10",Mark Word中儲存就是指向monitor
物件的指標,當前執行緒以及後面等待鎖的執行緒也要進入阻塞狀態。
釋放鎖的過程:
- 使用
CAS
操作將物件當前的Mark Word
和執行緒中複製的Displaced Mark Word
替換回來(依據Mark Word
中鎖記錄指標是否還指向本執行緒的鎖記錄),如果替換成功,則執行步驟2
,否則執行步驟3
。 - 如果替換成功,整個同步過程就完成了,恢復到無鎖的狀態(01)。
- 如果替換失敗,說明有其他執行緒嘗試獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的執行緒。
偏向鎖
偏向鎖的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS
操作區消除同步使用的互斥量,那麼偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS
操作都不用做了。偏向鎖預設是開啟的,也可以關閉。
偏向鎖"偏",就是"偏心"的"偏",它的意思是這個鎖會偏向於第一個獲得它的程式,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。
獲取鎖的過程:
- 檢查
Mark Word
是否為可偏向鎖的狀態,即是否偏向鎖即為1即表示支援可偏向鎖,否則為0表示不支援可偏向鎖。 - 如果是可偏向鎖,則檢查
Mark Word
儲存的執行緒ID
是否為當前執行緒ID
,如果是則執行同步塊,否則執行步驟3
。 - 如果檢查到
Mark Word
的ID
不是本執行緒的ID
,則通過CAS
操作去修改執行緒ID
修改成本執行緒的ID
,如果修改成功則執行同步程式碼塊,否則執行步驟4
。 - 當擁有該鎖的執行緒到達安全點之後,掛起這個執行緒,升級為輕量級鎖。
鎖釋放的過程:
- 有其他執行緒來獲取這個鎖,偏向鎖的釋放採用了一種只有競爭才會釋放鎖的機制,執行緒是不會主動去釋放偏向鎖,需要等待其他執行緒來競爭。
- 等待全域性安全點(在這個是時間點上沒有位元組碼正在執行)。
- 暫停擁有偏向鎖的執行緒,檢查持有偏向鎖的執行緒是否活著,如果不處於活動狀態,則將物件頭設定為無鎖狀態,否則設定為被鎖定狀態。如果鎖物件處於無鎖狀態,則恢復到無鎖狀態(01),以允許其他執行緒競爭,如果鎖物件處於鎖定狀態,則掛起持有偏向鎖的執行緒,並將物件頭
Mark Word
的鎖記錄指標改成當前執行緒的鎖記錄,鎖升級為輕量級鎖狀態(00)。
鎖的轉換過程
鎖主要存在4
種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭的情況逐漸升級,這幾個鎖只有重量級鎖是需要使用作業系統底層mutex
互斥原語來實現,其他的鎖都是使用物件頭來實現的。需要注意鎖可以升級,但是不可以降級。
三種鎖的優缺點比較
參考
深入理解Java虛擬機器
Java的物件頭和物件組成詳解
JVM(三)JVM中物件的記憶體佈局詳解
JVM——深入分析物件的記憶體佈局
啃碎併發(七):深入分析Synchronized原理
Java Synchronized實現原理
原文地址:ddnd.cn/2019/03/22/…