多線程併發是Java語言中很是重要的一塊內容,同時,也是Java基礎的一個難點。說它重要是由於多線程是平常開發中頻繁用到的知識,說它難是由於多線程併發涉及到的知識點很是之多,想要徹底掌握Java的併發相關知識並不是易事。也正所以,Java併發成了Java面試中最高頻的知識點之一。本系列文章將從Java內存模型、volatile關鍵字、synchronized關鍵字、ReetrantLock、Atomic併發類以及線程池等方面來系統的認識Java的併發知識。經過本系列文章的學習你將深刻理解volatile關鍵字的做用,瞭解到synchronized實現原理、AQS和CLH隊列鎖,清晰的認識自旋鎖、偏向鎖、樂觀鎖、悲觀鎖...等等一系列讓人眼花繚亂的併發知識。html
多線程併發系列文章:java
這一次,完全搞懂Java內存模型與volatile關鍵字linux
這一次,完全搞懂Java中的synchronized關鍵字git
這一次,完全搞懂Java中的ReentranLock實現原理github
深刻理解Java線程的等待與喚醒機制(二)markdown
Java併發系列終結篇:完全搞懂Java線程池的工做原理數據結構
本文是Java併發系列的第二篇文章,將詳細的講解synchronized關鍵字以及其底層實現原理。多線程
開始以前先給你們推薦一下AndroidNote這個GitHub倉庫,這裏是個人學習筆記,同時也是我文章初稿的出處。這個倉庫中彙總了大量的java進階和Android進階知識。是一個比較系統且全面的Android知識庫。對於準備面試的同窗也是一份不可多得的面試寶典,歡迎你們到GitHub的倉庫主頁關注。
上篇文章詳細講解了volatile關鍵字,咱們知道volatile關鍵字能夠保證共享變量的可見性和有序性,但並不能保證原子性。若是既想保證共享變量的可見性和有序性,又想保證原子性,那麼synchronized關鍵字是一個不錯的選擇。
synchronized的使用很簡單,能夠用它來修飾實例方法和靜態方法,也能夠用來修飾代碼塊。值的注意的是synchronized是一個對象鎖,也就是它鎖的是一個對象。所以,不管使用哪種方法,synchronized都須要有一個鎖對象
synchronized修飾實例方法只須要在方法上加上synchronized關鍵字便可。
public synchronized void add(){
i++;
}
複製代碼
此時,synchronized加鎖的對象就是這個方法所在實例的自己。
synchronized修飾靜態方法的使用與實例方法並沒有差異,在靜態方法上加上synchronized關鍵字便可
public static synchronized void add(){
i++;
}
複製代碼
此時,synchronized加鎖的對象爲當前靜態方法所在類的Class對象。
synchronized修飾代碼塊須要傳入一個對象。
public void add() {f synchronized (this) {
i++;
}
}
複製代碼
很明顯,此時synchronized加鎖對象即爲傳入的這個對象實例。
到這裏不是道你是否有個疑問,synchronized關鍵字是如何對一個對象加鎖實現代碼同步的呢?若是想弄清楚,那就不得不先了解一下Java對象的對象頭了。
在JVM中,對象在內存中存儲的佈局能夠分爲三個區域,分別是對象頭、實例數據以及填充數據。
在對象頭的Mark Word中主要存儲了對象自身的運行時數據,例如哈希碼、GC分代年齡、鎖狀態、線程持有的鎖、偏向線程ID以及偏向時間戳等。同時,Mark Word也記錄了對象和鎖有關的信息。
當對象被synchronized關鍵字當成同步鎖時,和鎖相關的一系列操做都與Mark Word有關。因爲在JDK1.6版本中對synchronized進行了鎖優化,引入了偏向鎖和輕量級鎖(關於鎖優化後邊詳情討論)。Mark Word在不一樣鎖狀態下存儲的內容有所不一樣。咱們以32位JVM中對象頭的存儲內容以下圖所示。
從圖中能夠清楚的看到,Mark Word中有2bit的數據用來標記鎖的狀態。無鎖狀態和偏向鎖標記位爲01,輕量級鎖的狀態爲00,重量級鎖的狀態爲10。
當前咱們只討論重量級鎖,由於重量級鎖至關於對synchronized優化以前的狀態。關於偏向鎖和輕量級鎖在後邊鎖優化章節中詳細講解。
能夠看到,當爲重量級鎖時,對象頭的MarkWord中存儲了指向Monitor對象的指針。那麼Monitor又是什麼呢?
Monitor對象被稱爲管程或者監視器鎖。在Java中,每個對象實例都會關聯一個Monitor對象。這個Monitor對象既能夠與對象一塊兒建立銷燬,也能夠在線程試圖獲取對象鎖時自動生成。當這個Monitor對象被線程持有後,它便處於鎖定狀態。
在HotSpot虛擬機中,Monitor是由ObjectMonitor實現的,它是一個使用C++實現的類,主要數據結構以下:
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0; // 線程重入次數
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 調用wait方法後的線程會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 阻塞隊列,線程被喚醒後根據決策判讀是放入cxq仍是EntryList
FreeNext = NULL ;
_EntryList = NULL ; // 沒有搶到鎖的線程會被放到這個隊列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
複製代碼
ObjectMonitor中有五個重要部分,分別爲_ower,_WaitSet,_cxq,_EntryList和count。
若是線程獲取到對象的monitor後,就會將monitor中的ower設置爲該線程的ID,同時monitor中的count進行加1. 若是調用鎖對象的wait()方法,線程會釋放當前持有的monitor,並將owner變量重置爲NULL,且count減1,同時該線程會進入到_WaitSet集合中等待被喚醒。
另外_WaitSet,_cxq與_EntryList都是鏈表結構的隊列,存放的是封裝了線程的ObjectWaiter對象。若是不深刻虛擬機查看相關源碼很難理解這幾個隊列的做用,關於源碼會在後邊系列文章中分析。這裏我簡單說下它們之間的關係,以下:
在多條線程競爭monitor鎖的時候,全部沒有競爭到鎖的線程會被封裝成ObjectWaiter並加入_EntryList隊列。 當一個已經獲取到鎖的線程,調用鎖對象的wait方法後,線程也會被封裝成一個ObjectWaiter並加入到_WaitSet隊列中。 當調用鎖對象的notify方法後,會根據不一樣的狀況來決定是將_WaitSet集合中的元素轉移到_cxq隊列仍是_EntryList隊列。 等到得到鎖的線程釋放鎖後,又會根據條件來執行_EntryList中的線程或者將_cxq轉移到_EntryList中再執行_EntryList中的線程。
因此,能夠看得出來,_WaitSet存放的是處於WAITING狀態等待被喚醒的線程。而_EntryList隊列中存放的是等待鎖的BLOCKED狀態。_cxq隊列僅僅是臨時存放,最終仍是會被轉移到_EntryList中等待獲取鎖。
瞭解了對象頭和Monitor,那麼synchronized關鍵字究竟是如何作到與monitor關聯的呢?
在Java代碼中,咱們只是使用了synchronized關鍵字就實現了同步效果。那他究竟是怎麼作到的呢?這就須要咱們經過javap工具來反彙編出字節指令一探究竟了。
經過javap -v來反彙編下面的一段代碼。
public void add() {
synchronized (this) {
i++;
}
}
複製代碼
能夠獲得以下的字節碼指令:
public class com.zhangpan.text.TestSync {
public com.zhangpan.text.TestSync();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void add();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // synchronized關鍵字的入口
4: getstatic #2 // Field i:I
7: iconst_1
8: iadd
9: putstatic #2 // Field i:I
12: aload_1
13: monitorexit // synchronized關鍵字的出口
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // synchronized關鍵字的出口
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
}
複製代碼
從字節碼指令中能夠看到add方法的第3條指令處和第1三、19條指令處分別有monitorenter和moniterexit兩條指令。另外第四、七、八、九、13這幾條指令其實就是i++的指令。由此能夠得出在字節碼中會在同步代碼塊的入口和出口加上monitorenter和moniterexit指令。當執行到monitorenter指令時,線程就會去嘗試獲取該對象對應的Monitor的全部權,即嘗試得到該對象的鎖。
當該對象的 monitor 的計數器count爲0時,那線程能夠成功取得 monitor,並將計數器值設置爲 1,取鎖成功。若是當前線程已經擁有該對象monitor的持有權,那它能夠重入這個 monitor ,計數器的值也會加 1。而當執行monitorexit指令時,鎖的計數器會減1。
假若其餘線程已經擁有monitor 的全部權,那麼當前線程獲取鎖失敗將被阻塞並進入到_WaitSet中,直到等待的鎖被釋放爲止。也就是說,當全部相應的monitorexit指令都被執行,計數器的值減爲0,執行線程將釋放 monitor(鎖),其餘線程纔有機會持有 monitor 。
同步方法的字節碼指令與同步代碼塊的字節指令有所差別。咱們先來經過javap -v查看下面代碼的字節碼指令。
public synchronized void add(){
i++;
}
複製代碼
反彙編後可獲得以下的字節指令
public synchronized void add();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 5: 0
line 6: 10
複製代碼
能夠看到這裏並無monitorenter和moniterexit兩條指令,而是在方法的flag上加入了ACC_SYNCHRONIZED的標記位。這其實也容易理解,由於整個方法都是同步代碼,所以就不須要標記同步代碼的入口和出口了。當線程線程執行到這個方法時會判斷是否有這個ACC_SYNCHRONIZED標誌,若是有的話則會嘗試獲取monitor對象鎖。執行步驟與同步代碼塊一致,這裏就再也不贅述了。
在Linux系統架構中能夠分爲用戶空間和內核,咱們的程序都運行在用戶空間,進入用戶運行狀態就是所謂的用戶態。在用戶態可能會涉及到某些操做如I/O調用,就會進入內核中運行,此時進程就被稱爲內核運行態,簡稱內核態。
上邊咱們已經提到了使用monitor是重量級鎖的加鎖方式。在objectMonitor.cpp中會涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等內核函數, 執行同步代碼塊,沒有競爭到鎖的對象會park()被掛起,競爭到鎖的線程會unpark()喚醒。這個時候就會存在操做系統用戶態和內核態的轉換,這種切換會消耗大量的系統資源。試想,若是程序中存在大量的鎖競爭,那麼會引發程序頻繁的在用戶態和內核態進行切換,嚴重影響到程序的性能。這也是爲何說synchronized效率低的緣由
爲了解決這一問題,在JDK1.6中引入了偏向鎖和輕量級鎖來優化synchronized。
JDK1.6中引入偏向鎖和輕量級鎖對synchronized進行優化。此時的synchronized一共存在四個狀態:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖着鎖競爭激烈程度,鎖的狀態會出現一個升級的過程。便可以從偏向鎖升級到輕量級鎖,再升級到重量級鎖。鎖升級的過程是單向不可逆的,即一旦升級爲重量級鎖就不會再出現降級的狀況。
接下來咱們來詳細的認識一下這幾種鎖狀態。
經研究發現,在大多數狀況下鎖不只不存在多線程競爭關係,並且大多數狀況都是被同一線程屢次得到。所以,爲了減小同一線程獲取鎖的代價而引入了偏向鎖的概念。
偏向鎖的核心思想是,若是一個線程得到了鎖,那麼鎖就進入偏向模式,此時Mark Word的結構也變爲偏向鎖結構,即將對象頭中Mark Word的第30bit的值改成1,而且在Mark Word中記錄該線程的ID。當這個線程再次請求鎖時,無需再作任何同步操做,便可獲取鎖的過程,這樣就省去了大量有關鎖申請的操做,從而也就提高了程序的性能。因此,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續屢次是同一個線程申請相同的鎖。
可是,對於鎖競爭比較激烈的狀況,偏向鎖就有問題了。由於每次申請鎖的均可能是不一樣線程。這種狀況使用偏向鎖就會得不償失,此時就會升級爲輕量級鎖。
輕量級鎖優化性能的依據是對於大部分的鎖,在整個同步生命週期內都不存在競爭。 當升級爲輕量級鎖以後,MarkWord的結構也會隨之變爲輕量級鎖結構。JVM會利用CAS嘗試把對象本來的Mark Word 更新爲Lock Record的指針,成功就說明加鎖成功,改變鎖標誌位爲00,而後執行相關同步操做。
輕量級鎖所適應的場景是線程交替執行同步塊的場合,若是存在同一時間訪問同一鎖的場合,就會致使輕量級鎖就會失效,進而膨脹爲重量級鎖。
輕量級鎖失敗後,虛擬機爲了不線程真實地在操做系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。
自旋鎖是基於在大多數狀況下,線程持有鎖的時間都不會太長。若是直接掛起操做系統層面的線程可能會得不償失,畢竟操做系統實現線程之間的切換時須要從用戶態轉換到核心態,這個狀態之間的轉換須要相對比較長的時間,時間成本相對較高,所以自旋鎖會假設在不久未來,當前的線程能夠得到鎖,所以虛擬機會讓當前想要獲取鎖的線程作幾個空循環(這也是稱爲自旋的緣由),不斷的嘗試獲取鎖。空循環通常不會執行太屢次,多是50個循環或100循環,在通過若干次循環後,若是獲得鎖,就順利進入同步代碼。若是還不能得到鎖,那就會將線程在操做系統層面掛起,即進入到重量級鎖。
這就是自旋鎖的優化方式,這種方式確實也是能夠提高效率的。
在瞭解了jdk1.6引入的這幾種鎖以後,咱們來詳細的看一下synchronized是怎麼一步步進行鎖升級的。
(1)當沒有被當成鎖時,這就是一個普通的對象,Mark Word記錄對象的HashCode,鎖標誌位是01,是否偏向鎖那一位是0;
(2)當對象被當作同步鎖並有一個線程A搶到了鎖時,鎖標誌位仍是01,可是否偏向鎖那一位改爲1,前23bit記錄搶到鎖的線程id,表示進入偏向鎖狀態;
(3) 當線程A再次試圖來得到鎖時,JVM發現同步鎖對象的標誌位是01,是否偏向鎖是1,也就是偏向狀態,Mark Word中記錄的線程id就是線程A本身的id,表示線程A已經得到了這個偏向鎖,能夠執行同步中的代碼;
(4) 當線程B試圖得到這個鎖時,JVM發現同步鎖處於偏向狀態,可是Mark Word中的線程id記錄的不是B,那麼線程B會先用CAS操做試圖得到鎖,這裏的得到鎖操做是有可能成功的,由於線程A通常不會自動釋放偏向鎖。若是搶鎖成功,就把Mark Word裏的線程id改成線程B的id,表明線程B得到了這個偏向鎖,能夠執行同步代碼。若是搶鎖失敗,則繼續執行步驟5;
(5) 偏向鎖狀態搶鎖失敗,表明當前鎖有必定的競爭,偏向鎖將升級爲輕量級鎖。JVM會在當前線程的線程棧中開闢一塊單獨的空間,裏面保存指向對象鎖Mark Word的指針,同時在對象鎖Mark Word中保存指向這片空間的指針。上述兩個保存操做都是CAS操做,若是保存成功,表明線程搶到了同步鎖,就把Mark Word中的鎖標誌位改爲00,能夠執行同步代碼。若是保存失敗,表示搶鎖失敗,競爭太激烈,繼續執行步驟6;
(6) 輕量級鎖搶鎖失敗,JVM會使用自旋鎖,自旋鎖不是一個鎖狀態,只是表明不斷的重試,嘗試搶鎖。從JDK1.7開始,自旋鎖默認啓用,自旋次數由JVM決定。若是搶鎖成功則執行同步代碼,若是失敗則繼續執行步驟7;
(7) 自旋鎖重試以後若是搶鎖依然失敗,同步鎖會升級至重量級鎖,鎖標誌位改成10。在這個狀態下,未搶到鎖的線程都會被阻塞。
synchronized關鍵字的使用能夠說很是簡單,可是想要徹底搞懂synchronized實際上並無那麼容易。由於它涉及到不少虛擬機底層的知識。同時,還要了解JDK1.6中對synchronized針對性的優化,其中牽扯到的東西又不少。好比,本篇文章並無講解什麼是CAS,若是你不懂CAS,就很難理解鎖升級的過程。須要不懂讀者自行去查閱相關資料。本篇文章對於synchronized的講解相對來講仍是很全面的。但願你看完能有所收穫。