咱們知道,在多線程程序中每每會出現這麼一個狀況:多個線程同時訪問某個線程間的共享變量。來舉個例子吧:算法
假設銀行存款業務寫了兩個方法,一個是存錢 store()
方法 ,一個是查詢餘額 get()
方法。假設初始客戶小明的帳戶餘額爲 0 元。(PS:這個例子只是個 toy demo
,爲了方便你們理解寫的,真實的業務場景不會這樣。)數組
// account 客戶在銀行的存款 public void store(int money){ int newAccount=account+money; account=newAccount; } public void get(){ System.out.print("小明的銀行帳戶餘額:"); System.out.print(account); }
若是小明爲本身存款 1 元,咱們指望的線程調用狀況以下:安全
- 首先會啓動一個線程調用
store()
方法,爲客戶帳戶餘額增長 1;- 再啓動一個線程調用
get()
方法,輸出客戶的新餘額爲 1。
但實際狀況可能因爲線程執行的前後順序,出現如圖所示的錯誤:多線程
小明會驚奇的覺得本身的錢沒存上。這就是一個典型的由共享數據引起的併發數據衝突問題。併發
解決方式也很簡單,讓併發執行會產生問題的代碼段不併發行了。函數
若是 store()
方法 執行完,才能執行 get()
方法,而不是像上圖同樣併發執行,天然不會出現這個問題。那如何才能作到呢?佈局
答案就是使用 synchronized
關鍵字。學習
咱們先從直覺上思考一下,若是要實現先執行 store()
方法,再執行 get()
方法的話該怎麼設計。spa
咱們能夠設置某個鎖,鎖會有兩種狀態,分別是上鎖和解鎖。在 store()
方法執行以前,先觀察這個鎖的狀態,若是是上鎖狀態,就進入阻塞,代碼不運行;操作系統
若是這把鎖是解鎖狀態,那就先將這把鎖狀態變爲上鎖,以後接着運行本身的代碼。運行完成以後再將鎖狀態設置爲解鎖。
對於 get()
方法也是如此。
Java 中的 synchronized
關鍵字就是基於這種思想設計的。在 synchronized
關鍵字中,鎖就是一個對象。
synchronized
一共有三種使用方法:
synchronized
修飾的方法,就會被阻塞。至關於把鎖記錄在這個方法對應的對象上。// account 客戶在銀行的存款 public synchronized void store(int money){ int newAccount=account+money; account=newAccount; } public synchronized void get(){ System.out.print("小明的銀行帳戶餘額:"); System.out.print(account); }
synchronized
修飾的靜態方法,就會被阻塞。至關於把鎖信息記錄在這個方法對應的類上。public synchronized static void get(){ ··· }
synchronized(對象0)
修飾的同步代碼塊時,也會被阻塞。public static void get(){ synchronized(對象0){ ··· } }
A問:我看了很多參考書還有網上資料,都說 synchronized
的鎖是鎖在對象上的。關於這句話,你能深刻講講嗎?
B回答道:別急,我先講講 Java 對象在內存中的表示。
講清 synchronized
關鍵字的原理前須要理清 Java 對象在內存中的表示方法。
上圖就是一個 Java 對象在內存中的表示。咱們能夠看到,內存中的對象通常由三部分組成,分別是對象頭、對象實際數據和對齊填充。
對象頭包含 Mark Word、Class Pointer和 Length 三部分。
- Mark Word 記錄了對象關於鎖的信息,垃圾回收信息等。
- Class Pointer 用於指向對象對應的 Class 對象(其對應的元數據對象)的內存地址。
- Length只適用於對象是數組時,它保存了該數組的長度信息。
對象實際數據包括了對象的全部成員變量,其大小由各個成員變量的大小決定。
對齊填充表示最後一部分的填充字節位,這部分不包含有用信息。
咱們剛纔講的鎖 synchronized
鎖使用的就是對象頭的 Mark Word 字段中的一部分。
Mark Word 中的某些字段發生變化,就能夠表明鎖不一樣的狀態。
因爲鎖的信息是記錄在對象裏的,有的開發者也每每會說鎖住對象這種表述。
無鎖狀態的 Mark Word
這裏咱們以無鎖狀態的 Mark Word 字段舉例:
若是當前對象是無鎖狀態,對象的 Mark Word 如圖所示。
咱們能夠看到,該對象頭的 Mark Word 字段分爲四個部分:
- 對象的 hashCode ;
- 對象的分代年齡,這部分用於對對象的垃圾回收;
- 是否爲偏向鎖位,1表明是,0表明不是;
- 鎖標誌位,這裏是 01。
講完了 Java 對象在內存中的表示,咱們下一步來說講 synchronized
關鍵字的實現原理。
從前文中咱們能夠看到, synchronized
關鍵字有兩種修飾方法
public synchronized static void `get()`{ ··· }
public static void `get()`{ synchronized(對象0){ ··· } }
針對這兩種狀況,Java 編譯時的處理方法並不相同。
對於第一種狀況,編譯器會爲其自動生成了一個 ACC_SYNCHRONIZED
關鍵字用來標識。
在 JVM 進行方法調用時,當發現調用的方法被 ACC_SYNCHRONIZED
修飾,則會先嚐試得到鎖。
對於第二種狀況,編譯時在代碼塊開始前生成對應的1個 monitorenter
指令,表明同步塊進入。2個 monitorexit
指令,表明同步塊退出。
這兩種方法底層都須要一個 reference 類型的參數,指明要鎖定和解鎖的對象。
若是 synchronized
明確指定了對象參數,那就是該對象。
若是沒有明確指定,那就根據修飾的方法是實例方法仍是類方法,取對應的對象實例或類對象(Java 中類也是一種特殊的對象)做爲鎖對象。
每一個對象維護着一個記錄着被鎖次數的計數器。當一個線程執行 monitorenter
,該計數器自增從 0 變爲 1;
當一個線程執行 monitorexit
,計數器再自減。當計數器爲 0 的時候,說明對象的鎖已經釋放。
A問:爲何會有兩個 monitorexit
指令呢?
B答:正常退出,得用一個 monitorexit
吧,若是中間出現異常,鎖會一直沒法釋放。因此編譯器會爲同步代碼塊添加了一個隱式的 try-finally
異常處理,在 finally
中會調用 monitorexit
命令最終釋放鎖。
重量級鎖
A問:那麼問題來了,以前你說鎖的信息是記錄在對象的 Mark Word 中的,那如今冒出來的 monitor
又是什麼呢?
B答:咱們先來看一下重量級鎖對應對象的 Mark Word。
在 Java 的早期版本中,synchronized
鎖屬於重量級鎖,此時對象的 Mark Word 如圖所示。
咱們能夠看到,該對象頭的 Mark Word 分爲兩個部分。第一部分是指向重量級鎖的指針,第二部分是鎖標記位。
而這裏所說的指向重量級鎖的指針就是 monitor
。
英文詞典翻譯 monitor
是監視器。Java 中每一個對象會對應一個監視器。
這個監視器其實也就是監控鎖有沒有釋放,釋放的話會通知下一個等待鎖的線程去獲取。
monitor
的成員變量比較多,咱們能夠這樣理解:
咱們能夠將 monitor
簡單理解成兩部分,第一部分表示當前佔用鎖的線程,第二部分是等待這把鎖的線程隊列。
若是當前佔用鎖的線程把鎖釋放了,那就須要在線程隊列中喚醒下一個等待鎖的線程。
可是阻塞或喚醒一個線程須要依賴底層的操做系統來實現,Java 的線程是映射到操做系統的原生線程之上的。
而操做系統實現線程之間的切換須要從用戶態轉換到核心態,這個狀態轉換須要花費不少的處理器時間,甚至可能比用戶代碼執行的時間還要長。
因爲這種效率過低,Java 後期作了改進,我再來詳細講一講。
在講其餘改進以前,咱們先來聊聊 CAS 算法。CAS 算法全稱爲 Compare And Swap。
顧名思義,該算法涉及到了兩個操做,比較(Compare)和交換(Swap)。
怎麼理解這個操做呢?咱們來看下圖:
咱們知道,在對共享變量進行多線程操做的時候,不免會出現線程安全問題。
對該問題的一種解決策略就是對該變量加鎖,保證該變量在某個時間段只能被一個線程操做。
可是這種方式的系統開銷比較大。所以開發人員提出了一種新的算法,就是大名鼎鼎的 CAS 算法。
CAS 算法的思路以下:
- 該算法認爲線程之間對變量的操做進行競爭的狀況比較少。
- 算法的核心是對當前讀取變量值
E
和內存中的變量舊值V
進行比較。- 若是相等,就表明其餘線程沒有對該變量進行修改,就將變量值更新爲新值
N
。- 若是不等,就認爲在讀取值
E
到比較階段,有其餘線程對變量進行過修改,不進行任何操做。
當線程運行 CAS 算法時,該運行過程是原子操做,原子操做的含義就是線程開始跑這個函數後,運行過程當中不會被別的程序打斷。
咱們來看看實際上 Java 語言中如何使用這個 CAS 算法,這裏咱們以 AtomicInteger
類中的 compareAndSwapInt()
方法舉例:
public final native boolean compareAndSwapInt (Object var1, long var2, int var3, int var4)
能夠看到,該函數原型接受四個參數:
- 第一個參數是一個
AtomicInteger
對象。- 第二個參數是該
AtomicInteger
對象對應的成員變量在內存中的地址。- 第三個參數是上圖中說的線程以前讀取的值
P
。- 第四個參數是上圖中說的線程計算的新值
V
。
JDK 1.6 中提出了偏向鎖的概念。該鎖提出的緣由是,開發者發現多數狀況下鎖並不存在競爭,一把鎖每每是由同一個線程得到的。
若是是這種狀況,不斷的加鎖解鎖是沒有必要的。
那麼能不能讓 JVM 直接負責在這種狀況下加解鎖的事情,不讓操做系統插手呢?
所以開發者設計了偏向鎖。偏向鎖在獲取資源的時候,會在資源對象上記錄該對象是否偏向該線程。
偏向鎖並不會主動釋放,這樣每次偏向鎖進入的時候都會判斷該資源是不是偏向本身的,若是是偏向本身的則不須要進行額外的操做,直接能夠進入同步操做。
下圖表示偏向鎖的 Mark Word結構:
能夠看到,偏向鎖對應的 Mark Word 包含該偏向鎖對應的線程 ID、偏向鎖的時間戳和對象分代年齡。
偏向鎖的申請流程
咱們再來看一下偏向鎖的申請流程:
- 首先須要判斷對象的 Mark Word 是否屬於偏向模式,若是不屬於,那就進入輕量級鎖判斷邏輯。不然繼續下一步判斷;
- 判斷目前請求鎖的線程 ID 是否和偏向鎖自己記錄的線程 ID 一致。若是一致,繼續下一步的判斷,若是不一致,跳轉到步驟4;
- 判斷是否須要重偏向,重偏向邏輯在後面一節批量重偏向和批量撤銷會說明。若是不用的話,直接得到偏向鎖;
- 利用 CAS 算法將對象的 Mark Word 進行更改,使線程 ID 部分換成本線程 ID。若是更換成功,則重偏向完成,得到偏向鎖。若是失敗,則說明有多線程競爭,升級爲輕量級鎖。
值得注意的是,在執行完同步代碼後,線程不會主動去修改對象的 Mark Word,讓它重回無鎖狀態。
因此通常執行完 synchronized
語句後,若是是偏向鎖的狀態的話,線程對鎖的釋放操做多是什麼都不作。
匿名偏向鎖
在 JVM 開啓偏向鎖模式下,若是一個對象被新建,在四秒後,該對象的對象頭就會被置爲偏向鎖。
通常來講,當一個線程獲取了一把偏向鎖時,會在對象頭和棧幀中的鎖記錄裏不只說明目前是偏向鎖狀態,也會存儲鎖偏向的線程 ID。
在 JVM 四秒自動建立偏向鎖的狀況下,線程 ID 爲0。
因爲這種狀況下的偏向鎖不是由某個線程求得生成的,這種狀況下的偏向鎖也稱爲匿名偏向鎖。
批量重偏向和批量撤銷
在生產者消費者模式下,生產者線程負責對象的建立,消費者線程負責對生產出來的對象進行使用。
當生產者線程建立了大量對象並執行加偏向鎖的同步操做,消費者對對象使用以後,會產生大量偏向鎖執行和偏向鎖撤銷的問題。
Russell K和 Detlefs D在他們的文章提出了批量重偏向和批量撤銷的過程。
在上圖情景下,他們探討了能不能直接將偏向的線程換成消費者的線程。
替換不是一件容易事,須要在 JVM 的衆多線程中找到相似上文情景的線程。
他們最後提出的解決方法是:
以類爲單位,爲每一個類維護一個偏向鎖撤銷計數器,每一次該類的對象發生偏向撤銷操做時,該計數器計數 +1,當這個計數值達到重偏向閾值時,JVM 就認爲該類可能不適合正常邏輯,適合批量重偏向邏輯。這就是對應上圖流程圖裏的是否須要重偏向過程。
以生產者消費者爲例,生產者生產同一類型的對象給消費者,而後消費者對這些對象都須要執行偏向鎖撤銷,當撤銷過程過多時就會觸發上文規則,JVM 就注意到這個類了。
具體規則是:
epoch
字段,每一個處於偏向鎖狀態對象的 Mark Word 中也有該字段,其初始值爲建立該對象時,類對象中的 epoch
的值。epoch
字段 +1,獲得新的值 epoch_new
。epoch
字段改成新值。根據線程棧的信息判斷出該線程是否鎖定了該對象,將如今偏向鎖還在被使用的對象賦新值 epoch_new
。epoch
值和類的 epoch
不相等,不會執行撤銷操做,而是直接經過 CAS 操做將其 Mark Word 的 Thread ID 改爲當前線程 ID。批量撤銷相對於批量重偏向好理解得多,JVM 也會統計重偏向的次數。
假設該類計數器計數繼續增長,當其達到批量撤銷的閾值後(默認40),JVM 就認爲該類的使用場景存在多線程競爭,會標記該類爲不可偏向,以後對於該類的鎖升級爲輕量級鎖。
輕量級鎖的設計初衷在於併發程序開發者的經驗「對於絕大部分的鎖,在整個同步週期內都是不存在競爭的」。
因此它的設計出發點也在線程競爭狀況較少的狀況下。咱們先來看一下輕量級鎖的 Mark Word 佈局。
若是當前對象是輕量級鎖狀態,對象的 Mark Word 以下圖所示。
咱們能夠看到,該對象頭Mark Word分爲兩個部分。第一部分是指向棧中的鎖記錄的指針,第二部分是鎖標記位,針對輕量級鎖該標記位爲 00。
A問:那這指向棧中的鎖記錄的指針是什麼意思呢?
B答:這得結合輕量級鎖的上鎖步驟來慢慢講。
若是當前這個對象的鎖標誌位爲 01(即無鎖狀態或者輕量級鎖狀態),線程在執行同步塊以前,JVM 會先在當前的線程的棧幀中建立一個 Lock Record,包括一個用於存儲對象頭中的 Mark Word 以及一個指向對象的指針。
而後 JVM 會利用 CAS 算法對這個對象的 Mark Word 進行修改。若是修改爲功,那該線程就擁有了這個對象的鎖。咱們來看一下若是上圖的線程執行 CAS 算法成功的結果。
固然 CAS 也會有失敗的狀況。若是 CAS 失敗,那就說明同時執行 CAS 操做的線程可不止一個了, Mark Word 也作了更改。
首先虛擬機會檢查對象的 Mark Word 字段指向棧中的鎖記錄的指針是否指向當前線程的棧幀。若是是,那就說明可能出現了相似 synchronized
中套 synchronized
狀況:
synchronized (對象0) { synchronized (對象0) { ··· } }
固然這種狀況下當前線程已經擁有這個對象的鎖,能夠直接進入同步代碼塊執行。
不然說明鎖被其餘線程搶佔了,該鎖還須要升級爲重量級鎖。
和偏向鎖不一樣的是,執行完同步代碼塊後,須要執行輕量級鎖的解鎖過程。解鎖過程以下:
咱們來總結一下輕量級鎖升級過程吧:
此次咱們瞭解了 synchronized
底層實現原理和對應的鎖升級過程。最後咱們再經過這張流程圖來回顧一下 synchronized
鎖升級過程吧。
歡迎你們關注個人公衆號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裏面更新,整理的資料也會放在裏面。
以爲寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!