1. 程式人生 > >深入理解Java多執行緒--synchronized的實現原理

深入理解Java多執行緒--synchronized的實現原理

執行緒安全是多執行緒程式設計中的一個重要的知識點,何為執行緒安全?在多執行緒併發中,有很多資料是執行緒共享的,當我們某個執行緒去操作共享資料的時候,需要先將共享資料複製到當前執行緒的記憶體空間中來,然後進行操作完畢之後再將資料更新到共享空間中去。這就造成了一個問題,當我們有多個執行緒去讀取和操作某個共享資料的時候,會造成資料的讀取的不確定性,即我們不能確定讀取的是其他執行緒操作之後還是之前的資料,我們來看看下面的一個例子:

public class CaculateSync {

    //共享資料
    private int i=0;

    private Runnable CaculateRnn=new Runnable() {
        @Override
        public void run() {
            for (int j=0;j<10000;j++){
                //自增
                i++;
            }
        }
    };

    public void test(){
        try {
            Thread thread1=new Thread(CaculateRnn);
            Thread thread2=new Thread(CaculateRnn);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            LogUtils.d("i的輸出結果="+i);
            i=0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

執行結果:

從執行結果來看,i最後輸出結果並不是20000,當然也有可能是20000。為什麼會這樣的呢?首先i++的操作並不具備原子性,執行分兩步進行,首先讀取i值,然後執行加1。在這個例子中,有兩個執行緒thread1和thread2同時讀取和操作共享資料i,假設thread1讀取了資料i,此時i的值為100,在未進行操作和更新資料i的時候執行緒thread2獲得了執行權也讀取了i=100的資料,然後thread1和thread2都在i=100的基礎上加1去更新資料,更新後的i值為101,雖然兩個執行緒一共進行了兩次加1操作,但最後i的卻只加了一次,這相當於操作失敗了一次。所以說上面的結果因為執行緒安全的問題,最後的結果就小於20000就不奇怪了。

那麼如何解決執行緒安全問題呢?如何讓多執行緒進行的時候在同一個時刻有且只有一個執行緒在操作共享資料呢?在java中,關鍵字synchronized可以解決上面的安全問題,synchronized關鍵字可以保證在同一時刻,只有一個執行緒執行一個程式碼塊或者方法。那麼我們用synchronized去解決上面例子的問題,來看看:

public class CaculateSync {

    //共享資料
    private int i = 0;

    private synchronized void add() {
        i++;
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 10000; j++) {
                //自增
                add();
            }
        }
    };

    public static void test() {
        try {
            Thread thread1 = new Thread(CaculateRnn);
            Thread thread2 = new Thread(CaculateRnn);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            LogUtils.d("i的輸出結果=" + i);
            i = 0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

輸出結果:

從輸出結果來看都是20000,說明用了synchronized關鍵字之後執行緒是安全的。其實執行緒同步的關鍵是哪個執行緒獲得了同步鎖,java規定非靜態方法的同步鎖預設為此方法所在類的例項物件,當某個執行緒獲得了這個物件鎖以後就意味著取得了當前方法的執行資格,只有當它執行完畢以後此執行緒才會釋放鎖,其他執行緒才有就會獲得鎖。當然其他執行緒還是可以進入沒有被synchronized修飾的程式碼和其他的有synchronized修飾的但鎖物件不一樣程式碼。

上面的例子是synchronized修飾非靜態方法的使用,synchronized還可以修飾靜態方法和程式碼塊。修飾靜態方法的時候鎖物件是當前類的Class物件,修飾程式碼塊的時候,鎖可以是任意物件。

總結一下synchronized三種最主要的應用方式,如下:

1.修飾例項非靜態方法,鎖物件為當前例項物件,進入同步程式碼塊需要取得物件鎖;

2.修飾靜態方法,鎖物件為當前類的Class物件,進入同步程式碼塊需要取得當前類物件的鎖;

3.修飾程式碼塊,需要自己指定鎖物件, 進入同步程式碼塊需要取得對應的鎖物件;

上面已經介紹了synchronized修飾非靜態方法的使用,下面我們來介紹一下synchronized修飾靜態方法和程式碼塊的情形:

synchronized修飾靜態方法

在講解synchronized修飾靜態方法之前我們先來看看使用synchronized修飾的非靜態方法可能出現的問題,來看一下下面程式碼的執行結果:

public class CaculateSync {

    //共享資料
    public static int i = 0;
    private byte[] lock = new byte[0];

    //修飾非靜態方法,鎖是當前類的例項物件
    private synchronized void add() {
        i++;
    }

    //修飾靜態方法,鎖是當前類的class物件
    private synchronized static void addStatic() {
        i++;
    }

    //修飾方法中的程式碼塊
    private void addArea() {
        //鎖可以是任意物件
        synchronized (lock) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //呼叫非靜態方法
                add();
            }
        }
    };


    public Thread test() {
        
        Thread thread1 = new Thread(CaculateRnn);

        thread1.start();
        return thread1;

    }

    public static void main() {

        try {
            //建立兩個物件
            Thread thread = new CaculateSync().test();
            Thread thread1 = new CaculateSync().test();
            thread.join();
            thread1.join();
            LogUtils.d("i的值=" + CaculateSync.i);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

執行上面的main方法,看結果是什麼:

結果小於2000000,為什麼呢?其實是因為我們建立了兩個CaculateSync物件,add方法的鎖物件為兩個不同的CaculateSync物件,所以此時方法的鎖不一樣也就不是安全的了。下面我們改為使用synchronized修飾的靜態方法來看看:

public class CaculateSync {

    //共享資料
    public static int i = 0;
    private byte[] lock = new byte[0];

    //修飾非靜態方法,鎖是當前類的例項物件
    private synchronized void add() {
        i++;
    }

    //修飾靜態方法,鎖是當前類的class物件
    private synchronized static void addStatic() {
        i++;
    }

    //修飾方法中的程式碼塊
    private void addArea() {
        //鎖可以是任意物件
        synchronized (lock) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //呼叫靜態方法
                addStatic();
            }
        }
    };


    public Thread test() {
        
        Thread thread1 = new Thread(CaculateRnn);

        thread1.start();
        return thread1;

    }

    public static void main() {

        try {
            //建立兩個物件
            Thread thread = new CaculateSync().test();
            Thread thread1 = new CaculateSync().test();
            thread.join();
            thread1.join();
            LogUtils.d("i的值=" + CaculateSync.i);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

執行結果:

可以看到此時執行緒是安全的,因為修飾靜態方法的時候,鎖物件是類的Class物件,雖然我們建立了兩個不同的例項物件, 但是例項物件都是公共一個Class物件,所以他們的鎖是一樣的,所以就執行緒安全了。

synchronized修飾程式碼塊:

修飾程式碼塊的時候,synchronized需要自己定義鎖物件,當然也可以用例項物件或者Class物件,如下

public class CaculateSync {

    //共享資料
    public static int i = 0;
    private byte[] lock = new byte[0];
    private byte[] lock1 = new byte[0];


    //修飾方法中的程式碼塊
    private void addArea() {
        //鎖可以是任意物件
        synchronized (lock) {
            i++;
        }
    }

    //修飾方法中的程式碼塊
    private void addArea1() {
        //鎖可以是任意物件
        synchronized (lock1) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //呼叫靜態方法
                addArea();
            }
        }
    };

    private Runnable CaculateRnn1 = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //呼叫靜態方法
                addArea1();
            }
        }
    };


    public Thread test() {
        try {
            Thread thread1 = new Thread(CaculateRnn);
            Thread thread2 = new Thread(CaculateRnn1);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            //i = 0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return null;
    }

}

通過測試得知,同樣的道理,當多執行緒訪問具有相同鎖的程式碼塊時也是安全的。上面展示了synchronized常用的使用方法,下面我們來探究下一synchronized的底層原理。

synchronized底層語義原理

在java虛擬機器中,java物件的記憶體結構主要分為:物件頭、例項資料、對齊填充三部分。如下:

其中我們重點關注物件頭,它是synchronized實現同步的基礎,在這裡我們不去過多地分析物件頭的資料結構,我們就知道在物件頭的Mark World中儲存著一個叫管程(Monitor)的東西,那我們先來了解一下什麼是管程(Monitor),在java虛擬機器中,monitor是由ObjectMonitor實現的,其主要資料結構如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; //指向持有ObjectMonitor物件的執行緒
    _WaitSet      = NULL; //處於wait狀態的執行緒,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處於等待鎖block狀態的執行緒,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

其實monitor就可以認為是多執行緒同步的時候所謂的鎖,誰持有鎖誰就取得程式碼的執行資格。我們重點關注上面的幾個變數:_count、_Waitset、_EntryList、_owner。其中_Waitset和_EntryList是兩個佇列,分別用來儲存等待的和就緒狀態的執行緒。_owner用來指向持有ObjectMonitor物件的執行緒,即指向取得鎖的那個執行緒。到這裡我們可以猜想到,其實synchronized實現同步的原理也就跟這個ObjectMonitor有直接的關係了。

回到前面的物件頭,物件頭存在於每一個java物件中,而物件頭的Mark Word部分儲存著指向monitor物件地址的指標,所以說每個物件都會關聯一個monitor物件,這也是為什麼任意物件都可以作為鎖的原因。比如我們的synchronized修飾非靜態方法的時候, 此時的鎖就是方法所在類的例項物件,那麼當這個例項物件關聯的monitor的_owner指向某個確定的執行緒的時候,就代表這個執行緒擁有了執行資格。

那麼synchronized又是如何讓執行緒去持有和釋放物件鎖的monitor的呢?其中這裡又分顯示同步和隱式同步,顯示同步有明確的 monitorenter 和 monitorexit 指令,比如同步程式碼塊就是顯示同步。隱式同步沒有明確的進入和退出指令,比如synchronized同步方法的時候,它是用 ACC_SYNCHRONIZED 標誌來實現的。下面我們分別利用反編譯去看一下synchronized修飾程式碼塊和修飾方法的位元組碼資訊,看能不能在其中找到關於顯示同步和隱式同步的有關資訊。

synchronized的顯示同步(同步程式碼塊):

我們通過編譯下面程式碼再反編譯其class檔案檢視位元組碼資訊:

public class Sync {

    private static int i = 0;

    private void add() {
        synchronized (this) {
            i++;
        }

    }
}

上面是同步程式碼塊的使用,按照猜想我們反編譯其class檔案以後,會看到對應的monitorenter 和 monitorexit 指令,下面我們來看看,貼出關鍵資訊:

private void add();
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  //進入同步程式碼塊
         4: getstatic     #2                  // Field i:I
         7: iconst_1
         8: iadd
         9: putstatic     #2                  // Field i:I
        12: aload_1
        13: monitorexit  //退出同步程式碼塊
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit  //退出同步程式碼塊
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 11: 0
        line 12: 4
        line 13: 12
        line 15: 22
      StackMapTable: number_of_entries = 2

從上面可以看到,資訊中可以明顯地看到一個monitorenter進入指令和兩個monitorexit退出指令,為什麼會有兩個退出指令?首先程式正常執行完畢有一個monitorexit,第二個退出指令其實是當執行發生異常的時候,我們也需要monitorexit來退出執行緒。我們來解析一下執行緒基於指令的執行情況,首先指令monitorenter指向同步程式碼塊的開始位置,monitorexit則指向同步程式碼塊的結束位置。當執行monitorenter進入指令時,當前執行緒將試圖獲取物件鎖所對應的monitor的持有權,當物件鎖的monitor的計數為0,即_count等於0時,此時執行緒就可以成功取得monitor,那麼計數值加1,其他執行緒被阻塞直到當前執行緒執行完畢,即執行緒執行到了monitorexit指令,執行緒將釋放鎖並且計數值為0。

synchronized隱式同步(同步方法):

方法級的同步是隱式的,即jvm會通過方法是否有設定了ACC_SYNCHRONIZED常量標識來區分此方法是否為同步方法。如果設定了,則執行執行緒將持有monitor物件, 然後執行方法,在執行方法期間,其他執行緒是無法獲取同一個monitor的,直到方法執行完畢釋放monitor,其他執行緒才有機會獲取。如果方法執行的過程了發生了異常,並且異常不是在方法內處理,那麼這個同步方法持有的monitor也將在異常丟擲到同步方法外是自動釋放。下面我們來看看位元組碼層面的相關資訊:

public class Sync {

    private static int i = 0;

    private synchronized void add() {
        i++;
    }
}

反編譯其class檔案貼出關鍵資訊如下:

 private synchronized void add();
   descriptor: ()V
    //方法標識為private,其中ACC_SYNCHRONIZED代表該方法為同步方法
   flags: ACC_PRIVATE, ACC_SYNCHRONIZED
   Code:
     stack=2, locals=1, args_size=1
        0: getstatic     #2                  // Field i:I
        3: iconst_1
        4: iadd
        5: putstatic     #2                  // Field i:I
        8: return
     LineNumberTable:
       line 11: 0
       line 12: 8

 static {};
   descriptor: ()V
   flags: ACC_STATIC
   Code:
     stack=1, locals=0, args_size=0
        0: iconst_0
        1: putstatic     #2                  // Field i:I
        4: return
     LineNumberTable:
       line 8: 0
}

我們可以看到同步方法中並沒有進入和退出指令,確實有ACC_SYNCHRONIZED標識,這個就是synchronized同步方法的原理了。上面我們講解的synchronized的同步是基於物件管理的monitor來實現的,而monitor是基於底層作業系統的Mutex Lock實現的,而作業系統實現執行緒的切換需要的時間成本比較高,所以說此時的synchronized實現效率比較低。在java6以後,為了減少獲得鎖和釋放鎖帶來的效能問題,引入了偏向鎖和輕量級鎖。

synchronized對鎖的優化:

偏向鎖

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

輕量級鎖

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

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

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

好了,我們對synchronized的講解就且到這裡吧!