1 為什么需要synchronized?
當一個共享資源有可能被多個線程同時訪問並修改的時候,需要用鎖來保證數據的正確性。請看下圖:
線程A和線程B分別往同一個銀行賬戶里面添加貨幣,A線程從內存中讀取(read)當前賬戶金額($=0)到線程A的本地棧,進行+100的操作后,這時B線程也從內存中讀取當前金額($=0)到線程B的本地棧,並且進行+200的操作后寫回主存,線程B前腳剛寫回之后,后腳線程A又把$=100寫會到本地內存中。我們順便來復習一下JMM內存模型的8個原子操作:
- read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
- load(載入):作用於工作內存的變量,它把read操作從主內存得到的變量值放入工作內存的變量副本中。
- use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
- assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
- store(存儲):作用於工作內存的變量,它把store操作從工作內存中得到的變量值放入主內存的變量中。
- write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值賦值給主內存的變量。
我們知道,volatile
關鍵字只能保證變量的有序性和可見性,但是不能保證原子性。在這個例子中,即使給$變量加上volatile
關鍵字也是不頂用的,原因可見volatile為什么不能保證原子性以及知乎提問:volatile為什么不能保證原子性。
這時候就輪到我們的synchronized
關鍵字出場了,它可以在訪問競態資源時加鎖,從而保證修改的時候不會出錯。它有三種作用范圍:
- 在靜態方法上加鎖,鎖住的是
.class
對象 - 在非靜態方法上枷鎖,鎖住的是當前對象
this
- 在代碼塊上加鎖,鎖住的是一個
Object
對象,比如monitor
2 JDK6之前 synchronized 的實現原理
在JDK6以前,synchronized
還屬於重量級鎖,每次枷鎖都依賴操作系統Mutex Lock實現,涉及到操作系統讓線程從用戶態切換到內核態,切換成本很高。在JDK6以后,研究人員引入了偏向鎖和輕量級鎖,因為Sun公司的程序員發現大部分程序大多數時間都不會發生多個線程同時訪問競態資源的情況,每次線程都加鎖解鎖,每次這么搞都要操作系統在用戶態和內核態之前來回切,太耗性能了。
首先要了解synchronized
的實現原理,需要理解二個預備知識:
- Java對象頭的結構。對象存儲在堆中,主要分為三部分內容,對象頭、對象實例數據和對齊填充(數組對象多一個區域:記錄數組長度)。HotSpot虛擬機的對象頭分為兩部分,第一部分用來存儲對象自身的運行時數據,如哈希碼,GC分代年齡。這部分數據的長度在32位和64位的Java虛擬機中分別會占用32個或64個比特,官方稱它為"Mark Word"。這部分是實現輕量級鎖和偏向鎖的關鍵。另一部分用於存儲指向方法區對象類型數據的指針,如果是數組對象,還會有一個額外的部分用於存儲數組的長度。
考慮到Java虛擬機的空間使用效率,Mark Word被設計成一個非固定的動態數據結構,以便在極小的空間內存儲盡量多的信息。它會根據對象的狀態復用自己的存儲空間。32位HotSpot虛擬機中,對象未被鎖定的狀態下,Mark Word的32個比特空間里的25個比特用來存儲對象哈希碼,4個比特用於存儲對象分代年齡,2個比特用於存儲鎖標志位,還有1個比特固定為0(表示未進入偏向模式)。對象除了未被鎖定的正常狀態外,還有輕量級鎖定、重量級鎖定、GC標記、可偏向等幾種不同的狀態。Mark Word結構如下圖所示:
- Monitor。每個對象都有一個與之關聯的Monitor 對象;Monitor對象屬性如下所示(Hospot 1.7 代碼) 。
//下圖詳細介紹重要變量的作用
ObjectMonitor() {
_header = NULL;
_count = 0; // 重入次數
_waiters = 0, // 等待線程數
_recursions = 0;
_object = NULL;
_owner = NULL; // 當前持有鎖的線程
_WaitSet = NULL; // 調用了 wait 方法的線程被阻塞 放置在這里
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 等待鎖 處於block的線程 有資格成為候選資源的線程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
對象關聯的 ObjectMonitor 對象有一個線程內部競爭鎖的機制,如下圖所示:
下面我們就來分析一下JDK6之前的synchronized
具體的實現邏輯。
- 當有二個線程A、線程B都要開始給賬戶的經濟
money
變量加錢,要進行操作的時候 ,發現方法上加了synchronized
鎖,這時線程調度到A線程執行,A線程就搶先拿到了鎖。拿到鎖的步驟為:
- 將
MonitorObject
中的_owner
設置成 A線程 - 將
Mark Word
設置為Monitor
對象地址,鎖標志位改為10; - 將B線程阻塞放到
ContentionList
隊列
- JVM 每次從
Waiting Queue
的尾部取出一個線程放到OnDeck
作為候選者,但是如果並發比較高,Waiting Queue
會被大量線程執行CAS操作,為了降低對尾部元素的競爭,將Waiting Queue
拆分成ContentionList
和EntryList
二個隊列, JVM將一部分線程移到EntryList
作為准備進OnDeck的預備線程。另外說明幾點:
- 所有請求鎖的線程首先被放在
ContentionList
這個競爭隊列中; Contention List
中那些有資格成為候選資源的線程被移動到Entry List
中;- 任意時刻,最多只有一個線程正在競爭鎖資源,該線程被稱為
OnDeck
; - 當前已經獲取到所資源的線程被稱為
Owner
; - 處於
ContentionList、EntryList、WaitSet
中的線程都處於阻塞狀態,該阻塞是由操作系統來完成的(Linux 內核下采用pthread_mutex_lock
內核函數實現的);
- 作為
Owner
的A線程執行過程中,可能調用wait
釋放鎖,這個時候A線程進入Wait Set
, 等待被喚醒。
以上就是synchronized
在 JDK 6之前的實現原理。
另外,synchronized
在在線程競爭鎖時,首先做的不是直接進ContentionList
隊列排隊,而是嘗試自旋獲取鎖(可能ContentionList
有別的線程在等鎖),如果獲取不到才進入 ContentionList
,這明顯對於已經進入隊列的線程是不公平的,所以synchronized
是非公平鎖。另一個不公平的是自旋獲取鎖的線程還可能直接搶占 OnDeck
線程的鎖資源。
3 JDK6之后synchronized優化
那么JDK6對synchronized
做了哪些優化呢?
3.1 偏向鎖
- 如果當前虛擬機啟用了偏向鎖(啟用參數
-XX:+UseBiasedLocking
,這是自JDK6起HotSpot虛擬機的默認值),那么當鎖對象第一次被線程1獲取的時候,虛擬機會把對象頭中的標志位設為01
、把偏向模式設置為1
,表示進入偏向模式。同時使用CAS操作把獲取到這個鎖的線程1的ID記錄在對象的Mark Word中,如果CAS操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊時。虛擬機都可以不再進行任何同步操作(例如加鎖、解鎖以及對Mark Word的更新操作等)。 - 當另一個線程去嘗試獲取這個對象的鎖時,它首先會檢測對象是否處於偏向模式,如果處於偏向模式,那么就會檢測該對象的對象頭是否存儲了線程2的ID,如果是線程2的ID那就直接進入線程2的偏向模式,如果不是,那么線程2會檢測線程1是否還存在,不存在的話就直接把偏向鎖標識置為
0
,然后用CAS替換線程ID為自己的ID,如果存在的話就撤銷偏向模式(偏向模式設為0
),隨后的操作就按照輕量級鎖進行。
3.2 輕量級鎖
3.2.1 加鎖
- 在代碼即將進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標志位為
01
狀態),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄的空間,用於存儲鎖對象目前的Mark Word拷貝(官方為這份拷貝加了個前綴Displaced)。 - 然后虛擬機將使用CAS操作嘗試把對象的Mark Word更新為指向Lock Record的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標志位(Mark Word的最后兩個比特)將轉變為
00
,表示此對象處於輕量級鎖定狀態。 - 如果這個操作失敗了,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀。如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶占了。當前線程會進行自旋(?)。如果出現兩條以上的線程爭用同一個鎖,或者當前線程自旋失敗(嘗試到一定次數,默認10次)的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標志的狀態值變為
10
,此時Mark Word中存儲的就是指向重量級鎖監視器ObjectMonitor
(互斥量)的指針,后面等待鎖的線程也必須進入阻塞狀態。
下圖是輕量級鎖CAS操作前后堆棧與對象的狀態:
3.2.2 解鎖
輕量級鎖的解鎖過程也是通過CAS操作來進行的,如果對象的Mark Word仍然指向線程的鎖記錄,那就用CAS操作把對象當前的Mark Word和線程中復制的Displaced Mark Word替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他線程嘗試過獲取該鎖(膨脹為重量級鎖,Mark Word指向了互斥量),就要在釋放重量級鎖的同時,喚醒被掛起的線程。
3.2.3 使用條件
輕量級鎖能提升程序同步性能的依據是“對於絕大部分鎖,在同步周期內都是不存在競爭的”這一經驗法則。如果沒有競爭,輕量級鎖通過CAS操作成功避免了使用互斥量的開銷;但如果確實存在競爭,除了互斥量本身的開銷之外,還額外發生了CAS操作的開銷。因此在有競爭的情況下,輕量級鎖反而會比傳統的重量級鎖更慢。
整個鎖升級的過程如下圖所示:
參考資料:微信公眾號:安琪拉的博客