1. 程式人生 > >synchronized底層實現學習

synchronized底層實現學習

  上文我們總結了 synchronized 關鍵字的基本用法以及作用,並未涉及 synchronized 底層是如何實現的,所謂刨根問底,本文我們就開始 synchronized 原理的探索之旅吧(*>﹏<*)。

 

1. 物件鎖是什麼

   不同於ReentrantLock的顯式加鎖,synchronized 的加鎖方式屬於隱式加鎖,從程式碼中看我們只知道當執行緒執行到被synchronized包圍的程式碼塊時會獲取鎖,那這把鎖到底是什麼?如何獲取?其實在前面的學習中,我們可以有個直觀的感覺,這把鎖是一個物件(類的當前例項物件、類的class物件或者指定的某個任意物件),但是是這樣嗎?

  既然鎖和物件有很大關係,那我們不妨考慮一下物件,什麼是Java物件?

  我的回答是存在於虛擬機器堆上的一系列位元組,我覺可以從這個層面來解釋。在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data)、對其填充(Padding)。其中,物件頭包括兩部分(有關這部分的詳細內容總結見--Java讀書筆記之記憶體管理):

  • 第一部分用於儲存物件自身的執行時資料,如雜湊碼、GC分代年齡、鎖狀態標誌執行緒持有的鎖、偏向執行緒ID、偏向時間戳等;
  • 第二部分是型別指標,指向該物件的類的元資料的指標;

  看到了嗎,鎖相關的資訊其實是儲存在物件頭中,在物件處於各種狀態下(未鎖定、輕量級鎖定、重量級鎖定、GC標記、偏斜鎖)物件頭中儲存的內容見下表:

儲存內容 標誌位 狀態
物件雜湊碼、物件分代年齡 01  未鎖定 
指向鎖記錄的指標  00  輕量級鎖定 
指向重量級鎖的指標  10  膨脹(重量級鎖定) 
空,不需要記錄資訊  11  GC標記 
偏向執行緒ID、偏向時間戳、物件分代年齡  01 
可偏向 

 

  當物件處於重量級鎖定時(為了簡單起見,我們暫且考慮這一種情況,後文有更詳細論述不同級別的鎖)物件頭中儲存的內容是指向重量級鎖的指標(我們暫且先忽略重量級),也就是說,物件頭中存有一個指標,指向一把鎖,這把鎖也就是synchronized的物件鎖,這其實是一個monitor物件(C++實現),裡面會記錄獲取鎖的執行緒以及競爭執行緒的一些相關資訊,我們可以大致瞭解一下:

ObjectMonitor(){
   _count         = 0;
   _owner         = NULL;
   _WaitSet       = NULL;
   _WaitSetLock   = 0;
   _EntryList     = NULL;  
}

  在HotSpot中,monitor是由ObjectMonitor實現的,如上是其中的幾個關鍵屬性,當多個執行緒訪問同一段同步程式碼時,會將其先存放到_EntryList佇列中,當某個執行緒獲取到物件的monitor後會將_owner變數設定為指向持有ObjectMonitor物件的執行緒也就是當前執行緒,同時_count會加1,如果執行緒呼叫wait()則會釋放持有的monitor,_owner會被置為null,_count減1,並且該執行緒進入_WaitSet佇列中,等待下一次被喚醒。若當前執行完畢,也將釋放monitor,同時_ownner置空,_count減1,執行緒退出。

 

