4.Synchronized實現原理
4.1 Synchronization
Synchronization in the Java Virtual Machine is implemented by monitor entry and exit, either explicitly (by use of the monitorenter and monitorexit instructions) or implicitly (by the method invocation and return instructions).
For code written in the Java programming language, perhaps the most common form of synchronization is the synchronized method. A synchronized method is not normally implemented using monitorenter and monitorexit. Rather, it is simply distinguished in the run-time constant pool by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions (§2.11.10).
4.2 反編譯
4.2.1 預準備
爲了能直觀瞭解Synchronized的工做原理,咱們經過反編譯SynchronizedDeme類的class文件的方式看看都發生了什麼
package concurrent;
public class SynchronizedDemo {
public static synchronized void staticMethod() throws InterruptedException {
System.out.println("靜態同步方法開始");
Thread.sleep(1000);
System.out.println("靜態同步方法結束");
}
public synchronized void method() throws InterruptedException {
System.out.println("實例同步方法開始");
Thread.sleep(1000);
System.out.println("實例同步方法結束");
}
public synchronized void method2() throws InterruptedException {
System.out.println("實例同步方法2開始");
Thread.sleep(3000);
System.out.println("實例同步方法2結束");
}
public static void main(String[] args) {
final SynchronizedDemo synDemo = new SynchronizedDemo();
Thread thread1 = new Thread(() -> {
try {
synDemo.method();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
try {
synDemo.method2();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
}
}
複製代碼
4.2.1 生成.class文件
javac SynchronizedDemo.java
複製代碼
注意:因爲筆者OS的默認編碼方式是UTF-8,所以可能出現如下錯誤
解決方案以下:只要經過 -encoding 指定指明編碼方式便可java
javac -encoding UTF-8 SynchronizedDemo.java
複製代碼
最終咱們將獲得一個 .class 文件,即 SynchronizedDemo.class數組
4.2.2 javap反編譯
javap -v SynchronizedDemo
複製代碼
經過反編譯咱們會獲得常量池、同步方法、同步代碼塊的不一樣編譯結果,以後咱們將基於這三個進行介紹安全
常量池圖示bash
常量池除了會包含基本類型和字符串及數組的常量值外,還包含以文本形式出現的符號引用: 數據結構
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法和名稱和描述符
同步方法圖示多線程
同步方法會包含一個ACC_SYNCHCRONIZED標記符併發
同步代碼塊圖示app
同步代碼塊會在代碼中插入 monitorenter 和 monitorexist 指令工具
4.3 同步代碼塊同步原理
4.3.1 monitor監視器
- 每一個對象都有一個監視器,在同步代碼塊中,JVM經過monitorenter和monitorexist指令實現同步鎖的獲取和釋放功能
- 當一個線程獲取同步鎖時,便是經過獲取monitor監視器進而等價爲獲取到鎖
- monitor的實現相似於操做系統中的管程
4.3.2 monitorenter指令
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
- 每一個對象都有一個監視器。當該監視器被佔用時便是鎖定狀態(或者說獲取監視器便是得到同步鎖)。線程執行monitorenter指令時會嘗試獲取監視器的全部權,過程以下:
- 若該監視器的進入次數爲0,則該線程進入監視器並將進入次數設置爲1,此時該線程即爲該監視器的全部者
- 若線程已經佔有該監視器並重入,則進入次數+1
- 若其餘線程已經佔有該監視器,則線程會被阻塞直到監視器的進入次數爲0,以後線程間會競爭獲取該監視器的全部權
- 只有首先得到鎖的線程才能容許繼續獲取多個鎖
4.3.3 monitorexit指令
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
- 執行monitorexit指令將遵循如下步驟:
- 執行monitorexit指令的線程必須是對象實例所對應的監視器的全部者
- 指令執行時,線程會先將進入次數-1,若-1以後進入次數變成0,則線程退出監視器(即釋放鎖)
- 其餘阻塞在該監視器的線程能夠從新競爭該監視器的全部權
4.3.4 實現原理
- 在同步代碼塊中,JVM經過monitorenter和monitorexist指令實現同步鎖的獲取和釋放功能
- monitorenter指令是在編譯後插入到同步代碼塊的開始位置
- monitorexit指令是插入到方法結束處和異常處
- JVM要保證每一個monitorenter必須有對應的monitorexit與之配對
- 任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態
- 線程執行monitorenter指令時,將會嘗試獲取對象所對應的monitor的全部權,即嘗試得到對象的鎖
- 線程執行monitorexit指令時,將會將進入次數-1直到變成0時釋放監視器
- 同一時刻只有一個線程可以成功,其它失敗的線程會被阻塞,並放入到同步隊列中,進入BLOCKED狀態
4.3.4 補充
- 關於objectref可參見 鎖的使用方式
- 因爲 wait/notify 等方法底層實現是基於監視器,所以只有在同步方法(塊)中才能調用wait/notify等方法,不然會拋出 java.lang.IllegalMonitorStateException 的異常的緣由
4.4 同步方法同步原理
- 區別於同步代碼塊的監視器實現,同步方法經過使用 ACC_SYNCHRONIZED 標記符隱示的實現
- 原理是經過方法調用指令檢查該方法在常量池中是否包含 ACC_SYNCHRONIZED 標記符,若是有,JVM 要求線程在調用以前請求鎖
5.進階原理
5.1 Monitor Obejct模式
5.1.1 Monitor Obejct模式綜述
- Monitor實際上是一種同步工具,也能夠說是一種同步機制,它一般被描述爲一個對象,主要特色是互斥和信號機制
- 互斥: 一個Monitor鎖在同一時刻只能被一個線程佔用,其餘線程沒法佔用
- 信號機制(signal): 佔用Monitor鎖失敗的線程會暫時放棄競爭並等待某個謂詞成真(條件變量),但該條件成立後,當前線程會經過釋放鎖通知正在等待這個條件變量的其餘線程,讓其能夠從新競爭鎖
Mesa派的signal機制源碼分析
- Mesa派的signal機制又稱"Non-Blocking condition variable"
- 佔有Monitor鎖的線程發出釋放通知時,不會當即失去鎖,而是讓其餘線程等待在隊列中,從新競爭鎖
- 這種機制裏,等待者拿到鎖後不能肯定在這個時間差裏是否有別的等待者進入過Monitor,所以不能保證謂詞必定爲真,因此對條件的判斷必須使用while
- Java中採用就是Mesa派的singal機制,即所謂的notify
5.1.2 Monitor Obejct模式結構
在 Monitor Object 模式中,主要有四種類型的參與者:
5.1.3 Monitor Obejct模式協做過程
1.同步方法的調用和串行化:
- 當客戶線程調用監視者對象的同步方法時,必須首先獲取它的監視鎖
- 只要該監視者對象有其餘同步方法正在被執行,獲取操做便不會成功
- 當監視者對象已被線程佔用時(即同步方法正被執行),客戶線程將被阻塞直到它獲取監視鎖
- 當客戶線程成功獲取監視鎖後,進入臨界區,執行方法實現的服務
- 一旦同步方法完成執行,監視鎖會被自動釋放,目的是使其餘客戶線程有機會調用執行該監視者對象的同步方法
2.同步方法線程掛起:若是調用同步方法的客戶線程必須被阻塞或是有其餘緣由不能馬上進行,它可以在一個監視條件(Monitor Condition)上等待,這將致使該客戶線程暫時釋放監視鎖,並被掛起在監視條件上
3.監視條件通知:一個客戶線程可以通知一個監視條件,目的是通知阻塞在該監視條件(該監視鎖)的線程恢復運行
4.同步方法線程恢復:
- 一旦一個早先被掛起在監視條件上的同步方法線程獲取通知,它將繼續在最初的等待監視條件的點上執行
- 在被通知線程被容許恢復執行同步方法以前,監視鎖將自動被獲取(線程間自動相互競爭鎖)
對於Monitor筆者將在 ReentractLock 一文中進一步闡述
5.2 對象頭
5.2.1 JVM內存中的對象
- 在JVM中,對象在內存中的佈局分紅三塊區域:對象頭、示例數據和對齊填充
- 對象頭: 對象頭主要存儲對象的hashCode、鎖信息、類型指針、數組長度(如果數組的話)等信息
- 示例數據:存放類的屬性數據信息,包括父類的屬性信息,若是是數組的實例部分還包括數組長度,這部份內存按4字節對齊
- 填充數據:因爲JVM要求對象起始地址必須是8字節的整數倍,當不知足8字節時會自動填充(所以填充數據並非必須的,僅僅是爲了字節對齊)
5.2.2 對象頭綜述
- synchcronized的鎖是存放在Java對象頭中的
- 若是對象是數組類型,JVM用3個子寬(Word)存儲對象頭,不然是用2個子寬
- 在32位虛擬機中,1子寬等於4個字節,即32bit;64位的話就是8個字節,即64bit
5.2.3 Mark Word的存儲結構
32位JVM的Mark Word的默認存儲結構(無鎖狀態)
在運行期間,Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化(32位)
64位JVM的Mark Word的默認存儲結構(對於32位無鎖狀態,有25bit沒有使用)
5.3 Monitor Record
5.3.1 Monitor Record綜述
- MonitorRecord(統一簡稱MR)是Java線程私有的數據結構,每個線程都有一個可用MR列表,同時還有一個全局的可用列表
- 一個被鎖住的對象都會和一個MR關聯(對象頭的MarkWord中的LockWord指向MR的起始地址)
- MR中有一個Owner字段存放擁有該鎖的線程的惟一標識,表示該鎖被這個線程佔用
5.3.2 Monitor Record結構
5.3.3 Monitor Record工做機理
- 線程若是得到監視鎖成功,將成爲該監視鎖對象的擁有者
- 在任一時刻,監視器對象只屬於一個活動線程(Owner)
- 擁有者能夠調用wait方法自動釋放監視鎖,進入等待狀態
6.鎖優化
6.1 自旋鎖
- 痛點:因爲線程的阻塞/喚醒須要CPU在用戶態和內核態間切換,頻繁的轉換對CPU負擔很重,進而對併發性能帶來很大的影響
- 現象:經過大量分析發現,對象鎖的鎖狀態一般只會持續很短一段時間,不必頻繁地阻塞和喚醒線程
- 原理:經過執行一段無心義的空循環讓線程等待一段時間,不會被當即掛起,看持有鎖的線程是否很快釋放鎖,若是鎖很快被釋放,那當前線程就有機會不用阻塞就能拿到鎖了,從而減小切換,提升性能
- 隱患:若鎖能很快被釋放,那麼自旋效率就很好(真正執行的自旋次數越少效率越好,等待時間就少);但如果鎖被一直佔用,那自旋其實沒有作任何有意義的事但又白白佔用和浪費了CPU資源,反而形成資源浪費
- 注意:自旋次數必須有個限度(或者說自旋時間),若是超過自旋次數(時間)還沒得到鎖,就要被阻塞掛起
- 使用: JDK1.6以上默認開啓-XX:+UseSpinning,自旋次數可經過-XX:PreBlockSpin調整,默認10次
6.2 自適應自旋鎖
- 痛點:因爲自旋鎖只能指定固定的自旋次數,但因爲任務的差別,致使每次的最佳自旋次數有差別
- 原理:經過引入"智能學習"的概念,由前一次在同一個鎖上的自旋時間和鎖的持有者的狀態來決定自旋的次數,換句話說就是自旋的次數不是固定的,而是能夠經過分析上次得出下次,更加智能
- 實現:若當前線程針對某鎖自旋成功,那下次自旋此時可能增長(由於JVM認爲此次成功是下次成功的基礎),增長的話成功概率可能更大;反正,若自旋不多成功,那麼自旋次數會減小(減小空轉浪費)甚至直接省略自旋過程,直接阻塞(由於自旋徹底沒有意義,還不如直接阻塞)
- 補充:有了自適應自旋鎖,隨着程序運行和性能監控信息的不斷完善,JVM對鎖的情況預測會愈來愈準確,JVM會變得愈來愈智能
6.3 阻塞鎖
6.3.1 阻塞鎖
- 加鎖成功:當出現鎖競爭時,只有得到鎖的線程可以繼續執行
- 加鎖失敗:競爭失敗的線程會由running狀態進入blocking狀態,並被放置到與目標鎖相關的一個等待隊列中
- 解鎖:當持有鎖的線程退出臨界區,釋放鎖後,會將等待隊列中的一個阻塞線程喚醒,令其從新參與到鎖競爭中
- 補充:本篇不會涉及到具體的JVM型號的分析,有興趣的讀者能夠看看針對HotSopt JVM的分析 深刻JVM鎖機制1-synchronized
6.3.2 公平鎖
公平鎖就是得到鎖的順序按照先到先得的原則,從實現上說,要求當一個線程競爭某個對象鎖時,只要這個鎖的等待隊列非空,就必須把這個線程阻塞並塞入隊尾(插入隊尾通常經過一個CAS操做保持插入過程當中沒有鎖釋放)
6.3.3 非公平鎖
相對的,非公平鎖場景下,每一個線程都先要競爭鎖,在競爭失敗或當前已被加鎖的前提下才會被塞入等待隊列,在這種實現下,後到的線程有可能無需進入等待隊列直接競爭到鎖(隨機性)
6.4 鎖粗化
- 痛點:屢次鏈接在一塊兒的加鎖、解鎖操做會形成
- 原理:將屢次鏈接在一塊兒的加鎖、解鎖操做合併爲一次,將多個連續的鎖擴展成一個範圍更大的鎖
- 使用:將多個彼此靠近的同步塊合同在一個同步塊 或 把多個同步方法合併爲一個方法
- 補充:在JDK內置的API中,例如StringBuffer、Vector、HashTable都會存在隱性加鎖操做,可合併
/**
* StringBuffer是線程安全的字符串處理類
* 每次調用stringBuffer.append方法都須要加鎖和解鎖,若是虛擬機檢測到有一系列連串的對同一個對象加鎖和解鎖操做,就會將其合併成一次範圍更大的加鎖和解鎖操做,即在第一次append方法時進行加鎖,最後一次append方法結束後進行解鎖
*/
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("kira");
stringBuffer.append("sally");
stringBuffer.append("mengmeng");
}
複製代碼
6.5 鎖消除
- 痛點:根據代碼逃逸技術,若是判斷到一段代碼中,堆上的數據不會逃逸出當前線程,那麼能夠認爲這段代碼是線程安全的,沒必要要加鎖
- 原理: JVM在編譯時經過對運行上下文的描述,去除不可能存在共享資源競爭的鎖,經過這種方式消除無用鎖,即刪除沒必要要的加鎖操做,從而節省開銷
- 使用: 逃逸分析和鎖消除分別可使用參數-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(鎖消除必須在-server模式下)開啓
- 補充:在JDK內置的API中,例如StringBuffer、Vector、HashTable都會存在隱性加鎖操做,可消除
/**
* 好比執行10000次字符串的拼接
*/
public static void main(String[] args) {
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
for (int i = 0 ; i < 10000 ; i++){
synchronizedDemo.append("kira","sally");
}
}
public void append(String str1,String str2){
//因爲StringBuffer對象被封裝在方法內部,不可能存在共享資源競爭的狀況
//所以JVM會認爲該加鎖是無心義的,會在編譯期就刪除相關的加鎖操做
//還有一點特別要註明:明知道不會有線程安全問題,代碼階段就應該使用StringBuilder
//不然在沒有開啓鎖消除的狀況下,StringBuffer不會被優化,性能可能只有StringBuilder的1/3
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}/**
複製代碼
6.6 鎖的升級
- 從JDK1.6開始,鎖一共有四種狀態:無鎖狀態、偏向鎖狀態、輕量鎖狀態、重量鎖狀態
- 鎖的狀態會隨着競爭狀況逐漸升級,鎖容許升級但不容許降級
- 不容許降級的目的是提升得到鎖和釋放鎖的效率
- 後面筆者會經過倒序的方式,即重量級鎖->輕量級鎖->偏向鎖進行講解,由於一般是前者的優化
鎖的升級過程
6.7 重量級鎖
- 重量級鎖經過對象內部的monitor實現(見上文的Monitor Object模式)
- monitor的本質是依賴於底層操做系統的MutexLock實現,操做系統實現線程間的切換是經過用戶態與內核態的切換完成的,而切換成本很高
- MutexLock最核心的理念就是 嘗試獲取鎖.若可獲得就佔有.若不能,就進入睡眠等待
- 有興趣的讀者能夠閱讀 淺談Mutex (Lock) ,該篇對Liunx的MutexLock作了很好的講解
6.8 輕量級鎖
6.8.1 輕量級鎖綜述
- 痛點:因爲線程的阻塞/喚醒須要CPU在用戶態和內核態間切換,頻繁的轉換對CPU負擔很重,進而對併發性能帶來很大的影響
- 主要目的: 在沒有多線程競爭的前提下,減小傳統的重量級鎖使用操做系統互斥量產生的性能消耗
- 升級時機: 當關閉偏向鎖功能或多線程競爭偏向鎖會致使偏向鎖升級爲輕量級鎖
- 原理: 在只有一個線程執行同步塊時進一步提升性能
- 數據結構: 包括指向棧中鎖記錄的指針、鎖標誌位
- 補充:建議讀者先閱讀<<深刻了解JVM虛擬機>>的第8章虛擬機字節碼執行引擎的棧幀相關知識
6.8.2 輕量級鎖流程圖
線程1和線程2同時爭奪鎖,並致使鎖膨脹成重量級鎖
6.8.3 輕量級鎖加鎖
- 1.線程在執行同步塊以前,JVM會先在當前線程的棧幀中建立用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中(Displaced Mark Word-即被取代的Mark Word)作一份拷貝
- 2.拷貝成功後,線程嘗試使用CAS將對象頭的Mark Word替換爲指向鎖記錄的指針(將對象頭的Mark Word更新爲指向鎖記錄的指針,並將鎖記錄裏的Owner指針指向Object Mark Word)
- 若是更新成功,當前線程得到鎖,繼續執行同步方法
- 若是更新失敗,表示其餘線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖,若自旋後沒有得到鎖,此時輕量級鎖會升級爲重量級鎖,當前線程會被阻塞
6.8.4 輕量級鎖解鎖
- 解鎖時會使用CAS操做將Displaced Mark Word替換回到對象頭,
- 若是解鎖成功,則表示沒有競爭發生
- 若是解鎖失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖,須要在釋放鎖的同時喚醒被阻塞的線程,以後線程間要根據重量級鎖規則從新競爭重量級鎖
6.8.5 輕量級鎖注意事項
- 隱患:對於輕量級鎖有個使用前提是"沒有多線程競爭環境",一旦越過這個前提,除了互斥開銷外,還會增長額外的CAS操做的開銷,在多線程競爭環境下,輕量級鎖甚至比重量級鎖還要慢
6.9 偏向鎖
6.9.1 偏向鎖綜述
- 痛點: Hotspot做者發如今大多數狀況下不存在多線程競爭的狀況,而是同一個線程屢次獲取到同一個鎖,爲了讓線程得到鎖代價更低,所以設計了偏向鎖 (這個跟業務使用有很大關係)
- 主要目的: 爲了在無多線程競爭的狀況下儘可能減小沒必要要的輕量級鎖執行路徑
- 原理: 在只有一個線程執行同步塊時經過增長標記檢查而減小CAS操做進一步提升性能
- 數據結構: 包括佔有鎖的線程id,是不是偏向鎖,epoch(偏向鎖的時間戳),對象分代年齡、鎖標誌位
6.9.2 偏向鎖流程圖
線程1演示了偏向鎖的初始化過程,線程2演示了偏向鎖的撤銷鎖過程
6.9.3 偏向鎖初始化
- 當一個線程訪問同步塊並獲取到鎖時,會在對象頭和棧幀中的鎖記錄裏存儲偏向鎖的線程ID,之後該線程在進入和退出同步塊時不須要花費CAS操做來加鎖和解鎖,而是先簡單檢查對象頭的MarkWord中是否存儲了線程:
- 若是已存儲,說明線程已經獲取到鎖,繼續執行任務便可
- 若是未存儲,則須要再判斷當前鎖否是偏向鎖(即對象頭中偏向鎖的標識是否設置爲1,鎖標識位爲01):
- 若是沒有設置,則使用CAS競爭鎖(說明此時並非偏向鎖,必定是等級高於它的鎖)
- 若是設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程,也就是結構中的線程ID
6.9.4 偏向鎖撤銷鎖
- 偏向鎖使用一種等到競爭出現才釋放鎖的機制,只有當其餘線程競爭鎖時,持有偏向鎖的線程纔會釋放鎖
- 偏向鎖的撤銷須要等待全局安全點(該時間點上沒有字節碼正在執行)
- 偏向鎖的撤銷須要遵循如下步驟:
首先會暫停擁有偏向鎖的線程並檢查該線程是否存活:
- 若是線程非活動狀態,則將對象頭設置爲無鎖狀態(其餘線程會從新獲取該偏向鎖)
- 若是線程是活動狀態,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,並將對棧中的鎖記錄和對象頭的MarkWord進行重置:
- 要麼從新偏向於其餘線程(即將偏向鎖交給其餘線程,至關於當前線程"被"釋放了鎖)
- 要麼恢復到無鎖或者標記鎖對象不適合做爲偏向鎖(此時鎖會被升級爲輕量級鎖)
最後喚醒暫停的線程,被阻塞在安全點的線程繼續往下執行同步代碼塊
6.9.5 偏向鎖關閉鎖
- 偏向鎖在JDK1.6以上默認開啓,開啓後程序啓動幾秒後纔會被激活
- 有必要可使用JVM參數來關閉延遲 -XX:BiasedLockingStartupDelay = 0
- 若是肯定鎖一般處於競爭狀態,則可經過JVM參數 -XX:-UseBiasedLocking=false 關閉偏向鎖,那麼默認會進入輕量級鎖
6.9.6 偏向鎖注意事項
- 優點:偏向鎖只須要在置換ThreadID的時候依賴一次CAS原子指令,其他時刻不須要CAS指令(相比其餘鎖)
- 隱患:因爲一旦出現多線程競爭的狀況就必須撤銷偏向鎖,因此偏向鎖的撤銷操做的性能損耗必須小於節省下來的CAS原子指令的性能消耗(這個一般只能經過大量壓測纔可知)
- 對比:輕量級鎖是爲了在線程交替執行同步塊時提升性能,而偏向鎖則是在只有一個線程執行同步塊時進一步提升性能
6.10 偏向鎖 vs 輕量級鎖 vs 重量級鎖
歡迎關注知乎專欄《跟上Java8》,分享優秀的Java8中文指南、教程,同時歡迎投稿高質量的文章。
Synchronized一文通(1.8版)
由
黃志鵬kira
創做,採用
知識共享 署名-非商業性使用 4.0 國際 許可協議進行許可。