互斥鎖——解決原子性問題

互斥鎖

1. 線程不安全的三大原因

線程不安全的源頭來自於無法保證以下特性:

  • 原子性 :把一個或者多個操作在CPU中的執行不被中斷的特性;
    • 操作系統的線程切換可以發生在任何一個指令之後,這就是原子性的根源;
  • 可見性:一個線程對共享變量的修改,其他線程能夠立馬得知
    • 緩存是導致可見性問題的根因
  • 有序性: 保證程序按照代碼的書寫順序來執行;
    • 導致有序性問題的就是編譯器的優化,會帶來指令重排序;

1.三大特性問題舉例

以上只是做了個簡單的分析介紹,下面來舉一些示例來進行說明

1. 原子性:

private int count = 1;
count += 1;   //2

假如線程一在執行上述語句2時,在CPU裏應該分成三條指令:

  • (1)向內存讀取count到工作內存
  • (2)執行+1操作
  • (3)寫回count到內存

當線程一執行到1的時候,時間片完了,這時線程二獲取到了時間片,來執行這三個步驟,並執行完成,此時寫回內存的是2,然後線程一又獲取到了時間片,然後繼續執行(2)、(3),最後寫回去的還是2,這就產生了原子性的問題;

2. 可見性

上面說了,可見性是緩存造成的,這是典型的硬件給軟件挖的坑!?,爲了提升運算效率,引入了緩存,同時也帶來了風險,(當時單核CPU的情形下是沒有風險的,因爲操作的都是同一塊緩存,不存在不可見問題),但現在多核多線程的時代,緩存帶來的不可見問題是肯定不可避免的;

這裏來舉個簡單的例子:(count初始值爲0)

count += 1

假如線程一來執行這條語句,會先從內存中讀取count到自己的工作內存中,然後執行加一,然後寫回主內存,問題就發生在這,寫回主內存的時間是不確定的,假如在寫回主內存前,假如有線程二來執行這條語句,此時從主存中讀取到的count到工作內存爲1,加一後爲2,這時最終寫回主內存的count值肯定爲2;

當然,發生上述是小概率事件,但你把線程數調到10000、100000,最終count的值肯定小於10000、100000;

3. 有序性

編譯器爲了優化性能,有時候會改變程序中語句的先後順序,但不會改變程序最終的執行結果,這裏指單線程下不會產生與原來不同的結果,但多線程就未必了,有時會產生意想不到的結果;

這裏最典型的例子就是double check(雙重單例模式)裏面可能產生空指針異常的問題,這裏就不再詳述,我的前面一篇博客有介紹:

2. Java解決有序性和可見性的方案

開門見山:
Java爲了解決有序性和可見性,引入了Java內存模型,這裏就不再詳細介紹,上面說到了,要解決這兩個問題,最直接的就是禁用緩存和編譯優化,但這大大的降低了效率,在單線程的情況下本來就線程安全,但卻使用不了緩存和編譯優化;
所以Java內存模型做到了按需禁用緩存以及編譯優化,將設置權交給程序員,至於如何按需禁用,Java裏提供了很多方法:volatile、synchronized、final、Happens-before原則;

這裏看一個例子:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 這裏 x 會是多少呢?
    }
  }
}

問題:
如上代碼所示,線程A執行writer方法,這時線程B執行reader方法,當v==true,進入if塊裏面時,x爲多少?

解答:
在JDK5之間可能是42,也有可能0,但在jdk5之後,x肯定爲42,具體爲什麼,先來看看happens-berore原則:
在談happens-berore規則前,先說明,happens-before並不是先行發生的意思,一定不要這樣理解,它的意思是:

  • A Happens before B,代表A操作的結果對B是可見的;

Happens-before原則約束了編譯器的優化行爲,雖然允許編譯器優化,但是要求編譯器優化後一定遵守Happens-before原則;

1 . 規則一:程序的順序性規則

一個線程中,按照程序的順序,前面的操作happens-before後續的任何操作。

