1. 程式人生 > >Java 中的 syncronized 你真的用對了嗎

Java 中的 syncronized 你真的用對了嗎

 

生活中隨處可見並行的例子,並行 顧名思義就是一起進行的意思,同樣的程式在某些時候也需要並行來提高效率,在上一篇文章中我們瞭解了 Java 語言對快取導致的可見性問題、編譯優化導致的順序性問題的解決方法,下面我們就來看看 Java 中解決因執行緒切換導致的原子性問題的解決方案 -- 鎖 。

 

說到鎖我們並不陌生,日常工作中也可能經常會用到,但是我們不能只停留在用的層面上,為什麼要加鎖,不加鎖行不行,不行的話會導致哪些問題,這些都是在使用加鎖語句時我們需要考慮的。

 

來看一個使用 32 位的 CPU 寫 long 型變數需不需要加鎖的問題:

 

我們知道 long 型變數長度為 64 位,在 32 位 CPU 上寫 long 型變數至少需要拆分成 2 個步驟:一次寫 高 32 位,一次寫低 32 位。

 

對於單核 CPU 來說,同一時刻只有一個執行緒在執行,禁止 CPU 中斷就意味著禁止執行緒切換,獲得 CPU 使用權的這個執行緒就會一直執行,所以 2 次寫操作要麼同時都被執行,要麼都不被執行,單核 CPU 是保證原子性的。

 

對於多核 CPU,同一時刻,一個執行緒在 CPU-1 上執行,另一個執行緒在 CPU-2 上執行,此時禁止 CPU 切換,只能保證 CPU 上有執行緒執行,並不能保證同一時刻只有一個執行緒執行,如果兩個執行緒同時都在寫高位,那麼得出的結果可就不正確了。

 

 所以,互斥修改共享變數這個條件非常重要,也就是說同一時刻只有一個執行緒在修改共享變數,只要保證這個條件,不論單核還是多核,操作就都是原子性的了。

 

一說到互斥、原子性,我們馬上就想到了程式碼加鎖,沒錯加鎖是正確的選擇,但是怎麼加呢? 要想知道怎麼加鎖,首先我們要知道加鎖鎖的是什麼以及我們想要保護的資源是什麼,看下圖說說鎖的是什麼,要保護的是什麼呢?

 

      圖中鎖的 M 資源,保護的也是 M 資源。

 

程式中的鎖與現實中的鎖也是類似的,每一把鎖都有自己要保護的資源,這是至關重要的,如圖保護資源 M 的鎖為 LM,就像我家大門的鎖保護我家,你家大門的鎖保護你家一樣,如果程式出現類似我家大門鎖保護你家的情況,那麼就會導致詭異的併發問題了。

 

瞭解了鎖的是什麼與保護的是什麼之後,我們看看怎麼加鎖的問題,還是用 count += 1 的例子,看程式碼:

 

class Test{
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

分析一下,這段程式碼中鎖的是當前物件,要保護的資源是物件中的成員屬性 value,這樣的加鎖方式開啟10 個執行緒分別呼叫 10000次 addOne()方法,我們預期的結果是 value 最終會達到 100000,結果如何呢 ?

 

經過測試,addOne() 不加 synchronized 結果會出現小於 100000 的情況,加上 synchronized 結果符合我們的預期,針對測試結果,簡要分析如下:

 

加鎖之後,執行緒之間是互斥的,也就是說同一時刻只有一個執行緒執行,這樣就原子性可以保證了。

 

那麼可見性呢?一個執行緒操作結束後另一個執行緒能獲取到上一個執行緒的操作結果嗎?答案是肯定的,這就跟我們上一章說的 happen before 原則聯絡到一起了,“一個鎖的解鎖操作對另一個鎖的加鎖操作是可見的”,再結合傳遞性規則,一個鎖在解鎖前,對共享變數的修改,即解鎖前對共享變數修改 happen before 於 這個鎖的解鎖,這個鎖的解鎖操作 happen before於另一個鎖的加鎖。

 

所以,解鎖前對共享變數修改happen before於另一個鎖的加鎖,也就是說解鎖前對共享變數修改對於另一個鎖的加鎖是可見的。

 

到這一切看似還挺完美,其實我們忽略了 get() 方法,多執行緒操作 get()  方法會是安全的嗎?在沒有任何前提操作的情況下,直接呼叫 get() 方法當然沒問題,就是取值又不涉及修改。但是如果在執行 addOne() 方法後呼叫呢?顯然,這時候 value 值的修改對 get()  方法是不可見的,happen before 中只說了鎖的規則,這裡要想保證可見性,對 get()方法也需要加上一把鎖。程式碼如下:

 

class Test{
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

這裡我們用同一把鎖,保護了共享資源 value。說到這,我們根據資源關係來將使用鎖的情況分為兩種:

 

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

     

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

 

對於 1 的情況,由於屬性之間沒有關係,每個資源都用一把鎖來控制,例如修改賬戶的密碼、修改餘額操作,密碼與餘額是沒有關係的資源,分別用兩把鎖來控制即可,這種鎖叫做細粒度鎖,使用不同的鎖對受保護的資源進行精細化管理,可以提升效能。

 

對於 2 的情況 ,則需要粒度更大的鎖去保護多個資源,看下面這段程式碼:

 

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

乍一看,沒問題,轉賬操作加了鎖,妥妥的。其實則不然,看圖就明白了:

 

 

現在這就是"用我家鎖鎖了你家"的典型例子,這時候臨界區有多個資源,我們應該使用更大粒度的鎖,看看這樣改怎麼樣:

 

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 作為更大粒度的鎖是可行的, class 就是我們常說的 “類模板”,在 JVM 中只會載入一次,所以所有 Account 物件的類模板都是相同的,這樣就能夠保證用一把大鎖鎖住了有關係的共享資源。

 

問題是解決了,仔細一想,如果用 Account.class 作為鎖,那豈不是所有的轉賬操作都是串行了,這樣肯定是不行的,生活中轉賬肯定也不是序列的,如果序列那效率真的是很太差了。

 

正確的方式應該是這樣的:

 

class Account {
 //靜態屬性 替代 Account.class 作為一把大鎖
  private static Object lock = new Object();
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    synchronized(lock) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
 

這樣一改,效率就上來了,問題也解決了,實際在開發中我們這也是我們最常用的加鎖的方式,使用靜態成員屬性作為鎖去保護有關係的多個資源。

 

 

總結:

我們從導致併發 bug 的原子性問題解決辦法---加鎖入手,瞭解了常規加鎖方式背後的邏輯---鎖的是什麼與保護的是什麼,與加鎖後變數的傳遞性規則,到最後不同資源關係對應著不同的加鎖方式---細粒度鎖,粗粒度鎖。

 

如果想了解更多關於鎖知識,請看我的這篇文章: 聊聊鎖機制

&n