Java內存模型

Java內存模型

這是《深刻理解Java虛擬機》的第十二章, 在以前內存區域篇章, 已經略微提到過這個概念. 由於每每有人對 內存區域 內存模型, 概念理解略有誤差.html

java 工做內存這篇文章裏, 對Java的內存區域劃分, 和 Java內存模型這兩個概念解釋的比較清楚, 這是從兩個角度去看待Java中變量的存儲方式, 不須要強行拿來比較. 不太合適.java

JVM的靜態內存儲模型(內存區域)只是一種對內 存的物理劃分而已,它只侷限在內存, 而計算機不只僅只有內存.程序員

序言

在這以前先了解一點課外知識:數據庫

CPU與內存的那些事數組

CPU的執行速度很快, 那麼到底有多快呢? 在Core 2 3.0GHz上,大部分簡單指令的執行只須要一個時鐘週期,也就是1/3納秒。即便是真空中傳播的光,在這段時間內也只能走10釐米(約4英寸), 因此在有關程序優化方面, 最重要的一點是: 從各個方面來肯定是否須要進行優化, 而幾個指令的優化在初始設計上, 是沒有必要考慮的.緩存

當CPU運轉起來以後, 它便會經過L1 cache和L2 cache對系統中的主存進行讀寫訪問。安全

而訪問速度呢? 咱們把CPU的一個時鐘週期看做一秒。那麼,從L1 cache讀取信息就好像是拿起桌上的一張草稿紙(3秒);從L2 cache讀取信息則是從身邊的書架上取出一本書(14秒);而從主存中讀取信息則至關於走到辦公樓下去買個零食(4分鐘)。而硬盤的尋道操做( 也就是在磁盤表面移動讀寫磁頭到正確的磁道上,而後再等待磁盤旋轉到正確的位置上,以便讀取指定扇區內的信息。)須要等待的時間則是至關於離開辦公大樓並開始長達一年零三個月的環球旅行.多線程

而L1 和 L2cache是什麼呢?app

參見連接:
CPU的緩存L1,L2,L3函數

也就是高速緩衝存儲器, 從內存中讀取數據的速度, 與 CPU的執行效率相較而言, 實在是差距太大, 所以插入了高速緩存這個設計. 將內存中讀取到的數據存儲在 高速緩存中, 須要時直接從高速緩存中讀取, 若是依次在 L1, L2中都讀取不到, 則從內存中對數據進行讀取, 加載至高速緩存中. 至於緩存命中率等等問題, 暫時就不在考慮範圍內了.

而當將數據存儲在高速緩存中以後呢? 將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中沒這樣處理器就無需等待緩慢的內存讀寫了。

可是引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每一個處理器都有本身的高速緩存,而他們又共享同一主存, 當回寫入主存時, 究竟以誰的數據爲準? 這就須要協議來進行約束. 緩存一致性協議.

而什麼是內存模型呢?

在特定的操做協議下, 對特定的內存或高速緩存進行讀寫的過程抽象。換句話說, 就是對變量的讀寫規則的定義,對於經常使用的計算機而言, 就是變量在CPU, 高速緩存, 內存中的一個流轉過程。

Java的內存模型

而與之相似的, Java的內存模型主要目的是定義Java程序中各個變量的訪問規則, 它包括了實例字段, 靜態字段, 構成數組對象的元素, 而不包含 局部變量 方法參數, 後者爲線程私有, 不會存在競爭問題.

在Java虛擬機中, 內存分爲主內存, 及工做內存:
主內存是全部的線程所共享的, 對應的是物理硬件的內存, 而工做內存則是cpu的寄存器和高速緩存的抽象描述。

而規範 JSR-133:Java內存模型與線程規範 中有更爲詳細的, 明確的規範, 我我的看得雲裏霧裏.

PDF版連接:JSR133中文版

而事實上, 目前僅僅須要瞭解, 該如何判斷 程序 是不是線程安全的, 若是不安全, 又是由於什麼緣由致使的?

線程不安全

原子性: 一個操做是不可中斷的,要麼所有執行成功要麼所有執行失敗,有着「同生共死」的感受。

可見性: 可見性是指當一個線程修改了共享變量後,其餘線程可以當即得知這個修改。但這裏的當即得知又有不一樣, 並不是是主動通知, 而是當須要讀的時候, 可以拿到這個變量的最新值, 因此纔會存在 volatile 並不可以保證線程安全.

示例代碼:

public class Main {

    public int i = 0;

    public static final Object obj = new Object();

    public void increment() {
        synchronized (obj) {
            this.i++;
        }
    }

    public static void main(String[] args) {
        Main main = new Main();
        for (int i = 0; i < 200; i++) {
            new Thread(() ->{
                for (int j = 0; j < 1000; j++) {
                    main.increment();

                }
            }).start();
        }
        //若是註釋掉下面這段循環, 毫無疑問能夠保證結果永遠爲200000
        for (int i = 0; i < 20; i++) {
            new Thread(() ->{
                for (int j = 0; j < 1000; j++) {
                    System.out.println(Thread.activeCount());
                    main.i++;
                }
            }).start();
        }
        //我是經過Idea直接執行, 活動線程最低爲2.
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(main.i);
    }

}

