深入理解 Java 虛擬機(十二)Java 內存模型與線程

概述

多任務處理是現在計算機操作系統必備的功能,在許多情況下,讓計算機同時做幾件事,不僅是因爲計算機的運算能力強大了,更重要的是計算機的運算速度與它的存儲和通信子系統速度的差距太大,大量的時間都花費在磁盤 I/O、網絡通信或者數據庫訪問上。因此纔有了併發,以儘可能充分地利用處理器的運算能力。

另一個更具體的併發應用場景是一個服務端同時對多個客戶端提供服務,衡量一個服務性能的高低好壞,每秒事務處理數(Transactions Per Second,TPS)是最重要的指標之一,它代表着一秒內服務端平均能響應的請求總數,TPS 值與程序的併發能力有着非常密切的關係。

硬件的效率與一致性

讓計算機併發執行並沒有想象中那麼簡單,一個重要的複雜性是絕大多數的運算任務都不能只靠處理器計算就能完成,處理器至少要與內存交互,由於內存與處理器的運算速度相差巨大,因此現在計算機系統不得不加入一層接近處理器運算速度的高速緩存來作爲內存與處理器之間的緩衝:將運算需要用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中。

但高速緩存也帶來了一個新的問題:緩存一致性,因爲每個處理器都有自己的高速緩存,而它們又共享同一主內存。爲了解決這個問題,每個處理器訪問緩存時都需要遵守例如 MSI 之類的協議。

緩存一致性

除此之外,爲了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致。因此,如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序不能靠代碼的先後順序來保證。類似的,Java 虛擬機的即時編譯器也有指令重排序優化。

Java 內存模型

Java 虛擬機規範試圖定義一種 Java 內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果。

主內存與工作內存

Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。變量指實例字段、靜態字段和構成數組對象的元素,不包括局部變量和方法參數。

Java 內存模型規定了所有的變量都存儲在主內存中(可類比爲物理硬件中的主內存),每條線程還有自己的工作內存(可類比爲高速緩存),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量,不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。線程、主內存、工作內存三者的交互關係如圖:

線程 - 工作內存 - 主內存交互

內存間交互操作

關於主內存和工作內存之間的交互,Java 內存模型中定義了以下 8 中操作來完成,虛擬機實現時必須保證這些操作都是原子的、不可再分的:

  1. lock,作用於主內存中的變量,把一個變量標識爲一條線程獨佔的狀態

  2. unlock,作用於主內存中的變量,釋放一個處於鎖定狀態的變量

  3. read,作用於主內存中的變量,把一個變量的值從主內存傳輸到線程的工作內存中

  4. load,作用於工作內存的變量,把 read 操作從主內存得到的變量值放入工作內存的變量副本中

  5. use,作用於工作內存的變量,把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時執行這個操作

  6. assign,作用於工作內存的變量,把一個從執行引擎收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作

  7. store,作用於工作內存的變量,把工作內存中一個變量的值傳遞到主內存中

  8. write,作用於主內存的變量,把 store 操作從工作內存中得到的變量的值放入主內存的變量中

同時,這 8 種操作必須滿足以下規則:

  1. read 和 load、store 和 write 必須成對出現

  2. 不允許一個線程丟棄它的最近的 assign 操作,即變量在工作內存中改變了之後必須把該變化同步回主內存中

  3. 不允許一個線程無原因地(沒有發生過任何 assign 操作)把數據從線程的工作內存同步回主內存中

  4. 新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化的變量,即,對一個變量實施 use、store 操作之前,必須先執行過 assign 和 load 操作

  5. 一個變量同一時刻只允許一條線程對其進行 lock 操作,同一條線程可以執行多次 lock,之後執行相同次數的 unlock 纔會解鎖該變量

  6. 對一個變量執行 lock 操作後,將會清空工作內存中此變量的值,執行引擎使用這個變量之前,需要重新執行 load 或 assign 操作初始化變量的值

  7. 不允許 unlock 一個未被 lock 的變量

  8. 對一個變量執行 unlock 操作之前,必須先把此變量同步回主內存中(store & write)

對 volatile 變量的特殊規則

當一個變量被定義爲 volatile 之後,它將具備兩種特性,第一是保證此變量對所有線程的可見性,可見性指當一條線程修改了這個變量的值,新值對於其它線程而言是可以立即獲知的,而普通變量在線程間的值傳遞需要通過主內存來完成。但 Java 裏面的運算並非原子操作,因此 volatile 變量的運算在併發下一樣是不安全的,比如:

