【併發編程】synchronized關鍵字最全詳解,看這一篇就夠了

目錄

引入

一、synchronized的特性

1.1 原子性

1.2 可見性

1.3 有序性

1.4 可重入性

二、synchronized的用法

根據修飾對象分類

1.同步方法

2.同步代碼塊

根據獲取的鎖分類

1.獲取對象鎖

2.獲取類鎖

三、synchronized鎖的實現 

3.1 爲什麼synchronized通過鎖機制可以保證原子性,可見性和有序性,其原理是什麼?

3.2 同步方法

3.3 同步代碼塊

monitorenter指令會發生的3中情況

monitorexit指令

四、synchronized鎖的底層實現

4.1 對象頭

4.2 monitor對象

五、JVM對synchronized的優化

5.1 鎖膨脹

5.1.1 偏向鎖(Biased Locking)

5.1.2 輕量級鎖

5.1.3 重量級鎖

三種索各自的優缺點和適用場景

5.2 鎖消除(Lock Elision)

原理

5.3 鎖粗化(Lock Coarsening)

對於鎖粗化的的理解

5.4 自旋鎖與自適應自旋鎖


引入

編寫一個類似銀行、醫院的叫號程序(要求:多個窗口叫號,不重號、不跳號)

 

這個用到多線程來實現多個窗口叫號的功能,首先要解決的就是資源共享問題,因爲不同線程(不同窗口)所使用的叫號計數器應該是同一個,否則就會出現重號的問題。

資源共享的解決方案有兩種:

  • 使用static關鍵字修飾要共享的變量,將其變爲全局靜態變量,也就是放到了JMM的主內存中,這要就實現了資源的共享。
  • 實現Runnable接口,這個接口和Thread類的區別之一就是可以實現資源的共享,因爲實現Runnable接口的線程所操作的資源對象本質是是同一個對象

解決完資源共享問題之後,還有一個新的問題,那就是併發量比較大的時候會出現:跳號、重號、超過最大值。因爲在加號過程中對index的增加操作在工作空間,就需要將index從主內存複製到工作空間進行操作,操作完再更新主內存中的index數據。很明顯這不是一個原子操作。這就造成了有可能線程1正在對index進行操作,還沒有操作完線程2也對index進行操作,線程2無法感知index已經被更新,這就不符合原子性和可見性原則。這就需要使用到synchronized關鍵字來保證原子性和可見性。

代碼:

public class  TicketDemo extends Thread{
    private static int index=1;//
    private static final int MAX=5000;
    @Override
    public void run() {
        synchronized (this){
            while(index<=MAX){
                System.out.println(Thread.currentThread().getName()+"叫到號碼是:"+(index++));
            }
        }
    }
    public static void main(String[] args) {
        TicketDemo t1=new TicketDemo();
        TicketDemo t2=new TicketDemo();
        TicketDemo t3=new TicketDemo();
        TicketDemo t4=new TicketDemo();
        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

如果某一個資源被多個線程共享,爲了避免因爲資源搶佔導致資源數據錯亂,我們需要對線程進行同步,那麼synchronized就是實現線程同步的關鍵字,可以說在併發控制中是必不可少的部分,今天就來看一下synchronized的使用和底層原理。可以通過synchronized關鍵字實現互斥同步,進而來實現線程安全。

 

一、synchronized的特性

synchronized是利用鎖的機制來實現同步的。下面synchronized的特性也就是該關鍵字在併發編程中能保證的特性。

1.1 原子性

所謂原子性就是指一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。但是像i++、i+=1等操作字符就不是原子性的,它們是分成讀取、計算、賦值幾步操作,原值在這些步驟還沒完成時就可能已經被賦值了,那麼最後賦值寫入的數據就是髒數據,無法保證原子性。

被synchronized修飾的類或對象的所有操作都是原子的,因爲在執行操作之前必須先獲得類或對象的鎖,直到執行完才能釋放,這中間的過程無法被中斷(除了已經廢棄的stop()方法),即保證了原子性。

注意!面試時經常會問比較synchronized和volatile,它們倆特性上最大的區別就在於原子性,volatile不具備原子性

 

1.2 可見性

可見性是指多個線程訪問一個資源時,該資源的狀態、值信息等對於其他線程都是可見的。

synchronized和volatile都具有可見性,其中synchronized對一個類或對象加鎖時,一個線程如果要訪問該類或對象必須先獲得它的鎖,而這個鎖的狀態對於其他任何線程都是可見的,並且在釋放鎖之前會將對變量的修改刷新到主存當中,保證資源變量的可見性,如果某個線程佔用了該鎖,其他線程就必須在鎖池中等待鎖的釋放。

而volatile的實現類似,被volatile修飾的變量,每當值需要修改時都會立即更新主存,主存是共享的,所有線程可見,所以確保了其他線程讀取到的變量永遠是最新值,保證可見性。

 

1.3 有序性

有序性值程序執行的順序按照代碼先後執行。

synchronized和volatile都具有有序性,Java允許編譯器和處理器對指令進行重排,但是指令重排並不會影響單線程的順序,它影響的是多線程併發執行的順序性。synchronized保證了每個時刻都只有一個線程訪問同步代碼塊,也就確定了線程執行同步代碼塊是分先後順序的,保證了有序性

 

1.4 可重入性

synchronized和ReentrantLock都是可重入鎖。當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處於阻塞狀態,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬於重入鎖。通俗一點講就是說一個線程擁有了鎖仍然還可以重複申請鎖同一個線程外層函數獲取到鎖之後,內層函數可以直接使用該鎖這樣的好處就是避免死鎖,如果不可重入,假設method1拿到鎖之後,在method1中又調用了method2,如果method2沒辦法使用method1拿到的鎖,那method2將一直等待,但是method1由於未執行完畢,又無法釋放鎖,就導致了死鎖,可重入正好避免這這種情況。

可重入性就是monitor中的鎖計數器來實現的,下面對monitor原理有講解

 

二、synchronized的用法

synchronized可以修飾靜態方法、成員函數(非靜態方法),同時還可以直接定義代碼塊,但是歸根結底它上鎖的資源只有兩類:一個是對象,一個是

synchronized修飾的對象有幾種:

  • 修飾一個方法:被修飾的方法稱爲同步方法,其作用的範圍是整個方法,作用的對象是調用這個方法的對象
  • 修飾一個靜態的方法:其作用的範圍是整個方法,作用的對象是這個類的所有對象
  • 修飾一個代碼塊,指定加鎖對象:被修飾的代碼塊稱爲同步語句塊,其作用範圍是大括號{}括起來的代碼塊,如果synchronized後面括號括起來的是一個類,那麼作用的對象是這個類的所有實例對象;如果synchronized後面括號括起來的是一個對象實例,那麼作用的對象是這個對象實例

 

根據修飾對象分類

1.同步方法

  • 同步非靜態方法  

必須先獲得該類的實力對象的鎖才能進入同步塊

public synchronized void methodName(){
	……
}

 

  • 同步靜態方法

必須先獲得該類的鎖才能進入同步塊

public synchronized static void methodName(){
	……
}

 

2.同步代碼塊

  • 修飾對象實例

必須先獲得該對象實例的鎖才能進入同步代碼塊

synchronized(this|object) {}

 

  • 修飾類

必須先獲得該類鎖才能進入同步代碼塊

synchronized(類.class) {}

 

 

根據獲取的鎖分類

1.獲取對象鎖

synchronized(this|object) {}

如果有多個對象就有相對應的多個鎖。

在 Java 中,每個對象都會有一個 monitor 對象,這個對象其實就是 Java 對象的鎖,通常會被稱爲“內置鎖”或“對象鎖”。類的對象可以有多個,所以每個對象有其獨立的對象鎖,互不干擾。

 

2.獲取類鎖

synchronized(類.class) {}

也叫全局鎖,不管有幾個對象就公用一把鎖

在 Java 中,針對每個類也有一個鎖,可以稱爲“類鎖”,類鎖實際上是通過對象鎖實現的,即類的 Class 對象鎖。每個類只有一個 Class 對象,所以每個類只有一個類鎖。

參考資料:【併發編程】類鎖和對象鎖的區別

 

三、synchronized鎖的實現 

synchronized關鍵字的功能是通過鎖機制來實現的,也就是通過之前講過的八種Java內存模型操作中的lock操作unlock操作來實現的。注意,synchronized 內置鎖 是一種 對象鎖(鎖的是對象而非引用變量),作用粒度是對象。Synchronized可以把任何一個非null對象(包括類的對象和類對象)作爲"鎖"。

 

3.1 爲什麼synchronized通過鎖機制可以保證原子性,可見性和有序性,其原理是什麼?

之前這一篇文章寫過JMM規定執行8中基本操作時必須滿足的準則,詳見【Java內存模型】Java內存模型(JMM)詳解以及併發編程的三個重要特性(原子性,可見性,有序性)

  • 用之前講過的執行8中操作必須滿足的條件,e條中一個變量在同一個時刻只允許一個線程對其進行lock,這就說明了持有一個鎖的兩個同步塊只能串行進行,這也就保證了原子性和有序性。
  • 可以看到最後一條synchronized 實現其實是靠lock和unlock實現的。在對一個變量unlock操作前。必須也先把此變量同步回主內存中。這就實現了可見性

所以synchronized之所以能保證三個特性,是因爲它是通過Java內存模型的lock操作和unlock操作來實現的,Java內存模型規定了執行這些操作必須滿足的規則,通過這些機制synchronized就保證了這三個特性。

 

瞭解完synchronized爲什麼能夠通過鎖機制滿足三種特性之後,下面我們就來看一看鎖機制在底層是怎麼實現的。

synchronized有兩種形式上鎖,一個是對方法上鎖,一個是構造同步代碼塊。他們的底層實現其實都一樣,在進入同步代碼之前先獲取鎖,獲取到鎖之後鎖的計數器+1,同步代碼執行完鎖的計數器-1,如果獲取失敗就阻塞式等待鎖的釋放。只是他們在同步塊識別方式上有所不一樣,從class字節碼文件可以表現出來,一個是通過方法flags標誌,一個是monitorenter和monitorexit指令操作。

 

3.2 同步方法

首先來看在方法上上鎖,我們就新定義一個同步方法然後使用 javap -v  反編譯進行反編譯,查看其字節碼

 

可以看到在add方法的flags裏面多了一個ACC_SYNCHRONIZED標誌,這標誌用來告訴JVM這是一個同步方法,在進入該方法之前先獲取相應的鎖,鎖的計數器加1,方法結束後計數器-1,如果獲取失敗就線程就會進入阻塞(BLOCK)狀態,直到該鎖被釋放,當鎖的計數器爲0時,線程就會釋放該鎖。

同步方法直接就在頭部標誌位用ACC_SYNCHRONIZED標誌標識,內部沒有使用monitorenter和monitorexit指令操作,因爲整個方法都是一個同步塊。JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用。

 

3.3 同步代碼塊

我們新定義一個同步代碼塊,編譯出class字節碼,然後找到method方法所在的指令塊,可以清楚的看到其實現上鎖和釋放鎖的過程,截圖如下:

代碼:

字節碼:

頭部只有一個方法的普通標識ACC_PUBLIC表明這是一個普通的成員方法。

如果還有一個ACC_STATIC標識,表明這是一個靜態方法

Monitorenter和Monitorexit指令,會讓線程在執行時,使其持有的鎖計數器加1或者減1。每一個對象在同一時間只與一個monitor(鎖)相關聯,而一個monitor在同一時間只能被一個線程獲得,一個線程在嘗試獲得與對象相關聯的Monitor鎖的所有權的時候:

 

monitorenter指令會發生如下3種情況之一:

  • monitor計數器爲0,意味着目前還沒有被獲得,那這個線程就會立刻獲得然後把鎖計數器+1,一旦+1,別的線程再想獲取,就需要等待
  • 如果這個monitor已經拿到了這個鎖的所有權,又重入了這把鎖,那鎖計數器就會累加,變成2,並且隨着重入的次數,會一直累加
  • 這把鎖已經被別的線程獲取了,等待鎖釋放

 

monitorexit指令:

  • 釋放對於monitor的所有權,釋放過程很簡單,就是講monitor的計數器減1,如果減完以後,計數器不是0,則代表剛纔是重入進來的,當前線程還繼續持有這把鎖的所有權,如果計數器變成0,則代表當前線程不再擁有該monitor的所有權,即釋放鎖。

 

從反編譯的同步代碼塊可以看到同步塊是由monitorenter指令進入,然後monitorexit釋放鎖,在執行monitorenter之前需要嘗試獲取鎖,如果這個對象沒有被鎖定,或者當前線程已經擁有了這個對象的鎖,那麼就把鎖的計數器加1。當執行monitorexit指令時,鎖的計數器也會減1。當獲取鎖失敗時會被阻塞(BLOCK),一直等待鎖被釋放。因爲synchronized的鎖是重入鎖,所以鎖的計數器可以大於1,只有當鎖的計數器爲0的時候,線程纔會釋放所持有的鎖。

但是爲什麼會有兩個monitorexit呢?其實第二個monitorexit是來處理異常的,仔細看反編譯的字節碼,正常情況下第一個monitorexit之後會執行goto指令,而該指令轉向的就是23行的return,也就是說正常情況下只會執行第一個monitorexit釋放鎖,然後返回。而如果在執行中發生了異常,第二個monitorexit就起作用了,它是由編譯器自動生成的,在發生異常時處理異常然後釋放掉鎖。

 

四、synchronized鎖的底層實現

在理解鎖實現原理之前先了解一下Java的對象頭和Monitor。

在 Java 中,每個對象都會有一個 monitor 對象(監視器)。

在JVM中,對象是分成三部分存在的:對象頭(Object Header)、實例數據、對其填充。

 

實例數據和對其填充與synchronized無關,這裏簡單說一下(我也是閱讀《深入理解Java虛擬機》學到的,讀者可仔細閱讀該書相關章節學習)。

  • 實例變量存放類的屬性數據信息(就是成員屬性的值),包括父類的屬性信息;
  • 填充數據不是必須部分,由於虛擬機要求對象起始地址必須是8字節的整數倍,對齊填充僅僅是爲了使字節對齊。

 

4.1 對象頭

對象頭是我們需要關注的重點,它是synchronized實現鎖的基礎,因爲synchronized申請鎖、上鎖、釋放鎖都與對象頭有關。HotSpot虛擬機的對象頭主要結構是由Mark Word Class Metadata Address組成,其中Mark Word存儲一些自身運行時數據,如對象的hashCode鎖信息或分代年齡或GC標誌等信息,這部分數據的長度在32位和64位的Java虛擬機中分別會佔用32個或64個比特;Class Metadata Address是類型指針指向對象的類元數據,JVM通過該指針確定該對象是哪個類的實例如果是數組對象,對象頭還會有一個額外的部分用於存儲數組長度。

 

  • 對象頭中的數據:

鎖也分不同狀態,JDK6之前只有兩個狀態:無鎖、有鎖(重量級鎖),而在JDK6之後對synchronized進行了優化,新增了兩種狀態,總共就是四個狀態:無鎖狀態、偏向鎖、輕量級鎖、重量級鎖,其中無鎖就是一種狀態了。鎖的類型和狀態在對象頭Mark Word中都有記錄,在申請鎖鎖升級等過程中JVM都需要讀取對象的Mark Word數據。

 

  • Mark Word中的數據:

由於對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到Java虛擬機的空間使用效 率,Mark Word被設計成一個非固定的動態數據結構,以便在極小的空間內存儲儘量多的信息。上圖爲32位系統Mark Word的數據結構圖,看出Mark Word的數據長度是一定的,但是裏面的內容會根據鎖狀態的改變而進行改變(橫向看不同的鎖狀態在Mark Word中存儲的數據是不同的)。

例如在32位的HotSpot虛擬機中,對象未被鎖定的狀態下, Mark Word的32個比特空間裏的25個比特將用於存儲對象哈希碼,4個比特用於存儲對象分代年齡(就是新生代to區和from區之間轉移的次數),2 個比特用於存儲鎖標誌位,還有1個比特固定爲0(這表示未進入偏向模式)。對象除了未被鎖定的正常狀態外,還有輕量級鎖定、重量級鎖定、GC標記(標識該對象需要被GC清除)、可偏向等幾種不同狀態

 

4.2 monitor對象

在HotSpot虛擬機中monitor對象是由ObjectMonitor實現的(C++實現)。每個對象都存在着一個monitor與之關聯,monitor對象存在於每個Java對象的對象頭中(對象頭的MarkWord中的LockWord指向monitor的起始地址),對象與其monitor之間的關係有存在多種實現方式,如monitor可以與對象一起創建銷燬或當線程試圖獲取對象鎖時自動生成,但當一個monitor被某個線程持有後,對象便處於鎖定狀態。所以說monitor是實現鎖機制的基礎,線程獲取鎖本質是線程獲取Java對象對應的monitor對象。重量級鎖就是通過ObjectMonitor實現的,也就是說重量級鎖是基於對象的monitor來實現的

例子:

ObjectMonitor() {
    _header       = NULL;
	_count        = 0;  //鎖計數器
	_waiters      = 0,
	_recursions   = 0;
	_object       = NULL;
	_owner        = NULL;// 標記當前持有Monitor的線程
	_WaitSet      = NULL; //處於wait狀態的線程,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
	_succ         = NULL ;
	_cxq          = NULL ;
	FreeNext      = NULL ;
	_EntryList    = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
	_SpinFreq     = 0 ;
	_SpinClock    = 0 ;
	OwnerIsThread = 0 ;
}

ObjectMonitor中有兩個隊列_WaitSet和_EntryList,用來保存ObjectWaiter對象列表(每個等待鎖的線程都會被封裝ObjectWaiter對象),_owner指向持有ObjectMonitor對象的線程,當多個線程同時訪問一段同步代碼時:

  1. 首先會進入_EntryList 集合,當線程獲取到對象的monitor 後進入 _Owner 區域並把monitor中的owner變量設置爲當前線程同時monitor中的計數器count加1
  2. 若線程調用 wait() 方法,將釋放當前持有的monitor,owner變量恢復爲null,count自減1,同時該線程進入 WaitSet集合中等待被喚醒
  3. 若當前線程執行完畢也將釋放monitor(鎖)並復位變量的值,以便其他線程進入獲取monitor(鎖)。

monitor對象存在於每個Java對象的對象頭中(存儲的指針的指向),synchronized鎖便是通過這種方式獲取鎖的,也是爲什麼Java中任意對象可以作爲鎖的原因,同時也是notify/notifyAll/wait等方法存在於頂級對象Object中的原因(關於這點稍後還會進行分析),同時notify/notifyAll/wait等方法會使用到Monitor鎖對象,所以必須在同步代碼塊中使用

 

下面通過此圖講解爲什麼notify/notifyAll/wait等方法必須在同步代碼塊中使用

如上圖所示,一個線程通過1號門進入Entry Set(入口區),如果在入口區沒有線程等待,那麼這個線程就會獲取監視器成爲監視器的Owner,然後執行監視區域的代碼。如果在入口區中有其它線程在等待,那麼新來的線程也會和這些線程一起等待。線程在持有監視器的過程中,有兩個選擇,一個是正常執行監視器區域的代碼,釋放監視器,通過5號門退出監視器;還有可能等待某個條件的出現,於是它會通過3號門到Wait Set(等待區)休息,直到相應的條件滿足後再通過4號門進入重新獲取監視器再執行。

 

注意:

當一個線程釋放監視器時,在入口區和等待區的等待線程都會去競爭監視器,如果入口區的線程贏了,會從2號門進入;如果等待區的線程贏了會從4號門進入。只有通過3號門才能進入等待區,在等待區中的線程只有通過4號門才能退出等待區,也就是說一個線程只有在持有監視器時才能執行wait操作,處於等待的線程只有再次獲得監視器才能退出等待狀態,在持有監視器的時候纔有可能進入到等待區,所以只有在同步代碼塊中才能使用wait()方法,因爲進入到同步代碼塊中才能保證線程一定持有監視器

 

監視器Monitor有兩種同步方式:互斥協作

  • 互斥的同步方式:在多線程環境下線程之間如果需要共享數據,需要解決互斥訪問數據的問題,監視器可以確保監視器上的數據在同一時刻只會有一個線程在訪問。
  • 協作的同步方式:一個線程向緩衝區寫數據,另一個線程從緩衝區讀數據,如果讀線程發現緩衝區爲空就會等待,當寫線程向緩衝區寫入數據,就會喚醒讀線程,這裏讀線程和寫線程就是一個合作關係。JVM通過Object類的wait方法來使自己等待,在調用wait方法後,該線程會釋放它持有的監視器,直到其他線程通知它纔有執行的機會。一個線程調用notify方法通知在等待的線程,這個等待的線程並不會馬上執行,而是要通知線程釋放監視器後,它重新獲取監視器纔有執行的機會。如果剛好喚醒的這個線程需要的監視器被其他線程搶佔,那麼這個線程會繼續等待。Object類中的notifyAll方法可以解決這個問題,它可以喚醒所有等待的線程,總有一個線程執行

 

五、JVMsynchronized的優化

從JDK5引入了現代操作系統新增加的CAS原子操作( JDK5中並沒有對synchronized關鍵字做優化,而是體現在J.U.C中,所以在該版本concurrent包有更好的性能 )

在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層的操作系統的 Mutex Lock(互斥鎖) 來實現的。Java 的線程是映射到操作系統的原生線程之上的(詳見Java線程和操作系統線程的關係)。如果要掛起或者喚醒一個線程,都需要操作系統幫忙完成,而操作系統實現線程之間的切換時需要從用戶態轉換到內核態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的 synchronized 效率低的原因。

慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6對鎖的實現引入了大量的優化,如自旋鎖適應性自旋鎖鎖消除鎖粗化等優化方法,又新增了兩個鎖的狀態:偏向鎖、輕量級鎖。現在synchronized一共有四種鎖的狀態,依次是:無鎖狀態偏向鎖狀態輕量級鎖狀態重量級鎖狀態,他們會隨着競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是爲了提高獲得鎖和釋放鎖的效率。給synchronized性能帶來了很大的提升。在 JDK 1.6 中默認是開啓偏向鎖和輕量級鎖的,可以通過-XX:-UseBiasedLocking來禁用偏向鎖。下面講解對synchronized鎖進行優化的幾種方法。

 

5.1 鎖膨脹

上面講到鎖有四種狀態,並且會因實際情況進行膨脹升級,其膨脹方向是:無鎖——>偏向鎖——>輕量級鎖——>重量級鎖,並且膨脹方向不可逆。也就是說對象對應的鎖是會根據當前線程申請,搶佔鎖的情況自行改變鎖的類型。

5.1.1 偏向鎖Biased Locking

一句話總結它的作用:減少同一線程獲取鎖的代價在大多數情況下,鎖不存在多線程競爭,總是由同一線程多次獲得,那麼此時就是偏向鎖。偏向鎖的“偏”就是偏心的偏,它的意思是會偏向於第一個獲得它的線程,如果在接下來的執行中,該鎖沒有被其他線程獲取,那麼持有偏向鎖的線程就不需要進行同步!線程的阻塞和喚醒需要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,使用偏向鎖也就去掉了這一部分的負擔,也取消掉了加鎖和解鎖的過程消耗

核心思想:

引入偏向鎖的目的和引入輕量級鎖的目的很像,他們都是爲了沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。但是不同是:輕量級鎖在無競爭的情況下使用 CAS 操作去代替使用互斥量。而偏向鎖在無競爭的情況下會把整個同步都消除掉

如果該鎖第一次被一個線程持有,那麼鎖就進入偏向模式,此時Mark Word的結構也就變爲偏向鎖結構,當該線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程只需要檢查Mark Word的鎖標記位是否爲偏向鎖以及當前線程ID是否等於Mark Word的ThreadID即可,這樣就省去了大量有關鎖申請的操作,減少不必要的CAS操作。申請獲取偏向鎖的時間非常短,這種鎖在競爭不激烈的時候比較適用。如果程序中大多數的鎖都總是被多個不同的線程訪 問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用參數-XX:UseBiasedLocking來禁止偏向鎖優化反而可以提升性能。

原理:

線程申請鎖的時候首先都會檢測Mark Word是否爲可偏向狀態即是否爲偏向鎖1,鎖標識位爲01;因爲當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設置爲“01”、把偏向模式設置爲“1”,表示進入偏向模式,表示對象處於可偏向的狀態,並且ThreadId爲0,這該對象是biasable&unbiased狀態

如果當前對象處於可偏向狀態,則測試線程ID是否爲當前線程ID如果是,則執行步驟5);否則執行步驟2),嘗試獲取偏向鎖。一旦出現另外一個線程去嘗試獲取這個鎖的情況,偏向模式就馬上宣告結束,由於鎖競爭應該直接進入步驟4)

