由面試題“併發程式設計的三個問題”深入淺出Synchronied

我可是千機傘發表於2020-09-23
在面試的時候經常會問道一個問題

併發程式設計的三個問題是什麼??

那麼我在這裡先回答這個問題的答案,有三個問題,可見性,原子性還有有序性。

  • 可見性:一個執行緒對一個主記憶體中的資料進行修改,其他執行緒也可以第一時間訪問到的。
  • 原子性:一個或者多個操作並行時,要不全部操作都執行成功,要不一個操作出現異常,其他操作都不執行成功。
/**
*案例演示:五個程式每一個都進行一千次number++操作
*/
Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                number++;
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }

        System.out.println(atomicInteger.get());
    }

這一段number++程式碼的新增操作在jvm虛擬機器中的指令是這樣的

9:getstatic      #12 //Fieldnumber:I
12:iconst_1
13:iadd
14:putstatic  #12//Fieldnumber:I

假如當A執行緒執行了getstatic之後,進行了iconst_1,之後iadd後,突然cpu排程到了B執行緒,B執行緒走完了這四個指令,使得主記憶體中的number為1了之後,cpu又排程到A執行緒,這時候存入主記憶體的number還是1,雖然兩個執行緒都執行了一次,結果應為2,但是最後還是1.

  • 有序性:操作的執行是按照順序執行的,但是事實並不是這樣,因為jvm會對方法進行優化,就會出現指令的重排序,在不影響邏輯和執行結果的時候對方法的操作進行一定的優化

我們要注意的是,對於併發程式設計中,由於cpu的排程問題,經常會出現原子性的問題,當一個執行緒對一個共享變數執行到一半的指令的時候,突然排程,干擾了前一個執行緒的操作。

剛剛在可見性中提到了主記憶體,那我就不得講一講java的記憶體模型了

java記憶體模型

java記憶體模型
首先有兩個概念,主記憶體和工作記憶體

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

那麼工作記憶體在從主記憶體獲得資料的時候,主記憶體會先有個lock操作,這個操作會先把工作記憶體之前的資料清除掉,然後在進行read,之後load載入到工作記憶體中進行資料的操作,最後在重新壓入寫入到主記憶體中,這時候主記憶體會有一個釋放鎖unlock的操作,在這個操作之前,工作記憶體的資料需要寫入主記憶體才可以。

主記憶體到共享記憶體的互動過程為Lock->Read->Road->Use->Assign->Store->Write->Unlock

這些都是題外話。那Synchronized是怎麼解決這三個問題的呢。

Synchronized如何解決併發程式設計的問題

  • 首先是可見性,由於不能及時的獲取最新的資料,那麼我們就需要給他在程式碼塊上新增Synchronized鎖,就可以保證資料可以被及時更新。

 public static  volatile boolean flag =true;
 public static Object object = new Object();


 new Thread(()->{
            synchronized (object){
            while (flag){
                System.out.println(flag);
            }}
        }).start();

        Thread.sleep(2000);

        new Thread(()->{
            while (flag){
                flag = false;

                System.out.println("更改flag為false");

            }
        }).start();

還有其他的方法,比如說使用volatile來修飾變數,再或者用一些加了鎖的方法來操作這個變數,也可以進行變數值的更新,比如最簡單的,加個 System.out.println();

  • 其次是原子性,也是在程式碼塊或者同步方法直接加鎖即可,這樣就可以保證同一時間只有一個執行緒進入這個方法。
 public static int number;
public static  Object object = new Object();
 Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                    number++;
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }
  • 最後有序性,這個問題加了鎖也不能解決本身的重排序,但是隻要解決了原子性,那麼不管再怎麼重排序,也不會對資料的結果進行改變(不信的可以自己跑一遍試試哦。)

為什麼會有重排序

為了提高程式的執行效率,編譯器和CPU會對程式中程式碼進行重排序。
會遵循as-if-serial語義,意思是:不管編譯器和CPU如何重排序,必須保證在單執行緒情況下程式的結果是正確的。以下資料有依賴關係,不能重排序。

寫後讀:

int a = 1;
System.out.println();

寫後寫:

int a = 1;
int a = 2;

讀後寫:

int a = 1;
int b = a;
int a = 2;

編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。

//這種操作 第一行第二行就可以重排序
int a =1 ;
int b = 2;
int c = b+a ;

但是,如果操作之間不存在資料依賴關係,這些操作就可能被編譯器和處理器重排序。

synchronized的特性

