Java 併發程式設計學習總結

huansky發表於2022-02-28

什麼是併發程式設計,簡單來說就是為了充分利用cpu,多個任務同時執行,快速完成任務。

併發程式設計相關的概念和技術看上非常零散,相關度也很低,想要學習好併發程式設計,可以從下面兩方面入手:一是建立全景圖,從細節“跳出來,看全景”,另一個是深挖細節,也就是“鑽進去,看本質”。

其實不止是併發程式設計的學習,任何的知識的學習都是一樣的。在學習的時候,要充分利用網上已有的知識體系,比如計算機網路的分層,沒必要自己再去重新分層。對於網上沒有的知識體系,自己要學會進行抽象和總結,建立方便自己理解的全景圖。

對於併發程式設計領域,借用王寶令老師的總結,可以抽象成三個核心問題:分工、同步和互斥。

分工

所謂分工,類似於現實中一個組織完成一個專案,專案經理要拆分任務,安排合適的成員去完成。

在併發領域裡,分工很重要,它直接決定了併發程式的效能。在現實世界裡,分工是很複雜的,著名數學家華羅庚曾用“燒水泡茶”的例子通俗地講解了統籌方法(一種安排工作程式的數學方法),“燒水泡茶”這麼簡單的事情都這麼多說道,更何況是併發程式設計裡的工程問題呢。

既然分工很重要又很複雜,那一定有前輩努力嘗試解決過,並且也一定有成果。的確,在併發程式設計領域這方面的成果還是很豐碩的。Java SDK 併發包裡的 Executor、Fork/Join、Future 本質上都是一種分工方法。除此之外,併發程式設計領域還總結了一些設計模式,基本上都是和分工方法相關的,例如生產者 - 消費者、Thread-Per-Message、Worker Thread 模式等都是用來指導你如何分工的。

同步

分好工之後,就是具體執行了。在併發程式設計領域裡的同步,主要指的就是執行緒間的協作,本質上和現實生活中的協作沒區別,不過是一個執行緒執行完了一個任務,如何通知執行後續任務的執行緒開工而已。

協作一般是和分工相關的。Java SDK 併發包裡的 Executor、Fork/Join、Future 本質上都是分工方法,但同時也能解決執行緒協作的問題。例如,用 Future 可以發起一個非同步呼叫,當主執行緒通過 get() 方法取結果時,主執行緒就會等待,當非同步執行的結果返回時,get() 方法就自動返回了。主執行緒和非同步執行緒之間的協作,Future 工具類已經幫我們解決了。除此之外,Java SDK 裡提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用來解決執行緒協作問題的。

在 Java 併發程式設計領域,解決協作問題的核心技術是管程,上面提到的所有執行緒協作技術底層都是利用管程解決的。管程是一種解決併發問題的通用模型,除了能解決執行緒協作問題,還能解決下面我們將要介紹的互斥問題。可以這麼說,管程是解決併發問題的萬能鑰匙。

管程,對應的英文是 Monitor,很多 Java 領域的同學都喜歡將其翻譯成“監視器”,這是直譯。作業系統領域一般都翻譯成“管程”,這個是意譯,而我自己也更傾向於使用“管程”。

所謂管程,指的是管理共享變數以及對共享變數的操作過程,讓他們支援併發。翻譯為 Java 領域的語言,就是管理類的成員變數和成員方法,讓這個類是執行緒安全的。那管程是怎麼管的呢?

Java 參考了 MESA 模型,語言內建的管程(synchronized)對 MESA 模型進行了精簡。MESA 模型中,條件變數可以有多個,Java 語言內建的管程裡只有一個條件變數。具體如下圖所示。

Java 中的管程示意圖

Java 內建的管程方案(synchronized)使用簡單,synchronized 關鍵字修飾的程式碼塊,在編譯期會自動生成相關加鎖和解鎖的程式碼,但是僅支援一個條件變數;而 Java SDK 併發包實現的管程支援多個條件變數,不過併發包裡的鎖,需要開發人員自己進行加鎖和解鎖操作。

