[Java併發-3]Java互斥鎖,解決原子性問題

在前面的分享中咱們提到。java

一個或者多個操做在 CPU 執行的過程當中不被中斷的特性,稱爲「原子性」

思考:在32位的機器上對long型變量進行加減操做存在併發問題,什麼緣由!?編程

原子性問題如何解決

咱們已經知道原子性問題是線程切換,而操做系統作線程切換是依賴 CPU 中斷的,因此禁止 CPU 發生中斷就可以禁止線程切換。併發

在單核 CPU 時代,這個方案的確是可行的。這裏咱們以 32 位 CPU 上執行 long 型變量的寫操做爲例來講明這個問題,long 型變量是 64 位,在 32 位 CPU 上執行寫操做會被拆分紅兩次寫操做(寫高 32 位和寫低 32 位,以下圖所示)。app

圖片描述

在單核 CPU 場景下,同一時刻只有一個線程執行,禁止 CPU 中斷,得到 CPU 使用權的線程就能夠不間斷地執行,因此兩次寫操做必定是:要麼都被執行,要麼都沒有被執行,具備原子性。性能

可是在多核場景下,同一時刻,有可能有兩個線程同時在執行,一個線程執行在 CPU-1 上,一個線程執行在 CPU-2 上,此時禁止 CPU 中斷,只能保證 CPU 上的線程連續執行,並不能保證同一時刻只有一個線程執行,若是這兩個線程同時寫 long 型變量高 32 位的話,仍是會出現問題。this

同一時刻只有一個線程執行這個條件很是重要,咱們稱之爲 互斥

若是咱們可以保證對共享變量的修改是互斥的,那麼,不管是單核 CPU 仍是多核 CPU,就都能保證原子性了。spa

簡易鎖模型

互斥的解決方案,。你們腦中的模型多是這樣的。操作系統

圖片描述

線程在進入臨界區以前,首先嚐試加鎖 lock(),若是成功,則進入臨界區,此時咱們稱這個線程持有鎖;不然就等待,直到持有鎖的線程解鎖;持有鎖的線程執行完臨界區的代碼後,執行解鎖 unlock()。線程

這樣理解自己沒有問題,但卻很容易讓咱們忽視兩個很是很是重要的點:設計

  1. 咱們鎖的是什麼?
  2. 咱們保護的又是什麼?

改進後的鎖模型

咱們知道在現實世界裏,鎖和鎖要保護的資源是有對應關係的,好比我用我家的鎖保護我家的東西。在併發編程世界裏,鎖和資源也應該有這個關係,但這個關係在咱們上面的模型中是沒有體現的,因此咱們須要完善一下咱們的模型。

圖片描述

首先,咱們要把臨界區要保護的資源標註出來,如圖中臨界區裏增長了一個元素:受保護的資源 R;其次,咱們要保護資源 R 就得爲它建立一把鎖 LR;最後,針對這把鎖 LR,咱們還需在進出臨界區時添上加鎖操做和解鎖操做。另外,在鎖 LR 和受保護資源之間,增長了一條連線,這個關聯關係很是重要,這裏很容易發生BUG,容易出現了相似鎖自家門來保護他家資產的事情。

Java語言提供的鎖

鎖是一種通用的技術方案,Java 語言提供的synchronized 關鍵字,就是鎖的一種實現。synchronized關鍵字能夠用來修飾方法,也能夠用來修飾代碼塊,基本使用:

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

參考咱們上面提到的模型,加鎖 lock() 和解鎖 unlock() 這兩個操做在Java 編譯會自動加上。這樣作的好處就是加鎖 lock() 和解鎖 unlock() 必定是成對出現的。

上面的代碼咱們看到只有修飾代碼塊的時候,鎖定了一個 obj 對象,那修飾方法的時候鎖定的是什麼呢?這個也是 Java 的一條隱式規則:

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

當修飾非靜態方法的時候,鎖定的是當前實例對象 this。

class X {
  // 修飾靜態方法
  synchronized(X.class) static void bar() {
    // 臨界區
  }
}

class X {
  // 修飾非靜態方法
  synchronized(this) void foo() {
    // 臨界區
  }
}

鎖解決 count+1 問題

