併發編程之五—你還不瞭解的互斥鎖

在之前的《併發編程學習筆記之二中併發問題的源頭》瞭解到引發原子性問題主要是線程切換。在Java中一行代碼最後可能會被翻譯成多條計算機指令,更何況是代碼塊或是方法。一個或者多個操作在CPU中執行不被中斷,稱之其具有原子性。

線程切換依賴的是CPU中斷。在單核CPU,阻止CPU中斷是一間相對可行的事,只要保證同一時刻只有一個線程執行就可以。不過到了多核CPU下,禁止中斷CPU只能保證CPU上的線程同時執行,但是並不能保證同一時刻只有一條線程執行。那麼還有如何保證同一時刻只有一條線程去修改共享變量(也就是互斥)的有效方法嗎?

簡易鎖模型

 

如圖就是一個簡易鎖模型,藍色的部分就是臨界區也就是每個線程間互斥的部分(也是受保護的資源)。線程在進入臨界區之前進行加鎖lock()操作,持有鎖的線程執行臨界區代碼後會進行解鎖unlock()操作。這就是最簡單的模型。就好像停車,當有空車位的時候,你可以把車停上去,也就是獲得這個車位的鎖。車位就是臨界區,在這段時間別的車是無法停靠到你的車位的,當你車子開走的時候的,也就是釋放鎖的過程,其他車可以停到這個車位了。

指定目標的鎖

加鎖可以解決併發問題,但是前提是加對鎖,鎖對資源。就比如在小區,把車停到別人家車庫,那是肯定不行的。那把上面的簡易鎖模型改進下就是:

 

這樣是不是就明確了是自家鎖,去鎖自家的資源了。首先,我們要把臨界區要保護的資源標註出來,如圖中臨界區裏增加了一個元素:受保護的資源 R;其次,我們要保護資源 R 就得爲它創建一把鎖 R;最後,針對這把鎖 R,我們還需在進出臨界區時添上加鎖操作和解鎖操作。另外,在鎖 R 和受保護資源之間,我特地用一條線做了關聯,這個關聯關係非常重要。

鎖在代碼中的體現:

鎖是一種通用的技術,在Java中synchronized就是一種鎖的體現,synchronized可以修飾靜態方法,非靜態方法,代碼塊等。

代碼事例:

class X {

    // 修飾非靜態方法
    synchronized void foo() {
    // 臨界區
    }
    // 修飾靜態方法
    synchronized static void bar() {
    // 臨界區
    }
    // 修飾代碼塊
    Object obj = new Object();
    void baz() {
        synchronized(obj) {
    // 臨界區
        }
    }
}

synchronized的加鎖和解鎖unlock的動作是被java默認加上的,是爲了操作的一把鎖。而對於鎖定的對象,synchronized也有默認的規則:

  • 當修飾靜態方法的時候,鎖定的是當前類的 Class 對象,在上面的例子中就是 Class X;
  • 當修飾非靜態方法的時候,鎖定的是當前實例對象 this。

還記得count+=1的原子性問題嗎,現在有了鎖就迎刃而解了。代碼如下:

class SafeCalc {
    long value = 0L;
    long get() {
    return value;
}

    synchronized void addOne() {
        value += 1;
    }
}

這裏加鎖的本意是任意時刻只有一條線程進去臨界區。所以此時無論是一個CPU還是多個CPU訪問addOne方法執行10000次,得到的最count的值都是10000。但是還有一個問題就是get獲取最終的值會是10000嗎?這個還真不一定。回想下鎖的Happens-Before原則,“對一個鎖解鎖 Happens-Before 後續對這個鎖的加鎖”,指的是前一個線程的解鎖操作對後一個線程的加鎖操作可見,綜合 Happens-Before 的傳遞性原則,我們就能得出前一個線程在臨界區修改的共享變量(該操作在解鎖之前),對後續進入臨界區(該操作在加鎖之後)的線程是可見的。而get方法沒有加鎖,所以不滿意Happens-Before原則,所以get方法獲取最終值不一定是10000。當然解決的方式也是給get方法加鎖。此時應該你應該注意到鎖和資源應該是有一定關係的。

鎖與資源的關係

鎖與資源的關係可以是一對多,也就是一把鎖可以鎖多個資源。但是不能多把鎖去一個資源。比如我們去看球賽,一張門票可以免費停車,那麼就是一張門票可以鎖定一個座位和一個車位。不可以出現兩張重複的票去鎖定一個座位或者車位。同時還有一個問題,鎖是不可變得。你不能用昨天票再來看今天的球賽,也不能用其他羽毛球比賽的票來看NBA的比賽。

如何用一把鎖鎖定多個資源

一把鎖保護多個沒有關聯關係的資源可以解決併發問題,也很符合我們的慣性思維。但是所有操作都是串行化的話,就會產生性能方面的問題,估計你自己也接受不了。最佳的解決方法就是將鎖細化也就是細粒度鎖。用不同的鎖對受保護資源進行精細化管理。來看下銀行賬戶類的例子:

