淺談Java內存模型以及交互

1、Java的運行時區域

在Java中,虛擬機將運行時區域分紅6種,如圖:web

淺談Java內存模型以及交互

  1. 程序計數器:用來記錄當前線程執行到哪一步操做。在多線程輪換的模式中,噹噹前線程時間片用完的時候記錄當前操做到哪一步,從新得到時間片時根據此記錄來恢復以前的操做。
  2. 虛擬機棧:這就是咱們平時所說的棧了,通常用來儲存局部變量表、操做數表、動態連接等。
  3. 本地方法棧:這是另外一個棧,用來提供虛擬機中用到的本地服務,像線程中的start方法,JUC包裏常用的CAS等方法都是從這來的。
  4. 堆:主要的儲存區域,平時所建立的對象都是放在這個區域。其內部還分爲新生代、老年代和永久代(也就是方法區,在Java8以後刪除了),新生代又分爲兩塊Survivor和一塊Eden,平時建立的對象其實都是在Eden區建立的,不過這些以後再跟垃圾回收器寫在一篇文章。
  5. 方法區:儲存符號引用、被JVM加載的類信息、靜態變量的地方。在Java8以後方法區被移除,使用元空間來存放類信息,常量池和其餘東西被移到堆中(其實在7的時候常量池和靜態變量就已經被移到堆中),再也不有永久代一說。刪除的緣由大體以下:安全

  6. 容易形成內存溢出或內存泄漏,例如 web開發中JSP頁面較多的狀況。
  7. 因爲類和方法的信息難以肯定,很差設定大小,太大則影響年老代,過小容易內存溢出。多線程

  8. GC很差處理,回收效率低下,調優困難。併發

  9. 常量池:存放final修飾的成員變量、直接定義的字符串(如 Sring s = "test";這種)還有6種數據類型包裝類型從-128~127對應的對象(這也解釋了咱們new兩個在這區間的包裝類型對象時,爲何他們是同樣的,布爾類型存放的是true和false兩種,浮點類型Double和Float由於精度問題不存入其中)等

 在上面的6種類型中,前三種是線程私有的,也就是說裏面存放的值其餘線程是看不到的,然後面三種(真正意義上講只有堆一種)是線程之間共享的,這裏面的變量對於各個線程都是可見的。以下圖所示,前三種存放在線程內存中,你們都是相互獨立的,而主內存能夠理解爲堆內存(實際上只是堆內存中的對象實例數據部分,其餘例如對象頭和對象的填充數據並不算入在內),爲線程之間共享:app

淺談Java內存模型以及交互

2、Java內存之間的變量交互

這裏的變量指的是能夠放在堆中的變量,其餘例如局部變量、方法參數這些並不算入在內。線程內存跟主內存變量之間的交互是很是重要的,Java虛擬機把這些交互規範爲如下8種操做,每一種都是原子性的(非volatile修飾的Double和Long除外)操做。ide

  1. Lock(鎖)操做:操做對象爲線程,做用對象爲主內存的變量,當一個變量被鎖住的時候,其餘線程只有等當前線程解鎖以後才能使用,其餘線程不能對該變量進行解鎖操做。
  2. Unlock(解鎖)操做:同上,線程操做,做用於主內存變量,令一個被鎖住的變量解鎖,使得其餘線程能夠對此變量進行操做,不能對未鎖住的變量進行解鎖操做。
  3. Read(讀):線程從主內存讀取變量值,load操做根據此讀取的變量值爲線程內存中的變量副本賦值。
  4. Load(加載):將Read讀取到的變量值賦到線程內存的副本中,供線程使用。
  5. Use(使用):讀取線程內存的做用值,用來執行咱們定義的操做。
  6. Assign(賦值):在線程操做中變量的值進行了改變,使用此操做刷新線程內存的值。
  7. Store(儲存):將當前線程內存的變量值同步到主內存中,與write操做一塊兒做用。
  8. Write(寫):將線程內存中store的值寫入到主內存中,主內存中的變量值進行變動。

可能有人會不理解read和load、store和write的區別,以爲這兩對的操做相似,能夠把其當作一個是申請操做,另外一個是審覈經過(容許賦值)。例如:線程內存A向主內存提交了變動變量的申請(store操做),主內存經過以後修改變量的值(write操做)。以下圖:函數

