1. 程式人生 > >【併發程式設計】synchronized的使用場景和原理簡介

【併發程式設計】synchronized的使用場景和原理簡介

1. synchronized使用

1.1 synchronized介紹

在多執行緒併發程式設計中synchronized一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,隨著Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了。

synchronized可以修飾普通方法,靜態方法和程式碼塊。當synchronized修飾一個方法或者一個程式碼塊的時候,它能夠保證在同一時刻最多隻有一個執行緒執行該段程式碼。

  • 對於普通同步方法,鎖是當前例項物件(不同例項物件之間的鎖互不影響)。

  • 對於靜態同步方法,鎖是當前類的Class物件。

  • 對於同步方法塊,鎖是Synchonized括號裡配置的物件。

當一個執行緒試圖訪問同步程式碼塊時,它首先必須得到鎖,退出或丟擲異常時必須釋放鎖。

1.2 使用場景

synchronized最常用的使用場景就是多執行緒併發程式設計時執行緒的同步。這邊還是舉一個最常用的列子:多執行緒情況下銀行賬戶存錢和取錢的列子。

public class SynchronizedDemo {


    public static void main(String[] args) {
        BankAccount myAccount = new BankAccount("accountOfMG",10000.00);
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.deposit(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);
                }
            }).start();
        }
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.withdraw(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);

                }
            }).start();
        }
    }

    private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }

        public double deposit(double amount){
            balance = balance + amount;
            return balance;
        }

        public double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }
}

上面的列子中,首先初始化了一個銀行賬戶,賬戶的餘額是10000.00,然後開始了200個執行緒,其中100個每次向賬戶中存1000.00,另外100個每次從賬戶中取1000.00。如果正常執行的話,賬戶中應該還是10000.00。但是我們執行多次這段程式碼,會發現執行結果基本上都不是10000.00,而且每次結果 都是不一樣的。

出現上面這種結果的原因就是:在多執行緒情況下,銀行賬戶accountOfMG是一個共享變數,對共享變數進行修改如果不做執行緒同步的話是會存線上程安全問題的。比如說現在有兩個執行緒同時要對賬戶accountOfMG存款1000,一個執行緒先拿到賬戶的當前餘額,並且將餘額加上1000。但是還沒將餘額的值重新整理回賬戶,另一個執行緒也來做相同的操作。此時賬戶餘額還是沒加1000之前的值,所以當兩個執行緒執行完畢之後,賬戶加的總金額還是隻有1000。

synchronized就是Java提供的一種執行緒同步機制。使用synchronized我們可以非常方便地解決上面的銀行賬戶多執行緒存錢取錢問題,只需要使用synchronized修飾存錢和取錢方法即可:

private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }
        //這邊給出一個程式設計建議:當我們對共享變數進行同步時,同步程式碼塊最好在共享變數中加
        public synchronized double deposit(double amount){
            balance = balance + amount;
            return balance;
        }
        
        public synchronized double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }

2. Java物件頭

上面提到,當執行緒進入synchronized方法或者程式碼塊時需要先獲取鎖,退出時需要釋放鎖。那麼這個鎖資訊到底存在哪裡呢?

Java物件儲存在記憶體中時,由以下三部分組成:

  • 物件頭
  • 例項資料
  • 對齊填充位元組

而物件頭又由下面幾部分組成:

  • Mark Word
  • 指向類的指標
  • 陣列長度(只有陣列物件才有)

1. Mark Word
Mark Word記錄了物件和鎖有關的資訊,當這個物件被synchronized關鍵字當成同步鎖時,圍繞這個鎖的一系列操作都和Mark Word有關。Mark Word在32位JVM中的長度是32bit,在64位JVM中長度是64bit。

Mark Word在不同的鎖狀態下儲存的內容不同,在32位JVM中是這麼存的:

其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。Epoch是指偏向鎖的時間戳。

JDK1.6以後的版本在處理同步鎖時存在鎖升級的概念,JVM對於同步鎖的處理是從偏向鎖開始的,隨著競爭越來越激烈,處理方式從偏向鎖升級到輕量級鎖,最終升級到重量級鎖。

