1. 程式人生 > 程式設計 >Synchronized 實現原理

Synchronized 實現原理

Java的鎖

鎖的記憶體語義

  1. 鎖可以讓臨界區互斥執行,還可以讓釋放鎖的執行緒向同一個鎖的執行緒傳送訊息
  2. 鎖的釋放要遵循Happens-before原則(鎖規則:解鎖必然發生在隨後的加鎖之前)
  3. 鎖在Java中的具體表現是 Synchronized 和 Lock

鎖的釋放

執行緒A釋放鎖後,會將共享變更操作重新整理到主記憶體中

鎖的獲取

執行緒B獲取鎖時,JMM會將該執行緒的本地記憶體置為無效,被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數

鎖的釋放與獲取

  1. 鎖獲取與volatile讀有相同的記憶體語義
  2. 執行緒A釋放一個鎖,實質是執行緒A告知下一個獲取到該鎖的某個執行緒其已變更該共享變數
  3. 執行緒B獲取一個鎖,實質是執行緒B得到了執行緒A告知其(在釋放鎖之前)變更共享變數的訊息執行緒
  4. A釋放鎖,隨後執行緒B競爭到該鎖,實質是執行緒A通過主記憶體向執行緒B發訊息告知其變更了共享變數

Synchronized的綜述

  1. 同步機制: synchronized是Java同步機制的一種實現,即互斥鎖機制,它所獲得的鎖叫做互斥鎖
  2. 互斥鎖: 指的是每個物件的鎖一次只能分配給一個執行緒,同一時間只能由一個執行緒佔用
  3. 作用: synchronized用於保證同一時刻只能由一個執行緒進入到臨界區,同時保證共享變數的可見性、原子性和有序性
  4. 使用: 當一個執行緒試圖訪問同步程式碼方法(塊)時,它首先必須得到鎖,退出或丟擲異常時必須釋放鎖

Synchronized的使用

Synchronized的三種應用方式

使用同步程式碼塊的好處在於其他執行緒仍可以訪問非synchronized(this)的同步程式碼塊

Synchronized的使用規則

/**
  * 先定義一個測試模板類
  *     這裡補充一個知識點:Thread.sleep(long)不會釋放鎖
  *     讀者可參見筆者的`併發番@Thread一文通`
  */ 
