synchronized原理剖析

synchronized原理剖析

並發編程存在什麼問題?

1️⃣ 可見性

可見性:是指當一個執行緒對共享變數進行了修改,那麼另外的執行緒可以立即看到修改後的最新值。

案例演示:一個執行緒A根據 boolean 類型的標記 flag,while死循環;另一個執行緒B改變這個flag變數的值;那麼執行緒A並不會停止循環。

/**
  案例演示:
          一個執行緒對共享變數的修改,另一個執行緒不能立即得到最新值
*/
public class Test01Visibility{
    // 多個執行緒都會訪問的數據,我們稱為執行緒的共享數據
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException{
      // t1執行緒不斷的來讀取run共享變數的取值
      Thread t1 = new Thread(() -> {
        while(flag){
			
        }
      });
      t1.start();

      Thread.sleep(1000);

      //t2執行緒對該共享變數的取值進行修改
      Thread t2 = new Thread(() -> {
        flag = false;
        System.out.println("時間到,線層2設置為false");
      });
      t2.start();

      // 可以觀測得到t2執行緒對 flag 共享變數的修改,t1執行緒並不能夠讀取到更改了之後的值,導致程式不能停止;
      // 這就出現了可見性問題
    }
}

解決可見性:

Ⅰ. 在共享變數前面加上volatile關鍵字修飾;

Q:為什麼volatile關鍵字能保證可見性?

  • volatile 的底層實現原理是記憶體屏障(Memory Barrier),保證了對 volatile 變數的寫指令後會加入寫屏障,對 volatile 變數的讀指令前會加入讀屏障。
    • 寫屏障(sfence)保證在寫屏障之前的,對共享變數的改動,都同步到主存當中;
    • 讀屏障(lfence)保證在讀屏障之後,對共享變數的讀取,載入的是主存中最新數據;

為什麼volatile關鍵字能解決有序性看下文有序性部分。

Ⅱ. 在死循環內寫一個 synchronized 同步程式碼塊,因為synchronized 同步時會對應 JMM 中的 lock 原子操作,lock 操作會刷新工作記憶體中的變數的值,得到共享記憶體(主記憶體)中最新的值,從而保證可見性。【看下文Java記憶體模型】

Q:為什麼synchronized 同步程式碼塊能保證可見性?

synchronized 同步的時候會對應8個原子操作當中的 lockunlock 這兩個原子操作,lock操作執行時該執行緒就會去主記憶體中獲取到共享變數最新值,刷新工作記憶體中的舊值,從而保證可見性。

    Thread t1 = new Thread(() -> {
        while(run){
            synchronized(obj) {		// 死循環內加一個同步程式碼塊
            
            }
        }
      });
      t1.start();
// 或者
    Thread t1 = new Thread(() -> {
        while(run){
            // 輸出語句也能保證可見性?
            // 因為PrintStream.java中的println(boolean x)方法中也使用到了synchronized,synchronized 能保證可見性
            System.out.println();
        }
      });
      t1.start();

小結:

可見性(Visibility):是指當一個執行緒對共享變數進行了修改,那麼另外的執行緒可以立即看到修改後的最新值。

synchronized 可以保證可見性,但缺點是 synchronized 鎖屬於重量級操作,性能相對更低。


2️⃣ 原子性

原子性(Atomicity): 在一次或多次操作中,要麼所有的操作都執行,並且不會受其他因素干擾而中斷,要麼所有的操作都不執行;

案例演示:5個執行緒各執行1000次i++操作:

/**
    案例演示:5個執行緒各執行1000次 i++;
*/
public class Test02Atomicity{
    private static int number = 0;
    public static void main(String[] args) throws InterruptedException{

        // 5個執行緒都執行1000次 i++
        Runnable increment = () -> {
            for( int i = 0 ; i < 1000; i++){
                number++;
            }
        };

        // 5個執行緒
        ArrayList<Thread> ts = new ArrayList<>();
        for(int i = 0; i < 5 ; i++){
            Thread t = new Thread(increment);
            t.start();
            ts.add(t);
        }

        for(Thread t : ts){
            t.join();
        }

        /* 最終的效果即,加出來的效果不是5000,可能會少於5000
        那麼原因就在於 i++ 並不是一個原子操作
        下面會通過java反彙編的方式來進行演示和分析,這個 i++ 其實有4條指令
    */
        System.out.println("number = "+ number);
    }
}

Idea 中找到target目錄,找到當前java文件的位元組碼.class文件,該目錄下打開cmd,輸入javap -p -v xxx.class,得到位元組碼指令,其中,number++對應的位元組碼指令為:

9: getstatic #18 // Field number:I	獲取靜態變數的值
12: iconst_1	// 準備一個常量1
13: iadd		// 讓靜態變數和1做相加操作
14: putstatic #18 // Field number:I		把相加後的結果賦值給靜態變數

number++是由四條位元組碼指令組成的,那麼在一個執行緒下是沒有問題的,但如果是放在多執行緒的情況下就有問題,比如執行緒 A 在執行 13: iadd 前,CPU又切換到另外一個執行緒B,執行緒 B 執行了 9: getstatic,就會導致兩次 number++,但實際上只加了1。

這個問題的原因就在於讓兩個執行緒來進行操作number++, 而number++的位元組碼指令又是多條指令(4條指令),其中一個執行緒執行到一半時,CPU又切換到另外一個執行緒,另外一個執行緒來執行,讀取到的值依然跟另一個執行緒一樣, 即第二個執行緒干擾了第一個執行緒的執行從而導致執行結果的錯誤,沒有保證原子性。

解決原子性:

synchronized 可以保證 number++ 的原子性。synchronized 能夠保證在同一時刻最多只有一個執行緒執行該段程式碼,已保證並發安全的效果。

synchronized(obj){
    number++;
}

加了 synchronized 同步程式碼塊後,每次運行的結果都是 5000.

Idea 中找到 target 目錄,找到當前java文件的位元組碼.class文件,該目錄下打開cmd,輸入javap -p -v xxx.class,得到位元組碼指令,其中,num++對應的位元組碼指令還是中間的四條,不過上下新增了幾條指令(後文會講到):

14: monitorenter

15: getstatic        #18        // Field number:I
18: iconst_1
19: iadd
20: putstatic        #18        // Field number:I

23: aload_1
24: monitorexit

小結:

原子性(Atomicity): 在一次的操作或多次操作中,要麼所有的操作全部都得到了執行並且不會受到任何因素的干擾而中斷,要麼所有的操作都不執行。

原子性可以通過 synchronized 同步程式碼塊或 ReentrantLock 來解決。


3️⃣ 有序性

有序性(Ordering):是指程式程式碼在執行過程中的先後順序,由於java在編譯器以及運行期的優化,導致了程式碼的執行順序未必就是開發者編寫程式碼的順序。

Q:為什麼要重排序?

一般會認為編寫程式碼的順序就是程式碼最終的執行順序,那麼實際上並不一定是這樣的,為了提高程式的執行效率,java在編譯時和運行時會對程式碼進行優化(JIT即時編譯器),會導致程式最終的執行順序不一定就是編寫程式碼時的順序。

重排序 是指 編譯器 和 處理器 為了優化程式性能 而對 指令序列 進行 重新排序 的一種手段;

解決有序性:

Ⅰ. 可以使用 synchronized 同步程式碼塊來保證有序性;

Q:synchronized保證有序性的原理是?

加了synchronized,依然會發生指令重排序(可以看看DCL單例模式),只不過,由於存在同步程式碼塊,可以保證只有一個執行緒執行同步程式碼塊當中的程式碼,也就能保證有序性。

Ⅱ. 除了可以使用synchronized來進行解決,還可以給共享變數加volatile關鍵字來解決有序性問題。

volatile如何保證有序性的?

  • 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後;
  • 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前;

總結

synchronized 可以保證原子性、有序性和可見性,而 volatile 只能保證有序性和可見性;

synchronized 是個重量級鎖,應盡量少使用;


Java記憶體模型

定義

java記憶體模型(即 java Memory Model,簡稱JMM),主要分成兩部分來看,一部分叫做主記憶體,另一部分叫做工作記憶體。

  • java當中的共享變數;都放在主記憶體當中,如類的成員變數(實例變數),還有靜態的成員變數(類變數),都是存儲在主記憶體中的。每一個執行緒都可以訪問主記憶體;
  • 每一個執行緒都有其自己的工作記憶體,當執行緒要執行程式碼的時候,就必須在工作記憶體中完成。比如執行緒操作共享變數,它是不能直接在主記憶體中操作共享變數的,只能夠將共享變數先複製一份,放到執行緒自己的工作記憶體當中,執行緒在其工作記憶體對該複製過來的共享變數處理完後,再將結果同步回主記憶體中去。