JVM一般是這樣使用鎖和Mark Word的:

  • step1:當沒有被當成鎖時,這就是一個普通的物件,Mark Word記錄物件的HashCode,鎖標誌位是01,是否偏向鎖那一位是0。

  • step2:當物件被當做同步鎖並有一個執行緒A搶到了鎖時,鎖標誌位還是01,但是否偏向鎖那一位改成1,前23bit記錄搶到鎖的執行緒id,表示進入偏向鎖狀態。

  • step3:當執行緒A再次試圖來獲得鎖時,JVM發現同步鎖物件的標誌位是01,是否偏向鎖是1,也就是偏向狀態,Mark Word中記錄的執行緒id就是執行緒A自己的id,表示執行緒A已經獲得了這個偏向鎖,可以執行同步鎖的程式碼。

  • step4:當執行緒B試圖獲得這個鎖時,JVM發現同步鎖處於偏向狀態,但是Mark Word中的執行緒id記錄的不是B,那麼執行緒B會先用CAS操作試圖獲得鎖,這裡的獲得鎖操作是有可能成功的,因為執行緒A一般不會自動釋放偏向鎖。如果搶鎖成功,就把Mark Word裡的執行緒id改為執行緒B的id,代表執行緒B獲得了這個偏向鎖,可以執行同步鎖程式碼。如果搶鎖失敗,則繼續執行步驟5。

  • step5:偏向鎖狀態搶鎖失敗,代表當前鎖有一定的競爭,偏向鎖將升級為輕量級鎖。JVM會在當前執行緒的執行緒棧中開闢一塊單獨的空間,裡面儲存指向物件鎖Mark Word的指標,同時在物件鎖Mark Word中儲存指向這片空間的指標。上述兩個儲存操作都是CAS操作,如果儲存成功,代表執行緒搶到了同步鎖,就把Mark Word中的鎖標誌位改成00,可以執行同步鎖程式碼。如果儲存失敗,表示搶鎖失敗,競爭太激烈,繼續執行步驟6。

  • step6:輕量級鎖搶鎖失敗,JVM會使用自旋鎖,自旋鎖不是一個鎖狀態,只是代表不斷的重試,嘗試搶鎖。從JDK1.7開始,自旋鎖預設啟用,自旋次數由JVM決定。如果搶鎖成功則執行同步鎖程式碼,如果失敗則繼續執行步驟7。

  • step7:自旋鎖重試之後如果搶鎖依然失敗,同步鎖會升級至重量級鎖,鎖標誌位改為10。在這個狀態下,未搶到鎖的執行緒都會被阻塞。

2. 指向類的指標
該指標在32位JVM中的長度是32bit,在64位JVM中長度是64bit。Java物件的類資料儲存在方法區。

3. 陣列長度
只有陣列物件儲存了這部分資料。該資料在32位和64位JVM中長度都是32bit。

synchronized對鎖的優化

Java 6中為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖”的概念。在Java 6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。

在聊偏向鎖、輕量級鎖和重量級鎖之前我們先來聊下鎖的巨集觀分類。鎖從巨集觀上來分類,可以分為悲觀鎖與樂觀鎖。注意,這裡說的的鎖可以是資料庫中的鎖,也可以是Java等開發語言中的鎖技術。悲觀鎖和樂觀鎖其實只是一類概念(對某類具體鎖的總稱),不是某種語言或是某個技術獨有的鎖技術。

樂觀鎖是一種樂觀思想,即認為讀多寫少,遇到併發寫的可能性低,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,採取在寫時先讀出當前版本號,然後加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重複讀-比較-寫的操作。java中的樂觀鎖基本都是通過CAS操作實現的,CAS是一種更新的原子操作,比較當前值跟傳入值是否一樣,一樣則更新,否則失敗。資料庫中的共享鎖也是一種樂觀鎖。

悲觀鎖是就是悲觀思想,即認為寫多,遇到併發寫的可能性高,每次去拿資料的時候都認為別人會修改,所以每次在讀寫資料的時候都會上鎖,這樣別人想讀寫這個資料就會block直到拿到鎖。java中典型的悲觀鎖就是Synchronized,AQS框架下的鎖則是先嚐試cas樂觀鎖去獲取鎖,獲取不到,才會轉換為悲觀鎖,如ReentrantLock。資料庫中的排他鎖也是一種悲觀鎖。

偏向鎖

Java 6之前的synchronized會導致爭用不到鎖的執行緒進入阻塞狀態,執行緒在阻塞狀態和runnbale狀態之間切換是很耗費系統資源的,所以說它是java語言中一個重量級的同步操縱,被稱為重量級鎖。為了緩解上述效能問題,Java 6開始,引入了輕量鎖與偏向鎖,預設啟用了自旋,他們都屬於樂觀鎖。

