1. 程式人生 > 其它 >淺談 Java 多執行緒(一) --- JMM

淺談 Java 多執行緒(一) --- JMM

JMM、Volatile、Happends-Before

為什麼使用多執行緒

  1. 更多的處理器核心數(硬體的發展使 CPU 趨向於更多的核心數,如果不能充分利用,就無法顯著提升程式的效率)
  2. 更快的響應時間(複雜的業務場景下,會存在許多資料一致性不強的操作,如果將這些操作並行執行,則響應時間會大大縮短)
  3. 更好的程式設計模型(Java 為多執行緒程式設計提供了良好的程式設計模型及眾多工具,使用起來非常方便)

併發程式設計的挑戰

  1. 執行緒的建立和銷燬需要開銷(可以通過執行緒池解決)
  2. 更多的執行緒意味著更多的上下文切換,有些情況下速度可能不如單執行緒
    1. 無鎖併發程式設計,多執行緒競爭鎖時會引起上下文切換
    2. 樂觀鎖:使用 CAS 演算法更新資料,而不需要鎖
    3. 使用最少執行緒:當任務很少卻建立大量的執行緒來處理的時候,很多執行緒是處於等待狀態的。這部分執行緒也會引起上下文切換
    4. 協程:在單執行緒中實現多工的排程
  3. 死鎖
    1. 避免一個執行緒同時獲取多個鎖
    2. 避免一個執行緒在鎖內同時佔用多個資源
    3. 嘗試使用其他鎖,如 lock.tryLock(timeout)來替代使用內部鎖機制
    4. 對於資料庫鎖,加鎖和解鎖必須在同一個事物內
  4. 軟硬體的資源限制(例如網路頻寬 2mb/s,某個資源下載速度是 1mb/s,啟動 10個執行緒下載不會讓下載速度到 10mb/s)
  5. 指令重排序可能會打破程式原本的語義
    1. 編譯器和處理器一般情況下僅對滿足資料依賴條件的兩個操作不做重排序
      1. 資料依賴條件:兩個操作訪問同一個變數 + 其中一個操作是寫操作

Java 記憶體模型(JMM)

併發程式設計首先要解決的兩個問題是:執行緒之間的通訊與同步,Java 多執行緒採用的是共享記憶體模型

來進行執行緒通訊,在共享記憶體模型裡,同步是顯式執行的

Java 記憶體模型的抽象結構示意圖

由上圖可以看出,如果兩個執行緒需要通訊的話,需要以下步驟

  1. 執行緒A把本地記憶體A裡面的共享變數副本重新整理到主記憶體
  2. 執行緒B讀取主記憶體中已經更新過的共享變數

Volatile 的記憶體語義

volatile 的特性

  • 可見性:對一個 volatile 變數的讀,總是能看到任意執行緒對該變數最後一次的寫入

  • (偽)原子性:對任意單個 volatile 變數的讀/寫具有原子性,但是類似於 v++ 這樣的操作不具備原子性

volatile 讀、寫的記憶體語義

  • 執行緒 A 寫一個 volatile 變數,實質上是對接下來要讀這個變數的執行緒發了(其對共享變數所做修改)訊息
  • 執行緒 B 讀一個 volatile 變數,實質上是接收之前某個執行緒傳送的(在寫這個變數之前對共享變數所做的修改的)訊息
  • 執行緒 A 寫,隨後執行緒 B 讀,這個過程實質上是執行緒 A 通過主記憶體向執行緒 B 傳送訊息

Happens-before

JSR-133 對 happens-before 的規則描述

  1. 程式順序規則:一個執行緒中,按照程式順序,前面的操作 happens-before 於後續的任意操作,例如
// A  happens-before B, B happens-before C
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
  1. 監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖
// 假設 x 的初始值是 10,執行緒 A 執行完程式碼塊後 x 的值會變成 12(解鎖),接著執行緒 B 進入程式碼塊(加鎖),此時觸發了監視器鎖規則。因此執行緒 A 對共享變數的操作能夠被 B 所看見,也就是執行緒 B 能夠看到 x == 12
synchronized (this) { // 加鎖
  // x 是共享變數,初始值 = 10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 解鎖
  1. volatile變數規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀
// 根據規則 1,可以確定 1 happens-before 2 、3 happens-before 4
// 根據規則 3,可以確定 2 happens-before 3
// 推導到這裡,好像感覺沒什麼用,並不能確定第 4 步輸出的 x 值,且繼續往下看第 4 條規則
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42; // 1
    v = true; // 2
  }
  public void reader() {
    if (v == true) { // 3
      sout(x); // 4
    }
  }
}
  1. 傳遞性:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C
// 有了這條規則,我們可以得出: 1 happens-before 4,那麼第 4 步看到的 x 值將是 42。
// 也就是通過 volatile 變數,執行緒 A 向執行緒 B 傳送了它對變數 x 所做的修改的訊息
  1. start() 規則:指執行緒 A 啟動子執行緒 B 後,子執行緒 B 能夠看到執行緒 A 在啟動子執行緒 B 前的操作

  2. join() 規則:如果執行緒A執行操作 ThreadB.join() 併成功返回,那麼執行緒B中的任意操作 happens-before 於執行緒 A 從 ThreadB.join() 操作成功返回

JSR-133 對 happens-before 的定義

  1. 如果一個操作 happens-before 於另一個操作,那麼第一個操作的執行結果對第二個操作可見,並且第一個操作先於第二個操作執行
  2. 兩個操作存在 happens-before 關係,並不意味著 Java 一定按照 happens-before 關係指定的順序執行,只要能保證程式語義不變的重排序也並不非法

定義 1 是 JMM 對程式設計師的承諾,從程式設計師的角度來說,如果 A happens-before B,則 A 的操作對 B 可見

定義 2 是對 Java 設計者的約束,對於設計者來說只要不改變程式語義,想怎麼優化、想怎麼重排序都可以。這麼做的目的就是在不改變程式語義的前提下,儘可能高的提高程式的併發度

總結

本篇簡要介紹了 Java 記憶體模型結構、volatile 和 happens-before 的相關概念,volatile 是 Java 中併發容器和原子類實現的基石,理解其記憶體語義有助於後續理解併發容器和原子類的實現原理;而 happens-before 是 JMM 最核心的概念。對於 Java 程式設計師來說,理解 happens-before 是理解 JMM 的關鍵,希望通過此篇可以幫助讀者解決 Java 併發程式設計中遇到的記憶體可見性問題