因此在多個地方均可以對共享變量更改的時候, 最好調用對應Class內部的更改方法, 同時在內部方法上加鎖便可, 不然即便在當前方法體內加鎖, 保證多線程執行當前方法時, 不存在安全問題, 但其餘地方一樣擁有對變量的修改權限時, 依然會致使問題.

一樣的, 延伸開來來看, 當須要從數據庫取數據, 判斷, 而後更新這種操做時, 在我目前看來最優的方式依然是將操做封裝, 在其餘地方不可以直接對 相應數據進行更新, 即便在數據庫自己 repeatable read 事務模式下, 依然不可以保證數據的正確性. 粗暴的加鎖, 僅能解決當前問題.

有序性: 指的是指令重排序所致使的問題, 代碼並不必定會按照其自己的順序被執行.

指令重排序:

大多數現代微處理器都會採用將指令亂序執行(out-of-order execution,簡稱OoOE或OOE)的方法.

在條件容許的狀況下,直接運行當前有能力當即執行的後續指令,避開獲取下一條指令所需數據時形成的等待。

經過亂序執行的技術,處理器能夠大大提升執行效率。

除了處理器,常見的Java運行時環境的JIT編譯器也會作指令重排序操做,即生成的機器指令與字節碼指令順序不一致。

boolean initialized = false;
//如下在 線程A中執行
threadA.doSth();
initialized = true;

//如下則在線程B中執行
while(!initialized) {
    sleep();
}
threadB.doSthAfterA();

在這樣的代碼中, 有可能就會出現 threadA.doSth() 還沒有執行, 而threadB.doSthAfterA() 已經執行, 爲何呢?(在JIT編譯以後, 生成本地代碼, 有可能會被對應平臺的處理器進行指令重排序) 就是由於指令重排序的存在. 在單線程環境中, threadA.doSth() 並不依賴 initialized; 因此將 initialized = true 放在 threadA.doSth() 在計算機看來也是徹底可行的. 但這樣就會致使對應的問題.

三大特性就是原子性, 可見性, 有序性.

volatile的特殊性

這個關鍵字用於保證當前屬性 的 可見性, 以及可以起到禁止指令重排序的做用.

可見性無需多言, 在上面已經提到過了. 而一樣的, 僅保證可見性, 而不保證原子性, 錯誤的操做依然會致使線程不安全.

volatile關鍵字, 僅保證, 當數據被更改時 會被當即更新到內存中去, 而並不能保證上次獲取的數據是最新數據.

public volatile int a;

當有threadA 讀取A 並進行 ++ 操做時, 分爲幾步, 讀取A, 將值放入棧中, 取出棧頂數 ++ 後 放回棧頂;

volatile僅保證在第一步, 讀取的時候讀取到的數據必定是最新值, 而在其後的幾步操做則沒法保證, 同時也保證, 在更新值時 會當即將數據更新至內存.

因此 volatile的適用範圍:

  1. 運算結果並不依賴當前值, 或確保只有單一的線程可以對值進行修改.

  2. 變量不須要與其餘變量一塊兒參與不變約束.(這句話在我理解來是這樣的:)

    public volatile boolean a = false;
    
     if (a && conditionA)
         doSth();

    這裏的 a && conditionA 當在斷定條件中, 在這種條件中就相似於進行了以下操做

    boolean result = a && conditionA;

    最終result的值, 依賴了 a的當前值.

那麼第二點, 有序性的保證:

參考: 指令重排序,內存模型排序規則,內存屏障

指令重排序的緣由, 原理等等能夠參考: 指令重排序

內存屏障:

內存屏障(Memory Barrier,或有時叫作內存柵欄,Memory Fence)是一種CPU指令,是CPU或編譯器在對內存隨機訪問的操做中的一個同步點,使得此點以前的全部讀寫操做都執行後才能夠開始執行此點以後的操做。

常見的x86/x64,一般使用lock指令前綴加上一個空操做來實現內存屏障,注意固然不能真的是nop指令,可是能夠用來實現空操做的指令實際上是不少的,好比Linux中採用的
1
addl $0, 0 (%esp)

Java編譯器也會根據內存屏障的規則禁止重排序。

內存屏障能夠被分爲如下幾種類型:

LoadLoad 屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操做要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。

StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操做執行前,保證Store1的寫入操做對其它處理器可見。

LoadStore 屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操做被刷出前,保證Load1要讀取的數據被讀取完畢。

StoreLoad 屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續全部讀取操做執行前,保證Store1的寫入對全部處理器可見。

它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能。

經過這種方式, 就達到了禁止指令重排序.

//經過這兩個參數, 輸出JIT編譯後的彙編代碼
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
public volatile int a = 0;

