多執行緒併發的一些解決思路

zane197發表於2020-08-28

一、利用不變性解決併發問題

不變性(Immutability)模式。所謂不變性,簡單來講,就是物件一旦被建立之後,狀態就不再發生變化。換句話說,就是變數一旦被賦值,就不允許修改了(沒有寫操作);沒有修改操作,也就是保持了不變性。
可以將一個類所有的屬性都設定成 final 的,並且只允許存在只讀方法,那麼這個類基本上就具備不可變性了
Java SDK 裡很多類都具備不可變性,例如經常用到的 String 和 Long、Integer、Double 等基礎型別的包裝類都具備不可變性,這些物件的執行緒安全性都是靠不可變性來保證的。他們都遵守不可變類的三點要求:類和屬性都是 final 的,所有方法均是隻讀的
**享元模式(Flyweight Pattern)。利用享元模式可以減少建立物件的數量,從而減少記憶體佔用。**Java 語言裡面 Long、Integer、Short、Byte 等這些基本資料型別的包裝類都用到了享元模式。
享元模式本質上其實就是一個物件池,利用享元模式建立物件的邏輯也很簡單:建立之前,首先去物件池裡看看是不是存在;如果已經存在,就利用物件池裡的物件;如果不存在,就會新建立一個物件,並且把這個新建立出來的物件放進物件池裡。

Long 這個類並沒有照搬享元模式,Long 內部維護了一個靜態的物件池,僅快取了 [-128,127] 之間的數字,這個物件池在 JVM 啟動的時候就建立好了,而且這個物件池一直都不會變化,也就是說它是靜態的。是因為 Long 這個物件的狀態種類,實在太多,不宜全部快取,而 [-128,127] 之間的數字利用率最高。

“Integer 和 String 型別的物件不適合做鎖”,其實基本上所有的基礎型別的包裝類都不適合做鎖,因為它們內部用到了享元模式,這會導致看上去私有的鎖,其實是共有的。

在使用 Immutability 來解決併發問題的時候,需要注意以下兩點:

  • 物件的所有屬性都是 final 的,並不能保證不可變性;
  • 不可變物件也需要正確釋出。

final 修飾的屬性一旦被賦值,就不可以再修改,但是如果屬性的型別是普通物件,那麼這個普通物件的屬性是可以被修改的。所以,在使用 Immutability 模式的時候一定要確認保持不變性的邊界在哪裡,是否要求屬性物件也具備不可變性。

在多執行緒領域,無狀態物件(無狀態物件內部沒有屬性,只有方法)沒有執行緒安全問題,無需同步處理,自然效能很好;在分散式領域,無狀態意味著可以無限地水平擴充套件,所以分散式領域裡面效能的瓶頸一定不是出在無狀態的服務節點上。

二、利用copy-on-write來解決併發問題

Java 裡 String 這個類在實現 replace() 方法的時候,並沒有更改原字串裡面 value[] 陣列的內容,而是建立了一個新字串,這種方法在解決不可變物件的修改問題時經常用到,就是Coyp-On-Write 即寫時複製。

CopyOnWriteArrayList 和 CopyOnWriteArraySet 這兩個 Copy-on-Write 容器,它們背後的設計思想就是 Copy-on-Write;通過 Copy-on-Write 這兩個容器實現的讀操作是無鎖的,由於無鎖,所以將讀操作的效能發揮到了極致。

Copy-on-Write 是一項非常通用的技術方案,在很多領域都有著廣泛的應用。不過,它也有缺點的,那就是消耗記憶體,每次修改都需要複製一個新的物件出來,好在隨著自動垃圾回收(GC)演算法的成熟以及硬體的發展,這種記憶體消耗已經漸漸可以接受了。所以在實際工作中,如果寫操作非常少,那你就可以嘗試用一下 Copy-on-Write,效果還是不錯的。

三、利用執行緒本地儲存解決併發問題

多個執行緒同時讀寫同一共享變數存在併發問題。如果沒有共享寫操作自然沒有併發問題了。其實還可以突破共享變數,沒有共享變數也不會有併發問題,正所謂是沒有共享,就沒有傷害。Java 語言提供的執行緒本地儲存(ThreadLocal)通過執行緒封閉就能夠做到區域性變數的避免共享。

