工作中經常遇到需要用鎖來控制并發的問題,java中提供一個鎖神器關鍵字-Synchronized。通過它可以來解決多線程問題。與Java中另一個Lock鎖相比,之前一直覺得Synchronized是重量級的鎖,很耗性能,真的是這樣嗎?
Synchronized的實現原理
synchronized可以保證方法或者代碼塊在運行時,同一時刻只有一個方法可以進入到臨界區,同時它還可以保證共享變量的內存可見性
同步的基礎
Java中每一個對象都可以作為鎖,這是synchronized實現同步的基礎:
- 普通同步方法,鎖是當前實例對象
- 靜態同步方法,鎖是當前類的class對象
- 同步方法塊,鎖是括號里面的對象
當一個線程訪問同步代碼塊時,它首先是需要得到鎖才能執行同步代碼,當退出或者拋出異常時必須要釋放鎖,那么它是如何來實現這個機制的呢?我們先看一段簡單的代碼:
public class SynchronizedTest {
public synchronized void test1() {
//do something
}
public void test2() {
synchronized (this) {
//do something
}
}
同步的原理
???JVM規范規定JVM基于進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實現,而方法同步是使用另外一種方式實現的,細節在JVM規范里并沒有詳細說明,而是在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1,表示該方法是同步方法并使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示Class做為鎖對象。
???monitorenter指令是在編譯后插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處, JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個 monitor 與之關聯,當且一個monitor 被持有后,它將處于鎖定狀態。線程執行到 monitorenter 指令時,將會嘗試獲取對象所對應的 monitor 的所有權,即嘗試獲得對象的鎖。
???在虛擬機規范的要求,在執行monitorenter指令時,首先嘗試獲取對象的鎖,如果這個對象沒有被鎖,或者當前線程已經擁有這個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將計數器減1,當計數器為0是,鎖被釋放。如果獲取對象的鎖失敗,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。
???在虛擬機規范對monitorenter和monitorexit的行為描述中,有兩點需要特別注意的。首先,synchronized同步塊對同一個線程來說是可重入的,不會出現自己把自己死鎖的問題。其次,同步塊在已進入的線程執行完之前,會阻塞后面其他線程的進入
Java對象頭
鎖存在Java對象頭里。如果對象是數組類型,則虛擬機用3個Word(字寬)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,一字寬等于四字節,即32bit。
長度 | 內容 | 說明 |
---|---|---|
32/64bit | Mark Word | 存儲對象的hashCode或鎖信息等。 |
32/64bit | Class Metadata Address | 存儲到對象類型數據的指針 |
32/64bit | Array length | 數組的長度(如果當前對象是數組) |
Java對象頭里的Mark Word里默認存儲對象的HashCode,分代年齡和鎖標記位。32位JVM的Mark Word的默認存儲結構如下:
25 bit | 4bit | 1bit是否是偏向鎖 | 2bit鎖標志位 | |
---|---|---|---|---|
無鎖狀態 | 對象的hashCode | 對象分代年齡 | 0 | 01 |
在運行期間Mark Word里存儲的數據會隨著鎖標志位的變化而變化。Mark Word可能變化為存儲以下4種數據:
在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下:
Monitor
???什么是Monitor?我們可以把它理解為一個同步工具,也可以描述為一種同步機制,它通常被描述為一個對象。
???與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成為Monitor的潛質,因為在Java的設計中 ,每一個Java對象自打娘胎里出來就帶了一把看不見的鎖,它叫做內部鎖或者Monitor鎖。
Monitor 是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程占用。其結構如下:
- Owner:初始時為NULL表示當前沒有任何線程擁有該monitor record,當線程成功擁有該鎖后保存線程唯一標識,當鎖被釋放時又設置為NULL;
- EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的線程。
- RcThis:表示blocked或waiting在該monitor record上的所有線程的個數。
- Nest:用來實現重入鎖的計數。
- HashCode:保存從對象頭拷貝過來的HashCode值(可能還包含GC age)。
- Candidate:用來避免不必要的阻塞或等待線程喚醒,因為每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然后因為競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的線程1表示要喚醒一個繼任線程來競爭鎖。
鎖性能優化
自旋鎖與自適應自旋
???如果物理機有超過一個以上的處理器,讓后面請求鎖的那個線程“稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖
自旋次數默認是10次,參數-XX:PreBlockSpin來更改
在JDK1.6中引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的
鎖消除
???虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判斷依據來源于逃逸分析的數據支撐,如果判斷在一段代碼中,堆上的所有數據都不會逃逸出去從而被其他線程鎖訪問到,那就可以把它們當做棧上數據對待,認為它們是線程私有的,無須加鎖
鎖粗化
???如果一系統的連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作是出現在循環體中,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗,如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的范圍擴展(粗化)到整個操作序列的外部
輕量級鎖
???輕量級鎖時JDK1.6之后加入的新型鎖機制,它名字中的“輕量級”是相對于使用操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就稱為“重量級”鎖。首先需要強調一點的是,輕量級鎖并不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。
獲取輕量級鎖步驟:
1)在代碼進入同步塊的時候,檢查此同步對象有沒有被鎖定(鎖標志狀態為“01”),若沒有被鎖定,虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word),否則執行步驟3)
2)虛擬機將使用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針,如果這個動作更新成功,那么這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖標志位(Mark Word的最后2bit)將轉變為“00”,即表示此對象處于輕量級鎖定狀態,否則執行步驟3)
3)虛擬機首先檢查對象的Mark Word是否指向當前線程的棧幀,如果指向,說明當前線程已經擁有這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說這個鎖對象已經被其他線程搶占了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標志的狀態值變為“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也要進入阻塞狀態
解除輕量級鎖步驟:
輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:
1)取出在獲取輕量級鎖保存在Displaced Mark Word中的數據;
2)用CAS操作將取出的數據替換當前對象的Mark
Word中,如果成功,則說明釋放鎖成功,否則執行(3);
3)如果CAS操作替換失敗,說明有其他線程嘗試獲取該鎖,則需要在釋放鎖的同時需要喚醒被掛起的線程。
偏向鎖
???偏向鎖也是JDK1.6中引入的一項鎖優化,它的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除了,連CAS操作都不做了
???偏向鎖的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是這個鎖會偏向于第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。
獲取鎖
1)檢測Mark Word是否為可偏向狀態,即是否為偏向鎖1,鎖標識位為01;
2)若為可偏向狀態,則測試線程ID是否為當前線程ID,如果是,則執行步驟(5),否則執行步驟(3);
3)如果線程ID不為當前線程ID,則通過CAS操作競爭鎖,競爭成功,則將Mark Word的線程ID替換為當前線程ID,否則執行線程(4);
4)通過CAS競爭鎖失敗,證明當前存在多線程競爭情況,當到達全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續往下執行同步代碼塊;
5)執行同步代碼塊
釋放鎖
???偏向鎖的釋放采用了一種只有競爭才會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點是上沒有正在執行的代碼)。其步驟如下:
1)暫停擁有偏向鎖的線程,判斷鎖對象石是否還處于被鎖定狀態;
2)撤銷偏向蘇,恢復到無鎖狀態(01)或者輕量級鎖的狀態;
重量級鎖
重量級鎖通過對象內部的監視器(monitor)實現,其中monitor的本質是依賴于底層操作系統的Mutex Lock實現,操作系統實現線程之間的切換需要從用戶態到內核態的切換,切換成本非常高。
鎖的優缺點對比
鎖 | 優點 | 缺點 | 適用場景 | |
---|---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。 | 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗。 | 適用于只有一個線程訪問同步塊場景。 | |
輕量級鎖 | 競爭的線程不會阻塞,提高了程序的響應速度。 | 如果始終得不到鎖競爭的線程使用自旋會消耗CPU。 | 追求響應時間。同步塊執行速度非常快。 | |
重量級鎖 | 線程競爭不使用自旋,不會消耗CPU。 | 線程阻塞,響應時間緩慢。 | 追求吞吐量。 | 同步塊執行速度較長。 |
參考資料
周志明:《深入理解Java虛擬機》
方騰飛:《Java并發編程的藝術》