public void doSth() {
    a++;
}

public static void main(String[] args) {
    Main main = new Main();
    //循環2000次,觸發JIT
    for (int i = 0; i < 2000; i++) {
        main.doSth();
    }
}

//截取部分彙編代碼, 在Idea中直接啓動便可. 在輸出中搜索doSth();

0x0000000003483cf8: je      3483d17h          ;*aload_0
                                                ; - controller.Main::doSth@0 (line 44)
                                                -- 對應a++;這行代碼;

0x0000000003483cfe: mov     esi,dword ptr [rdx+0ch]  ;*getfield a
                                                ; - controller.Main::doSth@2 (line 44)

0x0000000003483d01: inc     esi
0x0000000003483d03: mov     dword ptr [rdx+0ch],esi
//內存屏障
0x0000000003483d06: lock add dword ptr [rsp],0h  ;

final關鍵字

參考:深刻理解 Java 內存模型(六)——final

我以爲相關文章中,解釋的已經至關詳細明瞭, 核心以下:

對 final 域的讀和寫更像是普通的變量訪問。對於 final 域,編譯器和處理器要遵照兩個重排序規則:

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

    a. JMM 禁止編譯器把 final 域的寫重排序到構造函數以外。

    b. 編譯器會在 final 域的寫以後,構造函數 return 以前,插入一個 StoreStore 屏障。這個屏障禁止處理器把 final 域的寫重排序到構造函數以外。

    言外之意是什麼呢? 這意味着對於普通域而言, 就沒有這樣的要求了對於如下代碼:

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

    先執行線程A, 再執行線程B, 就有可能會出現這樣一種狀況, B線程已經拿到obj的真實引用, 但obj對象的普通域 i 由於 重排序, 在構造器return以後,才進行賦值操做. 這樣就會致使先後讀取到的值並不一致.

    而對final的重排序規則則解決了這個問題.

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

    a. 在一個線程中,初次讀對象引用與初次讀該對象包含的 final 域,JMM 禁止處理器重排序這兩個操做(注意,這個規則僅僅針對處理器)。編譯器會在讀 final 域操做的前面插入一個 LoadLoad 屏障。

  3. 引用類型

    a. 在構造函數內對一個 final 引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。

而除了以上規則之外, 還須要一個保證:在構造函數內部,不能讓這個被構造對象的引用爲其餘線程可見,也就是對象引用不能在構造函數中「逸出」。

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample () {
        i = 1;               //1 寫 final 域 
        obj = this;          //2 this 引用在此「逸出」
    }

    public static void writer() {
        new FinalReferenceEscapeExample ();
    }

    public static void reader {
        if (obj != null) {           //3
            int temp = obj.i;        //4
        }
    }
}

final語義加強的目的是什麼呢?

在舊的 Java 內存模型中 ,最嚴重的一個缺陷就是線程可能看到 final 域的值會改變。好比,一個線程當前看到一個整形 final 域的值爲 0(還未初始化以前的默認值),過一段時間以後這個線程再去讀這個 final 域的值時,卻發現值變爲了 1(被某個線程初始化以後的值)。

爲了修補這個漏洞,JSR-133 專家組加強了 final 的語義。經過爲 final 域增長寫和讀重排序規則,能夠爲 java 程序員提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造函數中沒有「逸出」),那麼不須要使用同步(指 lock 和 volatile 的使用),就能夠保證任意線程都能看到這個 final 域在構造函數中被初始化以後的值。

如何判斷

那麼更重要的一個問題是, 對於咱們開發者而言, 又該怎樣判斷一段代碼是否是線程安全的呢?

Happens-before 先行發生 原則

程序次序法則:線程中的每一個動做A都happens-before於該線程中的每個動做B,其中,在程序中,全部的動做B都能出如今A以後。(單線程中)

監視器鎖法則:對一個監視器鎖的解鎖 happens-before於每個後續對同一監視器鎖的加鎖。

volatile變量法則:對volatile域的寫入操做happens-before於每個後續對同一個域的讀寫操做。

線程啓動法則:在一個線程裏,對Thread.start的調用會happens-before於每一個啓動線程的動做。

線程終結法則:線程中的任何動做都happens-before於其餘線程檢測到這個線程已經終結、或者從Thread.join調用中成功返回,或Thread.isAlive返回false。

中斷法則: 一個線程調用另外一個線程的interrupt happens-before於被中斷的線程發現中斷。

終結法則:一個對象的構造函數的結束happens-before於這個對象finalizer的開始。

傳遞性:若是A happens-before於B,且B happens-before於C,則A happens-before於C

Happens-before關係只是對Java內存模型的一種近似性的描述,它並不夠嚴謹,但便於平常程序開發參考使用.

關於更嚴謹的Java內存模型的定義和描述,請閱讀JSR-133原文; JSR-133連接上面已經給出來了.

若是不知足上面的條件, 那麼就必須考慮是不是線程安全的了.

相關文章
相關標籤/搜索