淺談Java內存模型以及交互

參照《深刻理解Java虛擬機》this

淺談Java內存模型以及交互

對於普通的變量來講(非volatile修飾的變量),虛擬機要求read、load有相對順序便可,例如從主內存讀取i、j兩個變量,可能的操做是read i=>read j=>load j=> load i,並不必定是連續的。此外虛擬機還爲這8種操做定製了操做的規則:線程

  • (read,load)、(store,write)不容許出現單獨的操做。也就是說這兩種操做必定是以組的形式出現的,有read就有load,有store就有write,不能讀取了變量值而不加載到線程內存中,也不能儲存了變量值而不寫到主內存中。
  • 不容許線程放棄最近的assign操做。也就是說當線程使用assign操做對私有內存的變量副本進行了變動的時候,其必須使用write操做將其同步到主內存當中去。
  • 不容許一個線程無緣由地(沒有進行assign操做)將私有內存的變量同步到主內存中。
  • 變量必須從主內存產生,即不容許在私有內存中使用未初始化(未進行load或者assgin操做)的變量。也就是說,在use以前必須保證執行了load操做,在store以前必須保證執行了assign操做,例若有成員變量a和局部變量b,若是想進行a = b的操做,必須先初始化b。(一開始說了,變量指的是能夠放在堆內存的變量)
  • 一個變量一次只能同時容許一個線程對其進行lock操做。一個主內存的變量被一個線程使用lock操做以後,在這個線程執行unlock操做以前,其餘線程不能對此變量進行操做。可是一個線程能夠對一個變量進行屢次鎖,只要最後釋放鎖的次數和加鎖的次數一致才能解鎖。
  • 當線程使用lock操做時,清除全部私有內存的變量副本。
  • 使用unlock操做時,必須在此操做以前將變量同步到主內存當中。
  • 不容許對沒有進行lock操做的變量執行unlock操做,也不容許線程去unlock其餘線程lock的變量。

3、改變規則的Volatile關鍵字

對於關鍵字volatile,你們都知道其通常做爲併發的輕量級關鍵字,而且具備兩個重要的語義3d

  1. 保證內存的可見性:使用volatile修飾的變量在變量值發生改變的時候,會馬上同步到主內存,並使其餘線程的變量副本失效。
  2. 禁止指令重排序:用volatile修飾的變量在硬件層面上會經過在指令先後加入內存屏障來實現編譯器級別則是經過下面的規則實現。

  這兩個語義都是由於JMM對於volatile關鍵字修飾的變量會有特殊的規則:

  1. 在對變量執行use操做以前,其前一步操做必須爲對該變量的load操做;在對變量執行load操做以前,其後一步操做必須爲該變量的use操做。也就是說,使用volatile修飾的變量其read、load、use都是連續出現的,因此每次使用變量的時候都要從主內存讀取最新的變量值,替換私有內存的變量副本值(若是不一樣的話)。
  2. 在對變量執行assign操做以前,其後一步操做必須爲store;在對變量執行store以前,其前一步必須爲對相同變量的assign操做。也就是說,其對同一變量的assign、store、write操做都是連續出現的,因此每次對變量的改變都會立馬同步到主內存中。
  3. 在主內存中有變量a、b,動做A爲當前線程對變量a的use或者assign操做,動做B爲與動做A對應load或store操做,動做C爲與動做B對應的read或write操做;動做D爲當前線程對變量b的use或assign操做,動做E爲與D對應的load或store操做,動做F爲與動做E對應的read或write操做;若是動做A先於動做D,那麼動做C要先於動做F。也就是說,若是當前線程對變量a執行的use或assign操做在對變量buse或assign以前執行的話,那麼當前線程對變量a的read或write操做確定要在對變量b的read或write操做以前執行。

  從上面volatile的特殊規則中,咱們能夠知道一、2條其實就是volatile內存可見性的語義,第三條就是禁止指令重排序的語義。另外還有其餘的一些特殊規則,例如對於非volatile修飾的double或者long這兩個64位的數據類型中,虛擬機容許對其當作兩次32位的操做來進行,也就是說能夠分解成非原子性的兩個操做,可是這種可能性出現的狀況也至關的小。由於Java內存模型雖然容許這樣子作,但卻「強烈建議」虛擬機選擇實現這兩種類型操做的原子性,因此平時不會出現讀到「半個變量」的狀況。

  volatile不具有原子性

