天天看點

Synchronized底層實作,鎖更新的具體過程

synchronized使用方式

我們知道并發程式設計會産生各種問題的源頭是可見性,原子性,有序性。

而synchronized能同時保證可見性,原子性,有序性。是以我們在解決并發問題的時候經常用synchronized,當然還有很多其他工具,如volatile。但是volatile隻能保證可見性,有序性,不能保證原子性。

synchronized可以用在如下地方

  1. 修飾執行個體方法,對目前執行個體對象this加鎖
  2. 修飾靜态方法,對目前類的Class對象加鎖
  3. 修飾代碼塊,指定加鎖對象,對給定對象加鎖

對方法進行加鎖,虛拟機會根據synchronized修飾的是執行個體方法還是類方法,去取對應的執行個體對象或者Class對象來進行加鎖。

synchronized實作原理

Java對象組成

我們都知道對象是放在堆記憶體中的,對象大緻可以分為三個部分,分别是對象頭,執行個體變量和填充位元組

Synchronized底層實作,鎖更新的具體過程
  • 對象頭,主要包括兩部分1. Mark Word (标記字段),2.Klass Pointer(類型指針)。Klass Point

    是對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體**(即指向方法區類的模版資訊)**。Mark Word用于存儲對象自身的運作時資料

  • 執行個體變量,存放類的屬性資料資訊,包括父類的屬性資訊,這部分記憶體按4位元組對齊
  • 填充資料,由于虛拟機要求對象起始位址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊

    填充資料主要是為了友善記憶體管理,如你想要10位元組的記憶體,但是會給你配置設定16位元組的記憶體,多出來的位元組就是填充資料

synchronized不論是修飾方法還是代碼塊,都是通過持有修飾對象的鎖來實作同步,那麼synchronized鎖對象是存在哪裡的呢?答案是存在鎖對象的對象頭Mark Word,來看一下Mark Word存儲了哪些内容?

由于對象頭的資訊是與對象自身定義的資料沒有關系的額外存儲成本,是以考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便存儲更多有效的資料,它會根據對象本身的狀态複用自己的存儲空間,也就是說,Mark Word會随着程式的運作發生變化,變化狀态如下 (32位虛拟機):

Synchronized底層實作,鎖更新的具體過程

其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的,稍後我們會簡要分析。這裡我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖辨別位為10,其中指針指向的是monitor對象(也稱為管程或螢幕鎖)的起始位址。每個對象都存在着一個 monitor 與之關聯。在Java虛拟機(HotSpot)中,monitor是由ObjectMonitor實作的,其主要資料結構如下(位于HotSpot虛拟機源碼ObjectMonitor.hpp檔案,C++實作的),省略部分屬性

ObjectMonitor() {
    _count        = 0; //記錄數
    _recursions   = 0; //鎖的重入次數
    _owner        = NULL; //指向持有ObjectMonitor對象的線程 
    _WaitSet      = NULL; //調用wait後,線程會被加入到_WaitSet
    _EntryList    = NULL ; //等待擷取鎖的線程,會被加入到該清單
}      
Synchronized底層實作,鎖更新的具體過程

結合線程狀态解釋一下執行過程。(狀态裝換參考自《深入了解Java虛拟機》)

  1. 建立(New),建立後尚未啟動的線程
  2. 運作(Runable),Runnable包括了作業系統線程狀态中的Running和Ready
  3. 無限期等待(Waiting),不會被配置設定CPU執行時間,要等待被其他線程顯式的喚醒。例如調用沒有設定Timeout參數的Object.wait()方法
  4. 限期等待(Timed Waiting),不會被配置設定CPU執行時間,不過無需等待其他線程顯示的喚醒,在一定時間之後會由系統自動喚醒。例如調用Thread.sleep()方法
  5. 阻塞(Blocked),線程被阻塞了,“阻塞狀态”與“等待狀态”的差別是:“阻塞狀态”在等待擷取着一個排他鎖,這個事件将在另外一個線程放棄這個鎖的時候發生,而“等待狀态”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,線程将進入這種狀态
  6. 結束(Terminated):線程結束執行

對于一個synchronized修飾的方法(代碼塊)來說:

  1. 當多個線程同時通路該方法,那麼這些線程會先被放進_EntryList隊列,此時線程處于blocked狀态
  2. 當一個線程擷取到了對象的monitor後,那麼就可以進入running狀态,執行方法,此時,ObjectMonitor對象的/_owner指向目前線程,_count加1表示目前對象鎖被一個線程擷取
  3. 當running狀态的線程調用wait()方法,那麼目前線程釋放monitor對象,進入waiting狀态,ObjectMonitor對象的/_owner變為null,_count減1,同時線程進入_WaitSet隊列,直到有線程調用notify()方法喚醒該線程,則該線程進入_EntryList隊列,競争到鎖再進入_Owner區
  4. 如果目前線程執行完畢,那麼也釋放monitor對象,ObjectMonitor對象的/_owner變為null,_count減1

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

synchronized如何擷取monitor對象?

synchronized修飾代碼塊

