看完你就明白的鎖系列之鎖的狀態

前面兩篇文章我介紹了一下

看完你就會知道,線程如果鎖住了某個資源,致使其他線程無法訪問的這種鎖被稱爲悲觀鎖,相反,線程不鎖住資源的鎖被稱爲樂觀鎖,而自旋鎖是基於 CAS 機制實現的,CAS又是樂觀鎖的一種實現,那麼對於鎖來說,多個線程同步訪問某個資源的流程細節是否一樣呢?換句話說,在多線程同步訪問某個資源時,鎖的狀態會如何變化呢?本篇文章來探討一下。

鎖狀態的分類

Java 語言專門針對 synchronized 關鍵字設置了四種狀態,它們分別是:無鎖、偏向鎖、輕量級鎖和重量級鎖,但是在瞭解這些鎖之前還需要先了解一下 Java 對象頭和 Monitor。

Java 對象頭

我們知道 synchronized 是悲觀鎖,在操作同步之前需要給資源加鎖,這把鎖就是對象頭裏面的,而Java 對象頭又是什麼呢?我們以 Hotspot 虛擬機爲例,Hopspot 對象頭主要包括兩部分數據:Mark Word(標記字段)Klass Pointer(類型指針)

Mark Word:默認存儲對象的HashCode,分代年齡和鎖標誌位信息。這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的數據。它會根據對象的狀態複用自己的存儲空間,也就是說在運行期間Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。

Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

在32位虛擬機和64位虛擬機的 Mark Word 所佔用的字節大小不一樣,32位虛擬機的 Mark Word 和 Klass Pointer 分別佔用 32bits 的字節,而 64位虛擬機的 Mark Word 和 Klass Pointer 佔用了64bits 的字節,下面我們以 32位虛擬機爲例,來看一下其 Mark Word 的字節具體是如何分配的

file

file

用中文翻譯過來就是

file

  • 無狀態也就是無鎖的時候,對象頭開闢 25bit 的空間用來存儲對象的 hashcode ,4bit 用於存放分代年齡,1bit 用來存放是否偏向鎖的標識位,2bit 用來存放鎖標識位爲01
  • 偏向鎖 中劃分更細,還是開闢25bit 的空間,其中23bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1bit 存放是否偏向鎖標識, 0表示無鎖,1表示偏向鎖,鎖的標識位還是01
  • 輕量級鎖中直接開闢 30bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標誌位,其標誌位爲00
  • 重量級鎖中和輕量級鎖一樣,30bit 的空間用來存放指向重量級鎖的指針,2bit 存放鎖的標識位,爲11
  • GC標記開闢30bit 的內存空間卻沒有佔用,2bit 空間存放鎖標誌位爲11。

其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。

關於爲什麼這麼分配的內存,我們可以從 OpenJDK 中的 markOop.hpp 類中的枚舉窺出端倪

file

來解釋一下

  • age_bits 就是我們說的分代回收的標識,佔用4字節
  • lock_bits 是鎖的標誌位,佔用2個字節
  • biased_lock_bits 是是否偏向鎖的標識,佔用1個字節
  • max_hash_bits 是針對無鎖計算的hashcode 佔用字節數量,如果是32位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節未使用,所以64位的 hashcode 佔用 31 byte
  • hash_bits 是針對 64 位虛擬機來說,如果最大字節數大於 31,則取31,否則取真實的字節數
  • cms_bits 我覺得應該是不是64位虛擬機就佔用 0 byte,是64位就佔用 1byte
  • epoch_bits 就是 epoch 所佔用的字節大小,2字節。

Synchronized鎖

synchronized用的鎖是存在Java對象頭裏的。

JVM基於進入和退出 Monitor 對象來實現方法同步和代碼塊同步。代碼塊同步是使用 monitorenter 和 monitorexit 指令實現的,monitorenter 指令是在編譯後插入到同步代碼塊的開始位置,而 monitorexit 是插入到方法結束處和異常處。任何對象都有一個 monitor 與之關聯,當且一個 monitor 被持有後,它將處於鎖定狀態。

根據虛擬機規範的要求,在執行 monitorenter 指令時,首先要去嘗試獲取對象的鎖,如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應地,在執行 monitorexit 指令時會將鎖計數器減1,當計數器被減到0時,鎖就釋放了。如果獲取對象鎖失敗了,那當前線程就要阻塞等待,直到對象鎖被另一個線程釋放爲止。

Monitor

Synchronized是通過對象內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的操作系統的 Mutex Lock(互斥鎖)來實現的。而操作系統實現線程之間的切換需要從用戶態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是爲什麼 Synchronized 效率低的原因。因此,這種依賴於操作系統 Mutex Lock 所實現的鎖我們稱之爲重量級鎖

Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了偏向鎖輕量級鎖:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖可以升級但不能降級。

所以鎖的狀態總共有四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖(但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級)。JDK 1.6中默認是開啓偏向鎖和輕量級鎖的,我們也可以通過-XX:-UseBiasedLocking=false來禁用偏向鎖。

鎖的分類及其解釋

無鎖

無鎖狀態,無鎖即沒有對資源進行鎖定,所有的線程都可以對同一個資源進行訪問,但是隻有一個線程能夠成功修改資源。

無鎖的特點就是在循環內進行修改操作,線程會不斷的嘗試修改共享資源,直到能夠成功修改資源並退出,在此過程中沒有出現衝突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。

偏向鎖

Hotspot 的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,還存在鎖由同一線程多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是爲了解決只有在一個線程執行同步時提高性能。