對於這一點,可能會有疑問。順序性是指,我們可以按照順序推演程序的執行結果,但是編譯器未必一定會按照這個順序編譯,但是編譯器保證結果一定==順序推演的結果。

2 . 規則二:volatile規則

對一個volatile變量的寫操作,happens-before後續對這個變量的讀操作。

3 . 規則三:傳遞性規則

如果A happens-before B,B happens-before C,那麼A happens-before C。

jdk1.5的增強就體現在這裏。回到上面例子中,線程A中,根據規則一,對變量x的寫操作是happens-before對變量v的寫操作的,根據規則二,對變量v的寫操作是happens-before對變量v的讀操作的,最後根據規則三,也就是說,線程A對變量x的寫操作,一定happens-before線程B對v的讀操作,那麼線程B在註釋處讀到的變量x的值,一定是42.

4 . 規則四:管程中的鎖規則

對一個鎖的解鎖操作,happens-before後續對這個鎖的加鎖操作。

這一點很好理解,例如線程一進入了一個synchronized塊,當它釋放鎖後,線程二又立馬獲取了鎖進入了synchronized塊,這時線程一在其中數據的改變對線程二是可見的;

5 . 規則五:線程start()規則

主線程A啓動線程B,線程B中可以看到主線程啓動B之前的操作。也就是start() happens before 線程B中的操作。

6 . 規則六:線程join()規則

主線程A等待子線程B完成,當子線程B執行完畢後,主線程A可以看到線程B的所有操作。也就是說,子線程B中的任意操作,happens-before join()的返回。

3. 互斥鎖——解決原子性問題

好啦這裏纔到本文的主題,前面提到,原子性問題是因爲CPU在切換線程時並不是以高級語言中的語句爲準,而是以指令,高級語言中的語句一般由好幾個指令構成,所以切換線程的時候就可能中斷在高級語言中的一個完整操作,這就導致了原子性問題的發生;

1. Long變量在32位機器上的問題

大家都知道,Long變量是8個字節,64位,如果在32位上的機器要操作(寫)Long變量,就得分成兩次寫操作來完成,即一次寫高32位,一次寫低32位,即:

Long x = 0;
x = 100//2

看似x = 100是個原子操作,但是這裏在32位機器上卻被分割成了兩個步驟,假如在寫完高32位時,時間片完了,另一個線程也來寫高32位,這時就發生了線程安全問題了,前面一個的值就會被覆蓋;

2. 互斥的定義

所謂互斥:就是同一時刻只有一個線程執行!!(⭐)

只有保證了對共享變量的修改是互斥的,就能保證原子性問題了;

談到互斥,這裏就想到了鎖,沒錯,就是如下這個典型的模型:
在這裏插入圖片描述

這裏,我們把一段需要互斥執行的代碼稱爲臨界區,線程進入臨界區前,先嚐試加鎖,如果失敗,說明鎖被其他線程佔用,就需要等待那個線程釋放鎖,然後獲取鎖;

但我們需要明確:我們鎖的是什麼?我們保護的又是什麼?
即鎖和鎖保護的資源應當是有關係的,這裏就有了改進的鎖模型:(這大大提升了多線程下的併發效率)

在這裏插入圖片描述

運用這種鎖模型,我們需要注意的是:

  • 認清鎖和資源的關係,不要出現“鎖自家門保護別家財產的事情”;

3. Java提供的互斥鎖:synchronized

synchronized模型就是上面的改進鎖模型,需要注意的是:

  • Java中的lock和unlock這兩個操作在synchronized中是被Java默默加上,不需要程序員去關心;這帶來的好處就是不用去擔心忘記解鎖帶來的問題;

關於synchronized,這裏有我之前專門的一篇文章來進行介紹:https://wonderyang.github.io/2019/05/13/線程同步與死鎖(synchronized關鍵字詳解)/

4. 超典型的銀行轉賬問題