2. 如何加鎖

   現在我們知道synchronized所使用的物件鎖是什麼東西了(雖然monitor是基於C++實現的,而本文並沒有深入到C++原始碼級別來探討monitor的實現原理O__O"),至少有了一個更直觀上的認識,我們可以從位元組碼層面來看一下加了synchronized關鍵字後多了什麼操作。

   這裡先寫一個小demo:

public class STest {
    public static void main(String[] args) {
        int i = 0;
        synchronized(STest.class) {
            i++;
        }
    }
    
    public synchronized void testMethod() {
        int i = 0;
        i ++ ;
    }   
}

  

  然後進入cmd命令視窗,在對應class檔案所在目錄下輸入:javap -verbose STest,輸出位元組碼檔案如下(這裡只截取了部分):

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: ldc           #1                  // class testPackage/STest
         4: dup
         5: astore_2
         6: monitorenter
         7: iinc          1, 1
        10: aload_2
        11: monitorexit
        12: goto          18
        15: aload_2
        16: monitorexit
        17: athrow
        18: return
      Exception table:
         from    to  target type
         。。。
        
  public synchronized void testMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=1, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iinc          1, 1
         5: return
      LineNumberTable:
        line 12: 0
        。。。

  在main方法中有一個同步程式碼塊,裡面完成了一個自增操作,對應的位元組碼是第6行的monitorenter和第11以及16行的monitorexit這兩個指令,所以被同步塊包圍的程式碼在生成位元組碼時會被monitorenter、monitorexit這對指令包圍,我們可以理解為執行緒執行到monitorenter時會獲取鎖,執到monitorexit時則會釋放鎖。JVM會保證每一個monitorenter指令都有一個monitorexit指令與之相對應,即只要獲取鎖就有釋放鎖操作與之對應。

   而對於方法testMethod(),位元組碼中並沒有出現monitorenter和monitorexit這對指令,對於被synchronized修飾的方法,JVM是通過識別符號ACC_SYNCHRONIZED該方法是一個同步方法,從而執行如上類似的操作。

 

3. synchronized如何保證執行緒安全

  好了,現在我們清楚了synchronized使用的鎖是什麼以及虛擬機器在位元組碼層面是如何實現加鎖以及釋放鎖的,我們再來理解synchronized是如何保證原子性、可見性以及有序性就更容易了。

原子性

  當一個執行緒獲取一把鎖(執行monitorenter指令)後,其他執行緒如果嘗試獲取同一把鎖則會阻塞,直到鎖被釋放(執行monitorexit並且_count值減為0)才會重新獲取鎖,獲取鎖成功的執行緒則會開始執行同步程式碼,這就保證了同一時刻只有一個執行緒在執行一段程式碼,並且從執行緒獲取鎖到釋放鎖這個過程中,該執行緒是不會被其他執行緒打斷的,這也就保證了執行緒在執行這段程式碼時的原子性。

可見性

   同步塊保證可見性主要是通過:

  • 執行緒獲取鎖時,JVM會把該執行緒對應的被同步塊保護的共享變數在本地的副本置為無效,並從主存中讀取;
  • 執行緒釋放鎖時,JVM會把該執行緒對應的被同步塊保護的共享變數從本地記憶體中更新到主記憶體中;

  這就使得程式進入同步塊時,從主存中獲取共享變數最新資料至執行緒本地副本,退出同步塊時將共享變數本地副本更新至主存中,從而保證可見性。

有序性

   關於有序性,synchronized的實現方式和volatile關鍵字是不一樣的,前者是關鍵字本身就有禁止指令重排序的語義,而synchronized是靠“一個變數同一時刻只允許一條執行緒對其進行lock操作”這條規則來保證執行緒操作之間的有序性,可以理解為持有同一把鎖的兩個同步塊只能序列地進入。我們先舉一個例子:

//執行緒1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//執行緒2:
while(!inited ){
  sleep();
}
doSomethingwithconfig(context);

  上面程式碼中,由於語句1和語句2沒有資料依賴性,因此可能會被重排序。假如發生了重排序,線上程1執行過程中先執行語句2,而此時執行緒2會以為初始化工作已經完成,那麼就會跳出while迴圈,去執行doSomethingwithconfig(context)方法,但此時context可能並沒有初始化完成,就會導致程式出錯。

  這裡如果給變數inited新增volatile關鍵字修飾,就可以解決問題,但是如果用synchronized怎麼解決呢?我的理解是對inited的賦值操作通過同步塊來保護,因為線上程獲取synchronized鎖時會強制將本地的變數更新回主存中,對應如上程式碼就是會將context更新回記憶體中,這代表context已經載入了,當退出synchronized時會把inited更新回主存中,所以執行緒2監控到inited為true的時候context已經初始化完畢了,再執行doSomethingwithconfig就沒有問題了。

//執行緒1:
context = loadContext();       //語句1
synchronized(Object.class){
     inited = true;              //語句2
}
 
//執行緒2:
while(!inited ){
  sleep();
}
doSomethingwithconfig(context);

  

4. synchronized優化

  前面我們我到synchronized經過編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令,在執行monitorenter時,會嘗試獲取物件的鎖,如果成功就執行同步塊中的程式碼,在鎖被釋放前,其他試圖獲取鎖的執行緒將阻塞。而Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來幫忙,這需要從使用者態轉換到核心態中,這會耗費很多的處理器時間,是一個重量級操作,所以JDK1.5以後,JVM對此進行了大刀闊斧的改進,如自旋鎖(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、偏斜鎖(Biased Locking)、輕量級鎖(Lightweight Locking)等。這些技術都是為了線上程間更高效地共享資料,以及解決競爭問題,從而提高程式的執行效率。

自旋鎖

   在利用synchronized進行執行緒間互斥同步時,阻塞的實現是一個很耗效能的操作,這會給系統的併發效能帶來很大壓力。並且在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一下”,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋。

  自旋等待本身雖然避免了執行緒切換的開銷,但是它是要佔用CPU時間的,如果鎖被佔用的時間很長,那隻會白白消耗處理器資源,反而會帶來效能上的浪費,因此自旋等待的時間必須要有一定的限度,超過一定次數就應該使用傳統方式來掛起執行緒,預設值是10次,可以使用引數-XX:PreBlockSpin來更改。

輕量級鎖

   輕量級鎖是JDK1.6之中加入的新型鎖機制,它名字中的“輕量級“是相對於使用作業系統互斥量來實現的傳統鎖而言的(即我們馬上要介紹的重量級鎖)。

  要理解輕量級鎖,以及後面會講到的偏斜鎖的原理和運作過程,必須瞭解虛擬機器的物件(物件頭部分)的記憶體佈局,前面我們有提到。 物件頭中包含用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡(Generational GC Age)等,官方稱它為“Mark Word”,它是實現偏斜鎖和輕量級鎖的關鍵。

  物件頭資訊是與物件自身定義的資料無關的額外儲存成本,考慮到虛擬機器的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。例如,在32位的HotSpot虛擬機器中物件未被鎖定的狀態下, Mark Word的32bit空間中的25bit用於儲存物件雜湊碼(HashCode),4bit用於儲存物件分代年齡,2bit用於儲存鎖標誌位,1bit固定為0,在其他狀態下的詳細儲存內容見下表:

  簡單介紹完物件的記憶體佈局後,我們再回到輕量級鎖的執行過程上。在程式碼進入同步塊的時候,如果此同步物件沒有被鎖定(鎖標誌位為“01”狀態),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced字首,即Displaced Mark Word),這時候執行緒堆疊與物件頭的狀態如下圖左側所示:

  然後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標。如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位(Mark Word的最後2bit)將轉變為“00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如下圖右側所示。

  如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是,說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其他執行緒搶佔了。

  上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是通過CAS操作來進行的,如果物件的Mark Word任然指向著執行緒的鎖記錄,那就用CAS操作將物件當前的Mark Word替換為獲取鎖時儲存線上程棧幀中的Displaced Mark Word,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他執行緒嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的執行緒。

偏斜鎖

  偏斜鎖是JDK1.6中引入的一項鎖優化,它的目的是消除資料在無競爭情況下的同步原語,提高程式的執行效能。

  偏斜鎖,顧名思義,就是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏斜鎖的執行緒將永遠不需要再進行同步。

  假設當前虛擬機器啟用了偏斜鎖(啟用引數-xx:+Use Biased Locking,這是JDK1.6的預設值),那麼,當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為“01”,即偏斜模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中,如果CAS操作成功,持有偏斜鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作(例如Locking、 Unlocking及對Mark Word的Update等)。
  當有另外一個執行緒去嘗試獲取這個鎖時偏斜模式就宣告結束。根據鎖物件目前是否處於被鎖定的狀態,撤銷偏斜(Revoke Bias)後恢復到未鎖定(標誌位為“01”)或輕量級鎖定(標誌位為“00”)的狀態,後續的同步操作就如上面介紹的輕量級鎖那樣執行。偏向鎖、輕量級鎖的狀態轉化及物件 Mark Word的關係如下圖所示。

重量級鎖

   如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。這裡的重量級鎖就是本開開頭所說的monitor物件,早期的虛擬機器中,synchronized的獲取鎖操作僅此一種,因為比較消耗效能,所以稱為重量級鎖,其獲取過程上文有論述。

 

  綜上,整個獲取鎖的過程可以總結如下(此處為個人理解,如有不對,歡迎指正^_^):

  1. 如果虛擬機器開啟偏斜鎖,會先獲取偏斜鎖,如果沒有則會直接獲取輕量級鎖;
  2. 這時如果有另一個執行緒嘗試獲取鎖,首先它會自旋一定次數,如果自旋結束鎖依舊沒有釋放,則它會嘗試獲取鎖;
  3. 這時步驟1中如果是獲取的偏斜鎖,則會升級成為輕量級鎖,如果這是依然存在競爭,則會升級成為重量級鎖;

 

5. 總結

  本文我們學習了synchronized是如何實現的,有什麼作用, 以及現代JVM對synchronized所做的優化。

  • synchronized可以實現原子性、可見性、有序性;
  • synchronized獲取monitor是發生在進入同步塊時執行monitorenter指令時;
  • 現代JVM對synchronized進行了大量優化,提供了三種不同的monitor實現:偏斜鎖、輕量級鎖、重量級鎖;

  輕量級鎖能提升程式同步效能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

  輕量級鎖並不是用來代替重量級鎖的,它的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。 

  如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏斜鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

 

參考文獻:

Moniter的實現原理

<<深入理解Java虛擬機器:JVM高階特性與最佳實踐>>--周志明