若當前對象的Mark Word中指向的持有鎖的線程ID不是該線程ID,則該線程就嘗試用CAS操作將自己的ThreadID放置到Mark Word中相應的位置,如果CAS操作成功,說明該線程成功獲取偏向鎖,進入到步驟3),否則進入步驟4)

進入到這一步代表當前沒有鎖競爭,此時ThreadID已經不爲0了,而是持有鎖的線程ID。持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作(例如加鎖、解鎖及對Mark Word的更新操作等),只需要檢查Mark Word的鎖標記位是否爲偏向鎖以及當前線程ID是否等於Mark Word的ThreadID,如果都滿足則進入步驟5執行同步代碼塊。

當線程執行CAS失敗,表示另一個線程當前正在競爭該對象上的鎖。當到達全局安全點時(cpu沒有正在執行的字節,即獲得偏向鎖的線程當前沒有執行,這個時間點是上沒有正在執行的代碼,注意當前持有偏向鎖的線程不執行並不一定就是它的操作已經執行完成,要釋放鎖了)之前持有偏向鎖的線程將被暫停,撤銷偏向(偏向位置0)

然後判斷鎖對象是否還處於被鎖定狀態,如果沒有被鎖定說明當前資源沒有被線程使用,則恢復到無鎖狀態(01),以允許其餘線程競爭。如果處於被鎖定狀態說明當前資源正在被線程使用,則掛起持有鎖的當前線程,並將指向當前線程的鎖記錄地址Lock Record的指針放入對象頭Mark Word,升級爲輕量級鎖狀態(00),然後恢復持有鎖的當前線程,進入輕量級鎖的競爭模式;後續的同步操作就按照輕量級鎖那樣去執行。同時被撤銷偏向鎖的線程繼續往下執行。

