Java併發編程之synchronized鎖優化

個人博客請訪問 http://www.x0100.top          

1. 爲什麼需要優化?

synchronized監視器鎖在互斥同步上對性能的影響很大。

Java的線程是映射到操作系統原生線程之上的,如果要阻塞或喚醒一個線程就需要操作系統的幫忙,這就要從用戶態轉換到內核態,狀態轉換需要花費很多的處理器時間。

所以頻繁的通過Synchronized實現同步會嚴重影響到程序效率,這種鎖機制也被稱爲重量級鎖,爲了減少重量級鎖帶來的性能開銷,JDK對Synchronized進行了種種優化。

2. 自旋鎖和適應自旋鎖

大多數情況下,線程持有鎖的時間都不會太長,爲了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。所以引入自旋鎖。

1)自旋鎖

當鎖被佔用時,當前想要獲取鎖的線程不會被立即掛起,而是做幾個空循環,看持有鎖的線程是否會很快釋放鎖。
在經過若干次循環後,如果得到鎖,就順利進入臨界區;如果還不能獲得鎖,那就會將線程在操作系統層面掛起。

2)自旋鎖和阻塞最大的區別

主要區別:是不是放棄處理器的執行時間。

阻塞放棄了CPU時間,進入了等待區,等待被喚醒。響應慢。自旋鎖一直佔用CPU時間,時刻檢查共享資源是否可以被訪問,所以響應速度更快。

3)缺點

如果持有鎖的線程很快就釋放了鎖,那麼自旋的效率就非常好。但是如果持有鎖的線程佔用鎖時間較長,等待鎖的線程自旋一定次數後還是拿不到鎖而被阻塞,那麼自旋就白白浪費了CPU的資源。
所以自旋的次數直接決定了自旋鎖的性能。JDK自旋的默認次數爲10次,可以通過參數-XX:PreBlockSpin來調整。

4)自適應自旋鎖

所謂自適應就意味着自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。線程如果自旋成功了,那麼下次自旋的次數會更加多,因爲虛擬機認爲既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。
如果對於某個鎖,很少有自旋能夠成功的,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。

有了自適應自旋鎖,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測會越來越準確,虛擬機會變得越來越聰明。

3. 鎖消除

如果JVM檢測到某段代碼不可能存在共享數據競爭,JVM會對這段代碼的同步鎖進行鎖消除。

在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱爲逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被髮布到其他線程。
如果同步塊所使用的鎖對象通過這種分析被證實只能夠被一個線程訪問,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。

舉例:

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

    System.out.println(vector);
}

 

Vector的add方法是Synchronized修飾的。

在運行這段代碼時,JVM可以明顯檢測到變量vector沒有逃逸出方法vectorTest()之外,所以JVM可以大膽地將vector內部的加鎖操作消除。

4. 鎖粗化

很多時候,我們提倡儘量減小鎖的粒度,可以避免不必要的阻塞。 讓同步塊的作用範圍儘可能小,僅在共享數據的實際作用域中才進行同步,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。

但是如果在一段代碼中連續的用同一個監視器鎖反覆的加鎖解鎖,甚至加鎖操作出現在循環體中的時候,就會導致不必要的性能損耗,這種情況就需要鎖粗化。

鎖粗化就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。

舉例:

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

會被粗化成:

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

5. 知識補充:Java對象頭

對象在內存中存儲的佈局可以分爲三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

普通對象的對象頭包括兩部分:Mark Word和Class Metadata Address (類型指針),如果是數組對象還包括一個額外的Array length數組長度部分。

Mark Word:用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,佔用內存大小與虛擬機位長一致。

Class Metadata Address:類型指針指向對象的類元數據,虛擬機通過這個指針確定該對象是哪個類的實例。

Mark Word

對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。

對mark word的設計方式上,非常像網絡協議報文頭:將mark word劃分爲多個比特位區間,並在不同的對象狀態下賦予比特位不同的含義。

下圖描述了在32位虛擬機上,在對象不同狀態時mark word各個比特位區間的含義。

對象頭——《Java併發編程藝術》

6. 偏向鎖、輕量級鎖、重量級鎖

從Java對象頭的Mark word中可以看到,synchronized鎖一共具有四種狀態:無鎖、偏向鎖、輕量級鎖、重量級鎖。

偏向鎖、輕量級鎖、重量級鎖三種形式,分別對應了鎖只被一個線程持有、不同線程交替持有鎖、多線程競爭鎖三種情況。

偏向鎖

目的:大多數情況下鎖不僅不存在多線程競爭,而且總是由同一個線程多次獲取,所以引入偏向鎖讓線程獲得鎖的代價更低。

偏向鎖認爲環境中不存在競爭情況,鎖只被一個線程持有,一旦有不同的線程獲取或競爭鎖對象,偏向鎖就升級爲輕量級鎖。

偏向鎖在無多線程競爭的情況下可以減少不必須要的輕量級鎖執行路徑。

輕量級鎖

目的:在大多數情況下同步塊並不會出現競爭情況,大部分情況是不同線程交替持有鎖,所以引入輕量級鎖可以減少重量級鎖對線程的阻塞帶來的開銷。

輕量級鎖認爲環境中線程幾乎沒有對鎖對象的競爭,即使有競爭也只需要稍微等待(自旋)下就可以獲取鎖,但是自旋次數有限制,如果超過該次數,則會升級爲重量級鎖。

重量級鎖

監視器鎖Monitor

7. 鎖的膨脹過程

synchronized鎖膨脹過程就是無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖的一個過程。這個過程是隨着多線程對鎖的競爭越來越激烈,鎖逐漸升級膨脹的過程。

如下分析,從一個沒有線程訪問的鎖逐漸升級到重量級鎖的過程:

1)一個鎖對象剛剛開始創建的時候,沒有任何線程來訪問它,此時線程狀態爲無鎖狀態。Mark word(鎖標誌位-01 是否偏向-0)

2)當線程A來訪問這個對象鎖時,它會偏向這個線程A。線程A檢查Mark word(鎖標誌位-01 是否偏向-0)爲無鎖狀態。此時,有線程訪問鎖了,無鎖升級爲偏向鎖,Mark word(鎖標誌位-01,是否偏向-1,線程ID-線程A的ID)

3)當線程A執行完同步塊時,不會主動釋放偏向鎖。持有偏向鎖的線程執行完同步代碼後不會主動釋放偏向鎖,而是等待其他線程來競爭纔會釋放鎖。Mark word不變(鎖標誌位-01,是否偏向-1,線程ID-線程A的ID)

4)當線程A再次獲取這個對象鎖時,檢查Mark word(鎖標誌位-01,是否偏向-1,線程ID-線程A的ID),偏向鎖且偏向線程A,可以直接執行同步代碼。這樣偏向鎖保證了總是同一個線程多次獲取鎖的情況下,每次只需要檢查標誌位就行,效率很高

5)當線程A執行完同步塊之後,線程B獲取這個對象鎖 檢查Mark word(鎖標誌位-01,是否偏向-1,線程ID-線程A的ID),偏向鎖且偏向線程A。有不同的線程獲取鎖對象,偏向鎖升級爲輕量級鎖,並由線程B獲取該鎖。

6)當線程A正在執行同步塊時,也就是正持有偏向鎖時,線程B獲取來這個對象鎖。

檢查Mark word(鎖標誌位-01,是否偏向-1,線程ID-線程A的ID),偏向鎖且偏向線程A。

線程A撤銷偏向鎖:

  1. 等到全局安全點執行撤銷偏向鎖,暫停持有偏向鎖的線程A並檢查程A的狀態;

  2. 如果線程A不處於活動狀態或者已經退出同步代碼塊,則將對象鎖設置爲無鎖狀態,然後再升級爲輕量級鎖。由線程B獲取輕量級鎖。

  3. 如果線程A還在執行同步代碼塊,也就是線程A還需要這個對象鎖,則偏向鎖膨脹爲輕量級鎖。

線程A膨脹爲輕量級鎖過程:

  1. 在升級爲輕量級鎖之前,持有偏向鎖的線程(線程A)是暫停的

  2. 線程A棧幀中創建一個名爲鎖記錄的空間(Lock Record)

  3. 鎖對象頭中的Mark Word拷貝到線程A的鎖記錄中

  4. Mark Word的鎖標誌位變爲00,指向鎖記錄的指針指向線程A的鎖記錄地址,Mark word(鎖標誌位-00,其他位-線程A鎖記錄的指針)

  5. 當原持有偏向鎖的線程(線程A)獲取輕量級鎖後,JVM喚醒線程A,線程A執行同步代碼塊

7)線程A持有輕量級鎖,線程A執行完同步塊代碼之後,一直沒有線程來競爭對象鎖,正常釋放輕量級鎖。釋放輕量級鎖操作:CAS操作將線程A的鎖記錄(Lock Record)中的Mark Word替換回鎖對象頭中。

8)線程A持有輕量級鎖,執行同步塊代碼過程中,線程B來競爭對象鎖。
Mark word(鎖標誌位-00,其他位-線程A鎖記錄的指針)

  1. 線程B會先在棧幀中建立鎖記錄,存儲鎖對象目前的Mark Word的拷貝

  2. 線程B通過CAS操作嘗試將鎖對象的Mark Word的指針指向線程B的Lock Record,如果成功,說明線程A剛剛釋放鎖,線程B競爭到鎖,則執行同步代碼塊。

  3. 因爲線程A一直持有鎖,大部分情況下CAS是會失敗的。CAS失敗之後,線程B嘗試使用自旋的方式來等待持有輕量級鎖的線程釋放鎖。

  4. 線程B不會一直自旋下去,如果自旋了一定次數後還是失敗,線程B會被阻塞,等待釋放鎖後喚醒。此時輕量級鎖就會膨脹爲重量級鎖。Mark word(鎖標誌位-10,其他位-重量級鎖monitor的指針)

  5. 線程A執行完同步塊代碼之後,執行釋放鎖操作,CAS 操作將線程A的鎖記錄(Lock Record)中的Mark Word 替換回鎖對象對象頭中,因爲對象頭中已經不是原來的輕量級鎖的指針了,而是重量級鎖的指針,所以CAS操作會失敗。

  6. 釋放輕量級鎖CAS操作替換失敗之後,需要在釋放鎖的同時需要喚醒被掛起的線程B。線程B被喚醒,獲取重量級鎖monitor

總結

synchronized實現同步會嚴重影響到程序效率,爲了減少重量級鎖帶來的性能開銷,JDK對Synchronized進行了優化。

自旋鎖:當鎖被佔用時,當前想要獲取鎖的線程不會被立即掛起,而是做幾個空循環,看持有鎖的線程是否會很快釋放鎖。如果此時鎖釋放,當前線程就可以獲得鎖。

鎖消除:如果JVM檢測到某段代碼不可能存在共享數據競爭,會對這段代碼的同步鎖進行鎖消除。

鎖粗化:將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。

偏向鎖、輕量級鎖、重量級鎖三種形式,分別對應了鎖只被一個線程持有、不同線程交替持有鎖、多線程競爭鎖三種情況。

synchronized鎖膨脹過程就是無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖的一個過程。這個過程是隨着多線程對鎖的競爭越來越激烈,鎖逐漸升級膨脹的過程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章