1. 程式人生 > 程式設計 >是你的益達,是你的synchronized

是你的益達,是你的synchronized


一、簡介

synchronized關鍵字是Java裡面最基本的同步手段,它經過編譯之後,會在同步塊的前後分別生成 monitorenter 和 monitorexit 位元組碼指令,這兩個位元組碼指令都需要一個引用型別的引數來指明要鎖定和解鎖的物件。

二、問題

  1. synchronized的特性?
  2. synchronized的實現原理?
  3. synchronized是否可重入?
  4. synchronized是否是公平鎖?
  5. synchronized的優化?
  6. synchronized的五種使用方式?

三、實現原理

在Java記憶體模型中有兩個指令:lockunlock

  • lock,鎖定,作用於主記憶體的變數,它把主記憶體中的變數標識為一條執行緒獨佔狀態。
  • unlock,解鎖,作用於主記憶體的變數,它把鎖定的變數釋放出來,釋放出來的變數才可以被其它執行緒鎖定。

但是這兩個指令並沒有直接提供給使用者使用,而是提供了兩個更高層次的指令 monitorenter monitorexit 來隱式地使用 lock unlock 指令。

synchronized 就是使用 monitorenter monitorexit 這兩個指令來實現的。 根據JVM規範的要求,在執行monitorenter指令的時候,首先要去嘗試獲取物件的鎖,如果這個物件沒有被鎖定,或者當前執行緒已經擁有了這個物件的鎖,就把鎖的計數器加1,相應地,在執行monitorexit

的時候會把計數器減1,當計數器減小為0時,鎖就釋放了。

我們還是來上一段程式碼,看看編譯後的位元組碼長啥樣來學習:

public class SynchronizedTest {

    public static void sync() {
        synchronized (SynchronizedTest.class) {
            synchronized (SynchronizedTest.class) {
            }
        }
    }

    public static void main(String[] args) {

    }
}
複製程式碼

我們這段程式碼很簡單,只是簡單地對SynchronizedTest.class物件加了兩次synchronized,除此之外,什麼也沒幹。

編譯後的sync()方法的位元組碼指令如下,為了便於閱讀,特意加上了註釋:

// 載入常量池中的SynchronizedTest類物件到運算元棧中
0 ldc #2 <com coolcoding code synchronize synchronizedtest>
// 複製棧頂元素
2 dup
// 儲存一個引用到本地變數0中,後面的0表示第幾個變數
3 astore_0
// 呼叫monitorenter,它的引數變數0,也就是上面的SynchronizedTest類物件
4 monitorenter
// 再次載入常量池中的SynchronizedTest類物件到運算元棧中
5 ldc #2 <com coolcoding code synchronize synchronizedtest>
// 複製棧頂元素
7 dup
// 儲存一個引用到本地變數1中
8 astore_1
// 再次呼叫monitorenter,它的引數是變數1,也還是SynchronizedTest類物件
9 monitorenter
// 從本地變量表中載入第1個變數
10 aload_1
// 呼叫monitorexit解鎖,它的引數是上面載入的變數1
11 monitorexit
// 跳到第20行
12 goto 20 (+8)
15 astore_2
16 aload_1
17 monitorexit
18 aload_2
19 athrow
// 從本地變量表中載入第0個變數
20 aload_0
// 呼叫monitorexit解鎖,它的引數是上面載入的變數0
21 monitorexit
// 跳到第30行
22 goto 30 (+8)
25 astore_3
26 aload_0
27 monitorexit
28 aload_3
29 athrow
// 方法返回,結束
30 return
複製程式碼

按照這樣的註釋讀起來,位元組碼比較簡單,我們的synchronized鎖定的是SynchronizedTest類物件,可以看到它從常量池中載入了兩次SynchronizedTest類物件,分別儲存在本地變數0和本地變數1中,解鎖的時候正好是相反的順序,先解鎖變數1,再解鎖變數0,實際上變數0和變數1指向的是同一個物件,所以synchronized是可重入的。

至於,被加鎖的物件具體在物件頭中是怎麼儲存的,這裡就不細講了,有興趣的可以看看《Java併發程式設計的藝術》這本書。

四、原子性|可見性|有序性

前面講解Java記憶體模型的時候我們說過記憶體模型主要就是用來解決快取一致性的問題的,而快取一致性主要包括原子性、可見性、有序性。

那麼,synchronized關鍵字能否保證這三個特性呢?

還是回到Java記憶體模型上來,synchronized關鍵字底層是通過monitorentermonitorexit實現的,而這兩個指令又是通過lockunlock來實現的。

而lock和unlock在Java記憶體模型中是必須滿足下面四條規則的:

(1)一個變數同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一個執行緒執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才能被解鎖。

(2)如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值;

(3)如果一個變數沒有被lock操作鎖定,則不允許對其執行unlock操作,也不允許unlock一個其它執行緒鎖定的變數;

(4)對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中,即執行store和write操作;

通過規則(1),我們知道對於lock和unlock之間的程式碼,同一時刻只允許一個執行緒訪問,所以,synchronized是具有原子性的。