注意:此處將 當前線程掛起再恢復的過程中並沒有發生鎖的轉移,鎖仍然在當前線程手中,只是穿插了個 “將對象頭中的線程ID變更爲指向鎖記錄地址的指針” 這麼個事(將偏向鎖轉換成輕量級鎖)。

                                執行同步代碼塊;

對象的哈希碼去哪了?

當對象進入偏向狀態的時候,Mark Word大部分的空間(23個比特)都用於存儲持有鎖的線程ID了,這部分空間佔用了原有存儲對象哈希碼的位置,那原 來對象的哈希碼怎麼辦呢?

在Java語言裏面一個對象如果計算過哈希碼,就應該一直保持該值不變(強烈推薦但不強制,因 爲用戶可以重載hashCode()方法按自己的意願返回哈希碼),否則很多依賴對象哈希碼的API都可能存在出錯風險。而作爲絕大多數對象哈希碼來源的Object::hashCode()方法,返回的是對象的一致性哈希碼(Identity Hash Code),這個值是能強制保證不變的,它通過在對象頭中存儲計算結果來保證第一次計算之後,再次調用該方法取到的哈希碼值永遠不會再發生改變因此,當一個對象已經計算過一致性哈希碼後,它就再也無法進入偏向鎖狀態了;而當一個對象當前正處於偏向鎖狀態,又收到需要計算其一致性哈希碼請求時,它的偏向狀態會被立即撤銷,並且鎖會膨脹爲重量級鎖。在重量級鎖的實現中,對象頭指向了重量級鎖的位置,代表重量級鎖的ObjectMonitor類裏有字段可以記錄非加鎖狀態(標誌位爲“01”)下的Mark Word,其中自然可以存儲原來的哈希碼

 

