【併發編程】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
  • 《併發編程藝術》

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

台灣寄大陸海運貨物規則及重量限制?

大陸寄台灣海運費用試算一覽表