在一個賬戶類下有餘額和密碼兩個成員變量。取款和查詢餘額操作餘額這個資源;修改密碼和查看密碼是操作的密碼這個資源。兩個資源沒有絕對的關聯關係。我們用一個final 對象 balLock 作爲鎖鎖定餘額,用一個 final 對象 pwLock 作爲鎖鎖定密碼。代碼如下:

class Account {
    // 鎖:保護賬戶餘額
    private final Object balLock = new Object();
    // 賬戶餘額
    private Integer balance;
    // 鎖:保護賬戶密碼
    private final Object pwLock = new Object();
    // 賬戶密碼
    private String password;
    // 取款
    void withdraw(Integer amt) {
        synchronized(balLock) {
            if (this.balance > amt){
                this.balance -= amt;
                }
            }
        }

    // 查看餘額
    Integer getBalance() {
        synchronized(balLock) {
            return balance;
        }
    }
    // 更改密碼
    void updatePassword(String pw){
        synchronized(pwLock) {
            this.password = pw;
        }
    }
    // 查看密碼
    String getPassword() {
        synchronized(pwLock) {
            return password;
        }
    }
}

取款,查詢餘額和查看密碼,修改密碼用同一把鎖來,四個操作只能串行化,同一時刻只能執行一個操作,而現在不同的鎖鎖不同的資源,操作密碼和操作餘額操作是可以並行的,效率大大提高,資源管理更細化,性能得到提高。總結一下就是:不相關的資源,用不同的鎖去保護。

用鎖來保護有關聯關係的資源

什麼是有關聯關係的資源,拿銀行的轉賬業務舉個例子,賬戶A給賬戶B轉100元,賬戶A減少100元,賬戶B增加100元,兩個賬戶是有關聯關係的。代碼示例如下:

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

聲明一個賬戶類,再聲明一個賬戶類的成員方法餘額,一個被鎖修飾的轉賬方法。此時synchronized鎖住的是兩個資源一個是當前賬戶,一個目標賬戶。看起來貌似沒有什麼問題。臨界區的兩個資源被同一把鎖住。但是問題就是出在這把鎖上。在非靜態方法中,synchronized鎖住的是當前對象this,也就是當前賬戶,那麼目標同時給其他轉賬時,那目標賬戶讀到的值一定就是最新的嗎?

 

再來具體分析下:兩條線程同時進行轉賬操作,涉及 A、B、C三個賬戶都是200元。線程1由A賬戶給B賬戶轉賬100元,線程2由賬戶B給賬戶C轉賬100元。理想情況是A賬戶最終是100元,B賬戶是200元,C賬戶是300元。實際可能並不是這樣的。

        假設線程1和2在兩個CPU上執行,那麼線程1和線程2都可以進去臨界區,因爲線程1鎖的賬戶A,線程2鎖的的賬戶B。那麼就有可能線程1和2讀到賬戶B的餘額都是200元。假如線程1先於線程2寫balance,線程1寫的balance會被線程2寫的balance覆蓋,那賬戶B的餘額就是100元;如果線程2先於線程1寫balance,線程2寫的balance會被線程1的balance覆蓋,那麼賬戶B的餘額會是300元,就是不可能是200元。流程圖如下:

上面發生的問題是每個資源都是自己的鎖鎖自己的資源。這對於有關聯的資源顯然是行不通的。那麼該如何用一把鎖去鎖住兩個資源呢?

       沒錯,可以新建一個對象,讓所有對象都總有一個唯一的對象,在創建賬戶的時候傳入。這樣就可以保證多個資源共享一把鎖。不過這裏有一個明顯的弊端就是每創建一個賬戶對象就需要把這個鎖傳入,這實施起來就很有難度了,畢竟在實施可能是多個工程下,這樣把保證傳入的是一個lock鎖對象,想想都覺得頭疼。同時,如果傳入的不是一個鎖對象的話,那問題就更嚴重了。那還有沒有更優的做法?這個確實有。代碼如下:

 

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

用 Account.class 作爲共享的鎖的優勢是不畢再擔心是否會是一把鎖了。Account.class 是所有 Account 對象共享的,而且這個對象是 Java 虛擬機在加載 Account 類的時候創建的,所以不用擔心它的唯一性。問題就這樣解決了。流程圖如下:

 

引申:轉賬的操作其實就是爲了保證轉賬過程“原子性”的特徵,只不過這裏的原子性是面向Java的,而JMM中的原子性是面向CPU指令的。而原子性的本質就是其實不是不可分割,不可分割只是外在表現,其本質是多個資源間有一致性的要求,操作的中間狀態對外不可見。解決原子性的本質就是保證中間狀態對外不可見。

死鎖問題是如何產生的?