併發程式設計裡兩大核心問題——互斥和同步,都可以由管程來幫你解決。學好管程,理論上所有的併發問題你都可以解決,並且很多併發工具類底層都是管程實現的,所以學好管程,就是相當於掌握了一把併發程式設計的萬能鑰匙。

這部分內容的學習,關鍵是理解管程模型,學好它就可以解決所有問題。其次是瞭解 Java SDK 併發包提供的幾個執行緒協作的工具類的應用場景,用好它們可以妥妥地提高你的工作效率。

互斥

分工、同步主要強調的是效能,但併發程式裡還有一部分是關於正確性的,用專業術語叫“執行緒安全”。導致不確定的主要源頭是可見性問題、有序性問題和原子性問題,關於這三個問題,也可以參考文章 可見性、原子性和有序性問題:併發程式設計Bug的源頭。為了解決這三個問題,Java 語言引入了記憶體模型,可以參考 Java 記憶體模型  一文。記憶體模型提供了一系列的規則來規範如何去訪問變數,利用這些規則,我們可以避免可見性問題、有序性問題,但是還不足以完全解決執行緒安全問題。解決執行緒安全問題的核心方案還是互斥。

所謂互斥,指的是同一時刻,只允許一個執行緒訪問共享變數。

實現互斥的核心技術就是鎖,Java 語言裡 synchronized、SDK 裡的各種 Lock 都能解決互斥問題。雖說鎖解決了安全性問題,但同時也帶來了效能問題,那如何保證安全性的同時又儘量提高效能呢?

第一,Java SDK 裡提供的 ReadWriteLock、StampedLock 就可以優化讀多寫少場景下鎖的效能。

第二,既然使用鎖會帶來效能問題,那最好的方案自然就是使用無鎖的演算法和資料結構了。在這方面有很多相關的技術,例如執行緒本地儲存 (Thread Local Storage, TLS)、寫入時複製 (Copy-on-write)、樂觀鎖等;Java 併發包裡面的原子類也是一種無鎖的資料結構;Disruptor 則是一個無鎖的記憶體佇列,效能都非常好……具體可以參考 Java CAS 原理詳解 來詳細瞭解無所化是如何實現的。

第三,減少鎖持有的時間。互斥鎖本質上是將並行的程式序列化,所以要增加並行度,一定要減少持有鎖的時間。這個方案具體的實現技術也有很多,例如使用細粒度的鎖,一個典型的例子就是 Java 併發包裡的 ConcurrentHashMap,它使用了所謂分段鎖的技術(這個技術後面我們會詳細介紹);還可以使用讀寫鎖,也就是讀是無鎖的,只有寫的時候才會互斥。

效能方面的度量指標有很多,我覺得有三個指標非常重要,就是:吞吐量、延遲和併發量。

  1. 吞吐量:指的是單位時間內能處理的請求數量。吞吐量越高,說明效能越好。

  2. 延遲:指的是從發出請求到收到響應的時間。延遲越小,說明效能越好。

  3. 併發量:指的是能同時處理的請求數量,一般來說隨著併發量的增加、延遲也會增加。所以延遲這個指標,一般都會是基於併發量來說的。例如併發量是 1000 的時候,延遲是 50 毫秒。

除此之外,還有一些其他的方案,原理是不共享變數或者變數只允許讀。這方面,Java 提供了 ThreadLocal 和 final 關鍵字,還有一種 Copy-on-write 的模式,其實  Copy-on-write 模式最早是出現在作業系統中的,當 fork 一個新的程式的時候,沒必要把所有的東西都拷貝,先讓兩個程式進行共享,只有在寫入資料的時候在進行拷貝和分離,這樣會減少cpu的損耗,以及節省時間 。

使用鎖除了要注意效能問題外,還需要注意死鎖問題。具體可以參考文章例項詳解 Java 死鎖與破解死鎖

還有就是執行緒封閉。

執行緒方法裡的區域性變數,因為不會和其他執行緒共享,所以沒有併發問題,這個思路很好,已經成為解決併發問題的一個重要技術,同時還有個響噹噹的名字叫做執行緒封閉,比較官方的解釋是:僅在單執行緒內訪問資料。由於不存在共享,所以即便不同步也不會有併發問題,效能槓槓的。