5.1.2 輕量級鎖

輕量級鎖是由偏向鎖升級而來,當存在第二個線程申請同一個鎖對象時,偏向鎖就會立即升級爲輕量級鎖。注意這裏的第二個線程只是申請鎖,不存在兩個線程同時競爭鎖,可以是一前一後地交替執行同步塊。(意思就是線程之間獲取鎖是沒有爭搶的,線程A持有了資源X的鎖,當時用完資源X之後,A線程釋放掉資源X的鎖,當線程B也想使用資源X去申請它的鎖的時候,就再次申請獲取資源X的鎖,兩個線程之間沒有發成爭搶,也就沒有必要使用以前的互斥量還要休眠進程白白降低效率)

輕量級鎖能提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”這一經驗法則。如果沒有競爭,輕量級鎖便通過CAS操作成功避免了使用互斥量的開銷;但如果確實存在鎖競爭,除了互斥量的本身開銷外,還額外發生了CAS操作的開銷。因此在有競爭的情況下, 輕量級鎖反而會比傳統的重量級鎖更慢

核心思想:

如果說偏向鎖是隻允許一個線程獲得鎖,那麼輕量級鎖就是允許多個線程獲得鎖,但是隻允許他們順序拿鎖,不允許出現競爭,也就是拿鎖失敗的情況。輕量級鎖的加鎖和解鎖都是通過CAS操作是現實

