Java 內存模型

Java 內存模型(Java Memory Model, JMM) 定義了 JVM 操作內存的行爲模式。

內存模型

JVM 將進程內存分爲線程棧區(Thread Stack)和堆區(Heap)。

JVM 上運行的每個線程都有自己的線程棧, 且只能訪問自己的線程棧。

棧的每一幀是一層方法調用,即調用棧。棧幀中保存了方法的局部變量、下一條指令的指針等運行時信息。

對於 int, boolean 等 built-in 類型變量本身保存在棧中;對於各種類對象,對象本身保存在堆中,若其引用作爲局部變量則會保存在棧中。

對象中的域不論是 built-in 類型還是類對象都會保存在堆中,built-in 類型的域會在對象中存儲域本身,而類對象只保存其引用。就是說 Java 中所有類對象都是以引用的形式進行訪問的。

JMM 定義了堆與線程棧之間6種交互行爲: load, save, read, write, assign 和 use。這些交互行爲具有原子性,且相互依賴。

我們可以簡單的認爲線程在在修改堆區某對象的值時會先將其拷貝到線程工作內存中修改完成後再將其寫入回主內存。

指令重排序與 happens-before

爲了充分發揮多核 CPU 的性能, Java 規範規定 JVM 線程中程序的執行結果只要等同於嚴格順序執行的結果,JVM 可以對指令進行重新排序或並行執行,即 as-is-serial 語義。

我們來看一個經典的示例:

public class Main {
    
    private int a = 1;
    
    private int b = 2;

    public void foo() {
        a = 3;
        b = 4;
    }
}

在線程 A 執行 main.foo 方法時線程 B 試圖訪問 main.amain.b 可能產生 4 種結果:

  • a = 1, b = 2: 均未改變
  • a = 3, b = 2: a 已改變, b 未改變
  • a = 3, b = 4: a、b 均已改變
  • a = 1, b = 4: a 未改變, b 已改變

根據 as-is-serial 語義, a = 3b = 4 兩條語句無論誰先執行均不影響結果,因此 JVM 可以先執行任意語句。

a = 1 語句並不是原子性的,包含將對象從堆拷貝線程工作內存,修改,寫回堆操作。 JVM 不保證兩條語句的內存操作是有序的, 可能 a 先修改,但 b 先寫回堆區。

綜上兩點,JVM 不保證其它線程看到 a、b 修改的順序。

JMM 爲了解決不同線程訪問對象狀態的順序一致性定義了 happens-before 規則。即想要保證執行動作 B 時可以看到動作 A 的結果(不論 A、B 是否在同一個線程中), 動作 A 必須 happens-before 於動作 B。

  • 程序次序規則: 線程中每個動作A 都happens-before 於該線程中的每一個動作B。那麼在程序中,所有的動作B都能出現在A之後。(即要求同一個線程中滿足 as-is-serial 語義)
  • 監視器鎖法則: 對一個監視器鎖的解鎖 happens-before 於每一個後續對同一監視器鎖的加鎖。(包括 synchronized 或 ReentrantLock 等)
  • volatile 變量法則: 對 volatile 域的寫入操作 happens-before 於每一個後續對同一域的讀操作。 即 volatile 域的寫入對其它線程立即可見。原子性變量同樣擁有 volatile 語義。
  • 線程啓動法則: Thread.start 的調用會 happens-before 於線程中其它所有動作
  • 線程終止法則: 線程中的任何動作都 happens-before 於其他線程檢測到這個線程已終結(包括從 Thread.join 方法調用中成功返回; thread.isAlive() == false)
  • 線程中斷法則: 一個線程調用另一個線程的 interrupt 方法 happens-before 於被中斷線程發現中斷(包括拋出InterruptedException、thread.isInterrupted() == true)
  • 對象終結法則: 對象構造器返回 happens-before 於對象終結過程開始

happens-before 是一個標準的偏序關係,具有傳遞性。

volatile 與 synchronized

synchronized 關鍵字會阻止其它線程獲得對象的監視器鎖,被保護的代碼塊無法被其它線程訪問也就無法併發執行。

synchronized 也會創建一個內存屏障,保證所有操作結果會被直接寫入主存中。也就是說,synchronized 會保證操作的原子性和可見性。

舉例來說,在多個線程同時嘗試更新同一個計數器時, 更新操作需要進行 讀取-修改-寫入 操作, 若無法保證更新操作i++的原子性則可能出現異常執行順序: 線程A讀取舊值i=0 -> 線程B讀取舊值i=0 -> 線程A在線程工作內存中修改i, 並寫入結果 i=1 -> 線程B寫入結果i=1。 最終導致兩個線程調用i++最終i只加1的情況。

上文已經提到, volatile 關鍵字保證變量可見性,即對 volatile 域修改對於其它線程立即可見。

volatile 關鍵字可以達到保證部分 built-in 類型(如 int、 short、 byte)操作原子性的效果, 但對於 double、 long 類型無法保證原子性。即使對於 int 類型 i++ 這類需要讀取-修改-寫入操作的語句也無法保證原子性。

因此, 不要使用 volatile 關鍵字來保證操作的原子性。請使用AtomicInteger等原子數據類或synchronized等鎖機制來保證。

volatile 同時會禁止指令重排序。我們通過經典的單例模式雙重檢查鎖實現來分析 volatile 禁止指令重排序的意義:

public class Singleton {
    
    volatile private static Singleton instance;
    
    private Singleton (){}

    public static Singleton getInstance() {
      if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
      }
      return instance;
    }
}

分析若 instance 域不使用 volatile 關鍵字修飾時可能出現的狀況。

instance = new Singleton() 可以分爲三步:

  1. 爲 instance 引用分配內存
  2. 調用 Singleton 的構造函數進行初始化
  3. 將 instance 引用指向分配的內存空間

在不禁止指令重排序的情況下可能出現1-2-3或1-3-2兩種執行順序。 若執行順序爲 1-3-2, 在線程A執行3後 instance 引用指向尚未初始化的對象。

此時線程B調用 getInstance 方法, 判斷instance != null 於是訪問了未初始化的對象造成錯誤。因此,需要使用 volatile 關鍵字禁止指令重排序。

final

使用不可變對象是保證線程安全最簡單可靠的辦法(笑

Java 對 final 域的重排序有如下約束:

  • 在構造函數內對一個final域的寫入, 與將一個引用指向被構造對象的操作之間不能重排序。

  • 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

就是說,JMM 對 final 域的保證可理解爲它只會在構造函數返回之前插入一個存儲屏障,保證構造函數內對 final 域的賦值在構造函數返回之前寫到主存。

因此,爲了保證對 final 域訪問的安全性需要防止在構造函數返回前將被構造對象的引用暴露出去。

public class Escape {
       
  private final int a; 

  public Escape (int a, List<Escape> list) {
    this.a = a;
    list.add(this);
  }
}

其它線程可能通過構造器中的 list 列表在構造器返回前獲得 this 指針, 此時對final域的訪問是不安全的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章