咱們來嘗試下用synchronized解決以前遇到的 count+=1 存在的併發問題,代碼以下所示。SafeCalc 這個類有兩個方法:一個是 get() 方法,用來得到 value 的值;另外一個是 addOne() 方法,用來給 value 加 1,而且 addOne() 方法咱們用 synchronized 修飾。那麼咱們使用的這兩個方法有沒有併發問題呢?

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

咱們先來看看 addOne() 方法,首先能夠確定,被 synchronized 修飾後,不管是單核 CPU 仍是多核 CPU,只有一個線程可以執行 addOne() 方法,因此必定能保證原子操做,那是否有可見性問題呢?
讓咱們回顧下以前講一條 Happens-Before的規則。

管程中鎖的規則:對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。

管程,就是咱們這裏的 synchronized.咱們知道 synchronized 修飾的臨界區是互斥的,也就是說同一時刻只有一個線程執行臨界區的代碼;而這裏指的就是前一個線程的解鎖操做對後一個線程的加鎖操做可見.咱們就能得出前一個線程在臨界區修改的共享變量(該操做在解鎖以前),對後續進入臨界區(該操做在加鎖以後)的線程是可見的。

按照這個規則,若是多個線程同時執行 addOne() 方法,可見性是能夠保證的,也就說若是有 1000 個線程執行 addOne() 方法,最終結果必定是 value 的值增長了 1000。

咱們在來看下,執行 addOne() 方法後,value 的值對 get() 方法是可見的嗎?這個可見性是無法保證的。管程中鎖的規則,是隻保證後續對這個鎖的加鎖的可見性,而 get() 方法並無加鎖操做,因此可見性無法保證。那如何解決呢?很簡單,就是 get() 方法也 synchronized 一下,完整的代碼以下所示。

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

上面的代碼轉換爲咱們提到的鎖模型,就是下面圖示這個樣子。get() 方法和 addOne() 方法都須要訪問 value 這個受保護的資源,這個資源用 this 這把鎖來保護。線程要進入臨界區 get() 和 addOne(),必須先得到 this 這把鎖,這樣 get() 和 addOne() 也是互斥的。

圖片描述

鎖和受保護資源的關係

咱們前面提到,受保護資源和鎖之間的關聯關係很是重要,他們的關係是怎樣的呢?一個合理的關係是:

受保護資源和鎖之間的關聯關係是 N:1 的關係

上面那個例子我稍做改動,把 value 改爲靜態變量,把 addOne() 方法改爲靜態方法,此時 get() 方法和 addOne() 方法是否存在併發問題呢?

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

若是你仔細觀察,就會發現改動後的代碼是用兩個鎖保護一個資源。這個受保護的資源就是靜態變量 value,兩個鎖分別是 this 和 SafeCalc.class。咱們能夠用下面這幅圖來形象描述這個關係。因爲臨界區 get() 和 addOne() 是用兩個鎖保護的,所以這兩個臨界區沒有互斥關係,臨界區 addOne() 對 value 的修改對臨界區 get() 也沒有可見性保證,這就致使併發問題了。

圖片描述

鎖小結

互斥鎖,在併發領域的知名度極高,只要有了併發問題,你們首先容易想到的就是加鎖,加鎖可以保證執行臨界區代碼的互斥性。

synchronized 是 Java 在語言層面提供的互斥原語,其實 Java 裏面還有不少其餘類型的鎖,但做爲互斥鎖,原理都是相通的:鎖,必定有一個要鎖定的對象,至於這個鎖定的對象要保護的資源以及在哪裏加鎖 / 解鎖,就屬於設計層面的事情。

如何一把鎖保護多個資源?

保護沒有關聯關係的多個資源

當咱們要保護多個資源時,首先要區分這些資源是否存在關聯關係。

一樣這對應到編程領域,也很容易解決。例如,銀行業務中有針對帳戶餘額(餘額是一種資源)的取款操做,也有針對帳戶密碼(密碼也是一種資源)的更改操做,咱們能夠爲帳戶餘額和帳戶密碼分配不一樣的鎖來解決併發問題,這個仍是很簡單的。