案例分析:

  • 賬戶類(Account)有兩個成員變量,分別是賬戶餘額balance和賬戶密碼password;
  • 該類提供兩個方法來訪問balance,分別是withdraw(取款)和getBalance(查看餘額);
  • 該類提供兩個方法來訪問password,分別是updatePassword(更改密碼)和getPassword(查看密碼);

1. 保護沒有關聯的多個資源

對於上述案例,我們要用怎樣的鎖來保護資源呢?

  • (1)所有方法加上synchronized;
  • (2)保護餘額對應一個鎖,賬戶密碼對應一個鎖;

上述兩種方法,第一種假如更改密碼和查看餘額就不能同時進行,但理論上它兩應該可以同時進行,因爲他們操作的不是一個資源,不會出現線程安全的問題,採用方法一會導致效率嚴重下降

下面就來實現一下第二種方案,這種方案中,用不同的鎖對受保護資源進行精細化管理,能夠提升性能,這種鎖也叫細粒度鎖(⭐);

class Account {
    private String password;
    private Integer balance;
    //保護密碼的鎖
    private final Object pwLock = new Object();
    //保護餘額的鎖
    private final Object balLock = new Object();

    public void updatePassword(String newPassword) {
        synchronized (pwLock) {
            this.password = newPassword;
        }
    }
    public String getPassword() {
        synchronized (pwLock) {
            return password;
        }
    }

    public void withdraw(Integer amt) {
        synchronized (balLock) {
            this.balance -= amt;
        }
    }
    public Integer getBalance() {
        synchronized (balLock) {
            return balance;
        }
    }
}  

2. 保護有關聯關係的多個資源

這裏還是上面那個例子,同樣有Account賬戶類,這裏來討論一下轉賬的問題,在A向B轉賬過程中,A賬戶餘額要減少,B賬戶餘額要增加,爲了線程安全,A和B賬戶的餘額都應該收到保護,那我們怎麼去保護這兩個有關聯的資源呢?

設轉賬操作的方法爲transfer;

1. 誤區方式

誤區:有人想到直接這樣不就好了?:

class Account {
    private Integer balance;
    public synchronized void transfer(Account target, int amt) {
        if(this.balance >= amt)
        balance -= amt;
        target.balance += amt;
    }
}

這樣做的話,我們new一個A賬戶和一個B賬戶,然後A賬戶進行轉賬操作,這時A賬戶就拿到了鎖進入了transfer方法中,試問此時B賬戶能不能執行他自己的轉賬操作?
有人會說不能啊,那個方法都被A鎖住了,是嗎?A拿的那把鎖是A的對象鎖,跟B有什麼關係,所以B此時也能執行自己的轉賬操作,此時併發問題就產生了嘛!!!
上面的問題就在:用A對象的鎖試圖去保護B賬戶這個資源,這當然是不行的;

舉例分析:
我們假設線程 1 執行賬戶 A 轉賬戶 B 的操作,線程 2 執行賬戶 B 轉賬戶 C 的操作。這兩個線程分別在顆 CPU 上同時執行,那它們是互斥的嗎?我們期望是,但實際上並不是。因爲線程 1 鎖定的是賬戶 A 的實例(A.this),而線程 2 鎖定的是賬戶 B 的實例(B.this),所以這兩個線程可以同時進入臨界區 transfer()。同時進入臨界區的結果是什麼呢?線程 1 和線程 2 都會讀到賬戶 B 的餘額爲 200,導致最終賬戶 B 的餘額可能是 300(線程 1 後於線程 2 寫 B.balance,線程 2 寫的 B.balance 值被線程 1 覆蓋),可能是 100(線程 1 先於線程 2 寫 B.balance,線程 1 寫的 B.balance 值被線程 2 覆蓋),就是不可能是 200。

2. 同一把鎖方式

對於上面的轉賬操作,我們緊接着就會想到,用同一把鎖就能解決問題了呀,這種方式是完全可以的,下面來看看代碼:

class Account {
    private Integer balance;
    private Object transferLock;

    public Account(Object transferLock) {
        this.transferLock = transferLock;
    }

    public void transfer(Account target, int amt) {
        synchronized (transferLock) {
            if(this.balance >= amt) {
                balance -= amt;
                target.balance += amt;
            }
        }
    }
}

這個方式的缺點:

  • 要求在創建賬戶的同時就得傳入同一個對象(指給需要轉帳的那兩個賬戶),如果出現了傳入的不是同一對象,就會鎖不住轉賬的兩方;
  • 在真實的項目中,創建Account對象的代碼可能分佈在各個工程中,傳入共享的Lock就會變得很難;
3. 用全局鎖(class對象鎖)

這裏直接上代碼,衆所周知,鎖住class對象的話,創建的所有Account賬戶就會持有同一把鎖,一旦鎖住一個,其他賬戶的所有操作就都不能執行了,這樣嚴重影響了併發,現實生活中的轉賬就不能影響到其他用戶(除轉賬對象賬戶)的操作;

class Account {
    private Integer balance;
    
    public void transfer(Account target, int amt) {
        synchronized (this.getClass()) {
            if(this.balance >= amt) {
                balance -= amt;
                target.balance += amt;
            }
        }
    }
}
4. 雙重鎖

對於上面的例子,還有一種方式可以解決:

class Account {
    private Integer balance;

    public void transfer(Account target, int amt) {
        //鎖定轉出賬戶
        synchronized (this) {
            //鎖定轉入賬戶
            synchronized (target) {
                if(this.balance >= amt) {
                    balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

這種方法一看,挺好的呀,消除了3中的弊病,也消除了2中的弊病,但是這也有一個致命缺點,容易造成死鎖;
具體的:

  • 當A賬戶準備給B賬戶轉賬,進入第一個synchronized塊後未進入第二個synchronized塊之前,此時B賬戶也執行給A賬戶轉賬,也進入了第一個未進入第二個synchronized塊,此時A賬戶等待B賬戶的鎖,B賬戶等待A賬戶的鎖,就進入了死鎖狀態;

這裏再分析一下死鎖,死鎖的四個必要條件: (⭐)

  • 互斥: 共享資源X和Y只能被一個線程佔有;

    • 這個條件我們無法破壞;?
  • 佔有且等待: 線程1已經獲得共享資源X,在等待共享資源Y的時候,不釋放共享資源X;

    • 我們可以設置一個類用來管理這兩把鎖,要麼都拿到,要麼都別拿,這樣就不會出現各持一把鎖的情況;
  • 不可搶佔: 其他線程不能強行搶佔線程1佔有的資源;

    • 這裏synchronized申請資源的時候,如果申請不到就直接進入阻塞,所以要破壞不可搶佔得使用Lock,這裏後面再具體說;
  • 循環等待: 線程1等待線程2的佔有的鎖,線程2等待線程1佔有的鎖;

    • 破壞這個條件,需要對資源進行排序,然後按序申請資源。這個實現非常簡單,我們假設每個賬戶都有不同的屬性 id,這個 id 可以作爲排序字段,申請的時候,我們可以按照從小到大的順序來申請。比如下面代碼中,①~⑥處的代碼對轉出賬戶(this)和轉入賬戶(target)排序,然後按照序號從小到大的順序鎖定賬戶。這樣就不存在“循環”等待了。
      代碼如下:

      class Account {
        private int id;
        private int balance;
        // 轉賬
        void transfer(Account target, int amt){
          Account left = this        //①
          Account right = target;    //②
          if (this.id > target.id) { //③
            left = target;           //④
            right = this;            //⑤
          }                          //⑥
          // 鎖定序號小的賬戶
          synchronized(left){
            // 鎖定序號大的賬戶
            synchronized(right){ 
              if (this.balance > amt){
                this.balance -= amt;
                target.balance += amt;
              }
            }
          }
        } 
      }
      
      
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章