主記憶體

  • 主記憶體是 所有執行緒共享的,都能訪問的。所有的共享變數存儲於主記憶體
  • 共享變數主要包括類當中的成員變數,以及一些靜態變數等。局部變數是不會出現在主記憶體當中的,因為局部變數只能執行緒自己使用;

工作記憶體

  • 每一個執行緒都有自己的工作記憶體,工作記憶體只存儲 該執行緒對共享變數的副本。執行緒對變數的所有讀寫操作都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變數,不同執行緒之間也不能直接訪問 對方工作記憶體中的 變數
  • 執行緒對共享變數的操作都是對其副本進行操作,操作完成之後再同步回主記憶體當中去;

作用

主要目的就是在多執行緒對共享變數進行讀寫時,來保證共享變數的可見性、有序性、原子性;在編程當中是通過兩個關鍵字 synchronizedvolatile 來保證共享變數的三個特性的。

主記憶體與工作記憶體如何交互

一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體的呢?

Java記憶體模型 中定義了上圖中的 8 種操作(橙色箭頭)來完成,虛擬機實現時必須保證每一種操作都是原子的、不可再分的。

舉個🌰:

假設現在執行緒1想要來訪問主記憶體當中的共享變數 x ,即當前主記憶體中的共享變數x的取值為 boolean x = true;
    
執行緒1首先會做一個原子操作叫做Read,讀取主記憶體當中的共享變數x的取值,即 boolean x = true;
    
接下來就是 Load 操作,把在主記憶體中讀取到的共享變數載入到了工作記憶體當中(副本);
    
接著執行 Use 操作,如果執行緒1需要對共享變數x進行操作,即會取到從主記憶體中載入過來的共享變數x的取值去進行一些操作;

操作之後會有一個新的結果返回,假設令這個共享變數的取值變為false,完成 Assign 操作,即給共享變數x賦新值;
    
操作完成之後;就需要同步回主記憶體,首先會完成一個 Store 的原子操作,來保存這個處理結果;
    
接著執行Write操作,即在工作記憶體中,Assign 賦值給共享變數的值同步到主記憶體當中,主記憶體中共享變數取值x由true更改為false。
--------------------------------
另外還有兩個與鎖相關的操作,Lock與unlock,比如說加了synchronized,才會產生有lock與unlock操作;
如果對共享變數的操作沒有加鎖,那麼也就不會有lock與unlock操作。

注意:

  • 如果對共享變數執行 lock 操作,該執行緒就會去主記憶體中獲取到共享變數的最新值,刷新工作記憶體中的舊值,保證可見性;(加鎖說明要對這個共享變數進行寫操作了,先刷新舊值,再操作新值)
  • 對共享變數執行 unlock 操作,必須先把此變數同步回主記憶體中,再執行 unlock;(因為對共享變數釋放鎖,接下來其他執行緒就能訪問到這個共享變數,就必須使這個共享變數呈現的是最新值)

這兩點就是 synchronized為什麼能保證「可見性」的原因。

小結

主記憶體 與 工作記憶體 之間的 數據交互過程(即主記憶體與工作記憶體的交互是通過這8個原子操作來保證數據的正確性的):

lock → read → load → use → assign → store → write → unlock


synchronized 的特性

synchronized 作為悲觀鎖,具有兩個特性,一個是 可重入性,一個是不可中斷性。

1️⃣ 可重入

定義

指的是 同一個執行緒的 可以多次獲得 同一把鎖(一個執行緒可以多次執行synchronized,重複獲取同一把鎖)。

/*
  可重入特性
    指的是 同一個執行緒獲得鎖之後,可以再次獲取該鎖。
*/
public class Demo01{
    public static void main(String[] args){
        Runnable sellTicket =  new Runnable(){
            @Override
            public void run(){
                synchronized(Demo01.class){
                    System.out.println("我是run");
                    test01();
                }
            }

            public void test01(){
                synchronized(Demo01.class){
                    System.out.println("我是test01");
                }
            }
        };

        new Thread(sellTicket).start();
        new Thread(sellTicket).start();
    }
}

原理

synchronized 的鎖對象中有一個計數器(recursions變數)會記錄執行緒獲得幾次鎖,每重入一次,計數器就 + 1,在執行完一個同步程式碼塊時,計數器數量就會減1,直到計數器的數量為0才釋放這個鎖。

優點

  1. 可以避免死鎖(如果不能重入,那就不能再次進入這個同步程式碼塊,導致死鎖);
  2. 更好地封裝程式碼(可以把同步程式碼塊寫入到一個方法中,然後在另一個同步程式碼塊中直接調用該方法實現可重入);

2️⃣ 不可中斷

定義

執行緒A獲得鎖後,執行緒B要想獲得鎖,必須處於阻塞或等待狀態。如果執行緒A不釋放鎖,那執行緒B會一直阻塞或等待,阻塞等待過程中,執行緒B不可被中斷。

synchronized 是不可中斷的,處於阻塞狀態的執行緒會一直等待鎖。

案例演示

public class Demo02_Uninterruptible{

    private static Object obj = new Object();		// 定義鎖對象

    public static void main(String[] args){
        // 1. 定義一個Runnable
        Runnable run = ()->{
            // 2. 在Runnable定義同步程式碼塊;同步程式碼塊需要一個鎖對象;
            synchronized(obj){

                // 列印是哪一個執行緒進入的同步程式碼塊
                String name = Thread.currentThread().getName();
                System.out.println(name + "進入同步程式碼塊");

                Thread.sleep(888888);
            }
        };

        // 3. 先開啟一個執行緒來執行同步程式碼塊
        Thread t1 = new Thread(run);
        t1.start();
        // 保證第一個執行緒先去執行同步程式碼塊
        Thread.sleep(1000);

        /**
      4. 後開啟一個執行緒來執行同步程式碼塊(阻塞狀態)到時候第二個執行緒去執行同步程式碼塊的時候,
      鎖已經被t1執行緒鎖獲取得到了;所以執行緒t2是無法獲取得到Object obj對象鎖的;
      那麼也就將在同步程式碼塊外處於阻塞狀態。*/
        Thread t2 = new Thread(run);
        t2.start();


        /** 5. 停止第二個執行緒;觀察此執行緒t2能否被中斷;*/
        System.out.println("停止執行緒前");
        t2.interrupt();	// 通過interrupt()方法給t2執行緒進行強行中斷
        System.out.println("停止執行緒後");

        // 最後得到兩個執行緒的執行狀態
        System.out.println(t1.getState());	// TIMED_WAITING
        System.out.println(t2.getState());	// BLOCKED
    }
}

// 運行結果:
Thread-0進入同步程式碼塊
    停止執行緒前
    停止執行緒後
    TIMED_WAITING
    BLOCKED		// t2的狀態依然為BLOCKED,說明synchronized是不可被中斷的

結果分析:

​ 通過interrupt()方法讓 t2 執行緒強行中斷,最後列印t2的狀態,依然為BLOCKED,即執行緒不可中斷。

對比 ReentrantLock

ReentrantLocklock方法是不可中斷的,tryLock方法是可中斷的。

Ⅰ. 演示 ReentrantLock 不可中斷:

public class Demo03_Interruptible{

    // 創建一個Lock對象
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args)throws InterruptedException{
        test01();
    }

    // 演示 Lock 不可中斷
    public static void test01(){
        Runnable run = ()->{

            String name =Thread.currentThread().getName();
            try{
                lock.lock();	// lock() 無返回值
                System.out.println(name + "獲得鎖,進入鎖執行");
                Thread.sleep(88888);
            }catch(InterruptedException e){
                e.printStackTrace();
            }finally{
                lock.unlock();	// unlock也是沒有返回值的
                System.out.println(name + "釋放鎖");
            }
        };

        Thread t1 = new Thread(run);
        t1.start();

        Thread.sleep(1000);

        Thread t2 = new Thread(run);
        t2.start();

        System.out.println("停止t2執行緒前");
        t2.interrupt();
        System.out.println("停止t2執行緒後");

        Thread.sleep(1000);

        System.out.println(t1.getState());
        System.out.println(t2.getState());
    }
}

--------------------------------------------------
    運行效果:
    Thread-0獲得鎖,進入鎖執行
    停止t2執行緒前
    停止t2執行緒後
    TIMED_WAITING	// t1執行緒在臨界區睡88888ms,有時限的等待
    WAITING		// t2執行緒處於等待狀態,WAITING

Ⅱ. 演示 ReentrantLock 可中斷:

public class Demo03_Interruptible{
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args)throws InterruptedException{
        test02();
    }

    // 演示 Lock 可中斷
    public static void test02() throws InterruptedException{
        Runnable run = ()->{
            String name = Thread.currentThread().getName();
            boolean b = false;
            try{

                b = lock.tryLock(3, TimeUnit.SECONDS);

                //說明嘗試獲取得到了鎖;則進入if塊當中
                if(b){
                    System.out.println(name + "獲得鎖,進入鎖執行");
                    Thread.sleep(888888);
                }else{
                    // 沒有獲取得到鎖執行else,證明了Lock.tryLock()是可中斷的;
                    System.out.println(name + "在指定時間內沒有獲取得到鎖則做其他操作");
                }
            }catch(InterruptedException e){
                e.printStackTrace();
            }finally{
                if(b){	//得到了鎖才釋放鎖
                    lock.unlock();
                    System.out.println(name + "釋放鎖");
                }
            }
        };

        Thread t1 = new Thread(run);
        t1.start();
        Thread t2 = new Thread(run);
        t2.start();
    }
}
--------------------------------------------------------
    程式碼執行效果:
    Thread-0獲得鎖,進入鎖執行
    Thread-1在指定時間沒有得到鎖做其他操作

小結

synchronizedReentrantLock 都是可重入鎖;

synchronized 獲取不到鎖會阻塞等待,該過程不可中斷,而ReentrantLocklock 方法不可中斷,tryLock方法是可中斷的。


synchronized 底層

首先通過javap反彙編的方式來學習synchronized原理:

舉個例子:

public class Demo01{
    //依賴的鎖對象
    private static Object obj = new Object();

    @Override
    public void run(){
        for(int i = 0; i < 1000; i++){

            // synchronized同步程式碼塊;且在程式碼塊當中做了簡單的列印操作;
            // 重點是看synchronized在反彙編之後形成的位元組碼指令
            synchronized( obj ){
                System.out.println("1");
            }
        }
    }

    // 編寫了一個synchronized修飾的方法
    // synchronized修飾程式碼塊與synchronized修飾方法反彙編之後的結果是不太一樣的;
    public synchronized void test(){
        System.out.println("a");
    }
};
// 程式碼寫好之後讓idea編譯得到位元組碼文件;
// 編譯好的位元組碼文件目錄:工程名/target/classes/xxx/demo04_synchronized_monitor/Demo01.class

找到 target目錄下的.class文件,cmd下輸入javap -p -v xxx.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: getstatic           #2        // Field obj:Ljava/lang/Object;
          3: dup
          4: astore_1
          
          5: monitorenter
          6: getstatic           #3        // Field java/lang/System.out:Ljava/io/PrintStream;
          9: 1dc                 #4        // String 1
         11: invokevirtual       #5        // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         14: aload_1
         15: monitorexit
         
         16: goto                24
         19: astore_2
         20: aload_1
         
         21: monitorexit	// 這個意思是說當同步程式碼塊內出現異常時,會自動幫我們釋放鎖
         22: aload_2
         23: athrow
         24: return
      Exception table:
          from     to    target   type
             6      16       19    any
            19      22       19    any

monitorenter

每一個synchronized 鎖對象 都會和 一個監視器monitor關聯,監視器被佔用時會被鎖住,其他執行緒無法來獲取該monitor(這個monitor才是真正的鎖),當JVM執行某個執行緒的某個方法內部的monitorenter時,它會嘗試去獲取當前對象對應的monitor的所有權(即嘗試去獲取這把鎖;有可能獲取到,也有可能獲取不到),過程如下:

  1. monitor的進入數為0,執行緒可以進入 monitor,並將 monitor的進入數 置為1。 當前執行緒成為 monitor的 owner(所有者);
  2. 若執行緒已擁有 monitor的所有權,允許它 重入 monitor,則進入monitor的進入數加1;
  3. 若其他執行緒已經佔有monitor的所有權,那麼當前嘗試獲取monitor的所有權的執行緒會被阻塞,直到monitor的進入數變為0,才能重新嘗試獲取monitor的所有權;

monitor內部有兩個重要的成員變數:

  • owner:擁有這把鎖的執行緒;
  • recursions:記錄執行緒擁有鎖的次數

monitorexit

能執行monitorexit指令的執行緒一定是擁有當前對象的monitor的所有權的執行緒。

執行monitorexit時會將monitor的進入數(已重入次數)減1,當monitor的進入數減為0時,當前執行緒退出monitor,不再擁有monitor的所有權,此時其他被這個monitor阻塞的執行緒可以嘗試去獲取這個monitor的所有權。

monitorexit插入在方法結束處和異常處(查看上面的位元組碼指令會發現有兩個monitorexit),jvm 保證每個monitorenter必須有一個對應的monitorexit

Q:synchronized 程式碼塊內出現異常會釋放鎖嗎?【面試題】

A:會自動釋放鎖,查看位元組碼指令可以知道,monitorexit插入在方法結束處和異常處。從Exception table異常表中也可以看出。

      Exception table:
          from     to    target   type
             6      16       19    any
            19      22       19    any
// from ... to : 指的是從哪一行到哪一行;即指的是6~16行或者19~22行之間的位元組碼指令,
// 出現了異常則會去執行19行及以後的程式碼,19後有個monitorexit指令,說明若在同步程式碼塊當中出現了異常,monitor會自動幫助釋放鎖

同步方法

上面介紹的是synchronized同步程式碼塊內的情況,當 synchronized 修飾方法時,查看反編譯後的位元組碼指令,可以看到同步方法在反彙編後,會增加 ACC_SYNCHRONIZED,會隱式地調用 monitorenter和 monitorexit,即在執行 同步方法 之前會調用 monitorenter,在執行完同步方法後會調用 monitorexit;

源碼:

  public synchronized void test(){
      System.out.println("a");
 }
public synchronized void test();
  descriptor: ()V
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=2,    locals=1,     args_size=1
        0: getstatic         #3        // Field java/lang/System.out:Ljava/io/PrintStream;
        3: 1dc               #6        // String a
        5: invokevirtual     #5        // Method java/io/PrintStream.println(Ljava/lang/String;)V
    LineNUmberTable:
      line 13: 0
      line 14: 0
    LocalVariableTalbe:
      Start       Length       Slot     Name     Signature
          0            9          0     this     Lcom/xxx/demo04_synchronized_monitor/Demo01;
......
----------------------------------------------------------------------
對應的源程式碼:
public synchronized void test(){
  System.out.println("a");
}

小結

通過 Javap 反彙編,可以看到

  • synchronized使用變成了monitorentermonitorexit兩個位元組碼指令,真正的鎖是monitor,而不是synchronized後面括弧中的對象;
  • 每個鎖對象都會關聯一個monitor(監視器,monitor才是真正的鎖對象),monitor內部有兩個重要的成員變數owner(owner:會保存獲得鎖的執行緒)和recursions(會保存執行緒獲得鎖的次數);
  • 執行monitorenter,執行緒就會來競爭 monitor 這把鎖,搶到monitor這把鎖之後,就會將 monito r中的成員變數 owner 的取值改為當前搶到鎖的執行緒,以及擁有鎖的次數 recursions 變為1;
  • 如果再次進入同步程式碼塊,即嵌套同步程式碼塊,也是同樣的一把鎖,那麼 monitor 的成員變數recursions的取值就會加一。當執行到monitorexit時,那麼monitor的成員變數recursions計數器就會減一;當monitor的計數器recursions減到0時,那麼當前擁有該鎖 monitor 的執行緒就會去釋放鎖;

一道面試題

synchronized與Lock的區別

  • synchronized是關鍵字,而Lock是一個介面(ReentrantLock為其實現類);
  • synchronized會自動釋放鎖,而Lock必須手動釋放鎖。(講一講monitorenter和monitorexit,同步程式碼塊內有異常也會釋放鎖)
  • synchronized是不可中斷的,而Lock可以是不可中斷的也可以是可中斷的( lock() 和 tryLock() 無參和有參方法);
  • 通過Lock可以知道執行緒有沒有拿到鎖(調用tryLock()),而synchronized不能;
  • synchronized 能鎖住方法(成員方法和靜態方法)和程式碼塊,而Lock只能鎖住程式碼塊(Lock只能在方法內部使用);
  • Lock 可以使用讀鎖提高多執行緒讀的效率(ReentrantReadWriteLock讀讀共享,讀寫互斥);
  • synchronized 是非公平鎖,ReentrantLock 可以控制是否是公平鎖(synchronized喚醒的時候並不是公平的先來後到的方式來進行喚醒,而是隨機喚醒一個等待的執行緒);

總結:從關鍵字類型、是否自動釋放鎖、是否可中斷、鎖的作用範圍、是否為公平鎖等方面來回答。

monitor監視器鎖

無論是 synchronized 程式碼塊 還是 synchronized 方法,最終都需要一個 java 對象,而 java 對象又會關聯到一個 monitor 監視器鎖的東西,真正的同步是靠monitor監視器鎖來實現的。

JVM 底層由 C++ 實現,在 HotSpot 虛擬機中,monitor是由 ObjectMonitor 實現的(C++程式碼),位於 HotSpot 虛擬機源碼 ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp)

