Java 中的 syncronized 你真的用對了嗎

  • 2019 年 10 月 3 日
  • 筆記

 

生活中隨處可見並行的例子,並行 顧名思義就是一起進行的意思,同樣的程序在某些時候也需要並行來提高效率,在上一篇文章中我們了解了 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 的原子性問題解決辦法—加鎖入手,了解了常規加鎖方式背後的邏輯—鎖的是什麼與保護的是什麼,與加鎖後變量的傳遞性規則,到最後不同資源關係對應着不同的加鎖方式—細粒度鎖,粗粒度鎖。

 

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