1. 程式人生 > >一篇完全瞭解java關鍵字synchronized

一篇完全瞭解java關鍵字synchronized


title: synchronized解析
tags: java 鎖
author: 辰砂


一、總體概述圖

二、實現原理

monitorenter:

  每個物件有一個monitor,即監視器,當且僅當monitor被佔用時,這個monitor就被鎖住了。執行緒執行monitorenter指令是為了嘗試獲取該monitor的所有權,過程為:
  1) 如果一個monitor的進入數為0,那麼該執行緒直接進入monitor,並且將monitor進入數置為1,該執行緒成為該monitor的所有者;
  2) 如果該程序是已經佔用該monitor,則直接進入,並且monitor進入數加1;
  3)如果該程序未佔有該monitor,即monitor被其他執行緒所佔有,那麼該執行緒會被阻塞,直到該monitor的進入數變為0,此時該執行緒會再次嘗試獲取該monitor。
  

 monitorexit:

  執行monitorexit指令的執行緒必須是已經擁有該monitor的執行緒,執行monitorexit指令後,該monitor的進入數減1,直到該monitor的進入數減為0,此時該執行緒不再是該monitor的所有者,其他被阻塞進入該monitor的執行緒可以嘗試獲取該monitor的所有權。
  這就是synchronized的實現原理。其實,wait/notify/notifyAll也是基於monitor物件實現的,這也是為什麼只有在同步塊中才能使用wait/notify/notifyAll方法。

如果用synchronized修飾方法,會是怎樣呢?我們用javap -verbose命令反編譯下面的程式,其中-verbose表示輸出堆疊大小、各方法的locals及args數,以及class檔案的編譯版本:
我們發現在方法體內部沒有monitorenter和monitorexit指令,但是注意我箭頭表示的地方,有一個ACC_SYNCHRONIZED標誌,JVM就是通過該標誌來判斷是否需要實現同步的,具體過程為:當執行緒執行該方法時,會先檢查該方法是否標誌了ACC_SYNCHRONIZED,如果標誌了,執行緒需要先獲取monitor,獲取成功後才能呼叫方法,方法執行完後再釋放monitor,在該執行緒呼叫方法期間,其他執行緒無法獲取同一個monitor物件。其實本質上和synchronized塊相同,只是同步方法是用一種隱式的方式來實現,而不是顯式地通過位元組碼指令。

三、如何使用

1. 同步一個程式碼塊

public void func() {
    synchronized (this) {
        // ...
    }
}

它只作用於同一個物件,如果呼叫兩個物件上的同步程式碼塊,就不會進行同步。

對於以下程式碼,使用 ExecutorService 執行了兩個執行緒,由於呼叫的是同一個物件的同步程式碼塊,因此這兩個執行緒會進行同步,當一個執行緒進入同步語句塊時,另一個執行緒就必須等待。

public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

對於以下程式碼,兩個執行緒呼叫了不同物件的同步程式碼塊,因此這兩個執行緒就不需要同步。從輸出結果可以看出,兩個執行緒交叉執行。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9

2. 同步一個方法

public synchronized void func () {
    // ...
}

它和同步程式碼塊一樣,作用於同一個物件。

3. 同步一個類

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用於整個類,也就是說兩個執行緒呼叫同一個類的不同物件上的這種同步語句,也會進行同步。

public class SynchronizedExample {

