深入理解Java虛擬機-第十二章 Java 內存模型與線程

第十二章 Java 內存模型與線程

12.1 概述

衡量一個服務性能的高低好壞,每秒事務處理數(Transaction Per Second,TPS)是最重要的指標之一,它代表着一秒內服務端平均能相應的請求總數,而 TPS 值與程序的併發能力又有非常密切的關係。對於計算量相同的任務,程序線程併發協調的越有條不紊,效率自然越高;反之,線程之間頻繁阻塞甚至死鎖,將會大大降低程序的併發能力。

12.2 硬件的效率與一致性

基於高速緩存的存儲交互很好地解決了處理器與內存速度之間的矛盾,但是也爲計算機系統帶來更高的複雜度,它引入了一個新的問題:緩存一致性(Cache Coherence)。在多路處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(Main Memory),這種系統稱爲共享內存多核系統(Shared Memory Multiprocessors System),如下圖所示。在這裏插入圖片描述
當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。如果真的發生這種情況,那同步回到主內存時該以誰的緩存數據爲準呢?爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。從本章開始,我們將會頻繁見到“內存模型”一詞,它可以理解爲在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的內存模型,而Java虛擬機也有自己的內存模型,並且與這裏介紹的內存訪問操作及硬件的緩存訪問操作具有高度的可類比性。
除了增加高速緩存之外,爲了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,因此如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的先後順序來保證。與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有指令重排序(Instruction Reorder)優化。

12.3 Java 內存模型

Java內存模型(Java Memory Model,JMM)是設計用來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。

12.3.1 主內存與工作內存

Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。這裏的變量指的不是編程中的變量,而是指的實例字段、靜態字段、構成數組的元素,但是不包括局部變量和方法參數,這些是線程私有的,存在工作內存即可。對於局部變量引用來說,其實例仍然存在主內存但是引用是在工作內存中不被共享的。
Java 內存模型規定了所有變量都存在主內存(Main Memory)中,每條線程還有自己的工作內存(Working Memory)。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據[插圖]。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關係如圖所示:
線程、主內存、工作內存三者的交互關係

12.3.2 內存間交互操作

關於主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存這一類的實現細節,Java內存模型中定義了以下8種操作來完成。Java虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的:

  • lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
  • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

read - load 和 store - write 兩個操作需要順序執行,但是不要求其是連續的,也就是說read與load之間、store與write之間是可插入其他指令的,例如如對主內存中的變量a、b進行訪問時,這種 read a、read b、load b、load a 順序的指令也可以出現。除此之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者工作內存發起回寫了但主內存不接受的情況出現。
  • 不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
  • 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use、store操作之前,必須先執行assign和load操作。
  • 一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
  • 如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作以初始化變量的值。
  • 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
  • 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。

12.3.3 對於volatile型變量的特殊規則

關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制,但是他其實並不能保證線程安全。當一個變量被定義成volatile之後,它將具備兩項特性:第一項是保證此變量對所有線程的可見性,這裏的 “可見性” 是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。比如,線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成了之後再對主內存進行讀取操作,新變量值纔會對線程B可見。例如如下代碼:

public class VolatileTest {
    static class Test {
        public boolean flag = false;
    }

