Synchronized 精講

雪中孤狼發表於2021-01-11

1.簡介

1.1 作用

併發場景中,保證同一時刻只有一個執行緒對有併發隱患的程式碼進行操作

1.2 錯誤案例

需求:兩個執行緒對 count 變數進行200000次迴圈增加,預期結果是400000次

public class SynchronizedDemo implements Runnable {
    private static int count = 0;
    static SynchronizedDemo synchronizedInstance = new SynchronizedDemo();
    public static void main(String[] args) {
        Thread t1 = new Thread(synchronizedInstance);
        Thread t2 = new Thread(synchronizedInstance);
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
            System.out.println("count 最終的值為: " + count);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 200000; i++) {
                count++;
            }
        }
    }
}

結果 :顯然不等於400000次所以出現了運算錯誤
圖片

原因:

 count++;

該語句包含三個操作:

  1. 執行緒t1、t2 從主記憶體中獲取共享變數count的值,到自己的工作記憶體中
  2. 將自己的工作記憶體中的count值進行+1操作
  3. 將修改完的count變數的值存入到主記憶體中

圖片

注意:他們是將自己工作記憶體中的值進行改變刷回主記憶體,假設當前count的值為8,t1、t2將count的值複製到自己的工作記憶體中進行修改,如果此時t1將count變成9、t2此時也將count的值變成9,當t1、t2兩個執行緒都將值刷回主記憶體的時候count值為9,並不是10,這個時候就會造成最後的結果和預期的不一致。

1.3 正確案例

  1. 程式碼塊上加物件鎖 this
@Override
public void run() {
    synchronized (this) {
        for (int i = 0; i < 200000; i++) {
            count++;
        }
    }
}
  1. 在普通方法上加鎖
@Override
public synchronized void run() {
    for (int i = 0; i < 200000; i++) {
            count++;
    }
}
  1. 加.class鎖
@Override
public void run() {
    for (int i = 0; i < 200000; i++) {
        synchronized (SynchronizedDemo.class) {
            count++;
        }
    }
}

輸出結果:
在這裡插入圖片描述

後文詳細講解四種加 synchronized 的方式


2.用法

2.1 物件鎖

2.1.1 方法鎖

修飾普通方法預設鎖物件為this當前例項物件

public synchronized void method() ;在普通方法上面加synchronized