    public void func2() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

4. 同步一個靜態方法

public synchronized static void fun() {
    // ...
}

作用於整個類。

四、優化

這裡的鎖優化主要是指虛擬機器對 synchronized 的優化。

1.自旋鎖

互斥同步的進入阻塞狀態的開銷都很大,應該儘量避免。在許多應用中,共享資料的鎖定狀態只會持續很短的一段時間。自旋鎖的思想是讓一個執行緒在請求一個共享資料的鎖時執行忙迴圈(自旋)一段時間,如果在這段時間內能獲得鎖,就可以避免進入阻塞狀態。

自選鎖雖然能避免進入阻塞狀態從而減少開銷,但是它需要進行忙迴圈操作佔用 CPU 時間,它只適用於共享資料的鎖定狀態很短的場景。

在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味著自旋的次數不再固定了,而是由前一次在同一個鎖上的自旋次數及鎖的擁有者的狀態來決定。

2.鎖消除

鎖消除是指對於被檢測出不可能存在競爭的共享資料的鎖進行消除。

鎖消除主要是通過逃逸分析來支援,如果堆上的共享資料不可能逃逸出去被其它執行緒訪問到,那麼就可以把它們當成私有資料對待,也就可以將它們的鎖進行消除。

對於一些看起來沒有加鎖的程式碼,其實隱式的加了很多鎖。例如下面的字串拼接程式碼就隱式加了鎖:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一個不可變的類,編譯器會對 String 的拼接自動優化。在 JDK 1.5 之前,會轉化為 StringBuffer 物件的連續 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每個 append() 方法中都有一個同步塊。虛擬機器觀察變數 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會“逃逸”到 concatString() 方法之外,其他執行緒無法訪問到它,因此可以進行消除。

3.鎖粗化

如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,頻繁的加鎖操作就會導致效能損耗。

上一節的示例程式碼中連續的 append() 方法就屬於這類情況。如果虛擬機器探測到由這樣的一串零碎的操作都對同一個物件加鎖,將會把加鎖的範圍擴充套件(粗化)到整個操作序列的外部。對於上一節的示例程式碼就是擴充套件到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。

4.輕量級鎖

JDK 1.6 引入了偏向鎖和輕量級鎖,從而讓鎖擁有了四個狀態:無鎖狀態(unlocked)、偏向鎖狀態(biasble)、輕量級鎖狀態(lightweight locked)和重量級鎖狀態(inflated)。

以下是 HotSpot 虛擬機器物件頭的記憶體佈局,這些資料被稱為 mark word。其中 tag bits 對應了五個狀態,這些狀態在右側的 state 表格中給出,應該注意的是 state 表格不是儲存在物件頭中的。除了 marked for gc 狀態,其它四個狀態已經在前面介紹過了。

下圖左側是一個執行緒的虛擬機器棧,其中有一部分稱為 Lock Record 的區域,這是在輕量級鎖執行過程建立的,用於存放鎖物件的 Mark Word。而右側就是一個鎖物件,包含了 Mark Word 和其它資訊。

輕量級鎖是相對於傳統的重量級鎖而言,它使用 CAS 操作來避免重量級鎖使用互斥量的開銷。對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,因此也就不需要都使用互斥量進行同步,可以先採用 CAS 操作進行同步,如果 CAS 失敗了再改用互斥量進行同步。

當嘗試獲取一個鎖物件時,如果鎖物件標記為 0 01,說明鎖物件的鎖未鎖定(unlocked)狀態。此時虛擬機器在當前執行緒棧中建立 Lock Record,然後使用 CAS 操作將物件的 Mark Word 更新為 Lock Record 指標。如果 CAS 操作成功了,那麼執行緒就獲取了該物件上的鎖,並且物件的 Mark Word 的鎖標記變為 00,表示該物件處於輕量級鎖狀態。

如果 CAS 操作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的虛擬機器棧,如果是的話說明當前執行緒已經擁有了這個鎖物件,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其他執行緒執行緒搶佔了。如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖。

5.偏向鎖

偏向鎖的思想是偏向於讓第一個獲取鎖物件的執行緒,這個執行緒在之後獲取該鎖就不再需要進行同步操作,甚至連 CAS 操作也不再需要。

當鎖物件第一次被執行緒獲得的時候,進入偏向狀態,標記為 1 01。同時使用 CAS 操作將執行緒 ID 記錄到 Mark Word 中,如果 CAS 操作成功,這個執行緒以後每次進入這個鎖相關的同步塊就不需要再進行任何同步操作。

當有另外一個執行緒去嘗試獲取這個鎖物件時,偏向狀態就宣告結束,此時撤銷偏向(Revoke Bias)後恢復到未鎖定狀態或者輕量級鎖狀態。

五、和其他鎖比較

1.volatile和synchronized的區別

  1. volatile本質是在告訴jvm當前變數在暫存器(工作記憶體)中的值是不確定的,需要從主存中讀取; synchronized則是鎖定當前變數,只有當前執行緒可以訪問該變數,其他執行緒被阻塞住。
  2. volatile僅能使用在變數級別;synchronized則可以使用在變數、方法、和類級別的
  3. volatile僅能實現變數的修改可見性,不能保證原子性;而synchronized則可以保證變數的修改可見性和原子性
  4. volatile不會造成執行緒的阻塞;synchronized可能會造成執行緒的阻塞。
  5. volatile標記的變數不會被編譯器優化;synchronized標記的變數可以被編譯器優化

2.synchronized 和 ReentrantLock 比較

1. 鎖的實現

synchronized 是 JVM 實現的,而 ReentrantLock 是 JDK 實現的。

2. 效能

新版本 Java 對 synchronized 進行了很多優化,例如自旋鎖等。目前來看它和 ReentrantLock 的效能基本持平了,因此效能因素不再是選擇 ReentrantLock 的理由。synchronized 有更大的效能優化空間,應該優先考慮 synchronized。

3. 功能

ReentrantLock 多了一些高階功能。

4. 使用選擇

除非需要使用 ReentrantLock 的高階功能,否則優先使用 synchronized。這是因為 synchronized 是 JVM 實現的一種鎖機制,JVM 原生地支援它,而 ReentrantLock 不是所有的 JDK 版本都支援。並且使用 synchronized 不用擔心沒有釋放鎖而導致死鎖問題,因為 JVM 會確保鎖的釋放。


參考 :

https://github.com/CyC2018/CS-Notes

網上優秀部落格(自己以前筆記整理)