偏向鎖更準確的說是鎖的一種狀態。在這種鎖狀態下,系統中只有一個執行緒來爭奪這個鎖。執行緒只要簡單地通過Mark Word中存放的執行緒ID和自己的ID是否一致就能拿到鎖。下面簡單介紹下偏向鎖獲取和升級的過程。

還是就著這張圖講吧,會清楚點。

當系統中還沒有訪問過synchronized程式碼時,此時鎖的狀態肯定是“無鎖狀態”,也就是說“是否是偏向鎖”的值是0,“鎖標誌位”的值是01。此時有一個執行緒1來訪問同步程式碼,發現鎖物件的狀態是"無鎖狀態",那麼操作起來非常簡單了,只需要將“是否偏向鎖”標誌位改成1,再將執行緒1的執行緒ID寫入Mark Word即可。

如果後續系統中一直只有執行緒1來拿鎖,那麼只要簡單的判斷下執行緒1的ID和Mark Word中的執行緒ID,執行緒1就能非常輕鬆地拿到鎖。但是現實往往不是那麼簡單的,現在假設執行緒2也要來競爭同步鎖,我們看下情況是怎麼樣的。

  • step1:執行緒2首先根據“是否是偏向鎖”和“鎖標誌位”的值判斷出當前鎖的狀態是“偏向鎖”狀態,但是Mark Word中的執行緒ID又不是指向自己(此時執行緒ID還是指向執行緒1),所以此時回去判斷執行緒1還是否存在;
  • step2:假如此時執行緒1已經不存在了,執行緒2會將Mark Word中的執行緒ID指向自己的執行緒ID,鎖不升級,仍為偏向鎖;
  • step3:假如此時執行緒1還存在(執行緒1還沒執行完同步程式碼,【不知道這樣理解對不對,姑且先這麼理解吧】),首先暫停執行緒1,設定鎖標誌位為00,鎖升級為“輕量級鎖”,繼續執行執行緒1的程式碼;執行緒2通過自旋操作來繼續獲得鎖。

在JDK6中,偏向鎖是預設啟用的。它提高了單執行緒訪問同步資源的效能。但試想一下,如果你的同步資源或程式碼一直都是多執行緒訪問的,那麼消除偏向鎖這一步驟對你來說就是多餘的。事實上,消除偏向鎖的開銷還是蠻大的。
所以在你非常熟悉自己的程式碼前提下,大可禁用偏向鎖:

 -XX:-UseBiasedLocking=false

輕量級鎖

"輕量級鎖"鎖也是一種鎖的狀態,這種鎖狀態的特點是:當一個執行緒來競爭鎖失敗時,不會立即進入阻塞狀態,而是會進行一段時間的鎖自旋操作,如果自旋操作拿鎖成功就執行同步程式碼,如果經過一段時間的自旋操作還是沒拿到鎖,執行緒就進入阻塞狀態。

1. 輕量級鎖加鎖流程
執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。

2. 輕量級鎖解鎖流程
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到物件頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

重量級鎖

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。

鎖自旋

自旋鎖原理非常簡單,如果持有鎖的執行緒能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做核心態和使用者態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的執行緒釋放鎖後即可立即獲取鎖,這樣就避免使用者執行緒和核心的切換的消耗。

但是執行緒自旋是需要消耗CPU的,說白了就是讓CPU在做無用功,執行緒不能一直佔用CPU自旋做無用功,所以需要設定一個自旋等待的最大時間。如果持有鎖的執行緒執行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的執行緒在最大等待時間內還是獲取不到鎖,這時爭用執行緒會停止自旋進入阻塞狀態。

自旋鎖儘可能的減少執行緒的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的程式碼塊來說效能能大幅度的提升,因為自旋的消耗會小於執行緒阻塞掛起操作的消耗!但是如果鎖的競爭激烈,或者持有鎖的執行緒需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是佔用cpu做無用功,執行緒自旋的消耗大於執行緒阻塞掛起操作的消耗,其它需要cup的執行緒又不能獲取到cpu,造成cpu的浪費。

JDK7之後,鎖的自旋特性都是由JVM自身控制的,不需要我們手動配置。

鎖對比

參考

  • https://blog.csdn.net/lkforce/article/details/81128115
  • 《併發程式設計藝術》