原理:

線程1在執行同步代碼塊之前,如果此同步對象沒有被鎖定(鎖標誌位爲“01”狀態),JVM會先在當前線程的棧幀中創建一個名爲鎖記錄(Lock Record)的空間用來存儲鎖記錄,然後再把對象頭中的MarkWord複製到該鎖記錄中,官方稱之爲Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的MarkWord 替換爲指向鎖記錄的指針(鎖狀態爲輕量級鎖的Mark Word中存儲的就是指向持有鎖的線程的所記錄的指針,這個操作詳細就是使用CAS操作嘗試將對象Mark Word中的Lock Word更新爲指向當前線程Lock Record的指針,並將Lock record裏的owner指針指向object mark word)。如果成功,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標誌位(Mark Word的 最後兩個比特)將轉變爲“00”,表示此對象處於輕量級鎖定狀態。進入步驟3)。如果該操作失敗,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入步驟5執行同步塊就可以了,否則就說明這個鎖對象已經被其他線程搶佔了,執行步驟2)

 

由上圖可看出Lock Record的數據結構

 

Lock Record是線程私有的數據結構,每一個線程都有一個可用Lock Record列表,同時還有一個全局的可用列表。每一個被鎖住的對象Mark Word都會和一個Lock Record關聯(對象頭的MarkWord中的Lock Word指向Lock Record的起始地址),同時Lock Record中有一個Owner字段存放擁有該鎖的線程的唯一標識(或者object mark word),表示該鎖被這個線程佔用。也就是對象頭中是Mark Word與線程中是Lock Record

 

如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹爲重量級鎖,但是對鎖進行了優化,線程會先進行一段時間的自旋狀態(輪詢申請鎖),先並不會進入阻塞狀態,如果在自旋期間成功獲得鎖,則進入步驟3)。如果自旋結束也沒有獲得鎖,則膨脹成爲重量級鎖,並把鎖標誌位變爲10,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針(Mark Word中重量級鎖狀態就存儲指向重量級鎖的指針),所有等待該鎖的線程也必須進入阻塞狀態,進入步驟3)