ObjectMonitor

下面是 ObjectMonitor 的構造器:

# 構造器,給很多的成員變數賦值(讓其與java源程式碼組合起來進行分析比較方便)
ObjectMonitor() {
  _header              = NULL;
  _count               = 0;
  _waiters             = 0;
  _recursions          = 0; 	// 執行緒的重入次數
  // 即obj對象會引用monitor對象;而monitor對象中的成員變數屬性_object取值也會引用著java對象;
  _object              = NULL; 	// 存儲該monitor的對象,即synchronized括弧中的對象
  _owner               = NULL; 	// 標識擁有該monitor的執行緒
  _WaitSet             = NULL; 	// 處於wait狀態的執行緒,會被加入到_WaitSet
  _WaitSetLock         = 0;
  _Responsible         = NULL;
  _succ                = NULL;
  _cxq                 = NULL; 	// 多執行緒競爭鎖時的單項列表(沒獲取到鎖的執行緒放入單項列表)
  FreeNext             = NULL;
  _EntryList           = NUll; 	// 處於等待鎖block狀態的執行緒,會被加入到該列表
  _SpinFreq            = 0;
  _SpinClock           = 0;
  OwnerIsThread        = 0;
}
  1. _owner: 初始化為NUll,當有執行緒佔有該monitor時,owner標記為該執行緒的唯一表示。當執行緒釋放monitor時,owner又恢復到NULL。owner是一個臨界資源,JVM是通過CAS操作來保證其執行緒安全的。
  2. _cxq: 競爭隊列,所有請求鎖的執行緒首先會被放在這個隊列中(單向鏈接)。_cxq 是一個臨界資源,JVM通過CAS原子指令來修改_cxq隊列。 修改前 _cxq的舊值 填入了 node的next欄位, _cxq指向新值(新執行緒)。因此 _cxq是一個後進先出的stack(棧)。
  3. _EntryList: _cxq隊列中 有資格成為 候選資源的 執行緒 會被移動到該隊列中;
  4. _WaitSet: 因為調用wait方法而被阻塞的 執行緒會被放在該隊列中;

ObjectMonitor 的構造器包含三種隊列: _cxq、_WaitSet和 _EntryList

上圖解析:

  • 等待一輪之後依舊沒有搶到鎖的執行緒被放置到_EntryList當中;

  • 執行obj.wait()方法的執行緒會被放置到_WaitSet單項列表中去,並釋放鎖;

  • 當執行緒擁有者(owner)在執行完成任務出同步程式碼塊釋放鎖時,有可能是當前釋放鎖的執行緒(重入鎖),有可能會是_EntryList當中正在阻塞的執行緒競爭獲取拿到鎖變成monitor當中的_owner,也有可能會是_WaitSet當中處於Wait的執行緒被別的操作所喚醒了,他有可能拿到鎖變成monitor當中的_owner。

  • 總結:能夠競爭得到鎖的執行緒有當前剛釋放鎖的執行緒(重入鎖)、處於阻塞狀態的執行緒、處於等待狀態的執行緒。

小結

每一個java對象都可以與一個監視器 monitor關聯,當一個執行緒想要執行一段被synchronized圈起來的同步方法或者程式碼塊時,該執行緒得先獲取到 synchronized 修飾的對象 obj 對應的 monitor。

java程式碼里不會顯式地去創造這麼一個 monitor對象,也無需創建,monitor並不是隨著對象創建而創建的,是通過 synchronized 修飾符告訴 JVM 需要為 某個對象創建關聯的 monitor對象。

ObjectMonitor 的構造器包含三種隊列: _cxq、_WaitSet和 _EntryList。


monitor 競爭

鎖競爭對應的底層c++程式碼的具體流程為:

  • 通過CAS嘗試把monitor_owner欄位設置為當前執行緒;
  • 如果設置之前的_owner指向的就是當前執行緒,說明當前執行緒再次進入monitor,即重入鎖,執行recursions++,記錄重入的次數;(如果在這之前的上一次競爭當前執行緒獲取到了該鎖,那麼本次競爭中當前執行緒又競爭到了該鎖;兩把鎖一樣;那麼說明是鎖重入)
  • 如果當前執行緒是第一次進入該monitor,設置recursions為1,_owner為當前執行緒,該執行緒成功獲得鎖並返回;
  • 如果獲取鎖失敗,則等待鎖的釋放(進入阻塞狀態,即進入到monitor對象的成員變數_cxq單向隊列,然後再進入_EntryList中);

monitor等待

未獲取鎖的執行緒會被放到_cxq單向列表中去阻塞,阻塞等待對應的底層c++程式碼的具體流程:

1、當前執行緒被封裝成ObjectWaiter對象node,狀態設置成ObjectWaiter::TS_CXQ

2、 在for循環中(用CAS嘗試把當前該執行緒放到_cxq的一個節點上去,因為同時有多個執行緒往單向列表_cxq當中放,所以使用了for循環,CAS多次嘗試),通過CAS把node節點push到_cxq列表中,因為同一時刻可能有多個執行緒把自己的node節點push到_cxq列表中;

3、 (沒有搶到鎖的執行緒在放到_cxq節點上之前)node結點push到_cxq列表之後,通過自旋嘗試獲取鎖,如果還是沒有獲取得到鎖,則通過park將當前執行緒掛起(park內核函數,讓當前執行緒掛起,其實也就相當於阻塞狀態,需要別的執行緒進行喚醒才能夠繼續往下執行),等待被喚醒。

4、 當該執行緒被喚醒時,會從掛起的點繼續執行,通過ObjectWaiter::TryLock嘗試獲取鎖;


monitor釋放

當某個持有鎖的執行緒執行完同步程式碼塊時,會進行鎖的釋放,給其他執行緒執行同步程式碼塊的機會,並通知被阻塞的執行緒。

Q:什麼時候釋放monitor?

A:獲得鎖的執行緒t1執行完同步程式碼塊之後,就需要出同步程式碼塊,在出同步程式碼塊的時候就會進行monitor的釋放操作。

Q:monitor釋放的過程是怎麼樣的?

A:釋放鎖時會喚醒之前正在等待阻塞中的執行緒,詳細過程如下:

  • 釋放monitor時,如果_recursions計數器不等於0,則執行減一操作,這個對應可重入鎖;如果_recursions等於0,則表示執行緒完全出了同步程式碼塊, 且把鎖釋放返回了;
  • 除了釋放鎖之外,還要去喚醒之前正在等待阻塞中的執行緒,喚醒哪一個呢?
    • 此時,有兩個鏈表當中都存放有需要被喚醒的執行緒,一個是_cxq,另外一個是EntryList,隨機喚醒兩個鏈表中的某一個執行緒,喚醒操作通過調用unpack()完成。

為什麼 monitor 是重量級鎖?

synchronized 程式碼塊在執行的時候,會涉及到大量的內核函數執行,就會存在作業系統的用戶態和內核態進行切換(狀態切換消耗的時間 有可能比 用戶程式碼執行的時間還要長),這種切換就會消耗大量的系統資源,這就是 synchronized 未優化之前,效率低的原因(屬於重量級鎖)。

ObjectMonitor的函數調用中會涉及到 Atomic::cmpxchg_ptrAtomic::inc_prt等內核函數,執行同步程式碼塊,沒有競爭到鎖的執行緒會調用park()被掛起,競爭到鎖的執行緒執行完成退出同步程式碼塊時(即當其他執行緒退出同步程式碼塊時)會調用unpark()喚醒上次那些沒有競爭到鎖從而被park()掛起的執行緒;這個時候就會存在作業系統用戶態和內核態的轉換,這種切換會消耗大量的系統資源。

註:這幾個方法都屬於內核函數,而內核函數的執行就會涉及到作業系統中用戶態和內核態的一個切換

Linux的體系結構

linux系統的體系架構由內核、系統調用、shell、公用函數庫和應用程式這幾個部分組成。

作業系統的內核:內核本質上也是一種應用程式,作用是來控制電腦的硬體資源的,比如說控制硬碟、記憶體、網路等相關的一些硬體設備比如說網卡、音效卡、鍵盤、滑鼠等;

應用程式:自己寫的程式被稱為普通的應用程式;用戶空間其實指的是自己所編寫的應用程式所運行的那一塊記憶體空間。

linux作業系統的體系架構分為:用戶空間(應用程式的活動空間)和內核。

  • 用戶空間:上層應用程式活動的空間。應用程式的執行必須依託於內核提供的資源,包括CPU資源、存儲資源、I/O資源等;
  • 內核:本質上可以理解為一種軟體,控制電腦的硬體資源,並提供上層應用程式運行的環境;
  • 系統調用:為了使上層應用能夠訪問到這些資源,內核必須為上層應用提供訪問的介面:即系統調用;
  • 所有進程初始都運行於用戶空間,此時即為用戶運行狀態(簡稱:用戶態);