採用執行緒封閉技術的案例非常多,例如從資料庫連線池裡獲取的連線 Connection,在 JDBC 規範裡並沒有要求這個 Connection 必須是執行緒安全的。資料庫連線池通過執行緒封閉技術,保證一個 Connection 一旦被一個執行緒獲取之後,在這個執行緒關閉 Connection 之前的這段時間裡,不會再分配給其他執行緒,從而保證了 Connection 不會有併發問題。

這部分內容比較複雜,往往還是跨領域的,例如要理解可見性,就需要了解一些 CPU 和快取的知識;要理解原子性,就需要理解一些作業系統的知識;很多無鎖演算法的實現往往也需要理解 CPU 快取。這部分內容的學習,需要博覽群書,在大腦裡建立起 CPU、記憶體、I/O 執行的模擬器。這樣遇到問題就能得心應手了。 

 如何寫好併發程式

那如何才能用物件導向思想寫好併發程式呢?可以從封裝共享變數、識別共享變數間的約束條件和制定併發訪問策略這三個方面下手。

一、封裝共享變數

併發程式,我們關注的一個核心問題,不過是解決多執行緒同時訪問共享變數的問題。我們類比過球場門票的管理,現實世界裡門票管理的一個核心問題是:所有觀眾只能通過規定的入口進入,否則檢票就形同虛設。在程式設計世界這個問題也很重要,程式設計領域裡面對於共享變數的訪問路徑就類似於球場的入口,必須嚴格控制。好在有了物件導向思想,對共享變數的訪問路徑可以輕鬆把控。

物件導向思想裡面有一個很重要的特性是封裝,封裝的通俗解釋就是將屬性和實現細節封裝在物件內部,外界物件只能通過目標物件提供的公共方法來間接訪問這些內部屬性,這和門票管理模型匹配度相當的高,球場裡的座位就是物件屬性,球場入口就是物件的公共方法。我們把共享變數作為物件的屬性,那對於共享變數的訪問路徑就是物件的公共方法,所有入口都要安排檢票程式就相當於我們前面提到的併發訪問策略。

利用物件導向思想寫併發程式的思路,其實就這麼簡單:將共享變數作為物件屬性封裝在內部,對所有公共方法制定併發訪問策略。就拿很多統計程式都要用到計數器來說,下面的計數器程式共享變數只有一個,就是 value,我們把它作為 Counter 類的屬性,並且將兩個公共方法 get() 和 addOne() 宣告為同步方法,這樣 Counter 類就成為一個執行緒安全的類了。

public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}
當然,實際工作中,很多的場景都不會像計數器這麼簡單,經常要面臨的情況往往是有很多的共享變數,例如,信用卡賬戶有卡號、姓名、身份證、信用額度、已出賬單、未出賬單等很多共享變數。這麼多的共享變數,如果每一個都考慮它的併發安全問題,那我們就累死了。但其實仔細觀察,你會發現,很多共享變數的值是不會變的,例如信用卡賬戶的卡號、姓名、身份證。對於這些不會發生變化的共享變數,建議你用 final 關鍵字來修飾。這樣既能避免併發問題,也能很明瞭地表明你的設計意圖,讓後面接手你程式的兄弟知道,你已經考慮過這些共享變數的併發安全問題了。

二、識別共享變數間的約束條件

識別共享變數間的約束條件非常重要。因為這些約束條件,決定了併發訪問策略。例如,庫存管理裡面有個合理庫存的概念,庫存量不能太高,也不能太低,它有一個上限和一個下限。關於這些約束條件,我們可以用下面的程式來模擬一下。在類 SafeWM 中,宣告瞭兩個成員變數 upper 和 lower,分別代表庫存上限和庫存下限,這兩個變數用了 AtomicLong 這個原子類,原子類是執行緒安全的,所以這兩個成員變數的 set 方法就不需要同步了。

public class SafeWM {
  // 庫存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 庫存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 設定庫存上限
  void setUpper(long v){
    upper.set(v);
  }
  // 設定庫存下限
  void setLower(long v){
    lower.set(v);
  }
  // 省略其他業務程式碼
}