    public static void main(String[] args) {
        Test test = new Test();

        new Thread(() -> {
            try {
                Thread.sleep(2000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            test.flag = true;
        }).start();

        while (true) {
            if (test.flag) {
                System.out.println("flag changed!");
                break;
            }
        }
    }
}

當在線程中 test.flag 改變時,while true 代碼塊中卻並未感知,這就是線程不可見。如若給 flag 加上 volatile ,則代碼塊中也就可以感知到。
但是 volatile 標記的變量就真的線程安全嗎?答案是 No。volatile 僅僅保證內存可見性,但是Java裏面的運算操作符並非原子操作,這導致volatile變量的運算在併發下一樣是不安全的。例如:

public class Test {

    public static volatile int race = 0;

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

    public static void main(String[] args) {
        Thread[] threads = new Thread[20];

        for (int i = 0; i < 20; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(race);

    }
}

這裏之所以大於 2,是因爲在IDEA中運行這段程序,會由於IDE自動創建一條名爲 Monitor Ctrl-Break 的線程(從名字看應該是監控 Ctrl-Break 中斷信號的)而導致 while 循環無法結束。
這段代碼的結果大家來猜一下是多少。沒錯!就是20000。 屁啦,是小於20w的數字。我執行出來是 78481 ,每次都不一樣的。這也側面說明了他不是線程安全的。原因嘛大家自行百度,書上寫了不想贅述。
由於volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized、java.util.concurrent中的鎖或原子類)來保證原子性:

  • 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
  • 變量不需要與其他的狀態變量共同參與不變約束。

使用volatile變量的第二個語義是禁止指令重排序優化,普通的變量僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因爲在同一個線程的方法執行過程中無法感知到這點,這就是Java內存模型中描述的所謂“線程內表現爲串行的語義”(Within-Thread As-If-Serial Semantics)。舉一個最經典的例子,雙鎖檢測(Double Check Lock,DCL)單例代碼:

public class Singleton {

    private volatile static Singleton instance;

    private Singleton() {}

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

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
    }

}

這裏的實例也需要加上 volatile ,卻不是因爲保證內存可見性,而是因爲保證指令不會重排序。指重排序時不能把後面的指令重排序到內存屏障之前的位置,只有一個處理器訪問內存時,並不需要內存屏障;但如果有兩個或更多處理器訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性了。引用書上的一段話解釋指令重排序:

從硬件架構上講,指令重排序是指處理器採用了允許將多條指令不按程序規定的順序分開發送給各個相應的電路單元進行處理。但並不是說指令任意重排,處理器必須能正確處理指令依賴情況保障程序能得出正確的執行結果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排——(A+10)2與A2+10顯然不相等,但指令3可以重排到指令1、2之前或者中間,只要保證處理器執行後面依賴到A、B值的操作時能獲取正確的A和B值即可。所以在同一個處理器中,重排序過的代碼看起來依然是有序的。

結論:我們在 volatile 與鎖中選擇的唯一判斷依據僅僅是volatile的語義能否滿足使用場景的需求。

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

Java內存模型要求lock、unlock、read、load、assign、use、store、write這八種操作都具有原子性,但是對於64位的數據類型(long和double)在模型中特別定義了一條寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的64位數據的讀寫操作劃分爲兩次32位的操作來進行,即允許虛擬機實現自行選擇是否要保證64位數據類型的 load、store、read 和 write 這四個操作的原子性,這就是所謂的“long 和 double 的非原子性協定”(Non-Atomic Treatment of double and long Variables)。
如果有多個線程共享一個並未聲明爲 volatile 的 long 或 double 類型的變量,並且同時對它們進行讀取和修改操作,那麼某些線程可能會讀取到一個既不是原值,也不是其他線程修改值的代表了“半個變量”的數值。
從 JDK 9 起,HotSpot 增加了一個實驗性的參數-XX:+AlwaysAtomicAccesses(這是 JEP 188 對 Java 內存模型更新的一部分內容)來約束虛擬機對所有數據類型進行原子性的訪問。

12.3.5 原子性、可見性與有序性

介紹完Java內存模型的相關操作和規則後,我們再整體回顧一下這個模型的特徵。Java內存模型是圍繞着在併發過程中如何處理原子性、可見性和有序性這三個特徵來建立的,我們逐個來看一下哪些操作實現了這三個特性。

  1. 原子性(Atomicity):由 Java 內存模型來直接保證的原子性變量操作包括read、load、assign、use、store 和 write 這六個,我們大致可以認爲,基本數據類型的訪問、讀寫都是具備原子性的,如果遇到更大範圍的原子性保證,則有 synchronize 關鍵字來保證,其字節碼指令爲 monitorenter 和 monitorexit ,最底層的實現則是 lock 和 unlock 兩個指令。
  2. 可見性(Visibility):可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此(只不過 volatile 是立即刷新主內存而已)。
    除了 volatile 之外,Java還有兩個關鍵字能實現可見性,它們是 synchronized 和final。同步塊的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行 store、write 操作)”這條規則獲得的。而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去( this 引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化了一半”的對象),那麼在其他線程中就能看見 final 字段的值
  3. 有序性(Ordering):如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。
    Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而 synchronized 則是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

12.3.6 先行發生原則

如果 Java 內存模型中所有的有序性都僅靠 volatile 和 synchronized 來完成,那麼有很多操作都將會變得非常囉嗦,但是我們在編寫 Java 併發代碼的時候並沒有察覺到這一點,這是因爲 Java 語言中有一個“先行發生”(Happens-Before)的原則。這個原則非常重要,它是判斷數據是否存在競爭,線程是否安全的非常有用的手段。依賴這個原則,我們可以通過幾條簡單規則一攬子解決併發環境下兩個操作之間是否可能存在衝突的所有問題,而不需要陷入Java內存模型苦澀難懂的定義之中。
先行發生是 Java 內存模型中定義的兩項操作之間的偏序關係,比如說操作 A 先行發生於操作 B,其實就是說在發生操作 B 之前,操作 A 產生的影響能被操作 B 觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。
下面是 Java 內存模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來,則它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序:

  • 程序次序規則(Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。注意,這裏說的是控制流順序而不是程序代碼順序,因爲要考慮分支、循環等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裏必須強調的是“同一個鎖”,而“後面”是指時間上的先後。
  • volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,這裏的“後面”同樣是指時間上的先後。
  • 線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
  • 線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。
  • 對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

這裏要額外注意,時間先後順序與先行發生原則之間基本沒有因果關係,所以我們衡量併發安全問題的時候不要受時間順序的干擾,一切必須以先行發生原則爲準。

讀書越多越發現自己的無知,Keep Fighting!

本文僅是在自我學習 《深入理解Java虛擬機》這本書後進行的自我總結,有錯歡迎友善指正。

歡迎友善交流,不喜勿噴~
Hope can help~

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