「系統調用」拓展:

Ⅰ. 當應用程式需要用到鍵盤、需要去讀取文件、需要通過網路去發送一些資源的時候,其實說白了也就是需要用到電腦的一些硬體資源時,這時就需要通過系統調用到內核來幫助執行;

Ⅱ. 普通的應用程式在用戶空間當中運行,那麼就稱之為用戶態;當應用程式如果需要調用內核的一些功能,即通過系統調用來進行調用內核當中的一些功能;那麼這個時候應用程式就會進入內核態

Ⅲ. 用戶態與內核態的切換是需要系統調用來進行的;

系統調用過程

  1. 首先應用程式屬於用戶態,即將調用內核函數的時候,那麼應用程式會把現在程式的一個運行狀態,主要是程式運行的一些運行數值進行保存,可能會保存在暫存器當中,也有可能使用參數創建一個堆棧來保存現在應用程式的一些運行資訊和運行參數;
  2. 接著用戶態的應用程式就會來進行執行系統調用;
  3. 經過系統調用之後,CPU就會切換到內核態;然後到記憶體當中指定的位置去執行相關指令;
  4. 接著,系統調用處理器(system call handler)會讀取程式放入記憶體的數據參數,然後會執行相應的內核函數以及請求一些內核的服務;
  5. 系統調用完成後,作業系統會重置CPU為用戶態並返回系統調用的結果。

用戶態切換至內核態 需要傳遞許多變數,同時內核還需要保護好用戶態在切換時的一些暫存器值、變數等,以備內核態切換回用戶態。這種切換就帶來了大量的系統資源消耗,這就是synchronized未優化之前,效率低的原因(屬於重量級鎖)。

CAS_AtomicInteger

CAS的全稱是 Compare And Swap(比較再交換),確切一點稱之為:比較並且相同再做交換,是現代CPU廣泛支援的一種對記憶體中的共享數據進行操作的一種特殊指令。

CAS的作用是可以保證共享變數賦值時的原子操作,這個原子操作直接由處理器CPU保證。

CAS在操作時依賴三個值:記憶體中的值V、舊的預估值X、要修改的新值B。如果舊的預估值X等於記憶體中的值V(讀取到修改這段時間內沒有其他執行緒來修改),就將新的值B保存到記憶體中,替換這個記憶體中的值V。

java當中已經提供好了一個類叫做AtomicInteger,這個類的底層使用的就是CAS;

存在並發安全的程式碼:

public class Demo01{
    // 定義一個共享變數 num
    private static int num = 0;
    public static void main(String[] args)throws InterruptedException{
        // 任務:對 num 進行1000次加操作
        Runnable mr = ()->{
            for(int i = 0; i < 1000; i++){
                num++;	// num++並不是原子操作,就會導致原子性問題的產生
            }
        };

        ArrayList<Thread> ts = new ArrayList<>();
        // 同時開闢5個執行緒執行任務
        for( int i=0; i < 5 ; i++){
            Thread t = new Thread(mr);
            t.start();
            ts.add(t);
        }

        for(Thread t : ts){
            t.join();
        }
        System.out.println("num = " + num);
    }
}

改用原子自增類:

public class Demo01{
    
    public static void main(String[] args)throws InterruptedException{
        // 
        AtomicInteger atomicInteger = new AtomicInteger();
        // 任務:自增 1000 次
        Runnable mr = ()->{
            for(int i = 0; i < 1000; i++){
                atomicInteger.incrementAndGet();	//該自增操作是一個原子性的操作
            }
        };

        ArrayList<Thread> ts = new ArrayList<>();
        for( int i=0; i < 5 ; i++){
            Thread t = new Thread(mr);
            t.start();
            ts.add(t);
        }

        for(Thread t:ts){
            t.join();
        }

        System.out.println("number = " + atomicInteger.get());
    }
}

CAS原理

AtomicInteger類當中其內部會包含一個叫做UnSafe的類,該類可以保證變數在賦值時的原子操作;

Unsafe類使java擁有了像C語言的指針一樣操作記憶體空間的能力(操作對象的記憶體空間即能夠操作對象裡面的內容,但是這個UnSafe類不太安全,如果使用不當會很危險,所以java官方並不推薦使用,並且在jdk當中也無法找到此類,該類不能直接調用,只能夠通過反射的方式才能夠找到該類),同時也帶來了指針的問題。