static class SafeDateFormat {
  // 定義 ThreadLocal 變數
  static final ThreadLocal<DateFormat>  tl=ThreadLocal.withInitial(
  			  ()-> new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss"));
      
  static DateFormat get(){
    return tl.get();
  }
}
// 不同執行緒執行下面程式碼
// 返回的 df 是不同的
DateFormat df =  SafeDateFormat.get();

Java 的實現裡面也有一個 Map,叫做 ThreadLocalMap,不過持有 ThreadLocalMap 的不是 ThreadLocal,而是 Thread。Thread 這個類內部有一個私有屬性 threadLocals,其型別就是 ThreadLocalMap,ThreadLocalMap 的 Key 是 ThreadLocal。hreadLocal 僅僅是一個代理工具類,內部並不持有任何與執行緒相關的資料,所有和執行緒相關的資料都儲存在 Thread 裡面。而從資料的親緣性上來講,ThreadLocalMap 屬於 Thread 也更加合理。

在使用的時候,其實每個執行緒內部都會維護一個ThreadLocalMap屬性,每份執行緒獨自的資料都存放在ThreadLocalMap中Entry[] table屬性裡,Entry物件的key就是ThreadLocal,value就是自己設定的值,如果程式裡有多個ThreadLocal屬性,每個執行緒在執行時會將用到ThreadLocal,生成Entry儲存到table中,有點需要注意的是同一個Entry中value重新設定會被替換,如Entry<ThreadLocal,value>中value被設定成value2,變成Entry<ThreadLocal,value2>,和Map類似,如果想儲存多個值,可以將value封裝成物件。

在這裡插入圖片描述

在這裡插入圖片描述
InheritableThreadLocal 與繼承性
通過 ThreadLocal 建立的執行緒變數,其子執行緒是無法繼承的。也就是說你線上程中通過 ThreadLocal 建立了執行緒變數 V,而後該執行緒建立了子執行緒,你在子執行緒中是無法通過 ThreadLocal 來訪問父執行緒的執行緒變數 V 的。

如果你需要子執行緒繼承父執行緒的執行緒變數,那該怎麼辦呢?其實很簡單,Java 提供了 InheritableThreadLocal 來支援這種特性,InheritableThreadLocal 是 ThreadLocal 子類,所以用法和 ThreadLocal 相同.

四、Guarded Suspension模式:等待喚醒機制

在這裡插入圖片描述
Guarded Suspension 模式本質上是一種等待喚醒機制的實現,只不過 Guarded Suspension 模式將其規範化了。規範化的好處是你無需重頭思考如何實現,也無需擔心實現程式的可理解性問題,同時也能避免一不小心寫出個 Bug 來。但 Guarded Suspension 模式在解決實際問題的時候,往往還是需要擴充套件的。

五、Balking 模式

某個共享變數是一個狀態變數,業務邏輯依賴於這個狀態變數的狀態:當狀態滿足某個條件時,執行某個業務邏輯,其本質其實不過就是一個 if 而已,放到多執行緒場景裡,就是一種“多執行緒版本的 if”。這種“多執行緒版本的 if”的應用場景還是很多的,所以也有人把它總結成了一種設計模式,叫做Balking 模式。
使用 Balking 模式規範化之後的寫法如下所示,你會發現僅僅是將 edit() 方法中對共享變數 changed 的賦值操作抽取到了 change() 中,這樣的好處是將併發處理邏輯和業務邏輯分開。

boolean changed=false;
// 自動存檔操作
void autoSave(){
  synchronized(this){
    if (!changed) {
      return;
    }
    changed = false;
  }
  // 執行存檔操作
  // 省略且實現
  this.execSave();
}
// 編輯操作
void edit(){
  // 省略編輯邏輯
  ......
  change();
}
// 改變狀態
void change(){
 	 synchronized(this){
   		 changed = true;
 	 }
}

六、Thread-Per-Message模式

併發程式設計領域的問題總結為三個核心問題:分工、同步和互斥。其中,同步和互斥相關問題更多地源自微觀,而分工問題則是源自巨集觀。我們解決問題,往往都是從巨集觀入手,在程式設計領域,軟體的設計過程也是先從概要設計開始,而後才進行詳細設計。解決併發程式設計問題,首要問題也是解決巨集觀的分工問題。併發程式設計領域裡,解決分工問題也有一系列的設計模式,比較常用的主要有 Thread-Per-Message 模式、Worker Thread 模式、生產者 - 消費者模式等等。今天我們重點介紹 Thread-Per-Message 模式。

這種委託他人辦理的方式,在併發程式設計領域被總結為一種設計模式,叫做Thread-Per-Message 模式,簡言之就是為每個任務分配一個獨立的執行緒。這是一種最簡單的分工方法,實現起來也非常簡單。

final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
// 處理請求    
try {
  while (true) {
    	// 接收請求
    	SocketChannel sc = ssc.accept();
  	  // 每個請求都建立一個執行緒
    new Thread(()->{
      try {
        // 讀 Socket
        ByteBuffer rb = ByteBuffer .allocateDirect(1024);
        sc.read(rb);
        // 模擬處理請求
        Thread.sleep(2000);
        // 寫 Socket
        ByteBuffer wb =  (ByteBuffer)rb.flip();
        sc.write(wb);
        // 關閉 Socket
        sc.close();
      }catch(Exception e){
        throw new UncheckedIOException(e);
      }
    }).start();
  }
} finally {
  ssc.close();
}   

Java 中的執行緒是一個重量級的物件,建立成本很高,一方面建立執行緒比較耗時,另一方面執行緒佔用的記憶體也比較大。所以,為每個請求建立一個新的執行緒並不適合高併發場景。

七、 Worker Thread 模式

在這裡插入圖片描述
Worker Thread 模式中Worker Thread 對應到現實世界裡,其實指的就是車間裡的工人。可以用執行緒池來實現。

ExecutorService es = Executors.newFixedThreadPool(500);
final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
// 處理請求    
try {
  while (true) {
    // 接收請求
    SocketChannel sc = ssc.accept();
    // 將請求處理任務提交給執行緒池
    es.execute(()->{
      try {
        // 讀 Socket
        ByteBuffer rb = ByteBuffer.allocateDirect(1024);
        sc.read(rb);
        // 模擬處理請求
        Thread.sleep(2000);
        // 寫 Socket
        ByteBuffer wb = (ByteBuffer)rb.flip();
        sc.write(wb);
        // 關閉 Socket
        sc.close();
      }catch(Exception e){
        throw new UncheckedIOException(e);
      }
    });
  }
} finally {
  ssc.close();
  es.shutdown();
}   

使用執行緒池過程中,還要注意一種執行緒死鎖的場景。如果提交到相同執行緒池的任務不是相互獨立的,而是有依賴關係的,那麼就有可能導致執行緒死鎖。當應用出現類似問題時,首選的診斷方法是檢視執行緒棧。同時也可以用過保證相同執行緒池中的任務一定相互獨立為不同的任務建立不同的執行緒池來進行避免。

八、執行緒停止的兩階段終止策略

顧名思義,就是將終止過程分成兩個階段,其中第一個階段主要是執行緒 T1 向執行緒 T2傳送終止指令,而第二階段則是執行緒 T2響應終止指令。
在這裡插入圖片描述

通過我們對Java中執行緒狀態的瞭解,讓執行緒從Runnable狀態才能進入terminated狀態,如果執行緒處在休眠狀態則需要將執行緒的狀態從休眠轉換到Runnable狀態。上面這個過程可以通過interrupt()方法,它可以將休眠狀態的執行緒轉換到 RUNNABLE 狀態。之後就是如何讓Java 執行緒自己執行完 run() 方法,一般我們採用的方法是設定一個標誌位,然後執行緒會在合適的時機檢查這個標誌位,如果發現符合終止條件,則自動退出 run() 方法。這個過程其實就是我們前面提到的第二階段:響應終止指令。具體示例如下:

class Proxy {
  // 執行緒終止標誌位
  volatile boolean terminated = false;
  boolean started = false;
  // 採集執行緒
  Thread rptThread;
  // 啟動採集功能
  synchronized void start(){
    // 不允許同時啟動多個採集執行緒
    if (started) {
      return;
    }
    started = true;
    terminated = false;
    rptThread = new Thread(()->{
      while (!terminated){
        // 省略採集、回傳實現
        report();
        // 每隔兩秒鐘採集、回傳一次資料
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e){
          // 重新設定執行緒中斷狀態
          Thread.currentThread().interrupt();
        }
      }
      // 執行到此處說明執行緒馬上終止
      started = false;
    });
    rptThread.start();
  }
  // 終止採集功能
  synchronized void stop(){
    // 設定中斷標誌位
    terminated = true;
    // 中斷執行緒 rptThread
    rptThread.interrupt();
  }
}

