徹底理解Synchronized底層實現原理

這篇文章會記錄Synchronized的常用使用場景與Synchronized的底層實現原理。雖然我們平時經常會在多線程中使用Synchronized關鍵字,但可能對於這個我們很熟悉的關鍵字的底層到底是怎樣實現的沒有過多關注。作爲開發者,既然使用到了,可以試着去一步一步揭開下它的底層面紗。

爲什麼要使用Synchronized?

首先我們來看下這段代碼

 


public class Demo {
    private static int count=0;
    public /*synchronized*/ static void inc(){
        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(()-> Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("運行結果"+count);
    }
}

這段代碼的運行結果:運行結果970
在這段代碼中,首先沒有加synchronized關鍵字,我們使用了循環的方法用1000個線程去訪問count這個變量,運行的結果告訴我們,這個共享變量的狀態是線程不安全的(我們期望1000次的訪問可以得到1000的結果)。要解決這個問題, Synchronized關鍵字就可以達到目的。

synchronized簡介

在多線程併發編程中 synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着 Java SE 1.6 對synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖。這塊在後續介紹中會慢慢引入。

synchronized的基本語法

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

  1. 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
  2. 靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
  3. 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。

我在網上找了張圖,大致也對應上面所說的。

 

Synchronized的使用場景

synchronized原理分析

Java對象頭和monitor是實現synchronized的基礎!下面就這兩個概念來做詳細介紹。

關於monitor,再來看一個小demo

 

package com.thread;

public class Demo1{

    private static int count = 0;

    public static void main(String[] args) {
        synchronized (Demo1.class) {
            inc();
        }

    }
    private static void inc() {
        count++;
    }
}

上面的代碼demo使用了synchroized關鍵字,鎖住的是類對象。編譯之後,切換到Demo1.class的同級目錄之後,然後用javap -v Demo1.class查看字節碼文件:

 

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: ldc           #2                  // class com/thread/SynchronizedDemo
         2: dup
         3: astore_1
         4: monitorenter       //注意這個
         5: invokestatic  #3                  // Method inc:()V
         8: aload_1
         9: monitorexit    //注意這個
        10: goto          18
        13: astore_2
        14: aload_1
        15: monitorexit   //注意這個
        16: aload_2
        17: athrow
        18: return

線程在獲取鎖的時候,實際上就是獲得一個監視器對象(monitor) ,monitor 可以認爲是一個同步對象,所有的Java 對象是天生攜帶 monitor。而monitor是添加Synchronized關鍵字之後獨有的。synchronized同步塊使用了monitorenter和monitorexit指令實現同步,這兩個指令,本質上都是對一個對象的監視器(monitor)進行獲取,這個過程是排他的,也就是說同一時刻只能有一個線程獲取到由synchronized所保護對象的監視器。
線程執行到monitorenter指令時,會嘗試獲取對象所對應的monitor所有權,也就是嘗試獲取對象的鎖,而執行monitorexit,就是釋放monitor的所有權。

對象在內存中的佈局

在 Hotspot 虛擬機中,對象在內存中的存儲佈局,可以分爲三個區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。一般而言,synchronized使用的鎖對象是存儲在Java對象頭裏。它是輕量級鎖和偏向鎖的關鍵。

 

image

Java對象頭

對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。
Klass Point:是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例;
Mark Word:用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等,它是實現輕量級鎖和偏向鎖的關鍵.

加鎖時MarkWord可能儲存的四種狀態

 

synchronized 鎖的升級

在分析 markword 時,提到了偏向鎖、輕量級鎖、重量級鎖。在分析這幾種鎖的區別時,我們先來思考一個問題使用鎖能夠實現數據的安全性,但是會帶來性能的下降。不使用鎖能夠基於線程並行提升程序性能,但是卻不能保證線程安全性。這兩者之間似乎是沒有辦法達到既能滿足性能也能滿足安全性的要求。

hotspot 虛擬機的作者經過調查發現,大部分情況下,加鎖的代碼不僅僅不存在多線程競爭,而且總是由同一個線程多次獲得。所以基於這樣一個概率,是的 synchronized 在JDK1.6 之後做了一些優化,爲了減少獲得鎖和釋放鎖來的性能開銷,引入了偏向鎖、輕量級鎖的概念。因此大家會發現在 synchronized 中,鎖存在四種狀態分別是:無鎖、偏向鎖、輕量級鎖、重量級鎖; 鎖的狀態根據競爭激烈的程度從低到高不斷升級。

偏向鎖的基本原理

偏向鎖的獲取
前面說過,大部分情況下,鎖不僅僅不存在多線程競爭,而是總是由同一個線程多次獲得,爲了讓線程獲取鎖的代價更低就引入了偏向鎖的概念。怎麼理解偏向鎖呢?當一個線程訪問加了同步鎖的代碼塊時,會在對象頭中存儲當前線程的 ID,後續這個線程進入和退出這段加了同步鎖的代碼塊時,不需要再次加鎖和釋放鎖。而是直接比較對象頭裏面是否存儲了指向當前線程的偏向鎖。如果相等表示偏向鎖是偏向於當前線程的,就不需要再嘗試獲得鎖了
偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。並且直接把被偏向的鎖對象升級到被加了輕量級鎖的狀態。
對原持有偏向鎖的線程進行撤銷時,原獲得偏向鎖的線程有兩種情況:

  1. 原獲得偏向鎖的線程如果已經退出了臨界區,也就是同步代碼塊執行完了,那麼這個時候會把對象頭設置成無鎖狀態並且爭搶鎖的線程可以基於 CAS 重新偏向但前線程
  2. 如果原獲得偏向鎖的線程的同步代碼塊還沒執行完,處於臨界區之內,這個時候會把原獲得偏向鎖的線程升級爲輕量級鎖後繼續執行同步代碼塊在我們的應用開發中,絕大部分情況下一定會存在 2 個以上的線程競爭,那麼如果開啓偏向鎖,反而會提升獲取鎖的資源消耗。所以可以通過 jvm 參數UseBiasedLocking 來設置開啓或關閉偏向鎖

這是網上一張很經典的偏向鎖流程圖

 

偏向鎖流程圖

輕量級鎖的基本原理

加鎖

鎖升級爲輕量級鎖之後,對象的 Markword 也會進行相應的的變化。升級爲輕量級鎖的過程:

  1. 線程在自己的棧楨中創建鎖記錄 LockRecord。
  2. 將鎖對象的對象頭中的MarkWord複製到線程的剛剛創建的鎖記錄中。
  3. 將鎖記錄中的 Owner 指針指向鎖對象。
  4. 將鎖對象的對象頭的 MarkWord替換爲指向鎖記錄的指針。

自旋鎖

輕量級鎖在加鎖過程中,用到了自旋鎖所謂自旋,就是指當有另外一個線程來競爭鎖時,這個線程會在原地循環等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之後,這個線程就可以馬上獲得鎖的。注意,鎖在原地循環的時候,是會消耗 cpu 的,就相當於在執行一個啥也沒有的 for 循環。所以,輕量級鎖適用於那些同步代碼塊執行的很快的場景,這樣,線程原地等待很短的時間就能夠獲得鎖了。自旋鎖的使用,其實也是有一定的概率背景,在大部分同步代碼塊執行的時間都是很短的。所以通過看似無異議的循環反而能提升鎖的性能。但是自旋必要有一定的條件控制,否則如果一個線程執行同步代碼塊的時間很長,那麼這個線程不斷的循環反而會消耗 CPU 資源。默認情況下自旋的次數是 10 次,可以通過 preBlockSpin 來修改在 JDK1.6 之後,引入了自適應自旋鎖,自適應意味着自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

解鎖

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

 

輕量級鎖流程圖

重量級鎖

當輕量級鎖膨脹到重量級鎖之後,意味着線程只能被掛起阻塞來等待被喚醒了。

各種鎖的比較

各種鎖的比較

總結

JVM在運行過程會根據實際情況對添加了Synchronized關鍵字的部分進行鎖自動升級來實現自我優化。以上就是Synchronized的實現原理和java1.6以後對其所做的優化以及在實際運行中可能遇到的鎖升級原理。雖然大家都懂得使用synchronized這個關鍵字,但我覺得一步一步深入挖掘它的原理實現的過程也是一種樂趣。

1 ReentrantLock和synchronized區別

  (1) synchronized 是Java的一個內置關鍵字,而ReentrantLock是Java的一個類。
  (2) synchronized只能是非公平鎖。而ReentrantLock可以實現公平鎖和非公平鎖兩種。
  (3) synchronized不能中斷一個等待鎖的線程,而Lock可以中斷一個試圖獲取鎖的線程。
  (4) synchronized不能設置超時,而Lock可以設置超時。
  (5) synchronized會自動釋放鎖,而ReentrantLock不會自動釋放鎖,必須手動釋放,否則可能會導致死鎖。

2 公平鎖和非公平鎖的區別

  公平鎖的獲取鎖的過程:

 

static final class FairSync extends Sync {
    final void lock() { // 1 注意對比公平鎖和非公平鎖的這個方法
        acquire(1);
    }
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            // 2 和非公平鎖相比,這裏多了一個判斷:是否有線程在等待
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

  非公平鎖的獲取鎖的過程:

 

static final class NonfairSync extends Sync {
    final void lock() {
      //  1 和公平鎖相比,這裏會直接先進行一次CAS,如果當前正好沒有線程持有鎖,
      // 如果成功獲取鎖就直接返回了,就不用像公平鎖那樣一定要進行後續判斷
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 2 這裏沒有對阻塞隊列進行判斷
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

  從源碼上看,可以看到公平鎖和非公平鎖的兩個區別:

(1) 線程在獲取鎖調用lock()時,非公平鎖首先會進行一次CAS嘗試搶鎖,如果此時沒有線程持有鎖或者正好此刻有線程執行完釋放了鎖(state == 0),那麼如果CAS成功則直接佔用鎖返回。
(2) 如果非公平鎖在上一步獲取鎖失敗了,那麼就會進入nonfairTryAcquire(int acquires),在該方法裏,如果state的值爲0,表示當前沒有線程佔用鎖或者剛好有線程釋放了鎖,那麼就會CAS搶鎖,如果搶成功了,就直接返回了,不管是不是有其他線程早就到了在阻塞隊列中等待鎖了。而公平鎖在這裏搶到鎖了,會判斷阻塞隊列是不是空的,畢竟要公平就要講先來後到,如果發現阻塞隊列不爲空,表示隊列中早有其他線程在等待了,那麼公平鎖情況下線程會乖乖排到阻塞隊列的末尾。
  如果非公平鎖 (1)(2) 都失敗了,那麼剩下的過程就和非公平鎖一樣了。
(3) 從(1)(2) 可以看出,非公平鎖可能導致線程飢餓,但是非公平鎖的效率要高。

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