`synchronized`作為Java併發編程的基礎構建塊,其簡潔易用的語法形式背後蘊含著複雜的底層實現原理和技術細節。深入理解`synchronized`的運行機制,不僅有助於我們更好地利用這一特性編寫出高效且安全的併發程式。 ...
引言
在現代軟體開發領域,多線程併發編程已經成為提高系統性能、提升用戶體驗的重要手段。然而,多線程環境下的數據同步與資源共用問題也隨之而來,處理不當可能導致數據不一致、死鎖等各種併發問題。為此,Java語言提供了一種內置的同步機制——synchronized
關鍵字,它能夠有效地解決併發控制的問題,確保共用資源在同一時間只能由一個線程訪問,從而維護程式的正確性與一致性。
synchronized
作為Java併發編程的基礎構建塊,其簡潔易用的語法形式背後蘊含著複雜的底層實現原理和技術細節。深入理解synchronized
的運行機制,不僅有助於我們更好地利用這一特性編寫出高效且安全的併發程式,同時也有利於我們在面對複雜併發場景時,做出更為明智的設計決策和優化策略。
本文將從synchronized
的基本概念出發,逐步剖析其內在的工作機制,探討諸如監視器(Monitor)等關鍵技術點,並結合實際應用場景來展示synchronized
的實際效果和最佳實踐。通過對synchronized
底層實現原理的深度解讀,旨在為大家揭示Java併發世界的一隅,提升對併發編程的認知高度和實戰能力。
synchronized是什麼?
synchronized
是Java中實現線程同步的關鍵字,主要用於保護共用資源的訪問,確保在多線程環境中同一時間只有一個線程能夠訪問特定的代碼段或方法。它提供了互斥性和可見性兩個重要特性,確保了線程間操作的原子性和數據的一致性。
synchronized的特性
synchronized
關鍵字具有三個基本特性,分別是互斥性、可見性和有序性。
互斥性
synchronized
關鍵字確保了在其控制範圍內的代碼在同一時間只能被一個線程執行,實現了資源的互斥訪問。當一個線程進入了synchronized
代碼塊或方法時,其他試圖進入該同步區域的線程必須等待,直至擁有鎖的線程執行完畢並釋放鎖。
可見性
synchronized
還確保了線程間的數據可見性。一旦一個線程在synchronized
塊中修改了共用變數的值,其他隨後進入同步區域的線程可以看到這個更改。這是因為synchronized
的解鎖過程包含了將工作記憶體中的最新值刷新回主記憶體的操作,而加鎖過程則會強制從主記憶體中重新載入變數的值。
有序性
synchronized
提供的第三個特性是有序性,它可以確保在多線程環境下,對於同一個鎖的解鎖操作總是先行於隨後對同一個鎖的加鎖操作。這就意味著,通過synchronized
建立起了線程之間的記憶體操作順序關係,有效地解決了由於編譯器和處理器優化可能帶來的指令重排序問題。
synchronized可以實現哪鎖?
有上述synchronized的特性,我們可以知道synchronized可以實現這些鎖:
- 可重入鎖(Reentrant Lock):
synchronized
實現的鎖是可重入的,這意味著同一個線程可以多次獲取同一個鎖,而不會被阻塞。這種鎖機制允許線程在持有鎖的情況下再次獲取相同的鎖,避免了死鎖的發生。 - 排它鎖/互斥鎖/獨占鎖:
synchronized
實現的鎖是互斥的,也就是說,在同一時間只有一個線程能夠獲取到鎖,其他線程必須等待該線程釋放鎖才能繼續執行。這確保了同一時刻只有一個線程可以訪問被鎖定的代碼塊或方法,從而保證了數據的一致性和完整性。 - 悲觀鎖:
synchronized
實現的鎖屬於悲觀鎖,因為它預設情況下假設會發生競爭,並且會導致其他線程阻塞,直到持有鎖的線程釋放鎖。悲觀鎖的特點是對併發訪問持保守態度,認為會有其他線程來競爭共用資源,因此在訪問共用資源之前會先獲取鎖。 - 非公平鎖:
synchronized
在早期的Java版本中,預設實現的是非公平鎖,也就是說,線程獲取鎖的順序並不一定按照它們請求鎖的順序來進行,而是允許“插隊”,即已經在等待隊列中的線程可能被後來請求鎖的線程搶占。
有關Java中的鎖的分類,請參考:阿裡二面:Java中鎖的分類有哪些?你能說全嗎?
synchronized使用方式
synchronized
關鍵字可以修飾方法、代碼塊或靜態方法,用於確保同一時間只有一個線程可以訪問被synchronized
修飾的代碼片段。
修飾實例方法
當synchronized
修飾實例方法時,鎖住的是當前實例對象(this)。這意味著在同一時刻,只能有一個線程訪問此方法,所有對該對象實例的其他同步方法調用將會被阻塞,直到該線程釋放鎖。
public class SynchronizedInstanceMethod implements Runnable{
private static int counter = 0;
// 修飾實例方法,鎖住的是當前實例對象
private synchronized void add() {
counter++;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
add();
}
}
public static void main(String[] args) throws Exception {
SynchronizedInstanceMethod sim = new SynchronizedInstanceMethod();
Thread t1 = new Thread(sim);
Thread t2 = new Thread(sim);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + counter);
}
}
像上述這個例子,大家在接觸多線程時一定會看過或者寫過類似的代碼,i++
在多線程的情況下是線程不安全的,所以我們使用synchronized
作用在累加的方法上,使其變成線程安全的。上述列印結果為:
Final block counter value: 2000
而對於synchronized
作用於實例方法上時,鎖的是當前實例對象,但是如果我們鎖住的是不同的示例對象,那麼synchronized
就不能保證線程安全了。如下代碼:
public class SynchronizedInstanceMethod implements Runnable{
private static int counter = 0;
// 修飾實例方法,鎖住的是當前實例對象
private synchronized void add() {
counter++;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
add();
}
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(new SynchronizedInstanceMethod());
Thread t2 = new Thread(new SynchronizedInstanceMethod());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + counter);
}
}
執行結果為:
Final counter value: 1491
修飾靜態方法
若synchronized
修飾的是靜態方法,那麼鎖住的是類的Class對象,因此,無論多少個該類的實例存在,同一時刻也只有一個線程能夠訪問此靜態同步方法。針對修飾實例方法的線程不安全的示例,我們只需要在synchronized
修飾的實例方法上加上static
,將其變成靜態方法,此時synchronized
鎖住的就是類的class對象。
public class SynchronizedStaticMethod implements Runnable{
private static int counter = 0;
// 修飾實例方法,鎖住的是當前實例對象
private static synchronized void add() {
counter++;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
add();
}
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(new SynchronizedStaticMethod());
Thread t2 = new Thread(new SynchronizedStaticMethod());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + counter);
}
}
執行結果為:
Final counter value: 2000
修飾代碼塊
通過指定對象作為鎖,可以更精確地控制同步範圍。這種方式允許在一個方法內部對不同對象進行不同的同步控制。可以指定一個對象作為鎖,只有持有該對象鎖的線程才能執行被synchronized
修飾的代碼塊。
public class SynchronizedBlock implements Runnable{
private static int counter = 0;
@Override
public void run() {
// 這個this還可以是SynchronizedBlock.class,說明鎖住的是class對象
synchronized (this){
for (int i = 0; i < 1000; i++) {
counter++;
}
}
}
public static void main(String[] args) throws Exception {
SynchronizedBlock block = new SynchronizedBlock();
Thread t1 = new Thread(block);
Thread t2 = new Thread(block);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + counter);
}
}
synchronized
內置鎖作為一種對象級別的同步機制,其作用在於確保臨界資源的互斥訪問,實現線程安全。它本質上鎖定的是對象的監視器(Object Monitor),而非具體的引用變數。這種鎖具有可重入性,即同一個線程在已經持有某對象鎖的情況下,仍能再次獲取該對象的鎖,這顯著增強了線程安全代碼的編寫便利性,併在一定程度上有助於降低因線程交互引起的死鎖風險。
關於如何避免死鎖,請參考:阿裡二面:如何定位&避免死鎖?連著兩個面試問到了!
synchronized的底層原理
在JDK 1.6之前,synchronized
關鍵字所實現的鎖機制確實被認為是重量級鎖。這是因為早期版本的Java中,synchronized的實現依賴於操作系統的互斥量(Mutexes)來實現線程間的同步,這涉及到了從用戶態到內核態的切換以及線程上下文切換等相對昂貴的操作。一旦一個線程獲得了鎖,其他試圖獲取相同鎖的線程將會被阻塞,這種阻塞操作會導致線程狀態的改變和CPU資源的消耗,因此在高併發、低鎖競爭的情況下,這種鎖機制可能會成為性能瓶頸。
而在JDK 1.6中,對synchronized進行了大量優化,其中包括引入了偏向鎖(Biased Locking)、輕量級鎖(Lightweight Locking)的概念。接下來我們先說一下JDK1.6之前synchronized
的原理。
對象的組成結構
在JDK1.6之前,在Java虛擬機中,Java對象的記憶體結構主要有對象頭(Object Header),實例數據(Instance Data),對齊填充(Padding) 三個部分組成。
-
對象頭(Object Header):
對象頭主要包含了兩部分信息:Mark Word(標記欄位)和指向類元數據(Class Metadata)的指針。Mark Word 包含了一些重要的標記信息,比如對象是否被鎖定、對象的哈希碼、GC相關信息等。類元數據指針指向對象的類元數據,用於確定對象的類型信息、方法信息等。 -
實例數據(Instance Data):
實例數據是對象的成員變數和實例方法所占用的記憶體空間,它們按照聲明的順序依次存儲在對象的實例數據區域中。實例數據包括對象的所有非靜態成員變數和非靜態方法。 -
填充(Padding):
在JDK 1.6及之前的版本中,為了保證對象在記憶體中的存儲地址是8位元組的整數倍,可能會在對象的實例數據之後添加一些填充位元組。這些填充位元組的目的是對齊記憶體地址,提高記憶體訪問效率。填充位元組通常不包含任何實際數據,只是用於占位。
對象頭
在JDK 1.6之前的Java HotSpot虛擬機中,對象頭的基本組成依然包含Mark Word和類型指針(Klass Pointer),但當時對於鎖的實現還沒有引入偏向鎖和輕量級鎖的概念,因此對象頭中的Mark Word在處理鎖狀態時比較簡單,主要是用來存儲鎖的狀態信息以及與垃圾收集相關的數據。在一個32位系統重對象頭大小通常約為32位,而在64位系統中大小通常為64位。
對象頭組成部分:
- Mark Word(標記字):
在早期版本的HotSpot虛擬機中,Mark Word主要存儲的信息包括:
- 對象的hashCode(在沒有鎖定時)。
- 對象的分代年齡(用於垃圾回收演算法)。
- 鎖狀態信息,如無鎖、重量級鎖狀態(在使用
synchronized
關鍵字時)。 - 對象的鎖指針(Monitor地址,當對象被重量級鎖鎖定時,存儲的是指向重量級鎖(Monitor)的指針)。
對象頭中的Mark Word是一個非固定的數據結構,它會根據對象的狀態復用自己的存儲空間,存儲不同的數據。在Java HotSpot虛擬機中,Mark Word會隨著程式運行和對象狀態的變化而存儲不同的信息。其信息變化如下:
從存儲信息的變化可以看出:
- 對象頭的最後兩位存儲了鎖的標誌位,01表示初始狀態,即未加鎖。此時,對象頭記憶體儲的是對象自身的哈希碼。無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。
- 當進入偏向鎖階段時,對象頭內的標誌位變為01,並且存儲當前持有鎖的線程ID。這意味著只有第一個獲取鎖的線程才能繼續持有鎖,其他線程不能競爭同一把鎖。
- 在輕量級鎖階段,標誌位變為00,對象頭記憶體儲的是指向線程棧中鎖記錄的指針。這種情況下,多個線程可以通過比較鎖記錄的地址與對象頭內的指針地址來確定自己是否擁有鎖。
其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的。重量級鎖也就是通常說synchronized的對象鎖,鎖標識位為10,其中指針指向的是monitor對象(也稱為管程或監視器鎖)的起始地址。
-
類型指針(Klass Pointer 或 Class Pointer):
類型指針指向對象的類元數據(Class Metadata),即對象屬於哪個類的類型信息,用於確定對象的方法表和欄位佈局等。在一個32位系統重大小通常約為32位,而在64位系統中大小通常為64位。 -
數組長度(Array Length)(僅對數組對象適用):
如果對象是一個數組,對象頭中會額外包含一個欄位來存儲數組的長度。在一個32位系統中大小通常約為32位,而在64位系統中大小通常為64位。
監視器(Monitor)
在Java中,每個對象都與一個Monitor關聯,Monitor是一種同步機制,負責管理線程對共用資源的訪問許可權。當一個Monitor被線程持有時,對象便處於鎖定狀態。Java的synchronized
關鍵字在JVM層面上通過MonitorEnter
和MonitorExit
指令實現方法同步和代碼塊同步。MonitorEnter
嘗試獲取對象的Monitor所有權(即獲取對象鎖),MonitorExit
確保每個MonitorEnter操作都有對應的釋放操作。
在HotSpot虛擬機中,Monitor具體由ObjectMonitor實現,其結構如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //鎖計數器,表示重入次數,每當線程獲取鎖時加1,釋放時減1。
_waiters = 0, //等待線程總數,不一定在實際的ObjectMonitor中有直接體現,但在管理線程同步時是一個重要指標。
_recursions = 0; //與_count類似,表示當前持有鎖的線程對鎖的重入次數。
_object = NULL; // 通常指向關聯的Java對象,即當前Monitor所保護的對象。
_owner = NULL; // 持有ObjectMonitor對象的線程地址,即當前持有鎖的線程。
_WaitSet = NULL; //存儲那些調用過`wait()`方法並等待被喚醒的線程隊列。
_WaitSetLock = 0 ; // 用於保護_WaitSet的鎖。
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //阻塞在EntryList上的單向線程列表,可能用於表示自旋等待隊列或輕量級鎖的自旋鏈表。
FreeNext = NULL ; // 在對象Monitor池中可能用於鏈接空閑的ObjectMonitor對象。
_EntryList = NULL ; // 等待鎖的線程隊列,當線程請求鎖但發現鎖已被持有時,會被放置在此隊列中等待。
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; // 標誌位,可能用於標識_owner是否指向一個真實的線程對象。
}
其中最重要的就是_owner
、_WaitSet
、_EntryList
和count
幾個欄位,他們之間的轉換關係:
-
_owner
:
當一個線程首次成功執行synchronized
代碼塊或方法時,會嘗試獲取對象的Monitor(即ObjectMonitor
),並將自身設置為_owner
。該線程此刻擁有了對象的鎖,可以獨占訪問受保護的資源。 -
_EntryList
→_owner
:
當多個線程同時嘗試獲取鎖時,除第一個成功獲取鎖的線程外,其餘線程會進入_EntryList
排隊等待。一旦_owner
線程釋放鎖,_EntryList
中的下一個線程將有機會獲取鎖併成為新的_owner
。 -
_owner
→_WaitSet
:
當_owner
線程在持有鎖的情況下調用wait()
方法時,它會釋放鎖(即_owner
置為NULL
),並把自己從_owner
轉變為等待狀態,然後將自己添加到_WaitSet
中。這時,線程進入等待狀態,暫停執行,等待其他線程通過notify()
或notifyAll()
喚醒。 -
_WaitSet
→_EntryList
:
當其他線程調用notify()
或notifyAll()
方法時,會選擇一個或全部在_WaitSet
中的線程,將它們從_WaitSet
移除,並重新加入到_EntryList
中。這樣,這些線程就有機會再次嘗試獲取鎖併成為新的_owner
。
有上述轉換關係我們可以發現,當多線程訪問同步代碼時:
- 線程首先嘗試進入_EntryList競爭鎖,成功獲取Monitor後,將_owner設置為當前線程並將count遞增。
- 若線程調用wait()方法,會釋放Monitor、清空_owner,並將線程移到_WaitSet中等待被喚醒。
- 當線程執行完畢或調用notify()/notifyAll()喚醒等待線程後,會釋放Monitor,使得其他線程有機會獲取鎖。
在Java對象的對象頭(Mark Word)中,存儲了與鎖相關的狀態信息,這使得任意Java對象都能作為鎖來使用,同時,notify/notifyAll/wait等方法正是基於Monitor鎖對象來實現的,因此這些方法必須在synchronized
代碼塊中調用。
我們查看上述同步代碼塊SynchronizedBlock
的位元組碼文件:
從上述位元組碼中可以看到同步代碼塊的實現是由monitorenter
和monitorexit
指令完成的,其中monitorenter
指令所在的位置是同步代碼塊開始的位置,第一個monitorexit
指令是用於正常結束同步代碼塊的指令,第二個monitorexit
指令是用於異常結束時所執行的釋放Monitor指令。
關於查看class文件的位元組碼文件,有兩種方式:1、通過命令: javap -verbose <class路徑>/class文件。2、IDEA中通過插件:
jclasslib Bytecode viewer
。
我們再看一下作用於同步方法的位元組碼:
我們可以看出同步方法上沒有monitorenter
和 monitorexit
這兩個指令了,而在查看該方法的class文件的結構信息時發現了Access flags
後邊的synchronized標識,該標識表明瞭該方法是一個同步方法。Java虛擬機通過該標識可以來辨別一個方法是否為同步方法,如果有該標識,線程將持有Monitor,在執行方法,最後釋放Monitor。
總結
synchronized
作用於同步代碼塊時的原理:
Java虛擬機使用monitorenter和monitorexit指令實現同步塊的同步。monitorenter指令在進入同步代碼塊時執行,嘗試獲取對象的Monitor(即鎖),monitorexit指令在退出同步代碼塊時執行,釋放Monitor。
而對於方法級別的同步的原理:
Java虛擬機通過在方法的訪問標誌(Access flags)中設置ACC_SYNCHRONIZED標誌來實現方法同步。當一個方法被聲明為synchronized
時,編譯器會在生成的位元組碼中插入monitorenter和monitorexit指令,確保在方法執行前後正確地獲取和釋放對象的Monitor。
本文已收錄於我的個人博客:碼農Academy的博客,專註分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中間件、架構設計、面試題、程式員攻略等