雖然volatile修飾的變量能夠強制刷新內存,可是其並不具有原子性,稍加思考就能夠理解,雖然其要求對變量的(read、load、use)、(assign、store、write)必須是連續出現,即以組的形式出現,可是這兩組操做仍是分開的。好比說,兩個線程同時完成了第一組操做(read、load、use),可是還沒進行第二組操做(assign、store、write),此時是沒錯的,而後兩個線程開始第二組操做,這樣最終其中一個線程的操做會被覆蓋掉,致使數據的不許確。以下面代碼:

public class TestForVolatile {

    public static volatile int i = 0;

    public static void main(String[] args) throws InterruptedException {
        // 建立四個線程,每一個線程對i執行必定次數的自增操做
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
        new Thread(() -> {
            int k = 0;
            while (k++ < 10000) {
                i++;
            }
            System.err.println("線程" + Thread.currentThread().getName() + "執行完畢");
        }).start();
     // 睡眠必定時間確保四個線程所有執行完畢
        Thread.sleep(1000);
      // 最終結果爲33555,沒有預期的4W
        System.out.println(i);
      
    }

}

結果圖:

淺談Java內存模型以及交互

解釋一下:由於i++操做其實爲i = i + 1,假設在主內存i = 99的時候同時有兩個線程完成了第一組操做(read、load、use),也就是完成了等號後面變量i的讀取操做,這時候是沒問題的,而後進行運算,都得出i+1=100的結果,接着對變量i進行賦值操做,這就開始第二組操做(assign、store、write),是否是同時賦值的無所謂,這樣一來,兩個線程都會以i = 100把值寫到主內存中,也就是說,其中一個線程的操做結果會被覆蓋,至關於無效操做,這就致使上面程序最終結果的不許確。

若是要保證原子性的話可使用synchronize關鍵字,其能夠保證原子性內存可見性(可是不具有有禁止指令重排序的語義,這也是爲何double-check的單例模式中,實例要用volatile修飾的緣由);固然你也可使用JUC包的原子類AtomicInteger之類的。

4、先行發生原則(happens-before)

若是單靠volatilesynchronized來維持程序的有序性的話,那麼不免會變得有些繁瑣。然而大部分時候咱們並不須要這樣作,由於Java中有一個「先行發生原則」:若是操做A先行發生於操做B,那麼進行B操做以前A操做的變化都能被B操做觀察到,也就是說B能看到A對變量進行的修改。 這裏的前後指的是執行順序的前後,與時間無關。例如在下面僞代碼中:

// 在線程A執行,定爲A操做
i = 0;

// 線程B執行,定義爲B操做
j = i;

// 線程C執行,定義爲C操做
i = 1;

假設A操做先於B操做發生,暫時忽略C操做,那麼最終獲得的結果一定是i = j = 1;可是若是此時加入C操做,而且跟A、B操做沒有肯定先行發生關係,那麼最終的結果就變成了不肯定,由於C可能在B以前執行也可能在B以後執行,因此此時就會出現數據不許確的狀況。若是一開始沒有A操做先行於B操做這個前提的話,那麼就算沒有C操做,結果也是不肯定的。

固然,符合先行發生原則的並不必定按照這個規則來執行,只有在操做之間會有依賴的時候(即下一個操做用到上個操做的變量),此時的先行發生原則才必定適用。例如在下面的僞代碼中,雖然符合先行發生原則,可是也不保證能有序執行。

// 同一線程執行如下操做
// A操做
int i = 0;
// B操做
int j = 1;