線上程池中的時候,可以使用**shutdown()和shutdownNow()**這兩個方法來進行實現。
shutdown() 方法是一種很保守的關閉執行緒池的方法。執行緒池執行 shutdown() 後,就會拒絕接收新的任務,但是會等待執行緒池中正在執行的任務和已經進入阻塞佇列的任務都執行完之後才最終關閉執行緒池。shutdown()呼叫後,還要再呼叫awaitTermination方法等待一點時間,執行緒池裡的執行緒才會終止。

而 shutdownNow() 方法,相對就激進一些了,執行緒池執行 shutdownNow() 後,會拒絕接收新的任務,同時還會中斷執行緒池中正在執行的任務,已經進入阻塞佇列的任務也被剝奪了執行的機會,不過這些被剝奪執行機會的任務會作為shutdownNow() 方法的返回值返回。因為 shutdownNow() 方法會中斷正在執行的執行緒,所以提交到執行緒池的任務,如果需要優雅地結束,就需要正確地處理執行緒中斷。

使用毒丸物件也能夠起到結束執行緒的作用。

生產者-消費者模式

生產者 - 消費者模式的核心是一個任務佇列,生產者執行緒生產任務,並將任務新增到任務佇列中,而消費者執行緒從任務佇列中獲取任務並執行。
在這裡插入圖片描述
從架構設計的角度來看,生產者 - 消費者模式有一個很重要的優點,就是解耦。解耦對於大型系統的設計非常重要,而解耦的一個關鍵就是元件之間的依賴關係和通訊方式必須受限。在生產者 - 消費者模式中,生產者和消費者沒有任何依賴關係,它們彼此之間的通訊只能通過任務佇列,所以生產者 - 消費者模式是一個不錯的解耦方案。
生產者 - 消費者模式還有一個重要的優點就是支援非同步,並且能夠平衡生產者和消費者的速度差異。在生產者 - 消費者模式中,生產者執行緒只需要將任務新增到任務佇列而無需等待任務被消費者執行緒執行完。

同時使用生產者 - 消費者模式還能夠支援批量執行以提升效能。
而且支援分階段提交。利用生產者 - 消費者模式還可以輕鬆地支援一種分階段提交的應用場景。我們知道寫檔案如果同步刷盤效能會很慢,所以對於不是很重要的資料,我們往往採用非同步刷盤的方式。

Java 語言提供的執行緒池本身就是一種生產者 - 消費者模式的實現,但是執行緒池中的執行緒每次只能從任務佇列中消費一個任務來執行,對於大部分併發場景這種策略都沒有問題。但是有些場景還是需要自己來實現,例如需要批量執行以及分階段提交的場景。

在分散式場景下,可以藉助分散式訊息佇列(MQ)來實現生產者 - 消費者模式。MQ 一般都會支援兩種訊息模型,一種是點對點模型,一種是釋出訂閱模型。這兩種模型的區別在於,點對點模型裡一個訊息只會被一個消費者消費,和 Java 的執行緒池非常類似(Java 執行緒池的任務也只會被一個執行緒執行);而釋出訂閱模型裡一個訊息會被多個消費者消費,本質上是一種訊息的廣播,在多執行緒程式設計領域,可以結合觀察者模式實現廣播功能。

相關文章