鎖的持有線程執行同步代碼,執行完之後如果對象的 Mark Word仍然指向線程的鎖記錄,那就用CAS操作將對象當前的Mark Word用線程中複製的Displaced Mark Word替換回來(也就是執行了compare and swap 比較然後交換操作),即CAS替換Mark Word釋放鎖,如果CAS執行成功,那整個同步過程就順利完成了,則流程結束;CAS執行失敗則進行步驟4)

CAS執行失敗說明期間有線程嘗試獲得鎖並自旋失敗,輕量級鎖升級爲了重量級鎖,此時釋放鎖之後,還要喚醒等待的線程

執行同步代碼塊;

 

爲什麼升級爲輕量鎖時要把對象頭裏的Mark Word複製到線程棧的鎖記錄中呢?

因爲在申請對象鎖時 需要以該值作爲CAS的比較條件,同時在升級到重量級鎖的時候,能通過這個比較判定是否在持有鎖的過程中此鎖被其他線程申請過,如果被其他線程申請了,則在釋放鎖的時候要喚醒被掛起的線程。

 

爲什麼會嘗試CAS不成功以及什麼情況下會不成功?

CAS本身是不帶鎖機制的,其是通過比較而來。假設如下場景:線程A和線程B都在對象頭裏的鎖標識爲無鎖狀態進入,那麼如線程A先更新對象頭爲其鎖記錄指針成功之後,線程B再用CAS去更新,就會發現此時的對象頭已經不是其操作前的對象HashCode了,所以CAS會失敗。也就是說,只有兩個線程併發申請鎖的時候會發生CAS失敗。

然後線程B進行CAS自旋,等待對象頭的鎖標識重新變回無鎖狀態或對象頭內容等於對象HashCode(因爲這是線程B做CAS操作前的值),這也就意味着線程A執行結束(參見後面輕量級鎖的撤銷,只有線程A執行完畢撤銷鎖了纔會重置對象頭),此時線程B的CAS操作終於成功了,於是線程B獲得了鎖以及執行同步代碼的權限。如果線程A的執行時間較長,線程B經過若干次CAS時鐘沒有成功,則鎖膨脹爲重量級鎖,即線程B被掛起阻塞、等待重新調度。

 

5.1.3 重量級鎖

重量級鎖是由輕量級鎖升級而來,當同一時間有多個線程競爭鎖時,鎖就會被升級成重量級鎖,此時其申請鎖帶來的開銷也就變大。

原理:

Synchronized是通過對象內部的監視器鎖(Monitor來實現的。但是監視器鎖本質又是依賴於底層的操作系統的Mutex Lock(互斥鎖)來實現的。實現Mutex Lock又需要進行兩個線程之間的切換,而操作系統實現線程之間的切換這就需要從用戶態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間這就是爲什麼Synchronized效率低的原因。因此,這種依賴於操作系統Mutex Lock所實現的鎖我們稱之爲 “重量級鎖”。它通過操作系統的互斥量和線程的阻塞和喚醒來實現鎖機制。

重量級鎖一般使用場景會在追求吞吐量,同步塊或者同步方法執行時間較長的場景

 

三種索各自的優缺點和適用場景:

 

5.2 鎖消除(Lock Elision

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,JIT編譯時,對運行上下文進行掃描,去除不可能存在競爭的鎖鎖消除可以節省毫無意義的請求鎖的時間。比如下面代碼的method1和method2的執行效率是一樣的,因爲object鎖是私有局部變量,不存在所得競爭關係。

 

原理:

“鎖消除”,是JIT編譯器對內部鎖的具體實現所做的一種優化。鎖消除是藉助逃逸分析實現的。

在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱爲逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被髮布到其他線程。

如果同步塊所使用的鎖對象通過這種分析被證實只能夠被一個線程訪問,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。

如以下代碼:

public void f() {
	bject hollis = new Object();
	synchronized(hollis) {
		ystem.out.println(hollis);
    }
}

 代碼中對hollis這個對象進行加鎖,但是hollis對象的生命週期只在f()方法中,並不會被其他線程所訪問到,所以在JIT編譯階段就會被優化掉。優化成:

public void f() {
	Object hollis = new Object();
	System.out.println(hollis);
}

 這裏,可能有讀者會質疑了,代碼是程序員自己寫的,程序員難道沒有能力判斷要不要加鎖嗎?就像以上代碼,完全沒必要加鎖,有經驗的開發者一眼就能看的出來的。其實道理是這樣,但是還是有可能有疏忽,雖然沒有顯示使用鎖,但是在使用一些JDK的內置API時,StringBufferVectorHashTable等,他們的方法很多都被進行了加鎖處理,會存在隱形的加鎖操作。比如我們經常在代碼中使用StringBuffer作爲局部變量,而StringBuffer中的append是線程安全的,有synchronized修飾的,這種情況開發者可能會忽略。再比如說Vectoradd()方法:

public void vectorTest(){
	Vector<String> vector = new Vector<String>();
	for(int i = 0 ; i < 10 ; i++){
		vector.add(i + "");
	}
	System.out.println(vector);
}

在運行這段代碼時,vector是這段代碼的局部變量,整個生命週期都是跟隨vectorTest()方法得,並沒有出現逃逸現象,那麼vector源碼中對add方法進行的加鎖操作也就失去了意義,所以JVM檢測到變量vector沒有逃逸出方法vectorTest()後,JVM就vector內部的加鎖操作消除。這時候,JIT就可以幫忙優化,進行鎖消除。

總之,在使用synchronized的時候,如果JIT經過逃逸分析之後發現同步塊中使用的鎖對象並沒有逃逸出去,不可能被其他線程所使用,並無線程安全問題的話,就會做鎖消除

 

5.3 鎖粗化Lock Coarsening

鎖粗化是虛擬機對另一種極端情況的優化處理,通過擴大鎖的範圍,避免反覆加鎖和釋放鎖。比如下面method3經過鎖粗化優化之後就和method4執行效率一樣了。

 

對於鎖粗化的的理解:

很多人都知道,在代碼中,需要加鎖的時候,我們提倡儘量減小鎖的粒度,這樣可以避免不必要的阻塞。

這也是很多人原因是用同步代碼塊來代替同步方法的原因,因爲往往他的粒度會更小一些,這其實是很有道理的

還是我們去銀行櫃檯辦業務,最高效的方式是你坐在櫃檯前面的時候,只辦和銀行相關的事情。如果這個時候,你拿出手機,接打幾個電話,問朋友要往哪個賬戶裏面打錢,這就很浪費時間了。最好的做法肯定是提前準備好相關資料,在辦理業務時直接辦理就好了。

加鎖也一樣,把無關的準備工作放到鎖外面,鎖內部只處理和併發相關的內容。這樣有助於提高效率。

那麼,這和鎖粗化有什麼關係呢?可以說,大部分情況下,減小鎖的粒度是很正確的做法,只有一種特殊的情況下,會發生一種叫做鎖粗化的優化

就像你去銀行辦業務,你爲了減少每次辦理業務的時間,你把要辦的五個業務分成五次去辦理,這反而適得其反了。因爲這平白的增加了很多你重新取號、排隊、被喚醒的時間。

如果在一段代碼中連續的對同一個對象反覆加鎖解鎖,其實是相對耗費資源的,這種情況可以適當放寬加鎖的範圍,減少性能消耗。

當JIT發現一系列連續的操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作出現在循環體中的時候,會將加鎖同步的範圍擴散(粗化)到整個操作序列的外部。

如以下代碼:

for(int i=0;i<100000;i++){  
	synchronized(this){  
	do();  
}  

 會被粗化成:

synchronized(this){  
	for(int i=0;i<100000;i++){  
	do();  
} 

這其實和我們要求的減小鎖粒度並不衝突。減小鎖粒度強調的是不要在銀行櫃檯前做準備工作以及和辦理業務無關的事情。而鎖粗化建議的是,同一個人,要辦理多個業務的時候,可以在同一個窗口一次性辦完,而不是多次取號多次辦理

 

5.4 自旋鎖與自適應自旋鎖

自旋鎖在 JDK1.4.2 實就已經引入了,不過是默認關閉的,需要通過--XX:+UseSpinning參數來開啓。JDK1.6及1.6之後,就改爲默認開啓的了。另外,在 JDK1.6 中引入了自適應的自旋鎖。

之前如果線程嘗試獲得鎖失敗,就會進入到阻塞狀態,線程進入到阻塞狀態是需要操作系統來講線程進行掛起,掛起和喚醒都是一個消耗時間和資源的操作,所以爲了避免這種情況,就出現了自旋鎖的概念。

輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。

互斥同步對性能最大的影響就是阻塞的實現,因爲掛起線程/恢復線程的操作都需要操作系統從用戶態轉入內核態中完成(用戶態轉換到內核態會耗費時間)。

 

自旋鎖:許多情況下,共享數據的鎖定狀態持續時間較短,切換線程不值得,通過讓線程執行循環等待鎖的釋放,不讓出CPU。如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式。但是它也存在缺點:如果鎖被其他線程長時間佔用,一直不釋放CPU,會帶來許多的性能開銷。自旋次數的默認值是10次,用戶可以修改--XX:PreBlockSpin來更改

自適應自旋鎖(Adaptive Locking)這種相當於是對上面自旋鎖優化方式的進一步優化,它的自旋的次數不再固定,其自旋的次數由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,這就解決了自旋鎖帶來的缺點。那它如何進行適應性自旋呢?

  • 線程如果自旋成功了,那麼下次自旋的次數會更加多,因爲虛擬機認爲既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。
  • 反之,如果對於某個鎖,很少有自旋能夠成功,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源

自旋鎖阻塞鎖最大的區別就是,到底要不要放棄處理器的執行時間。對於阻塞鎖和自旋鎖來說,都是要等待獲得共享資源。但是阻塞鎖是放棄了CPU時間,進入了等待區,等待被喚醒。而自旋鎖是一直“自旋”在那裏,時刻的檢查共享資源是否可以被訪問。


 其他相關文章:【併發編程】volatile關鍵字最全詳解,看這一篇就夠了
                          【併發編程】線程安全和線程不安全的定義以及實現線程安全的方法有哪些
                          【併發編程】Java中的鎖有哪些?各自都有什麼樣的特性?


參考資料:

  • 微信公衆號 - 北風IT之路(beifengtz)
  • 深入理解Java虛擬機:JVM高級特性與最佳實踐(第3版)- 周志明
發佈了32 篇原創文章 · 獲贊 26 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章