相關的示例代碼以下,帳戶類 Account 有兩個成員變量,分別是帳戶餘額 balance 和帳戶密碼 password。取款 withdraw() 和查看餘額 getBalance() 操做會訪問帳戶餘額 balance,咱們建立一個 final 對象 balLock 做爲鎖(類比球賽門票);而更改密碼 updatePassword() 和查看密碼 getPassword() 操做會修改帳戶密碼 password,咱們建立一個 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;
    }
  }
}

固然,咱們也能夠用一把互斥鎖來保護多個資源,例如咱們能夠用 this 這一把鎖來管理帳戶類裏全部的資源:可是用一把鎖就是性能太差,會致使取款、查看餘額、修改密碼、查看密碼這四個操做都是串行的。而咱們用兩把鎖,取款和修改密碼是能夠並行的。

用不一樣的鎖對受保護資源進行精細化管理,可以提高性能 。這種鎖還有個名字,叫 `細粒度鎖`

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

若是多個資源是有關聯關係的,那這個問題就有點複雜了。例如銀行業務裏面的轉帳操做,帳戶 A 減小 100 元,帳戶 B 增長 100 元。這兩個帳戶就是有關聯關係的。那對於像轉帳這種有關聯關係的操做,咱們應該怎麼去解決呢?先把這個問題代碼化。咱們聲明瞭個帳戶類:Account,該類有一個成員變量餘額:balance,還有一個用於轉帳的方法:transfer(),而後怎麼保證轉帳操做 transfer() 沒有併發問題呢?

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

相信你的直覺會告訴你這樣的解決方案:用戶 synchronized 關鍵字修飾一下 transfer() 方法就能夠了,因而你很快就完成了相關的代碼,以下所示。

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

在這段代碼中,臨界區內有兩個資源,分別是轉出帳戶的餘額 this.balance 和轉入帳戶的餘額 target.balance,而且用的是一把鎖this,符合咱們前面提到的,多個資源能夠用一把鎖來保護,這看上去徹底正確呀。真的是這樣嗎?惋惜,這個方案僅僅是看似正確,爲何呢?

問題就出在 this 這把鎖上,this 這把鎖能夠保護本身的餘額 this.balance,卻保護不了別人的餘額 target.balance,就像你不能用自家的鎖來保護別人家的資產,也不能用本身的票來保護別人的座位同樣。

圖片描述

下面咱們具體分析一下,假設有 A、B、C 三個帳戶,餘額都是 200 元,咱們用兩個線程分別執行兩個轉帳操做:帳戶 A 轉給帳戶 B 100 元,帳戶 B 轉給帳戶 C 100 元,最後咱們指望的結果應該是帳戶 A 的餘額是 100 元,帳戶 B 的餘額是 200 元, 帳戶 C 的餘額是 300 元。

咱們假設線程 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。

圖片描述

使用鎖的正確知識

在上一篇文章中,咱們提到用同一把鎖來保護多個資源,也就是現實世界的「包場」,那在編程領域應該怎麼「包場」呢?很簡單,只要咱們的 鎖能覆蓋全部受保護資源 就能夠了。

這裏咱們用 Account.class· 做爲共享的鎖。Account.class 是全部 Account 對象共享的,並且這個對象是 Java 虛擬機在加載 Account 類的時候建立的,因此咱們不用擔憂它的惟一性。

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 來保護不一樣對象的臨界區的。

圖片描述

思考下:上面的寫法不是最佳實踐,鎖是可變的。

鎖與資源關係小結

對如何保護多個資源已經頗有心得了,關鍵是要分析多個資源之間的關係。若是資源之間沒有關係,很好處理,每一個資源一把鎖就能夠了。若是資源之間有關聯關係,就要選擇一個粒度更大的鎖,這個鎖應該可以覆蓋全部相關的資源。除此以外,還要梳理出有哪些訪問路徑,全部的訪問路徑都要設置合適的鎖。

問題:在第一個示例程序裏,咱們用了兩把不一樣的鎖來分別保護帳戶餘額、帳戶密碼,建立鎖的時候,咱們用的是: private final Object xxxLock = new Object();若是帳戶餘額用 this.balance 做爲互斥鎖,帳戶密碼用 this.password 做爲互斥鎖,你以爲是否能夠呢?
相關文章
相關標籤/搜索