public class SynchronizedDemo {
    public static synchronized void staticMethod(){
        System.out.println(Thread.currentThread().getName() + "訪問了靜態同步方法staticMethod"
); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "結束訪問靜態同步方法staticMethod"); } public static void staticMethod2(){ System.out.println(Thread.currentThread().getName() + "訪問了靜態同步方法staticMethod2"); synchronized (SynchronizedDemo.class){ System.out.println(Thread.currentThread().getName() + "在staticMethod2方法中獲取了SynchronizedDemo.class"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public synchronized void synMethod(){ System.out.println(Thread.currentThread().getName() + "訪問了同步方法synMethod"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "結束訪問同步方法synMethod"); } public synchronized void synMethod2(){ System.out.println(Thread.currentThread().getName() + "訪問了同步方法synMethod2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "結束訪問同步方法synMethod2"); } public void method(){ System.out.println(Thread.currentThread().getName() + "訪問了普通方法method"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "結束訪問普通方法method"); } private Object lock = new Object(); public void chunkMethod(){ System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod方法"); synchronized (lock){ System.out.println(Thread.currentThread().getName() + "在chunkMethod方法中獲取了lock"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public void chunkMethod2(){ System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod2方法"); synchronized (lock){ System.out.println(Thread.currentThread().getName() + "在chunkMethod2方法中獲取了lock"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public void chunkMethod3(){ System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod3方法"); //同步程式碼塊 synchronized (this){ System.out.println(Thread.currentThread().getName() + "在chunkMethod3方法中獲取了this"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public void stringMethod(String lock){ synchronized (lock){ while (true){ System.out.println(Thread.currentThread().getName()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } } } 複製程式碼

普通方法與同步方法呼叫互不關聯

當一個執行緒進入同步方法時,其他執行緒可以正常訪問其他非同步方法

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫普通方法
        synDemo.method();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步方法
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}

複製程式碼

輸出:

    Thread-1訪問了同步方法synMethod
    Thread-0訪問了普通方法method
    Thread-0結束訪問普通方法method
    Thread-1結束訪問同步方法synMethod
複製程式碼

分析:通過結果可知,普通方法和同步方法是非阻塞執行的

所有同步方法只能被一個執行緒訪問

當一個執行緒執行同步方法時,其他執行緒不能訪問任何同步方法

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        synDemo.synMethod();
        synDemo.synMethod2();
    });
    Thread thread2 = new Thread(() -> {
        synDemo.synMethod2();
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}
複製程式碼

輸出:

    Thread-0訪問了同步方法synMethod
    Thread-0結束訪問同步方法synMethod
    Thread-0訪問了同步方法synMethod2
    Thread-0結束訪問同步方法synMethod2
    Thread-1訪問了同步方法synMethod2
    Thread-1結束訪問同步方法synMethod2
    Thread-1訪問了同步方法synMethod
    Thread-1結束訪問同步方法synMethod
複製程式碼

分析:通過結果可知,任務的執行是阻塞的,顯然Thread-1必須等待Thread-0執行完畢之後才能繼續執行

同一個鎖的同步程式碼塊同一時刻只能被一個執行緒訪問

當同步程式碼塊都是同一個鎖時,方法可以被所有執行緒訪問,但同一個鎖的同步程式碼塊同一時刻只能被一個執行緒訪問

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.chunkMethod2();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.synMethod2();
    });
    thread1.start();
    thread2.start();
}
複製程式碼

輸出:

Thread-0訪問了chunkMethod方法
Thread-1訪問了chunkMethod方法
Thread-0在chunkMethod方法中獲取了lock  
...停頓等待...
Thread-1在chunkMethod方法中獲取了lock
...停頓等待...
Thread-0訪問了chunkMethod2方法
Thread-0在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1訪問了chunkMethod2方法
Thread-1在chunkMethod2方法中獲取了lock
複製程式碼

分析可知:

  1. 即使普通方法有同步程式碼塊,但方法的訪問是非阻塞的,任何執行緒都可以自由進入
  2. 對於同一個鎖的同步程式碼塊的訪問一定是阻塞的

執行緒間同時訪問同一個鎖的多個同步程式碼的執行順序不定

執行緒間同時訪問同一個鎖多個同步程式碼的執行順序不定,即使是使用同一個物件鎖

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.chunkMethod2();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod2();
        synDemo.chunkMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了chunkMethod方法
Thread-1訪問了chunkMethod2方法
Thread-0在chunkMethod方法中獲取了lock
...停頓等待...
Thread-0訪問了chunkMethod2方法
Thread-1在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1訪問了chunkMethod方法
Thread-0在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1在chunkMethod方法中獲取了lock

//分析可知:
//現象:對比20行、22行和24行、25行可知,雖然是同一個lock物件,但其不同程式碼塊的訪問是非阻塞的
//原因:根源在於鎖的釋放和重新競爭,當Thread-0訪問完chunkMethod方法後會先釋放鎖,這時Thread-1就有機會能獲取到鎖從而優先執行,依次類推到24行、25行時,Thread-0又重新獲取到鎖優先執行了
//注意:但有一點是必須的,對於同一個鎖的同步程式碼塊的訪問一定是阻塞的
//補充:同步方法之所以會被全部阻塞,是因為synDemo物件一直被執行緒在內部把持住就沒釋放過
複製程式碼

不同鎖之間訪問非阻塞

由於三種使用方式的鎖物件都不一樣,因此相互之間不會有任何影響但有兩種情況除外:

  1. 當同步程式碼塊使用的Class物件和類物件一致時屬於同一個鎖
  2. 當同步程式碼塊使用的是this,即與同步方法使用鎖屬於同一個鎖
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> synDemo.chunkMethod() );
    Thread thread2 = new Thread(() -> synDemo.chunkMethod3());
    Thread thread3 = new Thread(() -> staticMethod());
    Thread thread4 = new Thread(() -> staticMethod2());
    thread1.start();
    thread2.start();
    thread3.start();
    thread4.start();
}
---------------------
//輸出:
Thread-1訪問了chunkMethod3方法
Thread-1在chunkMethod3方法中獲取了this
Thread-2訪問了靜態同步方法staticMethod
Thread-0訪問了chunkMethod方法
Thread-0在chunkMethod方法中獲取了lock
Thread-3訪問了靜態同步方法staticMethod2
...停頓等待...
Thread-2結束訪問靜態同步方法staticMethod
Thread-3在staticMethod2方法中獲取了SynchronizedDemo.class
//分析可知:
//現象:雖然是同一個lock物件,但其不同程式碼塊的訪問是非阻塞的
//原因:根源在於鎖的釋放和重新競爭,當Thread-0訪問完chunkMethod方法後會先釋放鎖,這時Thread-1就有機會能獲取到鎖從而優先執行,,Thread-0又重新獲取到鎖優先執行了
複製程式碼