通過規則(1)(2)和(4),我們知道每次lockunlock時都會從主記憶體載入變數或把變數重新整理回主記憶體,而lockunlock之間的變數(這裡是指鎖定的變數)是不會被其它執行緒修改的,所以,synchronized是具有可見性的。

通過規則(1)和(3),我們知道所有對變數的加鎖都要排隊進行,且其它執行緒不允許解鎖當前執行緒鎖定的物件,所以,synchronized是具有有序性的。

綜上所述,synchronized是可以保證原子性、可見性和有序性的。

五、公平鎖 VS 非公平鎖

通過上面的學習,我們知道了synchronized的實現原理,並且它是可重入的,那麼,它是否是公平鎖呢? 如下程式碼:

public class SynchronizedTest {

    public static void sync(String tips) {
        synchronized (SynchronizedTest.class) {
            System.out.println(tips);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->sync("執行緒1")).start();
        Thread.sleep(100);
        new Thread(()->sync("執行緒2")).start();
        Thread.sleep(100);
        new Thread(()->sync("執行緒3")).start();
        Thread.sleep(100);
        new Thread(()->sync("執行緒4")).start();
    }
}
複製程式碼

在這段程式中,我們起了四個執行緒,且分別間隔100ms啟動,每個執行緒裡面列印一句話後等待1000ms,如果synchronized是公平鎖,那麼列印的結果應該依次是 執行緒1、2、3、4。

但是,實際執行的結果幾乎不會出現上面的樣子,所以,synchronized是一個非公平鎖。

六、鎖優化

Java在不斷進化,同樣地,Java中像synchronized這種古老的東西也在不斷進化,比如ConcurrentHashMap在jdk7的時候還是使用ReentrantLock加鎖的,在jdk8的時候已經換成了原生的synchronized了,可見synchronized有原生的支援,它的進化空間還是很大的。

那麼,synchronized有哪些進化中的狀態呢?

我們這裡稍做一些簡單地介紹:

(1)偏向鎖,是指一段同步程式碼一直被一個執行緒訪問,那麼這個執行緒會自動獲取鎖,降低獲取鎖的代價。

(2)輕量級鎖,是指當鎖是偏向鎖時,被另一個執行緒所訪問,偏向鎖會升級為輕量級鎖,這個執行緒會通過自旋的方式嘗試獲取鎖,不會阻塞,提高效能。

(3)重量級鎖,是指當鎖是輕量級鎖時,當自旋的執行緒自旋了一定的次數後,還沒有獲取到鎖,就會進入阻塞狀態,該鎖升級為重量級鎖,重量級鎖會使其他執行緒阻塞,效能降低。

    七、synchronized的五種使用方式

    通過上面的分析,我們知道synchronized是需要一個引用型別的引數的,而這個引用型別的引數在Java中其實可以分成三大類:類物件、例項物件、普通引用,使用方式分別如下:

    public class SynchronizedTest2 {
    
        public static final Object lock = new Object();
    
        // 鎖的是SynchronizedTest.class物件
        public static synchronized void sync1() {
    
        }
    
        public static void sync2() {
            // 鎖的是SynchronizedTest.class物件
            synchronized (SynchronizedTest.class) {
    
            }
        }
    
        // 鎖的是當前例項this
        public synchronized void sync3() {
    
        }
    
        public void sync4() {
            // 鎖的是當前例項this
            synchronized (this) {
    
            }
        }
    
        public void sync5() {
            // 鎖的是指定物件lock
            synchronized (lock) {
    
            }
        }
    }
    複製程式碼

    在方法上使用synchronized的時候要注意,會隱式傳參,分為靜態方法和非靜態方法,靜態方法上的隱式引數為當前類物件,非靜態方法上的隱式引數為當前例項this。

    另外,多個synchronized只有鎖的是同一個物件,它們之間的程式碼才是同步的,這一點在使用synchronized的時候一定要注意。

    八、總結

    1. synchronized在編譯時會在同步塊前後生成monitorenter和monitorexit位元組碼指令;
    2. monitorenter和monitorexit位元組碼指令需要一個引用型別的引數,基本型別不可以哦;
    3. monitorenter和monitorexit位元組碼指令更底層是使用Java記憶體模型的lock和unlock指令;
    4. synchronized是可重入鎖;
    5. synchronized是非公平鎖;
    6. synchronized可以同時保證原子性、可見性、有序性;
    7. synchronized有三種狀態:偏向鎖、輕量級鎖、重量級鎖;

    優點

    缺點

    適用場景

    偏向鎖

    加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距

    如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗

    適用於只有一個執行緒訪問同步塊場景(只有一個執行緒進入臨界區)

    輕量級鎖

    競爭的執行緒不會阻塞,提高了程式的響應速度

    如果始終得不到索競爭的執行緒,使用自旋會消耗CPU

    追求響應速度,同步塊執行速度非常快(多個執行緒交替進入臨界區)

    重量級鎖

    執行緒競爭不使用自旋,不會消耗CPU

    執行緒阻塞,響應時間緩慢

    追求吞吐量,同步塊執行速度較慢(多個執行緒同時進入臨界區)