Java併發程式設計—synchronized保證執行緒安全的原理分析

若小寒發表於2019-03-30

前言

程安全是併發程式設計中的重要關注點,應該注意到的是,造成執行緒安全問題的主要誘因有兩點,一是存在共享資料(也稱臨界資源),二是存在多條執行緒共同操作共享資料。因此為了解決這個問題,我們可能需要這樣一個方案,當存在多個執行緒操作共享資料時,需要保證同一時刻有且只有一個執行緒在操作共享資料,其他執行緒必須等到該執行緒處理完資料後再進行,這種方式有個高尚的名稱叫互斥鎖,即能達到互斥訪問目的的鎖,也就是說當一個共享資料被當前正在訪問的執行緒加上互斥鎖後,在同一個時刻,其他執行緒只能處於等待的狀態,直到當前執行緒處理完畢釋放該鎖。在 Java 中,關鍵字 synchronized可以保證在同一個時刻,只有一個執行緒可以執行某個方法或者某個程式碼塊(主要是對方法或者程式碼塊中存在共享資料的操作),同時我們還應該注意到synchronized另外一個重要的作用,synchronized可保證一個執行緒的變化(主要是共享資料的變化)被其他執行緒所看到(保證可見性,完全可以替代Volatile功能),這點確實也是很重要的。

注:文章有點長可以根據目錄來看!

synchronized的三種應用方式

synchronized關鍵字最主要有以下3種應用方式,下面分別介紹

  • 修飾例項方法,作用於當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖

  • 修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖

  • 修飾程式碼塊,指定加鎖物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。

synchronized作用於例項方法

所謂的例項物件鎖就是用synchronized修飾例項物件中的例項方法,注意是例項方法不包括靜態方法,如下

public class AccountingSync implements Runnable{
    //共享資源(臨界資源)
    static int i=0;

    /**
     * synchronized 修飾例項方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 輸出結果:
     * 2000000
     */
}
複製程式碼

上述程式碼中,我們開啟兩個執行緒操作同一個共享資源即變數i,由於i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取i的域值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全。此時我們應該注意到synchronized修飾的是例項方法increase,在這樣的情況下,當前執行緒的鎖便是例項物件instance,注意Java中的執行緒同步鎖可以是任意物件。從程式碼執行結果來看確實是正確的,倘若我們沒有使用synchronized關鍵字,其最終輸出結果就很可能小於2000000,這便是synchronized關鍵字的作用。這裡我們還需要意識到,當一個執行緒正在訪問一個物件的 synchronized 例項方法,那麼其他執行緒不能訪問該物件的其他 synchronized 方法,畢竟一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法訪問該物件的其他synchronized例項方法,但是其他執行緒還是可以訪問該例項物件的其他非synchronized方法,當然如果是一個執行緒 A 需要訪問例項物件 obj1 的 synchronized 方法 f1(當前物件鎖是obj1),另一個執行緒 B 需要訪問例項物件 obj2 的 synchronized 方法 f2(當前物件鎖是obj2),這樣是允許的,因為兩個例項物件鎖並不同相同,此時如果兩個執行緒運算元據並非共享的,執行緒安全是有保障的,遺憾的是如果兩個執行緒操作的是共享資料,那麼執行緒安全就有可能無法保證了,如下程式碼將演示出該現象

public class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新例項
        Thread t1=new Thread(new AccountingSyncBad());
        //new新例項
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含義:當前執行緒A等待thread執行緒終止之後才能從thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
複製程式碼

上述程式碼與前面不同的是我們同時建立了兩個新例項AccountingSyncBad,然後啟動兩個不同的執行緒對共享變數i進行操作,但很遺憾操作結果是1452317而不是期望結果2000000,因為上述程式碼犯了嚴重的錯誤,雖然我們使用synchronized修飾了increase方法,但卻new了兩個不同的例項物件,這也就意味著存在著兩個不同的例項物件鎖,因此t1和t2都會進入各自的物件鎖,也就是說t1和t2執行緒使用的是不同的鎖,因此執行緒安全是無法保證的。解決這種困境的的方式是將synchronized作用於靜態的increase方法,這樣的話,物件鎖就當前類物件,由於無論建立多少個例項物件,但對於的類物件擁有隻有一個,所有在這樣的情況下物件鎖就是唯一的。下面我們看看如何使用將synchronized作用於靜態的increase方法。

synchronized作用於靜態方法

當synchronized作用於靜態方法時,其鎖就是當前類的class物件鎖。由於靜態成員不專屬於任何一個例項物件,是類成員,因此通過class物件鎖可以控制靜態 成員的併發操作。需要注意的是如果一個執行緒A呼叫一個例項物件的非static synchronized方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的class物件,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖,看如下程式碼