Synchronized的可重入性

重入鎖:當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功實現:一個執行緒得到一個物件鎖後再次請求該物件鎖,是允許的,每重入一次,monitor進入次數+1

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        synDemo.synMethod();
        synDemo.synMethod2();
    });
    Thread thread2 = new Thread(() -> {
        synDemo.synMethod2();
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了同步方法synMethod
Thread-0結束訪問同步方法synMethod
Thread-0訪問了同步方法synMethod2
Thread-0結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod2
Thread-1結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod
Thread-1結束訪問同步方法synMethod
//分析:在程式碼塊中繼續呼叫了當前例項物件的另外一個同步方法,再次請求當前例項鎖時,將被允許,進而執行方法體程式碼,這就是重入鎖最直接的體現
複製程式碼

Synchronized與String鎖

隱患:由於在JVM中具有String常量池快取的功能,因此相同字面量是同一個鎖!!!注意:嚴重不推薦將String作為鎖物件,而應該改用其他非快取物件提示:對字面量有疑問的話請先回顧一下String的基礎

public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> synDemo.stringMethod("sally"));
    Thread thread2 = new Thread(() -> synDemo.stringMethod("sally"));
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0
Thread-0
Thread-0
Thread-0
...死迴圈...
//分析:輸出結果永遠都是Thread-0的死迴圈,也就是說另一個執行緒,即Thread-1執行緒根本不會執行
//原因:同步塊中的鎖是同一個字面量
複製程式碼

Synchronized與不可變鎖

隱患:當使用不可變類物件(finalClass)作為物件鎖時,使用synchronized同樣會有併發問題原因:由於不可變特性,當作為鎖但同步塊內部仍然有計算操作,會生成一個新的鎖物件注意:嚴重不推薦將final Class作為鎖物件時仍對其有計算操作補充:雖然String也是final Class,但它的原因卻是字面量常量池

public class SynchronizedDemo {
    static Integer i = 0;   //Integer是final Class
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int j = 0;j<10000;j++){
                    synchronized (i){
                        i++;
                    }
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(i);
    }
}
---------------------
//輸出:
14134
//分析:跟預想中的20000不一致,當使用Integer作為物件鎖時但還有計算操作就會出現併發問題
複製程式碼我們通過反編譯發現執行i++操作相當於執行了i = Integer.valueOf(i.intValue()+1)通過檢視Integer的valueOf方法實現可知,其每次都new了一個新的Integer物件,鎖變了有木有!!!
複製程式碼
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);  //每次都new一個新的鎖有木有!!!
}
複製程式碼

Synchronized與死鎖

死鎖:當執行緒間需要相互等待對方已持有的鎖時,就形成死鎖,進而產生死迴圈