public class SyncCodeBlock {
    public int count = 0;
    public void addOne() {
        synchronized (this) {
            count++;
        }
    }
}      
javac SyncCodeBlock.java
javap -v SyncCodeBlock.class      

反編譯的位元組碼如下

public void addOne();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter // 進入同步方法
         4: aload_0
         5: dup
         6: getfield      #2                  // Field count:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field count:I
        14: aload_1
        15: monitorexit // 退出同步方法
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit // 退出同步方法
        22: aload_2
        23: athrow
        24: return
      Exception table:      

可以看到進入同步代碼塊,執行monitorenter指令,退出同步代碼塊,執行monitorexit指令,可以看到有2個monitorexit指令,第一個是正常退出執行的,第二個是當異常發生時執行的

synchronized修飾方法

public class SyncMethod {
    public int count = 0;
    public synchronized void addOne() {
        count++;
    }
}      

反編譯的位元組碼如下

public synchronized void addOne();
    descriptor: ()V
    // 方法辨別ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field count:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field count:I
        10: return
      LineNumberTable:      

我們并沒有看到monitorenter和monitorexit指令,那是怎麼來實作同步的呢?

可以看到方法被辨別為ACC_SYNCHRONIZED,表明這是一個同步方法

鎖的更新

在Java早期版本中,synchronized屬于重量級鎖,效率低下,因為作業系統實作線程之間的切換時需要從使用者态轉換到核心态,這個狀态之間的轉換需要相對比較長的時間,時間成本相對較高。

慶幸的是在jdk1.6之後Java官方對從JVM層面對synchronized較大優化,是以現在的synchronized鎖效率也優化得很不錯了,Jdk1.6之後,為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了偏向鎖和輕量級鎖,簡單介紹一下

synchronized鎖有四種狀态,無鎖,偏向鎖,輕量級鎖,重量級鎖,這幾個狀态會随着競争狀态逐漸更新,鎖可以更新但不能降級,但是偏向鎖狀态可以被重置為無鎖狀态

鎖實際上是加在對象上的,那麼被加了鎖的對象我們稱之為鎖對象,在java中,任何一個對象都能成為鎖對象。對象中關于鎖的資訊是存在Markword裡的

偏向鎖

當我們建立一個對象LockObject時,該對象的Markword字段,偏向鎖的标志位是“01”,狀态是“0”,表示該對象還沒有被加上偏向鎖。(“1”是表示被加上偏向鎖)。該對象被建立出來的那一刻,就有了偏向鎖的标志位,這也說明了所有對象都是可偏向的,但所有對象的狀态都為“0”,也同時說明所有被建立的對象的偏向鎖并沒有生效。

不過,當線程執行到臨界區(critical section)時,此時會利用CAS(Compare and Swap)操作,将線程ID插入到Markword中,同時修改偏向鎖的标志位為“1”,說明對象的偏向鎖生效了,目前這個線程獲得了該對象的鎖。

什麼是偏向鎖?

偏向鎖是jdk1.6引入的一項鎖優化,其中的“偏”是偏心的偏。它的意思就是說,這個鎖會偏向于第一個獲得它的線程,在接下來的執行過程中,假如該鎖沒有被其他線程所擷取,沒有其他線程來競争該鎖,那麼持有偏向鎖的線程将永遠不需要進行同步操作。

也就是說:

在此線程之後的執行過程中,如果再次進入或者退出同一段同步塊代碼,并不再需要去進行加鎖或者解鎖操作,而是會做以下的步驟:

偏向鎖更新過程

  1. Load-and-test,也就是簡單判斷一下目前線程id是否與java對象頭中Markword當中的線程id是否一緻.
  2. 如果一緻,則說明此線程已經成功獲得了鎖,繼續執行下面的代碼.
  3. 如果不一緻(其他線程,如線程2要競争鎖對象,而偏向鎖不會主動釋放是以還是存儲的線程1的threadID),那麼需要檢視Java對象頭中記錄的線程1是否存活;
  4. 如果沒有存活,那麼鎖對象被重置為無鎖狀态,其它線程(線程2)可以競争将其設定為偏向鎖;如果存活,那麼立刻查找該線程(線程1)的棧幀資訊,如果還是需要繼續持有這個鎖對象,那麼暫停目前線程1,撤銷偏向鎖,更新為輕量級鎖,如果線程1 不再使用該鎖對象,那麼将鎖對象狀态設為無鎖狀态,重新偏向新的線程。

可以看出,偏向鎖是針對于一個線程而言的,線程獲得鎖之後就不會再有解鎖等操作了,這樣可以省略很多開銷。假如有兩個線程來競争該鎖話,那麼偏向鎖就失效了,進而更新成輕量級鎖了。這也就是經常所說的鎖膨脹。

在Jdk1.6中,偏向鎖的開關是預設開啟的,适用于隻有一個線程通路同步塊的場景。

為什麼要引入偏向鎖?

因為經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競争的,常常是一個線程多次獲得同一個鎖,是以如果每次都要競争鎖會增大很多沒有必要付出的代價,為了降低擷取鎖的代價,才引入的偏向鎖。

輕量級鎖

為什麼要引入輕量級鎖?