這裏徹底符合程序次序規則(先行發生原則的一種),可是兩個操做之間並無依賴,因此虛擬機徹底能夠對其進行重排序,使得B操做在A操做以前執行,固然這對程序的正確性並無影響。

  那麼該如何判斷是否符合先行發生原則呢?就連前面的例子都是經過假設來得出先行發生的。莫慌,Java內存模型爲咱們提供一些規則,只要符合這些規則之一,那就符合先行發生原則。能夠類比爲先行發生原則爲接口,下面的規則則爲實現此接口的實現類。

  • 程序次序規則:在同一個線程中,代碼書寫在前面的操做先行發生於書寫在後面的操做。(以編譯後的class文件爲準)
  • 管程鎖定規則:對於同一把鎖,unlock操做老是先行發生於後面對此鎖的lock操做以前。後面指的是時間上的順序
  • volatile變量規則:對於volatile修飾的變量中,對此變量的寫操做老是先行發生於後面對此變量的讀操做。這裏的後面一樣指的是時間上的順序。
  • 線程啓動規則:一個線程的start()方法先行發生於該線程的每個動做,也就是說線程的start()方法要先於該線程的run()方法中的任何操做。以下面例子,我在線程A中改變了共享變量i的值,而後在啓動B線程,B線程中run方法是讀取並打印i的值,執行1W次,最終的結果讀取到的都爲1:

    public static int i = 0;
    
    public static void main(String[] args) {
        for (int k = 0; k < 10000; k++) testThread();
    }
    
    public static void testThread() {
        Thread threadB = new Thread(() -> {
            System.err.println("線程B中i的值爲:" + i);
            System.err.println("線程B執行結束");
        });
        new Thread(() -> {
            i = 1;
            // 在修改了共享變量i的值後,啓動線程B
            threadB.start();
            System.err.println("線程A中執行完以後i的值爲:" + i);
        }).start();
    }

    結果圖:

淺談Java內存模型以及交互

  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到代碼中斷時間的發生。
  • 線程終止規則:線程的全部操做先行於該線程的終止檢測,也就是先於join()方法執行。以下面代碼中,我在A線程對共享變量i執行100W的自增,再執行100W-1的自減,執行1000次左右,最終join的全部結果都必定是1。

    public static int i = 0;
    
    public static void main(String[] args) throws InterruptedException {
        // 執行1000次
        for (int k = 0; k < 1000; k++) {
            i = 0;
            testThread();
        }
    }
    
    public static void testThread() throws InterruptedException {
        Thread threadA = new Thread(() -> {
            int k = 0;
            while (k++ < 100 * 100 * 100) {
                i++;
            }
            while (--k > 1) {
                i--;
            }
            System.err.println("線程A中執行完以後i的值爲:" + i);
        });
        threadA.start();
        // 加上下面這段代碼的話,join以前讀到的i可能爲0也可能大於0(不必定是1),緣由是變量i主內存的read和write操做沒有固定順序
        // TimeUnit.NANOSECONDS.sleep(1);
        System.out.println("主線程中開啓線程A後i的值爲:" + i);
        // 線程A終止
        threadA.join();
        // join以後的結果必定爲1
        System.err.println("Join以後i的值爲:" + i);
    }

    結果圖:

淺談Java內存模型以及交互

  • 對象終結規則:一個對象的初始化完成(構造函數執行完畢)先行於其finalize()方法的開始。
  • 傳遞性:若是A操做先行於B操做,B操做先行於C操做,那麼A操做先行於C操做。

  這8種就是Java提供的不須要任何同步器的天然規則了,只要符合在8條之一,那麼就符合先行發生原則;反之,則否則。能夠經過下面的例子理解:

// 對象中有一個變量i
private int i = 0;
public int getI() {
    return i;
}

public void setI(int i) {
    this.i = i;
}
// 在線程A執行set操做A 
setI(1);

// 在線程B執行相同對象的get操做B 
int j = getI();

咱們假設在時間上A操做先執行,而後再接着執行B操做,那麼B獲得的i是多少呢?

咱們將上面的規則一個個的往裏套,不一樣線程,程序次序規則OUT;沒有加鎖和volatile關鍵字,管程鎖定和volatile變量規則OUT;關於線程的三個規則和對象終止規則也不符合,OUT;最後一個更不用提,OUT;綜上,這個操做並不符合先行發生原則,因此這個操做是無法保證的,也就是說B獲得的變量i爲1爲0都有可能,便是線程不安全的。因此判斷線程是否安全的依據是先行發生原則,跟時間順序並無太大的關係。

像上面這種狀況要修正的話,使其符合其中一條規則便可,例如加上volatile關鍵字或者加鎖(同一把鎖)均可以解決這個問題。

相關文章
相關標籤/搜索