public class AccountingSyncClass implements Runnable{
    static int i=0;

    /**
     * 作用於靜態方法,鎖是當前class物件,也就是
     * AccountingSyncClass類對應的class物件
     */
    public static synchronized void increase(){
        i++;
    }

    /**
     * 非靜態,訪問時鎖不一樣不會發生互斥
     */
    public synchronized void increase4Obj(){
        i++;
    }

    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新例項
        Thread t1=new Thread(new AccountingSyncClass());
        //new心事了
        Thread t2=new Thread(new AccountingSyncClass());
        //啟動執行緒
        t1.start();t2.start();

        t1.join();t2.join();
        System.out.println(i);
    }
}
複製程式碼

由於synchronized關鍵字修飾的是靜態increase方法,與修飾例項方法不同的是,其鎖物件是當前類的class物件。注意程式碼中的increase4Obj方法是例項方法,其物件鎖是當前例項物件,如果別的執行緒呼叫該方法,將不會產生互斥現象,畢竟鎖物件不同,但我們應該意識到這種情況下可能會發現執行緒安全問題(操作了共享靜態變數i)。

synchronized同步程式碼塊

除了使用關鍵字修飾例項方法和靜態方法外,還可以使用同步程式碼塊,在某些情況下,我們編寫的方法體可能比較大,同時存在一些比較耗時的操作,而需要同步的程式碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步程式碼塊的方式對需要同步的程式碼進行包裹,這樣就無需對整個方法進行同步操作了,同步程式碼塊的使用示例如下:

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {
        //省略其他耗時操作....
        //使用同步程式碼塊對變數i進行同步操作,鎖物件為instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}
複製程式碼

從程式碼看出,將synchronized作用於一個給定的例項物件instance,即當前例項物件就是鎖物件,每次當執行緒進入synchronized包裹的程式碼塊時就會要求當前執行緒持有instance例項物件鎖,如果當前有其他執行緒正持有該物件鎖,那麼新到的執行緒就必須等待,這樣也就保證了每次只有一個執行緒執行i++;操作。當然除了instance作為物件外,我們還可以使用this物件(代表當前例項)或者當前類的class物件作為鎖,如下程式碼:

//this,當前例項物件鎖
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

//class物件鎖
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
複製程式碼

瞭解完synchronized的基本含義及其使用方式後,下面我們將進一步深入理解synchronized的底層實現原理。

synchronized底層語義原理

Java 虛擬機器中的同步(Synchronization)基於進入和退出管程(Monitor)物件實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步程式碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法 並不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法呼叫指令讀取執行時常量池中方法的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念Java物件頭,這對深入理解synchronized實現原理非常關鍵。

  如果對上面的執行結果還有疑問,也先不用急,我們先來了解Synchronized的原理,再回頭上面的問題就一目瞭然了。我們先通過反編譯下面的程式碼來看看Synchronized是如何實現對程式碼塊進行同步的:

public class SynchronizedDemo {
     public void method() {
         synchronized (this) {
            System.out.println("Method 1 start");
         }
     }
 }
複製程式碼

反編譯結果:
image.png

關於這兩條指令的作用,我們直接參考JVM規範中描述:

monitorenter :
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.
複製程式碼

這段話的大概意思為:

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

  1. 如果monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者。

  2. 如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.

  3. 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。

monitorexit:
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
複製程式碼

這段話的大概意思為:

執行monitorexit的執行緒必須是objectref所對應的monitor的所有者。

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

  通過這兩段描述,我們應該能很清楚的看出Synchronized的實現原理,Synchronized的語義底層是通過一個monitor的物件來完成,其實wait/notify等方法也依賴於monitor物件,這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

我們再來看一下同步方法的反編譯結果:

原始碼:

public class SynchronizedMethod {
     public synchronized void method() {
        System.out.println("Hello World!");
    }
 }
複製程式碼

反編譯結果:image.png

從反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來完成(理論上其實也可以通過這兩條指令來實現),不過相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。

關於synchronized 可能需要了解的關鍵點

synchronized的可重入性

從互斥鎖的設計上來說,當一個執行緒試圖操作一個由其他執行緒持有的物件鎖的臨界資源時,將會處於阻塞狀態,但當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功,在java中synchronized是基於原子性的內部鎖機制,是可重入的,因此在一個執行緒呼叫synchronized方法的同時在其方法體內部呼叫該物件另一個synchronized方法,也就是說一個執行緒得到一個物件鎖後再次請求該物件鎖,是允許的,這就是synchronized的可重入性。如下:

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){

            //this,當前例項物件鎖
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }

    public synchronized void increase(){
        j++;
    }


    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}
複製程式碼