public static void main(String[] args) {
    Object lock = new Object();
    Object lock2 = new Object();
    Thread thread1 = new Thread(() -> {
        synchronized (lock){
            System.out.println(Thread.currentThread().getName() + "獲取到lock鎖");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock2){
                System.out.println(Thread.currentThread().getName() + "獲取到lock2鎖");
            }
        }
    });
    Thread thread2 = new Thread(() -> {
        synchronized (lock2){
            System.out.println(Thread.currentThread().getName() + "獲取到lock2鎖");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock){
                System.out.println(Thread.currentThread().getName() + "獲取到lock鎖");
            }
        }
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-1獲取到lock2鎖
Thread-0獲取到lock鎖
.....
//分析:執行緒0獲得lock鎖,執行緒1獲得lock2鎖,但之後由於兩個執行緒還要獲取對方已持有的鎖,但已持有的鎖都不會被雙方釋放,執行緒"假死",無法往下執行,從而形成死迴圈,即死鎖,之後一直在做無用的死迴圈,嚴重浪費系統資源
複製程式碼

我們用 jstack 檢視一下這個任務的各個執行緒執行情況,可以發現兩個執行緒都被阻塞 BLOCKED

我們很明顯的發現,Java-level=deadlock,即死鎖,兩個執行緒相互等待對方的鎖

Synchronized實現原理

Synchronization

  1. 在JVM中,同步的實現是通過監視器鎖的進入和退出實現的,要麼顯示得通過monitorenter 和 monitorexit指令實現,要麼隱示地通過方法呼叫和返回指令實現
  2. 對於Java程式碼來說,或許最常用的同步實現就是同步方法(程式碼塊)。其中同步程式碼塊是通過使用 monitorenter 和 monitorexit 實現的,而同步方法卻是使用 ACC_SYNCHRONIZED 標記符隱示的實現,原理是通過方法呼叫指令檢查該方法在常量池中是否包含 ACC_SYNCHRONIZED 標記符

反編譯

預準備

為了能直觀瞭解Synchronized的工作原理,我們通過反編譯SynchronizedDeme類的class檔案的方式看看都發生了什麼

public class SynchronizedDemo {
    public static synchronized void staticMethod() throws InterruptedException {
        System.out.println("靜態同步方法開始");
        Thread.sleep(1000);
        System.out.println("靜態同步方法結束");
    }
    public synchronized void method() throws InterruptedException {
        System.out.println("例項同步方法開始");
        Thread.sleep(1000);
        System.out.println("例項同步方法結束");
    }
    public synchronized void method2() throws InterruptedException {
        System.out.println("例項同步方法2開始");
        Thread.sleep(3000);
        System.out.println("例項同步方法2結束");
    }
    public static void main(String[] args) {
        final SynchronizedDemo synDemo = new SynchronizedDemo();
        Thread thread1 = new Thread(() -> {
            try {
               synDemo.method();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        Thread thread2 = new Thread(() -> {
            try {
                synDemo.method2();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread1.start();
        thread2.start();
    }
}
複製程式碼

生成.class檔案

javac -encoding UTF-8 SynchronizedDemo.java

最終我們將得到一個 .class 檔案,即 SynchronizedDemo.class

javap反編譯

javap -v SynchronizedDemo

複製程式碼通過反編譯我們會得到常量池、同步方法、同步程式碼塊的不同編譯結果

常量池圖

常量池除了會包含基本型別和字串及陣列的常量值外,還包含以文字形式出現的符號引用:

類和介面的全限定名

欄位的名稱和描述符

方法和名稱和描述符
複製程式碼

同步方法圖示

同步方法會包含一個ACC_SYNCHCRONIZED標記符

同步程式碼塊圖示

同步程式碼塊會在程式碼中插入 monitorenter 和 monitorexist 指令

同步程式碼塊同步原理

monitor監視器

  1. 每個物件都有一個監視器,在同步程式碼塊中,JVM通過monitorenter和monitorexist指令實現同步鎖的獲取和釋放功能
  2. 當一個執行緒獲取同步鎖時,即是通過獲取monitor監視器進而等價為獲取到鎖
  3. monitor的實現類似於作業系統中的管程

monitorenter指令

每個物件都有一個監視器。當該監視器被佔用時即是鎖定狀態(或者說獲取監視器即是獲得同步鎖)。執行緒執行monitorenter指令時會嘗試獲取監視器的所有權,過程如下:

  1. 若該監視器的進入次數為0,則該執行緒進入監視器並將進入次數設定為1,此時該執行緒即為該監視器的所有者
  2. 若執行緒已經佔有該監視器並重入,則進入次數+1
  3. 若其他執行緒已經佔有該監視器,則執行緒會被阻塞直到監視器的進入次數為0,之後執行緒間會競爭獲取該監視器的所有權只有首先獲得鎖的執行緒才能允許繼續獲取多個鎖

monitorexit指令

執行monitorexit指令將遵循以下步驟:

  1. 執行monitorexit指令的執行緒必須是物件例項所對應的監視器的所有者
  2. 指令執行時,執行緒會先將進入次數-1,若-1之後進入次數變成0,則執行緒退出監視器(即釋放鎖)其他阻塞在該監視器的執行緒可以重新競爭該監視器的所有權

實現原理

  1. 在同步程式碼塊中,JVM通過monitorenter和monitorexist指令實現同步鎖的獲取和釋放功能
  2. monitorenter指令是在編譯後插入到同步程式碼塊的開始位置
  3. monitorexit指令是插入到方法結束處和異常處
  4. JVM要保證每個monitorenter必須有對應的monitorexit與之配對
  5. 任何物件都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態
  6. 執行緒執行monitorenter指令時,將會嘗試獲取物件所對應的monitor的所有權,即嘗試獲得物件的鎖
  7. 執行緒執行monitorexit指令時,將會將進入次數-1直到變成0時釋放監視器
  8. 同一時刻只有一個執行緒能夠成功,其它失敗的執行緒會被阻塞,並放入到同步佇列中,進入BLOCKED狀態

補充

由於 wait/notify 等方法底層實現是基於監視器,因此只有在同步方法(塊)中才能呼叫wait/notify等方法,否則會丟擲 java.lang.IllegalMonitorStateException 的異常的原因

同步方法同步原理

區別於同步程式碼塊的監視器實現,同步方法通過使用 ACC_SYNCHRONIZED 標記符隱示的實現原理是通過方法呼叫指令檢查該方法在常量池中是否包含 ACC_SYNCHRONIZED 標記符,如果有,JVM 要求執行緒在呼叫之前請求鎖

進階原理

Monitor Obejct模式

Monitor Obejct模式綜述

Monitor其實是一種同步工具,也可以說是一種同步機制,它通常被描述為一個物件,主要特點是互斥和訊號機制

  1. 互斥: 一個Monitor鎖在同一時刻只能被一個執行緒佔用,其他執行緒無法佔用
  2. 訊號機制(signal): 佔用Monitor鎖失敗的執行緒會暫時放棄競爭並等待某個謂詞成真(條件變數),但該條件成立後,當前執行緒會通過釋放鎖通知正在等待這個條件變數的其他執行緒,讓其可以重新競爭鎖

Mesa派的signal機制

  1. Mesa派的signal機制又稱"Non-Blocking condition variable"
  2. 佔有Monitor鎖的執行緒發出釋放通知時,不會立即失去鎖,而是讓其他執行緒等待在佇列中,重新競爭鎖
  3. 這種機制裡,等待者拿到鎖後不能確定在這個時間差裡是否有別的等待者進入過Monitor,因此不能保證謂詞一定為真,所以對條件的判斷必須使用while
  4. Java中採用就是Mesa派的singal機制,即所謂的notify

Monitor Obejct模式結構

在 Monitor Object 模式中,主要有四種型別的參與者:

Monitor Obejct模式協作過程

  1. 同步方法的呼叫和序列化:

    • 當客戶執行緒呼叫監視者物件的同步方法時,必須首先獲取它的監視鎖
    • 只要該監視者物件有其他同步方法正在被執行,獲取操作便不會成功
    • 當監視者物件已被執行緒佔用時(即同步方法正被執行),客戶執行緒將被阻塞直到它獲取監視鎖
    • 當客戶執行緒成功獲取監視鎖後,進入臨界區,執行方法實現的服務
    • 一旦同步方法完成執行,監視鎖會被自動釋放,目的是使其他客戶執行緒有機會呼叫執行該監視者物件的同步方法
  2. 同步方法執行緒掛起:

    • 如果呼叫同步方法的客戶執行緒必須被阻塞或是有其他原因不能立刻進行,它能夠在一個監視條件(Monitor Condition)上等待,這將導致該客戶執行緒暫時釋放監視鎖,並被掛起在監視條件上
  3. 監視條件通知:

    • 一個客戶執行緒能夠通知一個監視條件,目的是通知阻塞在該監視條件(該監視鎖)的執行緒恢復執行
  4. 同步方法執行緒恢復:

    • 一旦一個早先被掛起在監視條件上的同步方法執行緒獲取通知,它將繼續在最初的等待監視條件的點上執行
    • 在被通知執行緒被允許恢復執行同步方法之前,監視鎖將自動被獲取(執行緒間自動相互競爭鎖)

物件頭

JVM記憶體中的物件

在JVM中,物件在記憶體中的佈局分成三塊區域:物件頭、示例資料和對齊填充

物件頭: 物件頭主要儲存 Mark Word(物件的hashCode、鎖資訊)、型別指標、陣列長度(若是陣列的話)等資訊

示例資料:存放類的屬性資料資訊,包括父類的屬性資訊,如果是陣列的例項部分還包括陣列長度,這部分記憶體按4位元組對齊

填充資料:由於JVM要求物件起始地址必須是8位元組的整數倍,當不滿足8位元組時會自動填充(因此填充資料並不是必須的,僅僅是為了位元組對齊)

物件頭綜述

  1. synchcronized的鎖是存放在Java物件頭中的

  2. 如果物件是陣列型別,JVM用3個字寬(Word)儲存物件頭,否則是用2個子寬在32位虛擬機器器中,1字寬等於4個位元組,即32bit;64位的話就是8個位元組,即64bit

Mark Word的儲存結構

32位JVM的Mark Word的預設儲存結構(無鎖狀態)

在執行期間,Mark Word裡儲存的資料會隨著鎖標誌位的變化而變化(32位)

64位JVM的Mark Word的預設儲存結構(對於32位無鎖狀態,有25bit沒有使用)

Monitor Record

Monitor Record綜述

  1. MonitorRecord(統一簡稱MR)是Java執行緒私有的資料結構,每一個執行緒都有一個可用MR列表,同時還有一個全域性的可用列表
  2. 一個被鎖住的物件都會和一個MR關聯(物件頭的MarkWord中的LockWord指向MR的起始地址)
  3. MR中有一個Owner欄位存放擁有該鎖的執行緒的唯一標識,表示該鎖被這個執行緒佔用

Monitor Record結構

Monitor Record工作原理

  1. 執行緒如果獲得監視鎖成功,將成為該監視鎖物件的擁有者
  2. 在任一時刻,監視器物件只屬於一個活動執行緒(Owner)
  3. 擁有者可以呼叫wait方法自動釋放監視鎖,進入等待狀態

鎖優化

自旋鎖

  1. 痛點:由於執行緒的阻塞/喚醒需要CPU在使用者態和核心態間切換,頻繁的轉換對CPU負擔很重,進而對併發效能帶來很大的影響
  2. 現象:通過大量分析發現,物件鎖的鎖狀態通常只會持續很短一段時間,沒必要頻繁地阻塞和喚醒執行緒
  3. 原理:通過執行一段無意義的空迴圈讓執行緒等待一段時間,不會被立即掛起,看持有鎖的執行緒是否很快釋放鎖,如果鎖很快被釋放,那當前執行緒就有機會不用阻塞就能拿到鎖了,從而減少切換,提高效能
  4. 隱患:若鎖能很快被釋放,那麼自旋效率就很好(真正執行的自旋次數越少效率越好,等待時間就少);但若是鎖被一直佔用,那自旋其實沒有做任何有意義的事但又白白佔用和浪費了CPU資源,反而造成資源浪費
  5. 注意:自旋次數必須有個限度(或者說自旋時間),如果超過自旋次數(時間)還沒獲得鎖,就要被阻塞掛起
  6. 使用: JDK1.6以上預設開啟-XX:+UseSpinning,自旋次數可通過-XX:PreBlockSpin調整,預設10次

自適應自旋鎖

  1. 痛點:由於自旋鎖只能指定固定的自旋次數,但由於任務的差異,導致每次的最佳自旋次數有差異
  2. 原理:通過引入"智慧學習"的概念,由前一次在同一個鎖上的自旋時間和鎖的持有者的狀態來決定自旋的次數,換句話說就是自旋的次數不是固定的,而是可以通過分析上次得出下次,更加智慧
  3. 實現:若當前執行緒針對某鎖自旋成功,那下次自旋此時可能增加(因為JVM認為這次成功是下次成功的基礎),增加的話成功機率可能更大;反正,若自旋很少成功,那麼自旋次數會減少(減少空轉浪費)甚至直接省略自旋過程,直接阻塞(因為自旋完全沒有意義,還不如直接阻塞)
  4. 補充:有了自適應自旋鎖,隨著程式執行和效能監控資訊的不斷完善,JVM對鎖的狀況預測會越來越準確,JVM會變得越來越智慧

阻塞鎖

阻塞鎖

  1. 加鎖成功:當出現鎖競爭時,只有獲得鎖的執行緒能夠繼續執行
  2. 加鎖失敗:競爭失敗的執行緒會由running狀態進入blocking狀態,並被放置到與目標鎖相關的一個等待佇列中
  3. 解鎖:當持有鎖的執行緒退出臨界區,釋放鎖後,會將等待佇列中的一個阻塞執行緒喚醒,令其重新參與到鎖競爭中

公平鎖

公平鎖就是獲得鎖的順序按照先到先得的原則,從實現上說,要求當一個執行緒競爭某個物件鎖時,只要這個鎖的等待佇列非空,就必須把這個執行緒阻塞並塞入隊尾(插入隊尾一般通過一個CAS操作保持插入過程中沒有鎖釋放)

非公平鎖

相對的,非公平鎖場景下,每個執行緒都先要競爭鎖,在競爭失敗或當前已被加鎖的前提下才會被塞入等待佇列,在這種實現下,後到的執行緒有可能無需進入等待佇列直接競爭到鎖(隨機性)

鎖粗化

  1. 痛點:多次連線在一起的加鎖、解鎖操作會造成
  2. 原理:將多次連線在一起的加鎖、解鎖操作合併為一次,將多個連續的鎖擴充套件成一個範圍更大的鎖
  3. 使用:將多個彼此靠近的同步塊合同在一個同步塊 或 把多個同步方法合併為一個方法
  4. 補充:在JDK內建的API中,例如StringBuffer、Vector、HashTable都會存在隱性加鎖操作,可合併
/**
  * StringBuffer是執行緒安全的字串處理類
  * 每次呼叫stringBuffer.append方法都需要加鎖和解鎖,如果虛擬機器器檢測到有一系列連串的對同一個物件加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次append方法時進行加鎖,最後一次append方法結束後進行解鎖
  */
StringBuffer stringBuffer = new StringBuffer();
public void append(){
    stringBuffer.append("kira");
    stringBuffer.append("sally");
    stringBuffer.append("mengmeng");
}
複製程式碼

鎖消除

  1. 痛點:根據程式碼逃逸技術,如果判斷到一段程式碼中,堆上的資料不會逃逸出當前執行緒,那麼可以認為這段程式碼是執行緒安全的,不必要加鎖
  2. 原理: JVM在編譯時通過對執行上下文的描述,去除不可能存在共享資源競爭的鎖,通過這種方式消除無用鎖,即刪除不必要的加鎖操作,從而節省開銷
  3. 使用: 逃逸分析和鎖消除分別可以使用引數-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(鎖消除必須在-server模式下)開啟
  4. 補充:在JDK內建的API中,例如StringBuffer、Vector、HashTable都會存在隱性加鎖操作,可消除
/**
  * 比如執行10000次字串的拼接
  */
public static void main(String[] args) {
    SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
    for (int i = 0 ; i < 10000 ; i++){
        synchronizedDemo.append("kira","sally");
    }
}
public void append(String str1,String str2){
    //由於StringBuffer物件被封裝在方法內部,不可能存在共享資源競爭的情況
    //因此JVM會認為該加鎖是無意義的,會在編譯期就刪除相關的加鎖操作
    //還有一點特別要註明:明知道不會有執行緒安全問題,程式碼階段就應該使用StringBuilder
    //否則在沒有開啟鎖消除的情況下,StringBuffer不會被優化,效能可能只有StringBuilder的1/3
    StringBuffer stringBuffer = new StringBuffer();
    stringBuffer.append(str1).append(str2);
}/** 
複製程式碼

鎖的升級

  1. 從JDK1.6開始,鎖一共有四種狀態:無鎖狀態、偏向鎖狀態、輕量鎖狀態、重量鎖狀態

  2. 鎖的狀態會隨著競爭情況逐漸升級,鎖允許升級但不允許降級

  3. 不允許降級的目的是提高獲得鎖和釋放鎖的效率

  4. 後面會通過倒序的方式,即重量級鎖->輕量級鎖->偏向鎖進行講解,因為通常後者是前者的優化

鎖的升級過程

重量級鎖

  1. 重量級鎖通過物件內部的monitor實現(見上文的Monitor Object模式)
  2. monitor的本質是依賴於底層作業系統的MutexLock實現,作業系統實現執行緒間的切換是通過使用者態與核心態的切換完成的,而切換成本很高
  3. MutexLock最核心的理念就是嘗試獲取鎖.若可得到就佔有.若不能,就進入睡眠等待

輕量級鎖

輕量級鎖綜述

  1. 痛點:由於執行緒的阻塞/喚醒需要CPU在使用者態和核心態間切換,頻繁的轉換對CPU負擔很重,進而對併發效能帶來很大的影響
  2. 主要目的: 在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗
  3. 升級時機: 當關閉偏向鎖功能或多執行緒競爭偏向鎖會導致偏向鎖升級為輕量級鎖
  4. 原理: 在只有一個執行緒執行同步塊時進一步提高效能
  5. 資料結構: 包括指向棧中鎖記錄的指標、鎖標誌位

輕量級鎖流程圖

執行緒1和執行緒2同時爭奪鎖,並導致鎖膨脹成重量級鎖

輕量級鎖加鎖

  1. 執行緒在執行同步塊之前,JVM會先在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中(Displaced Mark Word-即被取代的Mark Word)做一份拷貝
  2. 拷貝成功後,執行緒嘗試使用CAS將物件頭的Mark Word替換為指向鎖記錄的指標(將物件頭的Mark Word更新為指向鎖記錄的指標,並將鎖記錄裡的Owner指標指向Object Mark Word)
  3. 如果更新成功,當前執行緒獲得鎖,繼續執行同步方法
  4. 如果更新失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖,若自旋後沒有獲得鎖,此時輕量級鎖會升級為重量級鎖,當前執行緒會被阻塞

輕量級鎖解鎖

  1. 解鎖時會使用CAS操作將Displaced Mark Word替換回到物件頭,
  2. 如果解鎖成功,則表示沒有競爭發生
  3. 如果解鎖失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖,需要在釋放鎖的同時喚醒被阻塞的執行緒,之後執行緒間要根據重量級鎖規則重新競爭重量級鎖

輕量級鎖注意事項

隱患:對於輕量級鎖有個使用前提是"沒有多執行緒競爭環境",一旦越過這個前提,除了互斥開銷外,還會增加額外的CAS操作的開銷,在多執行緒競爭環境下,輕量級鎖甚至比重量級鎖還要慢

偏向鎖

偏向鎖綜述

  1. 痛點: Hotspot作者發現在大多數情況下不存在多執行緒競爭的情況,而是同一個執行緒多次獲取到同一個鎖,為了讓執行緒獲得鎖代價更低,因此設計了偏向鎖 (這個跟業務使用有很大關係)
  2. 主要目的: 為了在無多執行緒競爭的情況下儘量減少不必要的輕量級鎖執行路徑
  3. 原理: 在只有一個執行緒執行同步塊時通過增加標記檢查而減少CAS操作進一步提高效能
  4. 資料結構: 包括佔有鎖的執行緒id,是否是偏向鎖,epoch(偏向鎖的時間戳),物件分代年齡、鎖標誌位

偏向鎖流程圖

執行緒1演示了偏向鎖的初始化過程,執行緒2演示了偏向鎖的撤銷鎖過程

偏向鎖初始化

  1. 當一個執行緒訪問同步塊並獲取到鎖時,會在物件頭和棧幀中的鎖記錄裡儲存偏向鎖的執行緒ID,以後該執行緒在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而是先簡單檢查物件頭的MarkWord中是否儲存了當前執行緒
  2. 如果已儲存,說明當前執行緒已經獲取到鎖,繼續執行任務即可
  3. 如果未儲存,則需要再判斷當前鎖否是偏向鎖(即物件頭中偏向鎖的標識是否設定為1,鎖標識位為01)
  4. 如果沒有設定,則使用CAS競爭鎖(說明此時並不是偏向鎖,一定是等級高於它的鎖)
  5. 如果設定了,則嘗試使用CAS將物件頭的偏向鎖指向當前執行緒,也就是結構中的執行緒ID

偏向鎖撤銷鎖

  1. 偏向鎖使用一種等到競爭出現才釋放鎖的機制,只有當其他執行緒競爭鎖時,持有偏向鎖的執行緒才會釋放鎖

  2. 偏向鎖的撤銷需要等待全域性安全點(該時間點上沒有位元組碼正在執行)

  3. 偏向鎖的撤銷需要遵循以下步驟: -

    • 首先會暫停擁有偏向鎖的執行緒並檢查該執行緒是否存活:
      • 如果執行緒非活動狀態,則將物件頭設定為無鎖狀態(其他執行緒會重新獲取該偏向鎖)
      • 如果執行緒是活動狀態,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,並將對棧中的鎖記錄和物件頭的MarkWord進行重置
  4. 要麼重新偏向於其他執行緒(即將偏向鎖交給其他執行緒,相當於當前執行緒"被"釋放了鎖)

  5. 要麼恢復到無鎖或者標記鎖物件不適合作為偏向鎖(此時鎖會被升級為輕量級鎖)

  6. 最後喚醒暫停的執行緒,被阻塞在安全點的執行緒繼續往下執行同步程式碼塊

偏向鎖關閉鎖

  1. 偏向鎖在JDK1.6以上預設開啟,開啟後程式啟動幾秒後才會被啟用
  2. 有必要可以使用JVM引數來關閉延遲 -XX:BiasedLockingStartupDelay = 0
  3. 如果確定鎖通常處於競爭狀態,則可通過JVM引數 -XX:-UseBiasedLocking=false 關閉偏向鎖,那麼預設會進入輕量級鎖

偏向鎖注意事項

  1. 優勢:偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令,其餘時刻不需要CAS指令(相比其他鎖)
  2. 隱患:由於一旦出現多執行緒競爭的情況就必須撤銷偏向鎖,所以偏向鎖的撤銷操作的效能損耗必須小於節省下來的CAS原子指令的效能消耗(這個通常只能通過大量壓測才可知)
  3. 對比:輕量級鎖是為了線上程交替執行同步塊時提高效能,而偏向鎖則是在只有一個執行緒執行同步塊時進一步提高效能

偏向鎖 vs 輕量級鎖 vs 重量級鎖