那麼接下來就是有一些深度的了,關於synchronized的特性我這邊總結兩點

  • 可重入性:指一個執行緒在獲取不同鎖的過程中,遇到用於做鎖的物件是相同的,那麼在獲取到第一個鎖之後,其他的鎖也可以直接進入,不用在進行獲取鎖的操作了。期底層會有一個計數器recurisons,每當重入一個鎖,recurisons就會執行+1的操作,當執行完了一個鎖的程式碼塊的方法,recurisons則會-1,當recurisons為0時,則會一起釋放改執行緒佔有的所有的鎖。

  • 不可中斷:則是一個被阻塞的執行緒不可以通過stop等指令進行停止,當然可以使用lock鎖,其中的trylock方法來判斷等操作最後進行中斷。

    tryLock實現可中斷以上就是tryLock的方法,執行緒t1首先進入了執行緒方法run,執行緒t2拿不到鎖,則進入等待池中,三秒之後tryLock檢測t2還沒獲取到鎖,即讓他結束了執行緒。

我們剛剛在說可重入性的時候,提到了recurisons計數器,那麼Synchronized底層到底是如何實現的呢。

Monitorenter指令

在jvm中,真正起作用的並不是Synchronized,而是會建立兩個指令,Monitorenter和Monitroexit。每當給程式碼塊修飾一個synchronized時,都會給用作鎖的物件建立一個Monitor,當然不是我們建立,而是由jvm來建立,其中有兩個值,分別是owner:擁有這把鎖的執行緒, recurisons會記錄執行緒擁有鎖的次數,當一個執行緒有monitor,其他執行緒就要等待。

但是在修飾同步方法的時候,jvm並不會生成Monitorenter,而是會增加ACC_SYNCHRONIZED的修飾,會隱形呼叫monitorenter和monitorexit指令,在執行同步方法前會呼叫monitorenter,在執行完同步方法後會呼叫monitorexit。

jdk6對synchronized的優化

首先講一講悲觀鎖和樂觀鎖

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

​ 總是假設最壞的情況,每次去拿資料的時候都會認為別人會修改,所以每次拿資料都會上鎖,這樣別人想拿的時候就會被阻塞,因此synchronized就是個悲觀鎖。

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

​ 總是假設最好的情況,每次拿資料都會認為別人不會修改,就算改了也沒關係,重試即可,但是在更新的時候回判斷在此期間有沒有其他執行緒修改這個資料,如果沒有則修改資料,有執行緒修改就重試。

我們所知的Synchronied則屬於悲觀鎖,而樂觀鎖的典型的就是CAS

CAS的作用可以將比較和交換 轉換為原子操作,這個原子操作直接由cpu保證。可以保證共享變數賦值時的原子操作,cas操作依賴三個值,記憶體中的值v,舊的預估值x和要修改的新值b,如果舊的預估值x等於記憶體中的值v,就將新的值b存到記憶體中。 因為沒有使用synchronized,所以效能會好,但是不適用於競爭激烈的場景,因為競爭激烈重試的次數也會變多,會降低效率


//CAS實現無鎖併發
public static  Object object = new Object();
public static AtomicInteger atomicInteger = new AtomicInteger();
    @Test
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = ()->{
            synchronized (object) {
                for (int i = 0; i < 1000; i++) {
                atomicInteger.incrementAndGet();
                }
            }

        };

        List<Thread> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }


        for (Thread lists:
             list) {
            lists.join();
        }

鎖的升級

眾所周知,鎖有四種級別,從低到高階別分別為

無狀態鎖->偏向鎖->輕量級鎖->(自旋鎖)->重量級鎖

每個鎖只能升級,不能降級,但是可以被清除。

  • 無狀態鎖就是沒有鎖。
  • 偏向鎖會偏向第一個獲取到鎖的執行緒,當執行緒第一個獲取到鎖的時候,虛擬機器會把該物件頭的標識設定為01,則設定成偏向鎖,隨後進行CAS操作的時候,會把執行緒的id存入mark word中,如果下次再進入鎖的時候,就會先把執行緒的id和存入markword中的id進行對比,如果相同則可以直接進入方法不用進行其他的同步操作。

偏向鎖只適用於沒有競爭關係的鎖,會大大的提高效率,但是在有競爭關係的鎖,就會升級成輕量級鎖

  • 輕量級鎖:將物件的mark word棧幀到lock recod中,將mark word的更新指向lock recod的指標,好處就是在多執行緒的競爭的情況下,可以避免重量級鎖引起的效能消耗。
  • 自旋鎖:在輕量級鎖升級成重量級鎖的一個過渡,可以在規定時間規定次數重新嘗試獲取鎖,時間和次數可以用jvm指令來實現更改,這樣可以大大減少輕量級鎖升級成重量級鎖。

鎖的消除

比如stringbuffer的append方法,是一個執行緒安全的方法,在一個物件中多次執行,但是jit檢測到是不可能發生鎖的競爭,而且也不會逃逸出這個方法,這時候程式碼塊再加鎖就沒意義,就會把append裡的鎖進行消除

鎖粗化

jvm會探測到一連串細小的操作都使用同一個物件加鎖,則將同步程式碼塊的範圍放大,放到這串操作的外面,這樣就只要加一次鎖即可

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章