Java併發程式設計(二)——Java併發底層實現原理
Java程式碼會被編譯後變成Java位元組碼,位元組碼會被類載入器載入到JVM中,JVM執行位元組碼,最終轉化成彙編指令在CPU上執行,Java中所使用的併發機制依賴於JVM的實現和CPU的指令。
volatile
在多執行緒併發程式設計中,synchronized和volatile都很重要,volatile是輕量級的synchronized,它在多處理器的開發中,保證了共享變數的可見性。*可見性指一個執行緒修改這個共享變數時,另外的執行緒能夠讀到這個修改的值。*volatile的成本更低,不會引起執行緒上下文的切換和排程。
volatile的定義和實現原理
Java語言規範中定義:Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準備和一致地更新,執行緒應該確保通過排他鎖單獨獲取這個變數。
要了解volatile的實現原理,首先要了解一些CPU的術語和說明
術語 | 英文 | 描述 |
---|---|---|
記憶體屏障 | Memory barriers | 是一組處理器指令,用於實現對記憶體操作的順序限制 |
快取行 | cache line | CPU快取記憶體中可以分配的最小儲存單位,處理器填寫緩衝行時會載入整個快取行,現在CPU需要執行幾百次CPU指令 |
原子操作 | atomic operation | 不可中斷的一個或一系列操作 |
快取行填充 | cache line fill | 當處理器識別到從記憶體中讀取運算元是可快取的,處理器讀取整個快取記憶體行到適當的快取 |
快取命中 | cache hit | 如果進行快取記憶體行填充操作的記憶體位置仍然是下次處理器訪問的地址時,處理器衝快取中讀取運算元,而不是記憶體 |
寫命中 | Write hit | 當處理器將運算元寫回一個記憶體快取的區域時,它首先會檢查這個快取的記憶體地址是否在快取行中,如果存在一個有效的快取行,則處理器將這個運算元回寫到快取,而不是記憶體 |
寫缺失 | Write misses the cache | 一個有效的快取行被寫入到不存在的記憶體區域 |
有volatile修飾的變數,進行寫操作的時候會出現lock指令,lock指令在多核處理器下引發兩件事情
- 將當前處理器快取行的資料寫回到系統記憶體
- 這個寫回記憶體的操作會使其它CPU裡快取了該記憶體地址的資料無效
為了提供處理速度,處理器不直接和記憶體通訊,而是通過快取記憶體工作。如果對聲明瞭volatile的變數進行了寫操作,JVM就會向處理器傳送一條lock字首的指令,將這個變數所在的快取行的資料寫回到系統記憶體。
就算寫會了系統記憶體,其它處理器快取的值還是舊的。所以在多處理器下,為保證快取的一致性,會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效,當處理器對這個資料進行操作時,會重新從系統記憶體中把資料讀到處理器快取裡。
synchronized的實現原理
synchronized是Java的重量級鎖,但是JDK1.6優化之後,也不是那麼重量級了。
synchronized實現同步的基礎:Java中的每一個物件都可以作為鎖
- 對於普通同步方法,鎖是當前例項物件
- 對於靜態同步方法,鎖是當前類的Class物件
- 對於同步方法塊,鎖是synchronized括號裡配置的物件
JVM基於進入和退出monitor物件來實現方法同步和程式碼塊的同步。程式碼塊的同步是基於monitorenter和monitorexit指令實現的,而方法同步是使用另一種實現的。
monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit指令是插入到方法結束處和異常處,JVM保證每個monitorenter必須有對應的monitorexit與之配對。任何物件都有一個monitor與之相關,當一個monitor被持有後,它將處於鎖定狀態。執行緒執行到monitorenter指令,就會嘗試去獲取物件所對應的monitor的所有權,即鎖。
Java物件頭
synchronized用的鎖存在Java物件頭裡面。如果物件是陣列型別,則用3個字寬;如果是非陣列則2個字寬。在32位虛擬機器裡,一個字寬等於4個位元組,即32Bit。
長度 | 內容 | 說明 |
---|---|---|
32/64 | Mark Word | 儲存物件的hashcode和鎖資訊等 |
32/64 | Class Metadata Address | 儲存到物件型別資料的指標 |
32/64 | Array length | 陣列的長度(如果物件是陣列的話) |
Mark Word是定長但是非結構的,會隨著鎖標誌位的變化而變化
預設儲存結構
鎖狀態 | 25Bit | 4Bit | 1Bit是否是偏向鎖 | 2Bit鎖標誌位 |
---|---|---|---|---|
無鎖狀態 | 物件的HashCode | 物件GC分代年齡 | 0 | 01 |
執行期間
鎖狀態 | 25Bit | 4Bit | 1Bit是否是偏向鎖 | 2Bit鎖標誌位 |
---|---|---|---|---|
輕量級鎖 | 指向棧中鎖記錄的指標(30Bit) | 0 | 00 | |
重量級鎖 | 指向互斥量(重量級鎖)的指標(30Bit) | 0 | 10 | |
GC標誌 | 空 | 空 | 空 | 11 |
偏向鎖 | 執行緒ID(23Bit)+epoch(2Bit) | 物件GC分代年齡 | 1 | 01 |
鎖升級與對比
Java為了減少獲取鎖和釋放鎖的效能消耗,引入偏向鎖,輕量級鎖。鎖一共有四種狀態,級別從低到高依次是:無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態。這幾種狀態會隨競爭情況升級,但是不能降級。
偏向鎖
HotSpot的作者發現,鎖不僅不存在多執行緒的競爭,而且總是由同一個執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。
當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡存放鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖或解鎖,只需要簡單測試一下物件頭的Mark Word是否儲存著指向當前執行緒的偏向鎖。如果成功,則執行緒已經獲得了鎖;如果失敗,需要在測試Mark Word中偏向鎖標誌是否為1。如果沒有設定,採用CAS競爭鎖;如果設定了,採用CAS將物件頭的偏向鎖指向當前執行緒。
偏向鎖的撤銷:偏向鎖是一種等到競爭出現才釋放鎖的機制,所以當其它執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。
偏向鎖在Java6和Java7中預設是啟動的
輕量級鎖
加鎖
執行緒在執行同步塊之前,JVM會先在當前棧幀中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中 的Mark Word 替換成指向鎖記錄的指標。如果成功,當前執行緒獲得鎖;如果失敗,表示其它執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。
解鎖
解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回物件頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖會膨脹為重量級鎖。
因為自旋會消耗CPU,為了避免無用的自旋,一旦鎖升級為重量級鎖就不會恢復到輕量級鎖狀態。當鎖處於重量級狀態時,其它執行緒試圖獲取鎖時,都會被阻塞,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒會開始新一輪競爭。
鎖的優缺點對比
鎖 | 優點 | 缺點 | 場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 | 如果執行緒間存在鎖競爭,就會帶來額外的鎖撤銷的消耗 | 適合只有一個執行緒訪問同步塊的場景 |
輕量級鎖 | 競爭的執行緒不會阻塞,提高了程式的響應速度 | 如果始終得不到鎖競爭的執行緒,使用自旋會消耗CPU | 追求響應時間,同步塊執行速度非常快 |
重量級鎖 | 執行緒競爭不使用自旋,不會消耗CPU | 執行緒阻塞,響應時間緩慢 | 追求吞吐量,同步塊執行速度較長 |