輕量級鎖考慮的是競争鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因為阻塞線程需要CPU從使用者态轉到核心态,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,是以這個時候就幹脆不阻塞這個線程,讓它自旋等待鎖釋放。

輕量級鎖原理和更新過程

線程1擷取輕量級鎖時會先把鎖對象的對象頭MarkWord複制一份到線程1的棧幀中建立的用于存儲鎖記錄的空間(稱為DisplacedMarkWord),然後使用CAS把對象頭中的内容替換為線程1存儲的鎖記錄(DisplacedMarkWord)的位址;

如果線上程1複制對象頭的同時(線上程1CAS之前),線程2也準備擷取鎖,複制了對象頭到線程2的鎖記錄空間中,但是線上程2CAS的時候,發現線程1已經把對象頭換了,線程2的CAS失敗,那麼線程2就嘗試使用自旋鎖來等待線程1釋放鎖。 自旋鎖簡單來說就是讓線程2在循環中不斷CAS

但是如果自旋的時間太長也不行,因為自旋是要消耗CPU的,是以自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了線程1還沒有釋放鎖,或者線程1還在執行,線程2還在自旋等待,這時又有一個線程3過來競争這個鎖對象,那麼這個時候輕量級鎖就會膨脹為重量級鎖。重量級鎖把除了擁有鎖的線程都阻塞,防止CPU空轉。

鎖标志位”00”表示輕量級鎖

輕量級鎖主要有兩種

  1. 自旋鎖
  2. 自适應自旋鎖

自旋鎖

所謂自旋,就是指當有另外一個線程來競争鎖時,這個線程會在原地循環等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之後,這個線程就可以馬上獲得鎖的。

注意,鎖在原地循環的時候,是會消耗cpu的,就相當于在執行一個啥也沒有的for循環。

是以,輕量級鎖适用于那些同步代碼塊執行的很快的場景,這樣,線程原地等待很短很短的時間就能夠獲得鎖了。

經驗表明,大部分同步代碼塊執行的時間都是很短很短的,也正是基于這個原因,才有了輕量級鎖這麼個東西。

自旋鎖的局限性

  1. 如果同步代碼塊執行的很慢,需要消耗大量的時間,那麼這個時侯,其他線程在原地等待空消耗cpu,這會讓人很難受。
  2. 本來一個線程把鎖釋放之後,目前線程是能夠獲得鎖的,但是假如這個時候有好幾個線程都在競争這個鎖的話,那麼有可能目前線程會擷取不到鎖,還得原地等待繼續空循環消耗cup,甚至有可能一直擷取不到鎖。

基于這個問題,我們必須給線程空循環設定一個次數,當線程超過了這個次數,我們就認為,繼續使用自旋鎖就不适合了,此時鎖會再次膨脹,更新為重量級鎖。

預設情況下,自旋的次數為10次,使用者可以通過-XX:PreBlockSpin來進行更改。

自旋鎖是在JDK1.4.2的時候引入的

自适應自旋鎖

所謂自适應自旋鎖就是線程空循環等待的自旋次數并非是固定的,而是會動态着根據實際情況來改變自旋等待的次數。

其大概原理是這樣的:

假如一個線程1剛剛成功獲得一個鎖,當它把鎖釋放了之後,線程2獲得該鎖,并且線程2在運作的過程中,此時線程1又想來獲得該鎖了,但線程2還沒有釋放該鎖,是以線程1隻能自旋等待,但是虛拟機認為,由于線程1剛剛獲得過該鎖,那麼虛拟機覺得線程1這次自旋也是很有可能能夠再次成功獲得該鎖的,是以會延長線程1自旋的次數。

另外,如果對于某一個鎖,一個線程自旋之後,很少成功獲得該鎖,那麼以後這個線程要擷取該鎖時,是有可能直接忽略掉自旋過程,直接更新為重量級鎖的,以免空循環等待浪費資源。

輕量級鎖也被稱為非阻塞同步、樂觀鎖,因為這個過程并沒有把線程阻塞挂起,而是讓線程空循環等待,串行執行。

重量級鎖

輕量級鎖膨脹之後,就更新為重量級鎖了。重量級鎖是依賴對象内部的monitor鎖來實作的,而monitor又依賴作業系統的MutexLock(互斥鎖)來實作的,是以重量級鎖也被成為互斥鎖。

當輕量級所經過鎖撤銷等步驟更新為重量級鎖之後,它的Markword的鎖标志位變為10

為什麼說重量級鎖開銷大

主要是,當系統檢查到鎖是重量級鎖之後,會把等待想要獲得鎖的線程進行阻塞,被阻塞的線程不會消耗cup。但是阻塞或者喚醒一個線程時,都需要作業系統來幫忙,這就需要從使用者态轉換到核心态,而轉換狀态是需要消耗很多時間的,有可能比使用者執行代碼的時間還要長。

互斥鎖(重量級鎖)也稱為阻塞同步、悲觀鎖

幾種鎖的優缺點

Synchronized底層實作,鎖更新的具體過程

錯誤的加鎖姿勢1

synchronized (new Object())      

每次調用建立的是不同的鎖,相當于無鎖

錯誤的加鎖姿勢2

private Integer count;
synchronized (count)