可以從對象頭的分配中看到,偏向鎖要比無鎖多了線程IDepoch,當一個線程訪問同步代碼塊並獲取鎖時,會在對象頭和棧幀的記錄中存儲線程的ID,等到下一次線程在進入和退出同步代碼塊時就不需要進行 CAS 操作進行加鎖和解鎖,只需要簡單判斷一下對象頭的 Mark Word 中是否存儲着指向當前線程的線程ID,判斷的標誌當然是根據鎖的標誌位來判斷的。

偏向鎖的獲取過程

  1. 訪問 Mark Word 中偏向鎖的標誌是否設置成 1,鎖的標誌位是否是 01 --- 確認爲可偏向狀態。
  2. 如果確認爲可偏向狀態,判斷當前線程id 和 對象頭中存儲的線程 ID 是否一致,如果一致的話,則執行步驟5,如果不一致,進入步驟3
  3. 如果當前線程ID 與對象頭中存儲的線程ID 不一致的話,則通過 CAS 操作來競爭獲取鎖。如果競爭成功,則將 Mark Word 中的線程ID 修改爲當前線程ID,然後執行步驟5,如果不一致,則執行步驟4
  4. 如果 CAS 獲取偏向鎖失敗,則表示有競爭(CAS 獲取偏向鎖失敗則表明至少有其他線程曾經獲取過偏向鎖,因爲線程不會主動釋放偏向鎖)。當到達全局安全點(SafePoint)時,會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否存活(因爲可能持有偏向鎖的線程已經執行完畢,但是該線程並不會主動去釋放偏向鎖),如果線程不處於活動狀態,則將對象頭置爲無鎖狀態(標誌位爲01),然後重新偏向新的線程;如果線程仍然活着,撤銷偏向鎖後升級到輕量級鎖的狀態(標誌位爲00),此時輕量級鎖由原持有偏向鎖的線程持有,繼續執行其同步代碼,而正在競爭的線程會進入自旋等待獲得該輕量級鎖。
  5. 執行同步代碼

偏向鎖的釋放過程

偏向鎖的釋放過程可以參考上述的步驟4 ,偏向鎖在遇到其他線程競爭鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位爲01)或輕量級鎖(標誌位爲00)的狀態。

關閉偏向鎖

偏向鎖在Java 6 和Java 7 裏是默認啓用的。由於偏向鎖是爲了在只有一個線程執行同步塊時提高性能,如果你確定應用程序裏所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。

關於 epoch

真正理解 epoch 的概念比較複雜,這裏簡單理解,就是 epoch 的值可以作爲一種檢測偏向鎖有效性的時間戳

輕量級鎖

輕量級鎖是指當前鎖是偏向鎖的時候,被另外的線程所訪問,那麼偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。

加鎖過程

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲 01 狀態,是否爲偏向鎖爲 0 ),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的 Mark Word 的拷貝,然後拷貝對象頭中的 Mark Word 複製到鎖記錄中。

file

拷貝成功後,虛擬機將使用 CAS 操作嘗試將對象的 Mark Word 更新爲指向 Lock Record 的指針,並將 Lock Record裏的 owner 指針指向對象的 Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲 00 ,表示此對象處於輕量級鎖定狀態。

file

如果這個更新操作失敗了,虛擬機首先會檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,輕量級鎖就要膨脹爲重量級鎖,鎖標誌的狀態值變爲 10 ,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。

重量級鎖

重量級鎖也就是通常說 synchronized 的對象鎖,鎖標識位爲10,其中指針指向的是 monitor 對象(也稱爲管程或監視器鎖)的起始地址。每個對象都存在着一個 monitor 與之關聯,對象與其 monitor 之間的關係有存在多種實現方式,如 monitor 可以與對象一起創建銷燬或當線程試圖獲取對象鎖時自動生成,但當一個 monitor 被某個線程持有後,它便處於鎖定狀態。

上圖簡單描述多線程獲取鎖的過程,當多個線程同時訪問一段同步代碼時,首先會進入 Entry Set當線程獲取到對象的 monitor 後進入 The Owner 區域並把 monitor 中的 owner 變量設置爲當前線程,同時 monitor 中的計數器count 加1,若線程調用 wait() 方法,將釋放當前持有的 monitor,owner變量恢復爲 null,count自減1,同時該線程進入 WaitSet 集合中等待被喚醒。若當前線程執行完畢也將釋放 monitor (鎖)並復位變量的值,以便其他線程進入獲取monitor(鎖)。

由此看來,monitor 對象存在於每個Java對象的對象頭中(存儲的指針的指向),synchronized 鎖便是通過這種方式獲取鎖的,也是爲什麼Java中任意對象可以作爲鎖的原因,同時也是 notify/notifyAll/wait 等方法存在於頂級對象Object中的原因。(部分來源於網絡)

下面爲自己做個宣傳,歡迎關注公衆號 Java建設者,號主是Java技術棧,熱愛技術,喜歡閱讀,熱衷於分享和總結,希望能把每一篇好文章分享給成長道路上的你。關注公衆號回覆 002 領取爲你特意準備的大禮包,你一定會喜歡並收藏的。
file

文章參考:

不可不說的Java“鎖”事

白話 Synchronized

https://gist.github.com/artur...

https://blog.csdn.net/zhoufan...

Synchronized鎖性能優化偏向鎖輕量級鎖升級 多線程中篇(五)

https://juejin.im/post/5bfe6d...

https://zhuanlan.zhihu.com/p/...

http://citeseerx.ist.psu.edu/...

https://blogs.oracle.com/dave...

[java 偏向鎖、輕量級鎖及重量級鎖synchronized原理](

本文由博客一文多發平臺 OpenWrite 發佈!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章