雖說上面的程式碼是沒有問題的,但是忽視了一個約束條件,就是庫存下限要小於庫存上限,這個約束條件能夠直接加到上面的 set 方法上嗎?我們先直接加一下看看效果(如下面程式碼所示)。我們在 setUpper() 和 setLower() 中增加了引數校驗,這乍看上去好像是對的,但其實存在併發問題,問題在於存在競態條件。這裡我順便插一句,其實當你看到程式碼裡出現 if 語句的時候,就應該立刻意識到可能存在競態條件。

我們假設庫存的下限和上限分別是 (2,10),執行緒 A 呼叫 setUpper(5) 將上限設定為 5,執行緒 B 呼叫 setLower(7) 將下限設定為 7,如果執行緒 A 和執行緒 B 完全同時執行,你會發現執行緒 A 能夠通過引數校驗,因為這個時候,下限還沒有被執行緒 B 設定,還是 2,而 5>2;執行緒 B 也能夠通過引數校驗,因為這個時候,上限還沒有被執行緒 A 設定,還是 10,而 7<10。當執行緒 A 和執行緒 B 都通過引數校驗後,就把庫存的下限和上限設定成 (7, 5) 了,顯然此時的結果是不符合庫存下限要小於庫存上限這個約束條件的。

public class SafeWM {
  // 庫存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 庫存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 設定庫存上限
  void setUpper(long v){
    // 檢查引數合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 設定庫存下限
  void setLower(long v){
    // 檢查引數合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他業務程式碼
}
在沒有識別出庫存下限要小於庫存上限這個約束條件之前,我們制定的併發訪問策略是利用原子類,但是這個策略,完全不能保證庫存下限要小於庫存上限這個約束條件。所以說,在設計階段,我們一定要識別出所有共享變數之間的約束條件,如果約束條件識別不足,很可能導致制定的併發訪問策略南轅北轍。

共享變數之間的約束條件,反映在程式碼裡,基本上都會有 if 語句,所以,一定要特別注意競態條件。

三、制定併發訪問策略

制定併發訪問策略,是一個非常複雜的事情。應該說整個專欄都是在嘗試搞定它。不過從方案上來看,無外乎就是以下“三件事”。

  1. 避免共享:避免共享的技術主要是利於執行緒本地儲存以及為每個任務分配獨立的執行緒。

  2. 不變模式:這個在 Java 領域應用的很少,但在其他領域卻有著廣泛的應用,例如 Actor 模式、CSP 模式以及函數語言程式設計的基礎都是不變模式。

  3. 管程及其他同步工具:Java 領域萬能的解決方案是管程,但是對於很多特定場景,使用 Java 併發包提供的讀寫鎖、併發容器等同步工具會更好。

接下來在我們們專欄的第二模組我會仔細講解 Java 併發工具類以及他們的應用場景,在第三模組我還會講解併發程式設計的設計模式,這些都是和制定併發訪問策略有關的。

除了這些方案之外,還有一些巨集觀的原則需要你瞭解。這些巨集觀原則,有助於你寫出“健壯”的併發程式。這些原則主要有以下三條。

  1. 優先使用成熟的工具類:Java SDK 併發包裡提供了豐富的工具類,基本上能滿足你日常的需要,建議你熟悉它們,用好它們,而不是自己再“發明輪子”,畢竟併發工具類不是隨隨便便就能發明成功的。

  2. 迫不得已時才使用低階的同步原語:低階的同步原語主要指的是 synchronized、Lock、Semaphore 等,這些雖然感覺簡單,但實際上並沒那麼簡單,一定要小心使用。

  3. 避免過早優化:安全第一,併發程式首先要保證安全,出現效能瓶頸後再優化。在設計期和開發期,很多人經常會情不自禁地預估效能的瓶頸,並對此實施優化,但殘酷的現實卻是:效能瓶頸不是你想預估就能預估的。

 

相關文章

例項詳解 Java 死鎖與破解死鎖

Java 記憶體模型 

可見性、原子性和有序性問題:併發程式設計Bug的源頭

Java CAS 原理詳解

深入詳解 Java 執行緒

Java 併發程式設計學習總結

 

相關文章