synchronized鎖升級原理分析(偏向鎖-輕量級鎖-重量級鎖)

初識 synchronized

在併發編程中,synchronized對我們來說並不陌生,我們都知道,當多個線程並行的情況下,程序是不安全的,這個不安全主要發生在共享變量的不安全,我們通過一個例子來說明:

package com.zwx.concurrent;

public class TestSynchronized {
    private static int count;

    public static void increment(){
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;

    }

    public static void main(String[] args) throws InterruptedException {
        for (int i=0;i<1000;i++){
            new Thread(()->TestSynchronized.increment()).start();
        }
        Thread.sleep(3000);
        System.out.println("結果:" + count);
    }
}

注意:除synchronized,我還分享了最新Java架構項目實戰教程+大廠面試題庫,有興趣的 點擊此處免費獲取,沒基礎勿進!

這裏的輸出結果我們預期是1000,然而實際上並不一定會輸出1000,產生這種狀況的原因是存在如下場景:
1、線程1獲取count爲0,這時候他去執行count++(非原子操作)
2、線程2又去獲取count,這時候因爲線程A還沒有返回結果,所以依然獲取到0
3、線程1執行count++後得到count=1,寫回內存
4、線程2執行count++後得到count=1,寫回內存
5、線程3去獲取count,這時候獲取到count爲1,然而實際上已經執行過2次count++操作了
假如線程是按照上面的1-5個步驟執行的話,就會導致最後的結果不會輸出1000,那麼如何解決這個問題呢?就是在increment()方法上加上synchronized關鍵字

synchronized 用法

synchronized 有三種方式來加鎖,分別是:

  • 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
public synchronized void test(){
        System.out.println("修飾實例方法");
    }
  • 修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
public static synchronized void test2(){
        System.out.println("修飾靜態方法");
    }
  • 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖
public void test3(){
        synchronized (this){
            System.out.println("修飾代碼塊");
        }
    }

鎖是如何存儲的

我們每個人在學習java中接觸到的最多的一句話之一我想肯定是:一切皆對象。鎖就是一個對象,那麼這個對象裏面的結構是怎麼樣的呢,鎖對象裏面都保存了哪些信息呢?
在Hotspot 虛擬機中,對象在內存中的存儲佈局,可以分爲三個區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。synchronized用的鎖是存在Java對象頭裏的,Java對象頭裏麪包含兩部分信息:
第一部分官方稱之爲“Mark Word” ,用於存儲自身的運行時數據,如:HashCode,GC分代年齡,鎖標記、偏向鎖線程ID等;第二部分是類型指針,即對象指向它的類元信息,虛擬機通過這個指針來確定這個對象是哪個類的實例(如果java對象是一個數組,那麼對象頭中還必須有一塊用於記錄數組長度的數據)
到這裏我們就知道了,鎖是記錄在對象頭中的“Mark Word”,那麼“Mark Word”又是如何存儲鎖的信息的呢?
在32位虛擬機中,“Mark Word”存儲結構如下圖:
在這裏插入圖片描述
在64位虛擬機中,“Mark Word”存儲結構如下圖:
在這裏插入圖片描述

synchronized 鎖升級

在多線程併發編程中synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是隨着Java SE 1.6 對synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖。
在Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態偏向鎖狀態輕量級鎖狀態重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。至於鎖的降級並沒有一個標準,在達到一定的苛刻條件之後可以進行降級,但是一般情況我們可以簡單的認爲鎖不可以降級,這裏不做過多的敘述。

偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,所以爲了讓線程獲得鎖的代價更低而引入了偏向鎖。
當一個線程訪問加了同步鎖的代碼塊時,會在對象頭中存儲當前線程的 ID,後續這個線程進入和退出這段加了同步鎖的代碼塊時,不需要再次加鎖和釋放鎖。而是直接比較對象頭裏面是否存儲了指向當前線程的線程ID。如果相等表示偏向鎖是偏向於當前線程的,就不需要再嘗試獲得鎖了。

偏向鎖的獲取

1、首先獲取鎖對象頭中的 Mark Word,判斷當前對象是否處於可偏向狀態(即當前沒有對象獲得偏向鎖)。
2、如果是可偏向狀態,則通過CAS原子操作,把當前線程的ID寫入到 MarkWord,如果CAS成功,表示獲得偏向鎖成功,會將偏向鎖標記設置爲1,且將當前線程的ID寫入Mark Word;如果CAS失敗則說明當前有其他線程獲得了偏向鎖,同時也說明當前環境存在鎖競爭,這時候就需要將已獲得偏向鎖的線程中的偏向鎖撤銷掉(具體參考下面偏向鎖的撤銷),並升級爲輕量級鎖。
3、如果當前線程是已偏向狀態,需要檢查Mark Word中的ThreadID是否和自己相等,如果相等則不需要再次獲得鎖,可以直接執行同步代碼塊,如果不相等,說明當前偏向的是其他線程,需要撤銷偏向鎖並升級到輕量級鎖。

偏向鎖的撤銷

偏向鎖的撤銷,需要等待全局安全點(即在這個時間點上沒有正在執行的字節碼),然後會暫停擁有偏向鎖的線程,並檢查持有偏向鎖的線程是否活着,主要有以下兩種情況:

  1. 如果線程不處於活動狀態,則將對象頭設置成無鎖狀態。
  2. 如果線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向於其他線程(重偏向需要滿足批量重偏向的條件),要麼恢復到無鎖或者標記對象不適合作爲偏向鎖。

最後喚醒暫停的線程。