正如程式碼所演示的,在獲取當前例項物件鎖後進入synchronized程式碼塊執行同步程式碼,並在程式碼塊中呼叫了當前例項物件的另外一個synchronized方法,再次請求當前例項鎖時,將被允許,進而執行方法體程式碼,這就是重入鎖最直接的體現,需要特別注意另外一種情況,當子類繼承父類時,子類也是可以通過可重入鎖呼叫父類的同步方法。注意由於synchronized是基於monitor實現的,因此每次重入,monitor中的計數器仍會加1。

執行緒中斷與synchronized

執行緒中斷

正如中斷二字所表達的意義,線上程執行(run方法)中間打斷它,在Java中,提供了以下3個有關執行緒中斷的方法

//中斷執行緒(例項方法)
public void Thread.interrupt();

//判斷執行緒是否被中斷(例項方法)
public boolean Thread.isInterrupted();

//判斷是否被中斷並清除當前中斷狀態(靜態方法)
public static boolean Thread.interrupted();
複製程式碼

當一個執行緒處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該執行緒,注意此時將會丟擲一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改為非中斷狀態),如下程式碼將演示該過程:

public class InterruputSleepThread3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                //while在try中,通過異常中斷就可以退出run迴圈
                try {
                    while (true) {
                        //當前執行緒處於阻塞狀態,異常必須捕捉處理,無法往外丟擲
                        TimeUnit.SECONDS.sleep(2);
                    }
                } catch (InterruptedException e) {
                    System.out.println("Interruted When Sleep");
                    boolean interrupt = this.isInterrupted();
                    //中斷狀態被複位
                    System.out.println("interrupt:"+interrupt);
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        //中斷處於阻塞狀態的執行緒
        t1.interrupt();

        /**
         * 輸出結果:
           Interruted When Sleep
           interrupt:false
         */
    }
}
複製程式碼

如上述程式碼所示,我們建立一個執行緒,並線上程中呼叫了sleep方法從而使用執行緒進入阻塞狀態,啟動執行緒後,呼叫執行緒例項物件的interrupt方法中斷阻塞異常,並丟擲InterruptedException異常,此時中斷狀態也將被複位。這裡有些人可能會詫異,為什麼不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其實原因很簡單,前者使用時並沒有明確的單位說明,而後者非常明確表達秒的單位,事實上後者的內部實現最終還是呼叫了Thread.sleep(2000);,但為了編寫的程式碼語義更清晰,建議使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是個列舉型別。ok~,除了阻塞中斷的情景,我們還可能會遇到處於執行期且非阻塞的狀態的執行緒,這種情況下,直接呼叫Thread.interrupt()中斷執行緒是不會得到任響應的,如下程式碼,將無法中斷非阻塞狀態下的執行緒:

public class InterruputThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(){
            @Override
            public void run(){
                while(true){
                    System.out.println("未被中斷");
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();

        /**
         * 輸出結果(無限執行):
             未被中斷
             未被中斷
             未被中斷
             ......
         */
    }
}
複製程式碼

雖然我們呼叫了interrupt方法,但執行緒t1並未被中斷,因為處於非阻塞狀態的執行緒需要我們手動進行中斷檢測並結束程式,改進後程式碼如下:

public class InterruputThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(){
            @Override
            public void run(){
                while(true){
                    //判斷當前執行緒是否被中斷
                    if (this.isInterrupted()){
                        System.out.println("執行緒中斷");
                        break;
                    }
                }

                System.out.println("已跳出迴圈,執行緒中斷!");
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();

        /**
         * 輸出結果:
            執行緒中斷
            已跳出迴圈,執行緒中斷!
         */
    }
}
複製程式碼

是的,我們在程式碼中使用了例項方法isInterrupted判斷執行緒是否已被中斷,如果被中斷將跳出迴圈以此結束執行緒。綜合所述,可以簡單總結一下中斷兩種情況,一種是當執行緒處於阻塞狀態或者試圖執行一個阻塞操作時,我們可以使用例項方法interrupt()進行執行緒中斷,執行中斷操作後將會丟擲interruptException異常(該異常必須捕捉無法向外丟擲)並將中斷狀態復位,另外一種是當執行緒處於執行狀態時,我們也可呼叫例項方法interrupt()進行執行緒中斷,但同時必須手動判斷中斷狀態,並編寫中斷執行緒的程式碼(其實就是結束run方法體的程式碼)。有時我們在編碼時可能需要兼顧以上兩種情況,那麼就可以如下編寫:

public void run(){
    try {
    //判斷當前執行緒是否已中斷,注意interrupted方法是靜態的,執行後會對中斷狀態進行復位
    while (!Thread.interrupted()) {
        TimeUnit.SECONDS.sleep(2);
    }
    } catch (InterruptedException e) {

    }
}
複製程式碼

中斷與synchronized

事實上執行緒的中斷操作對於正在等待獲取的鎖物件的synchronized方法或者程式碼塊並不起作用,也就是對於synchronized來說,如果一個執行緒在等待鎖,那麼結果只有兩種,要麼它獲得這把鎖繼續執行,要麼它就儲存等待,即使呼叫中斷執行緒的方法,也不會生效。演示程式碼如下

public class SynchronizedBlocked implements Runnable{