public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加線程都結束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

只有一行代碼的 increase() 方法在 Class 文件中是由 4 條字節碼指令(使用字節碼指令來分析問題是不嚴謹的,因爲字節碼指令在解釋執行時可能會轉化成多條本地機器碼指令,即使字節碼指令只有一條,也不一定是原子操作,這裏只是爲了方便說明問題)構成的:

  public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field race:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field race:I
         8: return

volatile 只能保證執行 getstatic 操作時,變量值是正確的,但不能保證在執行 iconst_1、iadd 時,其它線程是否把變量的值加大了。

因此,在不符合以下兩條規則的運算中,仍然需要通過加鎖來保證原子性:

  1. 運算結果不依賴變量的當前值,或者確保只有單一的線程修改變量的值
  2. 變量不需要與其它的狀態變量共同參與不變約束

而像下面這種場景,則適合使用 volatile:

volatile boolean shouldShutdown = false;

// 假設這個方法在線程 A 執行
public void shutdown() {
    shouldShutdown = true;
}

// 假設這個方法在線程 B 執行
public void doWork() {
    while (!shouldShutdown) {
        // do something
    }
}

volatile 的第二個特性是能夠禁止指令重排序優化,比如:

Map configOptions;
char[] configText;
// 這個變量必須定義爲 volatile
volatile boolean initialized = false;

// 假設以下代碼在線程 A 執行
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假設以下代碼在線程 B 執行
while (!initialized) {
    sleep();
}
// 使用線程 A 配置好的數據
doSomethingWithConfig();

如果 initialized 沒有使用 volatile 修飾,就可能由於指令重排序優化,而導致位於線程 A 的最後一句代碼 initialized = true 被提前執行,進而導致在線程 B 中使用的配置信息出錯。

在某些情況下,volatile 的同步機制的性能優於鎖,但由於虛擬機對鎖實行的許多消除和優化,很難量化地認爲 volatile 就會比 synchronized 快多少。但可以確定一個原則:volatile 變量讀操作的性能消耗與普通變量幾乎沒有區別,但是寫操作可能會慢一些,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不會發生亂序執行。因此,大多數場景下,volatile 的總開銷仍然比鎖要低,選擇 volatile 還是鎖的唯一依據是 volatile 是否能滿足需求。

對於 long 和 double 型變量的特殊規則

對於 64 位的數據類型,Java 內存模型定義了一條相對寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操作劃分爲兩次 32 位的操作來進行,即允許虛擬機可以不保證 64 位數據的 load、store、read 和 write 這 4 個操作的原子性,這就是所謂的 long 和 double 的非原子性協定。

如果有多個線程共享一個未被聲明爲 volatile 的 long 或 double 類型的數據,並且同時對它們進行讀取或修改操作,那麼某些線程可能會讀到一個既非原值,也不是其它線程修改值的代表了“半個變量”的數值。不過這種情況十分罕見,Java 內存模型強烈建議 Java 虛擬機把 long 和 double 變量的讀寫操作實現爲原子操作,實際開發中,目前各種平臺下的虛擬機幾乎都選擇遵循這個建議。

原子性、可見性與有序性

原子性:由 Java 內存模型來直接保證原子性的變量操作包括 read、load、assing、use、store 和 write,基本可以認爲基本數據類型的訪問讀寫是具備原子性的。如果需要保證更大範圍的原子性,可以使用關鍵字 synchronized,synchronized 對應的字節碼指令是 moniterenter、moniterexit。

可見性:指當一個線程修改了變量的值,其它線程能夠立即獲知這個修改。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是普通變量還是 volatile 變量都是如此,區別在於,volatile 可以保證多線程操作時變量的可見性。除了 volatile 之外,Java 還有兩個關鍵字能實現可見性:synchronized、final。

有序性:Java 程序天然的有序性可以總結爲:如果在本線程內觀察,所有操作都是有序的;如果在其它線程觀察,所有操作都是無序的(比如上面的 initialzied 的例子)。Java 提供了 volatile 和 synchronized 來保證線程之間操作的有序性。

先行發生原則

如果 Java 內存模型中所有的有序性都要靠 volatile 或 synchronized 來保證,那麼一些操作將會變得很繁瑣,但我們編寫代碼時沒有感覺到這一點,是因爲 Java 中有一個先行發生的原則,這個原則是判斷數據是否存在競爭、線程是否安全的主要依據。

先行發生是 Java 內存模型中定義的兩項操作之間的偏序關係,如果有操作 A 先行發生於操作 B,那麼操作 A 產生的影響能被操作 B 觀察到,影響包括內存中共享變量的值、發送了消息、調用了方法等。例如:

i = 1; // 在線程 A 執行
j = i; // 在線程 B 執行
i = 2; // 在線程 C 執行

假如線程 A 中的操作 i = 1 先行發生於線程 B 中的操作 j = 1,則 j 必然等於 1,但如果線程 C 也在線程 A 之後執行,但線程 C 和線程 B 之間沒有先行發生關係,則 j 的值是不確定的。

Java 內存模型中天然的先行發生關係有:

  1. 程序次序規則:在一個線程內,書寫在前面的操作先行發生於書寫在後面的操作

  2. 管程鎖定規則:一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作

  3. volatile 變量規則:對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作

  4. 線程啓動規則:線程中的所有操作都先行發生於對此線程的終止檢測

  5. 線程中斷規則:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生

  6. 對象終結原則:一個對象的初始化完成先行發生於它的 finalize 方法的開始

  7. 傳遞性:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C

如果兩個操作之間的關係不在此列,則虛擬機可以對它們隨意地進行重排序

Java 與線程

線程的實現

實現線程主要有 3 種方式:使用內核線程實現、使用用戶線程實現、使用用戶線程加輕量級進程混合實現

使用內核線程實現

內核線程就是直接由操作系統內核支持的線程,這種線程由內核來完成線程切換,內核通過操作調度器對線程進行調度,並負責將線程的任務映射到各個處理器上,每個內核線程可以視爲內核的一個分身。

程序一般不會直接使用內核線程,而是使用內核線程的一種高級接口——輕量級進程(Light Weight Process,LWP),輕量級進程就是通常意義上的線程,每個輕量級進程都由一個內核線程支持:

在這裏插入圖片描述

輕量級進程基於內核線程實現,因此各種線程操作,包括創建、析構和同步,都需要進行系統調用,調用代價較高,且需要在用戶態和內核態中來回切換,會消耗一定的內核資源,因此一個系統支持的輕量級進程是有限的。

使用用戶線程實現

廣義上的用戶線程指內核線程之外的線程,狹義上的用戶線程指完全建立在用戶空間的線程庫上,系統內核不能感知,建立、同步、銷燬和調度完全在用戶態中完成,不需要內核的幫助的線程。

如果程序實現得當,這種線程不需要切換到內核態,因此可以非常快速且低消耗,也可以支持更大規模的線程數量。

在這裏插入圖片描述

但也因爲沒有系統內核的支援,實現起來異常複雜且困難,因此現在使用用戶線程的程序越來越少了,Java 也放棄了它。

使用用戶線程加輕量級進程混合實現

在這種混合實現下,用戶線程還是完全建立在用戶空間中,因此線程的創建、切換、析構等操作依然廉價,並且可以支持大規模的用戶線程併發,同時,可以使用內核提供的線程調度功能及處理器映射,大大降低了進程阻塞的風險。

在這裏插入圖片描述

Java 線程的實現

Java 在 JDK 1.2 之前是使用用戶線程實現的,目前的 JDK 版本中,虛擬機規範並未規定 Java 需要使用的線程模型。對於 Sun JDK 來說,Window、Linux 版本都是使用一對一的線程模型實現的。

Java 線程調度

線程調度是指系統爲線程分配處理器使用權的過程,主要有協同式線程調度、搶佔式線程調度兩種。

對於協同式線程調度,線程的執行時間是由線程本身控制的,線程把自己的工作完成之後就主動通知系統切換到另一條線程上。這種方式的最大好處是實現簡單,切換操作對於線程自己是可知的,沒有線程同步的問題;壞處是執行時間不可控制,可能會由於某個線程編寫有問題而導致整個程序一直阻塞在那裏。

對於搶佔式線程調度,將由系統來分配線程的執行時間,線程的切換不由線程本身來決定(Java 中的 Thread.yield() 可以讓出執行時間,但沒有取得執行時間的方法)。在這種方式下,線程的執行時間是系統可控的,不會有一個線程導致整個進程阻塞的問題,Java 使用的就是這種。

Java 可以爲線程分配優先級來建議系統給某些線程多分配一些時間,不過這種方式不是太靠譜,因爲線程調度最終取決於操作系統,而操作系統提供的優先級的概念不一定和 Java 提供的相對應,而且優先級可能會被系統自行改變。

狀態轉換

Java 定義了 5 種線程狀態:

在這裏插入圖片描述

總結

思維導圖

在這裏插入圖片描述

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