偏向鎖的批量重偏向

一個線程創建了大量對象而且執行了同步操作後另一個線程又來將這些對象作爲鎖對象進行操作,並且達到閾值,此時就會發生偏向鎖重偏向的操作(除了這種情況,其他情況只有有線程來競爭鎖,則偏向鎖狀態就結束了)。
-XX:BiasedLockingBulkRebiasThreshold 爲重偏向閾值JVM參數,默認20,可以通過-XX:+PrintFlagsFinal打印出默認參數,接下來我們通過一個示例來演示一下批量重偏向:

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol-core</artifactId>
     <version>0.10</version>
 </dependency>
package com.zwx.concurrent;

import com.zwx.model.User;
import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class BiasedLockDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);//默認延遲4s纔會開啓偏向鎖,休眠5s確保開啓偏向鎖
        
        List<User> list = new ArrayList<>();
        new Thread(()->{
            for (int i=0;i<20;i++){
                //這裏必須要new不同的對象,不能共用同一個對象
                User user = new User();//只是一個空對象
                synchronized (user){
                    list.add(user);
                    System.out.println("t1線程第" + (i+1) + "對象:" + ClassLayout.parseInstance(user).toPrintable());
                }
            }
        },"t1").start();

        try {
            Thread.sleep(10000);//確保t1創建對象完畢
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("------------------------------------------------------");
        new Thread(()->{
            for (int j=0;j<20;j++){
                User user = list.get(j);
                synchronized (user){
                    System.out.println("t2線程第" + (j+1) + "對象:" + ClassLayout.parseInstance(user).toPrintable());
                }
            }
        },"t2").start();
    }
}

運行結果部分截圖(t1線程肯定是101,就不截圖了,t2前面19個都是000,第20個達到閾值了,發生了重偏向):
101三位數說明:
第一位:0-表示非偏向 1-表示偏向
後兩位:00-表示輕量級鎖 01-表示偏向鎖 10表示重量級鎖
在這裏插入圖片描述
當然,有批量重偏向,也有批量撤銷,在這裏就不做過多敘述,以後有時間了可以單獨更深入的寫一寫,感興趣的可以關注留意!

偏向鎖及撤銷流程圖

在這裏插入圖片描述

偏向鎖注意事項

偏向鎖在Java SE 1.6和Java SE 1.7裏是默認啓用的,但是它在應用程序啓動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序裏所有的鎖通常情況下都處於競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:- UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。
如果我們的應用中大多數情況存在線程競爭,那麼建議是關閉偏向鎖,因爲開啓反而會因爲偏向鎖撤銷操作而引起更多的資源消耗

輕量級鎖

輕量級鎖,一般用於兩個線程在交替使用鎖的時候,由於沒有同時搶鎖,屬於一種比較和諧的狀態,就可以使用輕量級鎖。

輕量級鎖加鎖

線程在執行同步代碼塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Displaced Mark Word。然後線程嘗試使用 CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

輕量級鎖解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖

輕量級鎖及膨脹流程圖

在這裏插入圖片描述

自旋鎖

輕量級鎖在加鎖過程中,用到了自旋鎖。所謂自旋,就是指當有另外一個線程來競爭鎖時,這個線程會在原地循環等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之後,這個線程就可以馬上獲得鎖的。
爲什麼要採用自旋等待呢
因爲絕大多數情況下線程獲得鎖和釋放鎖的過程都是非常短暫的,自旋一定次數之後極有可能碰到獲得鎖的線程釋放鎖,所以,輕量級鎖適用於那些同步代碼塊執行很快的場景,這樣,線程原地等待很短的時間就能夠獲得鎖了。
注意:鎖在原地循環等待的時候,是會消耗CPU資源的。所以自旋必須要有一定的條件控制,否則如果一個線程執行同步代碼塊的時間很長,那麼等待鎖的線程會不斷的循環反而會消耗CPU資源。默認情況下鎖自旋的次數是 10 次,可以使用-XX:PreBlockSpin參數來設置自旋鎖等待的次數。

自適應自旋

在 JDK1.7 開始,引入了自適應自旋鎖,修改自旋鎖次數的JVM參數被取消,虛擬機不再支持由用戶配置自旋鎖次數,而是由虛擬機自動調整。自適應意味着自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

重量級鎖

當輕量級鎖膨脹到重量級鎖之後,意味着線程只能被掛起阻塞來等待喚醒了。每一個對象中都有一個Monitor監視器,而Monitor依賴操作系統的 MutexLock(互斥鎖)來實現的, 線程被阻塞後便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響鎖的性能。
monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。而且當一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。我們可以簡單的理解爲,在加重量級鎖的時候會執行monitorenter指令,解鎖時會執行monitorexit指令。

鎖的優缺點對比

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步代碼塊僅存在納秒級差距 如果線程間存在鎖競爭,會帶來額外的鎖撤銷消耗 適用於只有一個線程訪問同步代碼塊場景
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 如果始終得不到鎖,使用自旋會消耗CPU 追求響應時間;同步代碼塊執行時間非常短
重量級鎖 線程競爭不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量;同步代碼塊執行時間較長

注意:最後送大家十套2020最新Java架構實戰教程+大廠面試題庫,點擊此處 ;免費獲取,沒基礎勿進哦

總結

synchronized可以解決併發編程中的三大問題:原子性可見性有序性,雖然JDK對其做了優化,有些時候並不那麼重了,但是在某些場景中,我們可以使用volatile關鍵字代替synchronized,如果volatile變量修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因爲它不會引起線程上下文的切換和調度。

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