public class SynchronizedDemo3 implements Runnable {
    static SynchronizedDemo3 synchronizedDemo3 = new SynchronizedDemo3();
    public synchronized void method() {
        System.out.println("執行緒名稱" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("執行緒名稱" + Thread.currentThread().getName() + "執行完成");
    }
    @Override
    public void run() {
        method();
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(synchronizedDemo3);
        t1.setName("我是執行緒 t1");
        Thread t2 = new Thread(synchronizedDemo3);
        t2.setName("我是執行緒 t2");
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果: 執行緒 t1 和執行緒 t2 執行過程是順序執行的
圖片

2.1.2 同步程式碼塊

  1. 程式碼示例:沒有加鎖而定義的兩個執行緒執行的情況

在這裡插入圖片描述

輸出結果:執行緒 t1 和執行緒 t2 交叉執行形成了亂序

圖片


  1. 程式碼示例:加Synchronized 鎖而定義的兩個執行緒執行的情況,鎖物件的是this(當前物件)

圖片

輸出結果:執行緒 t1 和執行緒 t2 執行過程是順序執行的

圖片


  1. 程式碼示例:加Synchronized 鎖而定義的兩個執行緒執行的情況,鎖物件的是自定義物件

在這裡插入圖片描述

輸出結果:執行緒 t1 和執行緒 t2 執行形成了順序,這種情況下和this沒有什麼區別,但是如果是多個同步程式碼塊的話就需要進行自定義物件鎖

圖片


  1. 程式碼示例:多個同步程式碼塊使用自定義物件鎖,(兩個自定義物件鎖對應兩個同步程式碼塊)

在這裡插入圖片描述

輸出結果:輸出順序執行緒t1 和執行緒t2 程式碼進行了交叉執行,出現了亂序

在這裡插入圖片描述


  1. 程式碼示例:多個同步程式碼塊使用自定義物件鎖,(一個自定義物件鎖對應兩個同步程式碼塊)

圖片

輸出結果:執行緒 t1 和執行緒 t2 執行形成了順序

圖片

2.2 類鎖

特點:類鎖只能在同一時間被一個物件擁有(無論有多少個例項想訪問也是一個物件持有它)

2.2.1 synchronized修飾靜態的方法

  1. 程式碼示例: synchronized 加在普通方法上面
public class SynchronizedDemo4 implements Runnable {
    private static SynchronizedDemo4 synchronizedInstance1 = new SynchronizedDemo4();
    private static SynchronizedDemo4 synchronizedInstance2 = new SynchronizedDemo4();
    public synchronized void method() {
        System.out.println("執行緒名稱" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("執行緒名稱" + Thread.currentThread().getName() + "執行完成");
    }
    @Override
    public void run() {
        method();
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(synchronizedInstance1);
        t1.setName("我是執行緒 t1");
        Thread t2 = new Thread(synchronizedInstance2);
        t2.setName("我是執行緒 t2");
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果:輸出順序執行緒t1 和執行緒t2 程式碼進行了交叉執行,出現了亂序
圖片

  1. 程式碼示例: synchronized 加在靜態方法上面

public static synchronized void method();使用方式

public class SynchronizedDemo4 implements Runnable {
    private static SynchronizedDemo4 synchronizedInstance1 = new SynchronizedDemo4();
    private static SynchronizedDemo4 synchronizedInstance2 = new SynchronizedDemo4();
    public static synchronized void method() {
        System.out.println("執行緒名稱" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("執行緒名稱" + Thread.currentThread().getName() + "執行完成");
    }
    @Override
    public void run() {
        method();
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(synchronizedInstance1);
        t1.setName("我是執行緒 t1");
        Thread t2 = new Thread(synchronizedInstance2);
        t2.setName("我是執行緒 t2");
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果:執行緒 t1 和執行緒 t2 執行形成了順序
圖片

2.2.2 指定鎖物件為Class物件

  1. 程式碼示例:synchronized 加.class

synchronized (SynchronizedDemo5.class)

public class SynchronizedDemo5 implements Runnable {
    private static SynchronizedDemo5 synchronizedInstance1 = new SynchronizedDemo5();
    private static SynchronizedDemo5 synchronizedInstance2 = new SynchronizedDemo5();
    void method() {
        synchronized (SynchronizedDemo5.class) { //類鎖只有一把
            System.out.println("執行緒名稱" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("執行緒名稱" + Thread.currentThread().getName() + "執行完成");
        }
    }
    @Override
    public void run() {
        method();
    }
    public static void main(String[] args) {
        Thread t1 = new Thread(synchronizedInstance1);
        t1.setName("我是執行緒 t1");
        Thread t2 = new Thread(synchronizedInstance2);
        t2.setName("我是執行緒 t2");
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果: 執行緒 t1 和執行緒 t2 執行形成了順序
圖片

3.性質

3.1 可重入性也叫遞迴鎖

就是說你已經獲取了一把鎖,等想要再次請求的時候不需要釋放這把鎖和其他執行緒一起競爭該鎖,可以直接使用該鎖

好處:避免死鎖

粒度:執行緒而非呼叫

3.2案例證明可重入性

  1. 證明同一個方法是可重入

程式碼例項:

package synchronizedPage;
public class SynchronizedDemo6 {
    int count = 0;
    public static void main(String[] args) {
        SynchronizedDemo6 synchronizedDemo6 = new SynchronizedDemo6();
        synchronizedDemo6.method();
    }
    private synchronized void method() {
        System.out.println(count);
        if (count == 0) {
            count++;
            method();
        }
    }
}

輸出結果:
在這裡插入圖片描述

  1. 證明可重入不要求是同一個方法

程式碼例項:

package synchronizedPage;
public class SynchronizedDemo7 {
    private synchronized void method1() {
        System.out.println("method1");
        method2();
    }
    private synchronized void method2() {
        System.out.println("method2");
    }
    public static void main(String[] args) {
        SynchronizedDemo7 synchronizedDemo7 = new SynchronizedDemo7();
        synchronizedDemo7.method1();
    }
}

輸出結果:
在這裡插入圖片描述

  1. 證明可重入不要求是同一個類中的

程式碼例項:

package synchronizedPage;
public class SynchronizedDemo8 {
    public synchronized void doSomething() {
        System.out.println("我是父類方法");
    }
}
class childrenClass extends SynchronizedDemo8{
    public synchronized void doSomething() {
        System.out.println("我是子類方法");
        super.doSomething();
    }
    public static void main(String[] args) {
        childrenClass childrenClass = new childrenClass();
        childrenClass.doSomething();
    }
}

輸出結果:
圖片

3.3 不可中斷

當A執行緒持有這把鎖時,B執行緒如果也想要A執行緒持有的鎖時只能等待,A永遠不釋放的話,那麼B執行緒永遠的等待下去。

4.底層原理實現

4.1 加鎖和釋放鎖的原理

  • synchronized加在程式碼塊上
public void test() {
  synchronized(this){
    count++;
  }
}

利用 javap -verbose 類的名字檢視編譯後的檔案
在這裡插入圖片描述

monitorenter:每個物件都是一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,執行緒執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

  1. 如果monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者
  2. 如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1【可重入性質
  3. 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權

monitorexit:執行monitorexit的執行緒必須是objectref所對應的monitor的所有者。指令執行時,monitor的進入數減1,如果減1後進入數為0,那執行緒退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權

monitorexit指令出現了兩次,第1次為同步正常退出釋放鎖;第2次為發生非同步退出釋放鎖

  • synchronized加在方法上(無論時普通方法還是靜態方法)
public synchronized void test() {
  count++;
}

利用 javap -verbose 類的名字檢視編譯後的檔案
圖片

方法的同步並沒有通過指令monitorentermonitorexit來完成,不過相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:

當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件,其實底層還是monitor物件鎖。

5.Java虛擬機器對synchronized的優化

從JDK6開始,就對synchronized的實現機制進行了較大調整,包括使用JDK5引進的CAS自旋之外,還增加了自適應的CAS自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖這些優化策略。所以synchronized關鍵字的優化使得效能極大提高,同時語義清晰、操作簡單、無需手動關閉,所以推薦在允許的情況下儘量使用此關鍵字,同時在效能上此關鍵字還有優化的空間。

5.1 鎖主要存在的四種狀態

無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態

鎖的膨脹過程

無鎖狀態 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

只能從低到高升級,不會出現鎖的降級

5.2 自旋鎖

所謂自旋鎖,就是指當一個執行緒嘗試獲取某個鎖時,如果該鎖已被其他執行緒佔用,就一直迴圈檢測鎖是否被釋放,而不是進入執行緒掛起或睡眠狀態。(減少執行緒切換)

使用場景: 自旋鎖適用於鎖保護的臨界區很小的情況,臨界區很小的話,鎖佔用的時間就很短。

缺點:雖然它可以避免執行緒切換帶來的開銷,但是它佔用了CPU處理器的時間。如果持有鎖的執行緒很快就釋放了鎖,那麼自旋的效率就非常好,反之,自旋的執行緒就會白白消耗掉處理的資源,它不會做任何有意義的工作,所以增加了適應性自選鎖

5.3 適應性自旋鎖

所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

執行緒如果自旋成功了,那麼下次自旋的次數會更加多,因為上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。反之,很少能夠成功,那麼以後自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。

5.4 鎖消除

為了保證資料的完整性,在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享資料競爭,這是JVM會對這些同步鎖進行鎖消除。作為寫程式的人應該會知道哪裡存在資料競爭,不可能隨便的加鎖。

5.5 鎖粗化

將多個連續的加鎖、解鎖操作連線在一起,擴充套件成一個範圍更大的鎖。雖然我們平時倡導把加鎖的片段儘量小為了增加併發效率和效能。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的效能損耗,所以引入鎖粗化。

5.6 偏向鎖

在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低,引進了偏向鎖。偏向鎖是在單執行緒執行程式碼塊時使用的機制,如果在多執行緒併發的環境下(即執行緒A尚未執行完同步程式碼塊,執行緒B發起了申請鎖的申請),則一定會轉化為輕量級鎖或者重量級鎖。

引入偏向鎖主要目的是:為了在沒有多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑。因為輕量級鎖的加鎖解鎖操作是需要依賴多次CAS原子指令的,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令。

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒進入和退出同步塊時不需要花費CAS操作來爭奪鎖資源,只需要檢查是否為偏向鎖、鎖標識為以及ThreadID即可,處理流程如下:

  1. 暫停擁有偏向鎖的執行緒
  2. 判斷鎖物件是否還處於被鎖定狀態,否,則恢復到無鎖狀態(01),以允許其餘執行緒競爭。是,則掛起持有鎖的當前執行緒,並將指向當前執行緒的鎖記錄地址的指標放入物件頭,升級為輕量級鎖狀態(00),然後恢復持有鎖的當前執行緒,進入輕量級鎖的競爭模式

偏向鎖的獲取和撤銷流程:

圖片

5.7 輕量級鎖

引入輕量級鎖的主要目的是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。當關閉偏向鎖功能或者多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:

  1. 線上程進入同步塊時,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的複製
  2. 拷貝物件頭中的Mark Word複製到鎖記錄(Lock Record)中。
  3. 拷貝成功後,虛擬機器將使用CAS操作嘗試將物件Mark Word中的Lock Word更新為指向當前執行緒Lock Record的指標,並將Lock record裡的owner指標指向object mark word。如果更新成功,則執行步驟(4),否則執行步驟(5)。
  4. 如果這個更新動作成功了,那麼當前執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,即表示此物件處於輕量級鎖定狀態
  5. 如果這個更新操作失敗了,虛擬機器首先會檢查物件Mark Word中的Lock Word是否指向當前執行緒的棧幀,如果是,就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,進入自旋執行(3),若自旋結束時仍未獲得鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,當前執行緒以及後面等待鎖的執行緒也要進入阻塞狀態。

圖片

輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下

  1. 通過CAS操作嘗試把執行緒中複製的Displaced Mark Word物件替換當前的Mark Word
  2. 如果替換成功,整個同步過程就完成了,恢復到無鎖狀態(01)
  3. 如果替換失敗,說明有其他執行緒嘗試過獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的執行緒

問題:

  1. 為什麼升級為輕量鎖時要把物件頭裡的Mark Word複製到執行緒棧的鎖記錄中呢?

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

  1. 為什麼會嘗試CAS不成功以及什麼情況下會不成功?
  2. CAS本身是不帶鎖機制的,其是通過比較來操作得。假設如下場景:執行緒A和執行緒B都在物件頭裡的鎖標識為無鎖狀態進入,那麼如執行緒A先更新物件頭為其鎖記錄指標成功之後,執行緒B再用CAS去更新,就會發現此時的物件頭已經不是其操作前的物件了,所以CAS會失敗。也就是說,只有兩個執行緒併發申請鎖的時候會發生CAS失敗。
  3. 此時執行緒B進行CAS自旋,等待物件頭的鎖標識重新變回無鎖狀態或物件頭內容等於物件,這也就意味著執行緒A執行結束,此時執行緒B的CAS操作終於成功了,於是執行緒B獲得了鎖以及執行同步程式碼的許可權。如果執行緒A的執行時間較長,執行緒B經過若干次CAS時鐘沒有成功,則鎖膨脹為重量級鎖,即執行緒B被掛起阻塞、等待重新排程

5.8 重量級鎖

Synchronized是通過物件內部的一個叫做監視器鎖(Monitor)來實現的。但是監視器鎖本質又是依賴於底層的作業系統的Mutex Lock來實現的而作業系統實現執行緒之間的切換這就需要從使用者態轉換到核心態,這個成本非常高,效能消耗特別嚴重。 因此,這種依賴於作業系統Mutex Lock所實現的鎖我們稱之為 “重量級鎖”。

6. 缺點

  1. 效率低
    • 鎖的釋放情況少
    • 試圖獲取鎖時不能設定超時
    • 不能中斷一個正在試圖獲得鎖的執行緒
  2. 不夠靈活
    • 加鎖和釋放鎖的時候單一,每個鎖僅有一個單一條件
  3. 不知道是否成功獲取鎖

相關文章