    public synchronized void f() {
        System.out.println("Trying to call f()");
        while(true) // Never releases lock
            Thread.yield();
    }

    /**
     * 在構造器中建立新執行緒並啟動獲取物件鎖
     */
    public SynchronizedBlocked() {
        //該執行緒已持有當前例項鎖
        new Thread() {
            public void run() {
                f(); // Lock acquired by this thread
            }
        }.start();
    }
    public void run() {
        //中斷判斷
        while (true) {
            if (Thread.interrupted()) {
                System.out.println("中斷執行緒!!");
                break;
            } else {
                f();
            }
        }
    }


    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlocked sync = new SynchronizedBlocked();
        Thread t = new Thread(sync);
        //啟動後呼叫f()方法,無法獲取當前例項鎖處於等待狀態
        t.start();
        TimeUnit.SECONDS.sleep(1);
        //中斷執行緒,無法生效
        t.interrupt();
    }
}
複製程式碼

我們在SynchronizedBlocked建構函式中建立一個新執行緒並啟動獲取呼叫f()獲取到當前例項鎖,由於SynchronizedBlocked自身也是執行緒,啟動後在其run方法中也呼叫了f(),但由於物件鎖被其他執行緒佔用,導致t執行緒只能等到鎖,此時我們呼叫了t.interrupt();但並不能中斷執行緒。

等待喚醒機制與synchronized

所謂等待喚醒機制本篇主要指的是notify/notifyAll和wait方法,在使用這3個方法時,必須處於synchronized程式碼塊或者synchronized方法中,否則就會丟擲IllegalMonitorStateException異常,這是因為呼叫這幾個方法前必須拿到當前物件的監視器monitor物件,也就是說notify/notifyAll和wait方法依賴於monitor物件,在前面的分析中,我們知道monitor 存在於物件頭的Mark Word 中(儲存monitor引用指標),而synchronized關鍵字可以獲取 monitor ,這也就是為什麼notify/notifyAll和wait方法必須在synchronized程式碼塊或者synchronized方法呼叫的原因。

synchronized (obj) {
       obj.wait();
       obj.notify();
       obj.notifyAll();         
 }
複製程式碼

需要特別理解的一點是,與sleep方法不同的是wait方法呼叫完成後,執行緒將被暫停,但wait方法將會釋放當前持有的監視器鎖(monitor),直到有執行緒呼叫notify/notifyAll方法後方能繼續執行,而sleep方法只讓執行緒休眠並不釋放鎖。同時notify/notifyAll方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的synchronized(){}/synchronized方法執行結束後才自動釋放鎖。

Java虛擬機器對synchronized的優化

鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級,關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖以及JVM的其他優化手段,這裡並不打算深入到每個鎖的實現和轉換過程更多地是闡述Java虛擬機器所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣,如需瞭解詳細過程可以查閱《深入理解Java虛擬機器原理》。

偏向鎖

偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程式的效能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的執行緒都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。下面我們接著瞭解輕量級鎖。

輕量級鎖

倘若偏向鎖失敗,虛擬機器並不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變為輕量級鎖的結構。輕量級鎖能夠提升程式效能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗資料。需要了解的是,輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹為重量級鎖。

自旋鎖

輕量級鎖失敗後,虛擬機器為了避免執行緒真實地在作業系統層面掛起,還會進行一項稱為自旋鎖的優化手段。這是基於在大多數情況下,執行緒持有鎖的時間都不會太長,如果直接掛起作業系統層面的執行緒可能會得不償失,畢竟作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的執行緒可以獲得鎖,因此虛擬機器會讓當前想要獲取鎖的執行緒做幾個空迴圈(這也是稱為自旋的原因),一般不會太久,可能是50個迴圈或100迴圈,在經過若干次迴圈後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將執行緒在作業系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級為重量級鎖了。

鎖消除

消除鎖是虛擬機器另外一種鎖的優化,這種優化更徹底,Java虛擬機器在JIT編譯時(可以簡單理解為當某段程式碼即將第一次被執行時進行編譯,又稱即時編譯),通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個區域性變數,並且不會被其他執行緒所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。

重量級鎖

即synchronized,一直等待執行緒施放鎖後才可以拿到資源。

最後

後續會持續更新併發程式設計專題知識,大家覺得不錯可以點個贊在關注下,以後還會分享更多文章!


相關文章