底層源碼:

	/* AtomicInteger.java */

    private volatile int value;	// value初始取值為0

    public final int incrementAndGet(){
        // this:自己 new 好的 atomicInteger對象
        // valueOffset:記憶體偏移量
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
	/* Unsafe.class */

    // var1:上面的this,即atomicInteger對象;	var2:valueOffset
    public final int getAndAddInt(Object var1, long var2, int var4){
        // var5 舊的預估值
        int var5;
        do {
            // this 和 記憶體 valueOffset,目的是找出這個 value的當前最新值(舊的預估值)
            var5 = this.getIntVolatile(var1 , var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

方法解析:

  • 通過 var1 和 var2 找出記憶體中 value共享變數的最新值;

  • var5 是舊的預估值,其實就是執行到getIntVolatile(var1 , var2)時,此時讀到的記憶體最新值;

  • 如果記憶體最新值和舊的預估值相等,就把 var5 + var4 (var5自增1的值)賦給共享變數 value,這個方法 compareAndSwapInt也會返回 true,該執行緒結束循環;

  • 如果記憶體最新值和舊的預估值不等,不等的話就不會把 var5 + var4 (var5自增1的值)賦給共享變數 value,並且返回 false,繼續while,再次進行比較;

先讀取記憶體中的值(通過var1和var2得到),賦給var5,然後判斷條件,執行compareAndSwapInt,看記憶體中最新的值(通過var1和var2得到)是否和方才讀取到的var5相等,相等,就自增並賦給共享變數value;不等,就再次循環進行比較。

樂觀鎖 & 悲觀鎖

悲觀鎖 從悲觀的角度出發:

總是假設最壞的情況,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖(這樣就只有一個執行緒進來,別的執行緒沒有鎖無法進入,即別的執行緒會阻塞,那麼也就保證數據操作沒有問題),這樣別人想拿這個數據就會阻塞。

synchronized、JDK中的ReentrantLock都是一種悲觀鎖。

樂觀鎖 從樂觀的角度出發:

總是假設最好的情況,每次去拿數據時都認為別人不會修改,就算改了也沒關係,再重試即可。所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去修改這個數據,如果沒有人修改則更新,如果有人修改則重試,重新去獲取記憶體中的最新值。

無鎖並發

CAS 可以保證對共享變數操作的原子性,而volatile可以實現可見性和有序性,結合CAS和volatile可以實現無鎖並發,適用於競爭不激烈,多核CPU的場景下。

  • CAS之所以效率高是因為在其內部沒有使用synchronized關鍵字,CAS不會讓執行緒進入阻塞狀態,那麼也就避免了synchronized當中用戶態和內核態的切換所帶來的的性能消耗問題,也避免了執行緒掛起等問題。
  • 如果競爭非常激烈,那麼CAS就會出現執行緒大量重試,因為多執行緒來進行競爭,那麼也就導致有可能很多的執行緒設置取值失敗,那麼又要進行while循環重試,即大量的執行緒進行重試操作,成功存的執行緒反而不多,那麼這樣的話反而會使性能大大降低。所以如果競爭太激烈還使用的是CAS機制,會導致其性能比synchronized還要低。

小結

CAS指的是Compare And Swap,CAS可以將比較和交換轉換為原子操作,這個原子操作直接由處理器保證(由CPU支援),會拿舊的預估值與記憶體當中的最新值進行比較;如果相同就進行交換並且把最新的值賦值到記憶體當中的這個變數;

CAS 必須藉助 volatile 才能讀取到共享變數的最新值來實現【比較並交換】的效果。

使用CAS時,執行緒數不要超過CPU的核心數,每個CPU核心都能同時並行某個執行緒,超過的話想運行也運行不了,得發生上下文切換。執行緒的上下文切換的成本很高,要保存執行緒的資訊,當從阻塞恢復成可運行,還要恢復執行緒的資訊。

synchronized鎖升級過程(💖)

在JDK1.5之前synchronized只包含有一種鎖,即monitor重量級鎖,所以在JDK1.5之前其效率是比較低的,因此在JDK1.6這個版本當中對synchronized做了重要改進,在JDK1.6當中synchronized就不僅僅只有monitor這一種重量級的鎖了,包括偏向鎖、輕量級鎖、適應性自旋、鎖消除、鎖優化等機制,另外到轉變成重量級鎖之前會有一個適應性自旋的過程進行搶救一下,這些機制的目的就是為了能夠讓synchronized的效率得到提升。

無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖

首先對象是無鎖狀態,如果需要進行加鎖,那麼就會添加一個偏向鎖,如果偏向鎖無法滿足的話就會換成輕量級鎖,如果輕量級鎖不行的話就有可能會進入適應性自旋的過程,如果通過適應性自旋依然沒有搶到鎖則換成重量級鎖。

JVM中對象的布局

JVM中,Java對象在記憶體中的布局總共由三部分組成:對象頭、實例數據(成員變數等)和對齊填充;

對象頭

對象頭由兩部分組成,分別是 Mark Word 和 Klass pointer,synchronized 鎖可能有很多狀態,這些狀態都是靠對象頭來存儲的。

在Hotspot虛擬機當中對象頭又分為兩種,一種是普通對象的對象頭即instanceOopDesc,另外一種是描述數組的對象頭即arrayOopDesc,當前我們僅關心普通對象的對象頭即instanceOopDescinstanceOopDesc的定義是在Hotspot源碼的 instanceOop.hpp 文件中,另外, arrayOopDesc的定義對應 arrayOop.hpp

instanceOop繼承了父類oopDesc,oopDesc的定義在Hotspot源碼中的 oop.hpp文件中;

class oopDesc{
    friend class VMStructs;
    private :
    
    volatile markOop _mark;
    union _metadata{
        Klass*       _klass; 	# 沒有開啟指針壓縮時的類型指針
		narrowKlass  _compressed_klass; 	# 開啟了指針壓縮
        } _metadata;

    // Fast access to barrier set. Must be initialized.
    static BarrierSet* _bs;
    // 省略其他程式碼
};

oopDesc中包含了兩個成員,分別是_mark_metadata

  • _mark表示對象標記、屬於markOop類型,也就是Mark Word,它記錄了對象和鎖有關的資訊;
  • _metadata表示類元資訊,類元資訊存儲的是對象指向它的類元數據(Klass)的首地址,其中Klass表示普通指針;
MarkWord

Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等等,佔用記憶體大小與虛擬機位長一致。

Mark Word對應的類型是markOop,源碼位於 markOop.hpp中。

在64位虛擬機下,Mark Word是 64bit 大小的,其存儲結構如下:

Klass pointer

java當中的對象肯定是由某個類所產生的,那麼Klass pointer就是用來表示該對象是哪一個類所產生的。Klass pointer會保存這個類的元資訊,如果是64位虛擬機,則其Klass pointer的大小為64位。

如果應用的對象過多,使用64位的指針將大量浪費記憶體,統計而言,64位的 JVM 將會比32位的 JVM 多耗費50%的記憶體。為了節約記憶體可以使用選項-XX:+UseCompressedOops開啟指針壓縮(JVM默認開啟),開啟該選項後,下列指針將壓縮至32位:

  1. 每個Class的屬性指針(即靜態變數);
  2. 每個對象的屬性指針(即成員變數);
  3. 普通對象數組的每個元素指針

對象頭 = Mark Word + 類型指針(未開啟指針壓縮的情況下)

  • 在32位系統中,Mark Word = 4 bytes, 類型指針 = 4bytes, 對象頭 = 8bytes = 64 bits;
  • 在64位系統中,Mark Word = 8 bytes, 類型指針 = 8bytes, 對象頭 = 16bytes = 128 bits;

開啟指針壓縮

  • 在64位系統中,Mark Word = 8 bytes, 類型指針 = 4bytes, 對象頭 = 12bytes ;

實例數據

對象真正存儲的有效資訊,也是在類中定義的各種類型的欄位內容;

對齊填充

不是必然存在的,也沒有什麼特別的含義,僅僅起佔位作用。因為 Hotspot 虛擬機的自動記憶體管理系統要求對象起始地址必須是 8 位元組的整數倍,換句話說就是對象的大小必須是 8 位元組的整數倍。而對象頭部分正好是 8 位元組的倍數(1 倍或 2 倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

對齊填充可能有可能沒有,如果java對象整體大小為8位元組的整數倍,那麼就不需要對齊填充;如果不是,則需要填充一些數據使之對齊,從而保證是8位元組的整數倍。這麼做是方便作業系統來進行定址。

查看對象布局

openjdk提供了一個工具jol-core,可以查看Java的對象布局。

引入依賴

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <aratifactId>jol-core</aratifactId>
  <version>0.9</version>
</dependency>

舉個例子

import org.openjdk.jol.info.ClassLayout;

public class LockObj{
    private int x;
}

class Demo01{
    public static void main(String[] args){
        LockObj obj = new LockObj();

        //parseInstance 解析實例對象;
        //toPrintable進行列印其解析的實例對象資訊
        ClassLayout.parseInstance(obj).toPrintable();
    }
}

輸出內容

 OFFSET   SIZE   TYPE  DESCRIPTION         VALUE
      0      4        (object header)     01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4      4        (object header)     00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8      4        (object header)     43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
     12      4    int   LockObj.x           0
 Instance size: 16 bytes
 Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

對象頭(object header)佔用了12個位元組,上文也提到過,這是因為JVM默認自動開啟了指針壓縮的選項參數,所以列印對象布局中的對象頭資訊所佔位元組為12個位元組而不是16個位元組。如果嘗試關閉指針壓縮(JVM默認就是開啟指針壓縮的),配置 -XX:-UseCompressedOops,再次列印對象布局:

 OFFSET   SIZE   TYPE  DESCRIPTION         VALUE
      0      4        (object header)     01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4      4        (object header)     00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8      4        (object header)     d8 34 5a 25 (11011000 00110100 01011010 00100101) (626668760)
     12      4        (object header)     00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16      4    int   LockObj.x           0
     20      4        (loss due to the next object alignment)
 Instance size: 24 bytes

可以看到對象頭(object header)佔用了16個位元組,然後整型變數 x 佔用4個位元組,這兩項共20個位元組。根據「對齊填充」,對象的大小必須是 8 位元組的整數倍,所以此時得再填充4個位元組的數據(最後一行),整個對象佔24個位元組。

偏向鎖

定義

HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得(即由同一個執行緒反覆的重入得到鎖釋放鎖,如果一上來就是重量級鎖的話,那麼得到鎖和釋放鎖都需要消耗一定的性能)為了讓執行緒獲得鎖的代價更低,引進了偏向鎖,減少不必要的CAS操作。

偏向鎖的「偏」,就是偏心的「偏」、偏袒的「偏」,它的意思是這個鎖會偏向於第一個獲得它的執行緒,會在對象頭存儲鎖偏向的執行緒ID,以後該執行緒進入和退出同步塊時只需要檢查是否為偏向鎖、鎖標誌位以及ThreadID即可。

不過一旦出現多個執行緒競爭時,必須撤銷偏向鎖,所以撤銷偏向鎖消耗的性能必須小於之前節省下來的CAS原子操作的性能消耗,不然就得不償失了。

根據上圖64 bit的 Mark Word可知,如果是偏向鎖,則偏向標誌位變成1,鎖標誌位變成01,另外由前面56位當中的前54位會來保存當前執行緒的ID,另外兩位用來保證Epoch即時間;

注意:這個偏向鎖僅限用於沒有競爭的狀態,也就是說反覆是同一個執行緒獲得鎖釋放鎖

舉個例子:

import org.openjdk.jol.info.ClassLayout;

public class Demo01{
    
    public static void main(String[] args){
        // 創建一個執行緒
        MyThread mt = new MyThread();
        mt.start();
    }
}

class MyThread extends Thread{
    static Object obj = new Object();

    @Override
    public void run(){
        // 任務:循環5次
        for( int i = 0; i < 5; i++){
            synchronized(obj){
                // ...
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }
    }
}
// 循環5次並且進出同步程式碼塊就只有一個執行緒;那麼這種情況就適合使用偏向鎖
// 即反覆是同一個執行緒進入同步程式碼塊的情況,但是如果遇到有執行緒來進行競爭,立即要撤銷掉偏向鎖從而升級到輕量級鎖;

加鎖原理(面試必背)

偏向鎖的核心原理是:

  • 如果不存在執行緒競爭,當某個執行緒獲得了鎖,那麼鎖就進入偏向狀態,此時Mark Word的結構變為偏向鎖結構,鎖對象的鎖標誌位(lock)被改為01,偏向標誌位(biased_lock)被改為1,然後執行緒的ID記錄在鎖對象的Mark Word中(使用CAS操作完成)。以後該執行緒獲取鎖時判斷一下執行緒ID和標誌位,就可以直接進入同步塊,連CAS操作都不需要,這樣就省去了大量有關鎖申請的操作,從而提升了程式的性能;
  • 但是,一旦有第二條執行緒需要競爭鎖,那麼偏向模式立即結束,進入輕量級鎖的狀態;
  • 假如在大部分情況下同步塊是沒有競爭的,那麼可以通過偏向來提高性能。在無競爭時,之前獲得鎖的執行緒再次獲得鎖時會判斷偏向鎖的執行緒ID是否指向自己:
    • 如果是,那麼該執行緒將不用再次申請獲得鎖,直接就可以進入同步塊;
    • 如果未指向當前執行緒,當前執行緒就會採用CAS操作將Mark Word中的執行緒ID設置為當前執行緒ID:
      • 如果CAS操作成功,那麼獲取偏向鎖成功,執行同步程式碼塊;
      • 如果CAS操作失敗,那麼表示有競爭,搶鎖執行緒被掛起,撤銷占鎖執行緒的偏向鎖,然後將偏向鎖膨脹為輕量級鎖。

當執行緒第一次訪問同步程式碼塊並獲取鎖時,偏向鎖處理流程如下:

  1. 檢測Mark Word中對象頭是否為 可偏向狀態,即是否為偏向鎖1,鎖標識是否為01;
  2. 若為 可偏向狀態,則看Mark Word中的執行緒ID是否為當前執行緒ID,如果是,執行同步程式碼塊,否則執行步驟(3);
  3. 如果執行緒ID不為當前執行緒ID,則通過CAS操作將Mark Word的執行緒ID替換為當前執行緒,執行同步程式碼塊,偏向鎖膨脹為輕量級鎖;

偏向鎖的撤銷

假如有多個執行緒來競爭偏向鎖,此對象鎖已經有所偏向,其他的執行緒發現偏向鎖並不是偏向自己,就說明存在了競爭,嘗試撤銷偏向鎖(很可能引入安全點),然後膨脹到輕量級鎖

所以,如果某些臨界區存在兩個及兩個以上的執行緒競爭,那麼偏向鎖反而會降低性能。在這種情況下,可以在啟動JVM時就把偏向鎖的默認功能關閉。

import org.openjdk.jol.info.ClassLayout;

public class Demo01{
    public static void main(String[] args){
        MyThread mt = new MyThread();
        mt.start();
		/**
        兩個執行緒去進行run()當中的synchronized;
        那麼兩個執行緒來執行的時候就需要將該偏向鎖給撤銷;
        */
        MyThread mt2 = new MyThread();
        mt2.start();
    }
}

class MyThread extends Thread{
    static Object obj = new Object();

    @Override
    public void run(){
        for( int i = 0; i < 5; i++){
            synchronized(obj){
                // ...
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }
    }
}
  1. 偏向鎖的撤銷動作必須等待全局安全點
  2. 暫停擁有偏向鎖的執行緒,判斷鎖對象是否處於被鎖定狀態;
  3. 撤銷偏向鎖,之後可以恢復到無鎖(標誌位為01)或輕量級鎖(標誌位為00)的狀態;

這個全局安全點是指,在這個點的時候所有的執行緒都會停下來,叫做全局安全點。只有到了全局安全點的時候才能來撤銷偏向鎖;

偏向鎖 在 jdk1.6 之後是默認啟用的,但是有延遲,要在應用程式啟動幾秒鐘才激活,可以使用-XX:BiasedLockingStartupDelay=0參數關閉延遲。

如果確定應用程式所有鎖通常情況下確實處於競爭狀態,可以通過-XX:-UseBiasedLocking = false參數關閉偏向鎖

偏向鎖的優缺點

優點:

偏向鎖適合在只有一個執行緒來獲取鎖的時候使用,即沒有競爭情況下,一個執行緒反覆進入同步程式碼塊退出同步程式碼塊的效率是很高的,只要進行判斷對象頭中的執行緒id(THREAD ID)跟現在要獲取鎖的執行緒的THREAD ID是否相同即可。如果ID相同則進入同步程式碼塊,退出同步程式碼塊時也不需要做什麼事情,所以性能高。

缺點:

如果存在有很多的執行緒來競爭鎖,那麼這個時候偏向鎖就起不到什麼作用了,反而會影響效率。因為每次撤銷一次偏向鎖,都必須要等待全局安全點,所有執行緒都會停下來才能夠進行撤銷偏向鎖。

補充

比如說使用執行緒池來執行程式碼的時候,執行緒池當中肯定有多個執行緒反覆去執行同樣的任務,多個執行緒會反覆去競爭同一把鎖,那麼這時使用偏向鎖就是多餘的了。

注意在 JDK1.5 的時候偏向鎖是默認關閉的,而在 JDK1.6 的時候偏向鎖是默認開啟的,如果不需要偏向鎖可以通過啟動參數-XX:-UseBiasedLocking=false來進行關閉偏向鎖,讓其直接進入重量級鎖。

輕量級鎖

定義

當偏向鎖出現競爭的時候,會撤下偏向鎖從而升級到輕量級鎖,輕量級鎖是JDK1.6當中為了優化synchronized而引入的一種新型鎖機制,「輕量級」是相對於使用monitor的傳統鎖(重量級🔒)而言的。輕量級鎖並不能夠用來代替重量級鎖,輕量級鎖只是在一定的情況下來減少消耗。

引入輕量級🔒的目的

在多執行緒交替執行同步塊的情況下,引入輕量級鎖可以減少重量級鎖引起的性能消耗,但是如果多個執行緒在同一時刻進入臨界區,會導致輕量級鎖膨脹升級重量級鎖,所以輕量級鎖的出現並非是要代替重量級鎖,也就是說在多執行緒交替執行(多個執行緒加鎖時間是錯開的)同步塊的時候,輕量級的性能才是比較好的。

輕量級鎖適用場景

適用於少量執行緒競爭鎖對象,且執行緒持有鎖的時間不長,追求響應速度的場景;

什麼時候會嘗試獲取輕量級鎖

  • 當關閉偏向鎖功能(JDK1.6默認開啟,可通過啟動參數來關閉偏向鎖) ;
  • 或者多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖時,則會嘗試獲取輕量級鎖。

原理

class MyThread extends Thread{
    static Object obj = new Object();

    @Override
    public void run(){
        synchronized(obj){
            // ...
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

當關閉偏向鎖功能 或者 多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其獲取鎖步驟如下:

  1. 判斷當前對象obj 是否處於無鎖狀態(hashcode、0、01),如果是,則JVM首先將在當前執行緒的棧幀中建立一個鎖記錄(Lock Record)空間,用於存儲鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word),將鎖對象的Mark Word複製到棧幀中的Lock Record中,將Lock Record的owner指向當前鎖對象(synchronized括弧中的鎖對象);
  2. JVM利用CAS操作嘗試將對象的Mark Word(前62位)更新為指向Lock Record的指針;如果CAS成功,表示競爭到鎖,則將鎖標誌位(後兩位)變成00,執行同步操作;
  3. 如果CAS失敗,則判斷當前對象的Mark Word()是否指向當前執行緒的棧幀,如果是則表示當前執行緒已經持有當前對象的鎖,直接執行同步程式碼塊;否則,只能說明該鎖對象已經被其他執行緒搶佔了,這時輕量級鎖需要膨脹為重量級鎖,鎖標誌位變為10,後面等待的執行緒就會進入阻塞狀態。

LockRecord是執行緒私有的,每個執行緒有自己的一份鎖記錄,在創建完鎖記錄空間後,會將當前鎖對象的MarkWord拷貝到鎖記錄中(Displaced Mark Word)

|---------------------------------------------------------------------------------|--------------------|
|                              Mark Word(64 bits)                                 |        State       |
|---------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcoder:31 | unused:1 | age:4 | biased_lock:1 | lock:2   |        Normal      |
|---------------------------------------------------------------------------------|--------------------|
| thread:54 |        epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2   |        Biased      |
|---------------------------------------------------------------------------------|--------------------|
|                   ptr_to_lock__record:62                             | lock:2   | Lightweight Locked |
|---------------------------------------------------------------------------------|--------------------|
|               ptr_to_heavyweight_monitor:62                          | lock:2   | Heavyweight Locked |
|---------------------------------------------------------------------------------|--------------------|
|                                                                      | lock:2   | Marked  for  GC    |
|---------------------------------------------------------------------------------|--------------------|

釋放輕量級鎖

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

  1. 取出在獲取輕量級鎖時保存在Displaced Mark Word中的數據;
  2. 用CAS操作 將取出的數據 替換當前對象的Mark Word,如果成功,則說明釋放鎖成功;
  3. 如果CAS操作替換失敗,說明有其他執行緒嘗試獲取該鎖,則需要將輕量級鎖需要膨脹升級為重量級鎖;

輕量級鎖的釋放就是把棧幀中的Lock Record中的displaced hdr中的hashCode、分代年齡以及鎖標誌位等都重新放回到對象頭原有的位置上,即無鎖狀態時的對應bit處。

撤銷輕量級鎖也是一個CAS操作,即如果將hashCode、分代年齡以及鎖標誌位都還原歸位了,那也就說明輕量級鎖已經被撤銷了。

小結

對於輕量級鎖而言,輕量級鎖的性能之所以高,是因為在絕大部分情況下,這個同步程式碼塊不存在有競爭的狀況,執行緒之間交替執行。
如果是多執行緒同時來競爭這個鎖的話,那麼這個輕量級鎖的開銷也就會更大,導致輕量級鎖膨脹升級為重量級鎖。

輕量級鎖的原理:

  1. 輕量級鎖會在棧幀(執行的方法)中創建一個叫做Lock record鎖記錄空間,鎖記錄空間內的displaced hdr會去保存對象頭中的hashCode、分代年齡以及鎖標誌等,另外鎖記錄空間當中的owner指向的是這個鎖對象;

  2. 並且在對象頭中會保存Lock Record鎖記錄的空間地址,然後將對象頭當中的鎖標誌改成00以表示輕量級鎖。

輕量級鎖的好處是什麼?

  • 在多執行緒交替執行同步塊的情況下,可以避免直接升級為重量級鎖引起的性能消耗。

自旋鎖

引入背景

  1. 之前說過monitor獲取鎖 的時候,會阻塞和喚醒執行緒,執行緒的阻塞和喚醒 需要CPU從用戶態轉化為內核態,頻繁的阻塞和喚醒對CPU來說 是一件負擔很重的工作,這些操作給系統的並發性能 帶來了很大的壓力。
  2. JVM開發團隊也注意到在許多應用上,共享數據的鎖定狀態(臨界區資源)只會持續很短的一段時間,為了這段時間阻塞和喚醒執行緒並不值得。
  3. 如果機器上有多個CPU,能讓兩個或兩個以上的執行緒同時並發執行,就可以讓後面請求鎖的那個執行緒循環 「稍等一下」,但不放棄處理器的執行時間,看看持有鎖的執行緒 是否很快就會釋放鎖。

簡述

自旋鎖在 JDK 1.4.2 中就已經引入,只不過默認是關閉的,可以使用-XX:+UseSpinning參數來開啟,在 JDK 6 中就已經改為默認開啟了。

自旋等待不能代替阻塞,且先不說處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的。

因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來性能上的浪費。

所以,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒,讓執行緒阻塞。自旋次數的默認值是10次,用戶可以使用參數-XX:PreBlockSpin來更改。

使用自旋的條件

  1. 同步程式碼塊執行時間較短,從而能夠很快的搶到鎖;
  2. CPU硬體要能夠支援兩個或兩個以上的執行緒並行執行,不是並發,是並行。一個執行緒在同步程式碼塊內執行,而另一個執行緒則在同步程式碼塊外進行自旋嘗試獲取鎖;

自旋的優缺點

使用自旋鎖可以防止執行緒阻塞和喚醒中要進行的狀態切換(CPU從用戶態切換至內核態的),減輕CPU的開銷。

自旋也是會消耗CPU的性能的,需要在同步程式碼塊外層不斷的進行循環重試獲取鎖,

  • 如果自旋的次數太多了那麼對於CPU的開銷也是很大的;
  • 如果自旋的次數太少了又有可能搶不到鎖導致白白自旋了(因此,自旋鎖默認的自旋次數是10次)。

所以在1.6中引入了下面的自適應自旋鎖

自適應自旋鎖

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

舉個例子:

1)假設一個執行緒A在同步程式碼塊上,自旋了10次並且獲得了鎖,那麼就會認為這個同步程式碼塊通過自旋是比較容易獲得鎖的。所以在後續的執行過程中也會進行自旋,並且還允許自旋的時間稍微長一點,因為之前自旋得到過🔒,所以感覺現在也能得到,所以自旋的時間還允許更長一點;

2)假設有一個同步程式碼塊,但是自旋從來就沒有在這個同步程式碼塊上成功獲取過鎖過,所以jvm就會認為這個同步程式碼塊很難通過自旋獲取到鎖,乾脆就不再進行自旋了,這樣就可以避免自旋導致性能的浪費;

鎖消除

鎖消除 是指 虛擬機 即時編譯器(JIT)在 運行時,對一些程式碼上要求同步,但是被檢測到 不可能存在共享數據競爭的鎖 進行消除。

鎖消除 的主要判定依據 來源於 逃逸分析的數據支援,在一段程式碼中,堆上的所有數據(對象) 都不會逃逸出去 從而被其他執行緒 訪問到,那就可以把它們當做棧上數據對待,認為它們是執行緒私有的,同步加鎖 自然就無需進行。

舉個例子:

public class Demo01{
    public static void main(String[] args){
        concatString("aa" , "bb" , "cc");
    }

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

// ---------------------------------- append() 方法的源碼
@Override
public synchronized StringBuffer append(String str){
    toStringCache = null;
    super.append(str);
    return this;
}

StringBuffer的append()方法使用了synchronized進行了同步處理,修飾實例方法,作用於當前實例,進入同步程式碼前需要先獲取實例的鎖。

concatString方法中調用了三次append方法,new StringBuffer()對象並沒有逃逸出concatString()這個方法,因為不管來幾個執行緒,每個執行緒都會以new StringBuffer()對象作為鎖來鎖住StringBuffer類的append()方法,每個執行緒獲取的是不同的鎖對象。

既然不存在競爭,那麼這個StringBuffer類當中的 append() 方法的同步程式碼塊synchronized就沒有必要了,所以會自動消除掉這個synchronized。

鎖粗化

JVM會探測到一連串細小的操作都是對同一個對象加鎖,將同步程式碼塊的範圍放大,放到這串操作的外面,那麼這樣只需要加一次鎖即可。

public void Demo01{
    public static void main(String[] args){

        StringBuffer sb = new StringBuffer();
        
        for( int i = 0; i < 100; i++){
            sb.append("aa");
        }

        System.out.println(sb.toString());
    }
}
// ------------------------------ append() 方法的源碼 ----------------------------
    @Override
    public synchronized StringBuffer append(String str){
        toStringCache = null;
        super.append(str);
        return this;
    }

以上main中會調用append方法100次,每次調用append都會進入到一個同步方法中,那麼這個性能消耗也是比較大的,jvm會對鎖粗化處理,將StringBuffer.append()方法當中的synchronized進行消除掉,然後再將synchronized加入到100次for循環的外面。

main函數中鎖粗化後的程式如下所示,只需要進入一次同步程式碼塊,然後再for循環100次即可。

public static void main(String[] args){
    StringBuffer sb = new StringBuffer();
    
    synchronized{
        for(int i = 0; i< 100 ; ++){
            sb.append("aa");
        }
    }
    System.out.println(sb.toString());
}

工作中如何優化synchronized

減少synchronized的範圍

同步程式碼塊中盡量短,減少同步程式碼塊中程式碼的執行時間,減少鎖的競爭。

盡量讓synchronized同步程式碼塊當中的程式碼少一點,這樣執行的時間也就會少一點,那麼在單位時間內所執行的執行緒也就多一點,等待的執行緒也就少一點;

另外由於執行比較短,由輕量級鎖就有可能搞得定,或者通過自旋鎖也可以搞定,避免升級到重量級鎖。

降低synchronized鎖的粒度

將一個鎖拆分為多個鎖提高並發度。盡量不要使用類名.class作為鎖對象。

public class Demo01{  
    public void test01(){
        // 盡量不要使用類名.class這樣的鎖
        synchronized(Demo01.class){

        }
    }
    public void test02(){
        synchronized(Demo01.class){

        }
    }
}

如HashTable,增刪改查時,它是對整個 put 、 get 或 remove 方法都加了加 synchronized,對這些實例方法加 synchronized,鎖對象是this,即程式設計師自己創建的hashtable對象。所以,HashTable的增刪改查獲取的是同一把鎖,就會有」讀讀互斥、讀寫互斥「的問題,效率低下。

public class Demo01{
    
    public static void main(String[] args){
        Hashtable hs = new Hashtable();
		// 以下四個操作獲取的是同一把鎖
        hs.put("aa","bb");
        hs.put("xx","yy");
        hs.get("a");
        hs.remove("b");
    }		
}
// ------------------- Hashtable 源碼
public synchronized V put(K key, V value){}

public synchronized V get(Object key){}

public synchronized V remove(Object key){}

因此 jdk 又推出了一個新的類叫做ConcurrentHashMap


ConCurrentHashMap,添加元素時,每次鎖的都是Segment(1.7版本),降低了鎖的粒度,並且對查詢方法 get() 不上鎖。

Hashtable VS ConcurrentHashMap,請參考另一篇文章: //www.yuque.com/itpeng/nqyl44/ef2zop#ab63g

如果還有synchronized的其他內容本文沒涉及到,後續再補充!!!

如發現有錯誤,歡迎評論區指正!

參考

部落格://blog.csdn.net/qq_43409111/article/details/115196711

影片://www.bilibili.com/video/BV1aJ411V763