在上面的銀行轉賬的例子中,共享鎖的問題雖然解決了一把鎖保護多個資源的併發問題,但是也產生了轉賬串行化的問題。在現實生活中,賬戶A向賬戶B轉賬,賬戶B向賬戶C轉賬的操作本來是可以並行的,但是共享鎖卻把他們串行化了,這麼差的性能怎麼能夠接受?所以上面的例子是脫離實際的。不過在這之前貌似有一種將鎖細化的得方法能解決串行化的問題,話不多說,

上代碼:

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

                }
            }
        }
     }
}

細粒度鎖解決了串行化的問題,但是這樣我們寫付出了一定的代碼,上述代碼執行過程中會遇到這樣的問題:

       假設兩個線程同時進行轉賬操作,線程1執行賬戶A向賬戶B轉賬,線程2執行賬戶B向賬戶A轉賬。此時兩個線程可進去transfer獲取到轉出賬戶的鎖,線程1獲得賬戶A的鎖,線程2獲得賬戶B的鎖。兩條線程繼續執行獲取轉入賬戶的鎖,問題就來了,線程1需要獲取賬戶B的鎖,可是線程 2還沒有釋放,所以就進入等待狀態(synchronized不會主動釋放鎖);而線程2需要獲取賬戶A的鎖,而賬戶A的鎖還在被線程1持有,所以也進去等待嘗試階段。最終結果就是兩條線程獲取不到想要的鎖,就會死等下去,這也就是死鎖。

         產生死鎖,一直等待下下去,CPU資源佔用會飆升,直到拖垮整個應用。最簡單的方式就是重啓應用,可這不能避免下次死鎖的產生。死鎖有沒有發生的必要條件,還真有,大牛,Coffman 早就總結過了,只有以下這四個條件都發生時纔會出現死鎖:

  • 互斥,共享資源 X 和 Y 只能被一個線程佔用;
  • 佔有且等待,線程 1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
  • 不可搶佔,其他線程不能強行搶佔線程 1 佔有的資源;
  • 循環等待,線程 1 等待線程 2佔有的資源,線程 2等待線程 1佔有的資源,就是循環等待。

在已經產生死鎖的四個必須條件,那麼只需要破壞掉其中一個條件死鎖也不會發生了:

用鎖就是爲了互斥,所以互斥的條件是破壞不了的,那麼其他三個條件呢:

  • 對於“佔用且等待”這個條件,我們可以一次性申請所有的資源,這樣就不存在等待了。在代碼中也就是一次獲得賬戶A和賬戶B
  • 對於“不可搶佔”這個條件,佔用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它佔有的資源,這樣不可搶佔這個條件就破壞掉了。對應代碼中,如果線程1執行,獲取不到賬戶B,那就釋放掉賬戶A;
  • 對於“循環等待”這個條件,可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序號小的,再申請資源序號大的,這樣線性化後自然就不存在循環了。

那麼如何優化銀行賬戶的例子呢,先來看如何破壞“佔用且等待”的條件。要保證一次獲取全部資源,重新定義一個類Alocator,聲明一個全局的list用來存儲賬戶資源。方法apply用來存儲資源,free方法用來歸還資源。在transfer方法之前先嚐試獲取資源,如果獲取全部資源再進行轉賬操作,轉賬結束釋放資源。要注意的是Alocator必須是單例,也只能是單例。代碼如下:

class Allocator {
    private List<Object> als =new ArrayList<Object>();
    // 一次性申請所有資源
    synchronized boolean apply(Object from, Object to){
        if(als.contains(from) ||
            als.contains(to)){
            return false;
        } else {
            als.add(from);
            als.add(to);
        }

        return true;
}

    // 歸還資源
    synchronized void free(Object from, Object to){
        als.remove(from);
       als.remove(to);
      }
}

class Account {
  // actr 應該爲單例
  private Allocator actr;
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    // 一次性申請轉出賬戶和轉入賬戶,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 鎖定轉出賬戶
      synchronized(this){              
        // 鎖定轉入賬戶
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

如何破壞不可搶佔資源?synchronized獲取不到資源的時候會進入阻塞狀態,不會釋放已鎖定的資源。只有sdk中的lock鎖來替換下。

        破壞這個條件,需要對資源進行排序,然後按序申請資源。這個實現非常簡單,我們假設每個賬戶都有不同的屬性 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;
                }
            }
        }
    }
}

以上的形式都是用自己

引申:到底互斥鎖的是什麼?

在上提到每一個對象都可以作爲鎖:

  • 對於普通同步方法,鎖是當前實例對象。
  • 對於靜態同步方法,鎖是當前類的Class對象。
  • 對於同步方法塊,鎖是Synchonized括號裏配置的對象

從JVM規範中Synchonized在JVM裏的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的,細節在JVM規範裏並沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現。

        monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

        synchronized用的鎖是存在Java對象頭裏的。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,1字寬等於4字節,即32bit。Java對象頭裏的Mark Word裏默認存儲對象的HashCode、分代年齡和鎖標記位。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章