面試官,你別再問了——JAVA以內存模型(簡化版)

在面試之時,不少面試官都喜歡問道,JMM清楚嗎?說說什麼是內存可見性,什麼是重排序?synchronized、volatile和final中的原理?等等諸如此類的問題。而網上一搜,巴啦啦一大堆,東西比較亂,也很難把面試官變相問題回答清楚。終於,下定決心給你們捋一捋JAVA簡化版的內存模型。java

一、緒論。

Java的併發採用的是共享內存模型,Java線程之間的通訊老是隱式的,整個通訊過程對程序員來講徹底是透明的。程序員

二、JMM簡述。

JMM是Java內存模型,它決定了一個線程對共享變量的寫入什麼時候對另外一個線程可見。從抽象的角度看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(Main Memory)中,每一個線程都有一個抽象的(真實不存在的)本地內存(Local Memory),本地內存存儲了該線程以讀、寫共享變量的副本。面試

知識點補充:(上述JMM所說的"共享變量"主要存在於java堆中)編程

JVM內存模型包括:
(1) 程序計數器。一塊很小的內存空間,用於記錄下一條要運行的指令。是線程私有的內存。
(2)java虛擬機棧。它和java線程同一時間建立,保存了局部變量、部分結果,並參與方法的調用和返回。是線程私有的內存。
(3)本地方法棧。它和java虛擬機棧的功能類似,主要爲Native方法服務。是線程私有的內存。
(4)java堆。爲全部建立的對象和數組分配內存空間。是線程共有的內存。
(5)方法區。也被稱爲永久區,與堆空間類似。是線程共有的內存。

複製代碼

三、JMM中的重排序。

在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。然而在程序最終執行以前,還要作一個內存的重排序。數組

重排序可能會致使多線程程序出現內存可見性問題。請看代碼例子:bash

class ReorderEample {
        int a = 0;
        boolean flag = false;
        //寫操做
        public void writer() {
            a = 1 ;  // (1)
            flag = true; //(2)
        }
        //讀操做
        public void reader() {
            if (flag) {    //(3)
                int i = a * a;  //(4)
                // 處理邏輯
            }
        }
    }
複製代碼

flag變量是個標記,標識a是否已被寫入。假設有兩個線程A和B,A首先執行了寫操做writer(),隨後B接着執行讀操做reader()方法。那麼線程B在執行操做(4)時,是否能看到線程A在操做(1)時對共享變量a的寫入?答案是未必能看到。由於在重排序時,A線程可能先標識了flag變量,再對a變量進行寫入,可是在它們發生之間,B線程此時來讀了,該程序的語義被破壞了。以下程序執行時序圖:多線程

四、volatile的內存語義

JMM中,被聲明成volatile的共享變量,線程經過排他鎖獲取這個變量,確保在線程中是可見的。爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。瞭解內存屏障詳情請看Java內存模型Cookbook(二)內存屏障併發

4.一、volatile的特性

  • 可見性。對一個volatile變量的讀取,總能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性。對任意單個volatile變量的讀/寫具備原子性。但對於多個volatile操做或類型volatile++這種複合操做不具備原子性。

4.二、volatile解決重排序問題

volatile遵循happens-before原則。請看以下代碼:app

class ReorderEample {
        int a = 0;
        volatile boolean flag = false;
        //寫操做
        public void writer() {
            a = 1 ;  // (1)
            flag = true; //(2)
        }
        //讀操做
        public void reader() {
            if (flag) {    //(3)
                int i = a * a;  //(4)
                // 處理邏輯
            }
        }
    }
複製代碼

假設線程A執行writer()方法以後,線程B執行reader()方法。根據happens-before原則,這個過程創建的happens-before關係分爲3類:函數

    1. 根據程序次序規則,(1) happens-before (2) ; (3) happens-before (4)。
    1. 根據volatile規則,(2) happens-before (3).
    1. 根據happens-before的傳遞性規則,(1) happens-before (4)。

其happens-before創建關係圖以下:

對於上一個例子來講,這個例子只對flag變量增長了volatile聲明。A線程寫入一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量以前全部可見的共享變量,在B線程讀同一個volatile變量後,當即對B線程可見。

happens-before規則知識點補充:

(1)程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做
(2)監視器鎖規則:對一個線程的解鎖,happens-before於隨後對這個線程的加鎖
(3)volatile變量規則:對一個volatile域的寫,happens-before於後續對這個volatile域的讀
(4)傳遞性:若是A happens-before B ,且 B happens-before C, 那麼 A happens-before C
(5)start()規則:若是線程A執行操做ThreadB_start()(啓動線程B) ,  那麼A線程的ThreadB_start()happens-before 於B中的任意操做
(6)join()原則:若是A執行ThreadB.join()而且成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。
(7)interrupt()原則: 對線程interrupt()方法的調用先行發生於被中斷線程代碼檢測到中斷事件的發生,能夠經過Thread.interrupted()方法檢測是否有中斷髮生
(8)finalize()原則:一個對象的初始化完成先行發生於它的finalize()方法的開始。
複製代碼

五、synchronized的內存語義

synchronized內存語義與volatile內存語義相似,在Java併發編程機制中,鎖除了讓臨界區互斥以外,還可讓釋放鎖的線程向獲取同一個鎖的線程發送消息。它的核心底層就是使用一個volatile聲明的state變量來維護同步狀態。

5.一、synchronized解決重排序問題

鎖也遵循happens-before規則。請看以下代碼:

class MonitorExample{
        int a = 0;
        //寫操做
        public synchronized void writer() { //(1)
            a ++;                           //(2)
        }                                   //(3)
        //讀操做
        public synchronized void reader() { //(4)
            int i = a;                      //(5)
            //處理邏輯
        }                                   //(6)
    }
複製代碼

假設線程A執行writer()方法,隨後線程B執行reader()方法。根據happens-before規則,這個過程包含的happens-before關係能夠分爲3類:

    1. 根據程序次序規則,(1) happens-before (2),(2) happens-before (3),(4) happens-before (5),(5) happens-before (6)。
    1. 根據監視器鎖規則,(3) happens-before (4)。
    1. 根據傳遞性規則,(2) happens-before (5)。

其happens-before創建關係圖以下:

線程A釋放了鎖以後,隨後線程B獲取同一個鎖。由於 (2) happens-before (5),因此線程A在釋放鎖以前全部可見的共享變量在線程B獲取同一個鎖以後對於B線程都變得可見。

六、final的內存語義

在JMM中,經過內存屏障禁止編譯器把final域的寫重排序到構造函數以外。所以,在對象引用爲任意線程可見以前,對象的final域已經被正確初始化(不爲null的狀況)了。 對於final域,編譯器和處理器遵循兩個重排序規則:

  • 在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
  • 初次讀一個包含final域的對象的醫用,與隨後初次讀這個final域,這兩個操做之間不能重排序。

下面經過兩個示例來講明這兩個規則。

6.一、示例一

public class FinalExample {
    int i;                           //普通變量
    final int j;                     //final變量
    static FinalExample obj;
    public FinalExample(int j) {     //構造函數
        i = 1;                       //寫普通域
        this.j = j;                  //寫final域
    }
    
    public static void writer() {    //寫線程A執行
        obj = new FinalExample(2);
    }
    
    public static void reader() {    //讀線程B執行
        FinalExample object = obj;   //讀引用對象
        int a = object.i;            //讀普通域
        int b = object.j;            //讀final域
    }
}
複製代碼

寫普通域的操做被編譯器重排序到了構造函數以外,讀線程B錯誤地讀取了普通變量i初始化以前的值。而寫final域操做後,被寫final域的重排序規則「限定」在了構造函數以內,讀線程B正確地讀取了final變量初始化的值。執行時序圖以下:

6.2 示例二

public class FinalReferenceExample {
    final int[] intArray;
    static FinalReferenceExample obj;

    public FinalReferenceExample() {   //構造函數
        intArray = new int[1];         //(1)
        intArray[0] = 1;               //(2)
    }

    public static void writeOne() {         //寫線程A執行
        obj = new FinalReferenceExample();  //(3)
    }

    public static void writeTwo() {         //寫線程B執行
        obj.intArray[0] = 2;                //(4)
    }

    public static void reader () {         //讀線程C執行
        if (obj != null) {                 //(5)
            int temp = obj.intArray[0];    //(6)
        }
    }
}
複製代碼

首先線程A執行writeOne()方法,執行完後線程B執行writeTwo方法,執行完後線程C執行reader方法。操做(1)對final域的寫入,操做(2)是對final域引用的對象的成員寫入,操做(3)是把被構造的對象的引用賦值給某個引用變量。這裏除了(1)和(3)不能重排序,(2)和(3)也不能重排序。所以,該程序的線程執行時序不可知,由於寫線程B和讀線程C之間存在數據競爭。

參考:《Java併發編程的藝術》 方騰飛 魏鵬 程曉明 《深刻理解Java虛擬機》 周志明

若是這篇文章對你有用,請你點個贊吧!你的支持是我分享的動力。

相關文章
相關標籤/搜索