【JDK併發基礎】Java內存模型詳解

       無論你是Java還是C,或者其他編程語言編寫高併發程序時,都或多或少的會涉及內存模型。高併發程序下數據訪問的一致性和安全性受到挑戰,爲了保證程序正確執行,Java內存模型(以下簡稱JMM)由此而誕生。如果不理解JMM,就會對內存可見性,有序性等問題出現時無從下手。本文將從以下幾個方面進行JMM的說明:

       1.內存模型的相關概念

       2.可見性

       3.有序性

       4.原子性

1.內存模型的相關概念

   1.1 Java虛擬機運行時數據區

       在共享內存模型裏,線程間的是通過讀-寫內存中的公共狀態進行隱式通信的,那線程在Java虛擬機裏是什麼位置呢?Java虛擬機運行時數據區:

                   

       堆:解決的是對象數據存儲問題。

       方法區:是先決條件,儲存已經被虛擬機加載過的類信息,常量,靜態變量等信息。它和堆描述的是Java共享數據(主)內存模型。

       虛擬機棧:棧解決的是程序運行的問題,即程序如何處理數據。從圖中可以看棧是線程私有的,函數的調用要用棧實現,函數運算並改變棧中存放數據的引用。所以虛擬機棧描述的是Java執行的內存模型: 每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、  操作數棧、 動態鏈接、 方法出口等信息。

       本地方法棧:和虛擬機棧類似,虛擬機棧爲Java方法服務,而本地方法棧爲虛擬機使用到的Native方法服務。程序計數器:cpu中的寄存器,它包含當前正在執行的指令的地址(位置)。(寄存器是cpu組成部分,暫存指令數據地址--百度百科)。

   1.2  緩存一致性協議

       CPU在執行指令過程中,勢必會牽扯到變量的讀和寫。共享變量存儲到內存中,CPU通過高速緩存(Cache)與內存進行通信,但是CPU執行指令比CPU從內存讀寫共享變量要快的多。而且在多核CPU時代,每條線程可能運行在不同的CPU裏。比如:

i=i+1;

      兩個線程讀取i的值並在自己的CPU的高速緩存中+1操作,當第一個線程運算完了存到高速緩存還沒有刷新到內存中,線程二讀取到i還是0,也做了+1操作,然後將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。

       最終結果i的值是1,而不是2。這就是緩存一致性問題。怎麼解決呢?當CPU寫數據時,發現其他CPU也在操作這個共享變量,會讓其他CPU的緩存無效且從內存中重新獲取。這個緩存一致性協議最出名的就是Intel 的MESI協議。

              

2.可見性

  • 可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改。

       程序在執行過程中任何時候都可能產生可見性問題,這裏只討論JMM相關的可見性問題。Java線程之間的通信由JMM控制,JMM的抽象示意圖如下:

                           

       本地內存A和B有主內存中共享變量x的副本。假設初始時三個內存中的x值都爲0。線程A修改x=1後-->主內存修改爲x=1-->最後由B修改x=1。這些步驟實質上就是A在給B發消息,且要通過主內存,及JMM通過控制主內存在控制線程之間的內存可見性。

       我們來看一個Java虛擬機層面產生的可見性問題:

public class Visibility implements Runnable{
	private  boolean isStoped = false;
	 
	public void run() {
	    int i = 0;
	    while(!isStoped) {
	        i++;
	    }
	    System.out.println("end and print,i=" + i);
	}
	 
	public void stopFunction() {
	    	isStoped = true;
	}
	 
	public boolean getStopStatus(){
	        return isStoped;
	}
	public static void main(String[] args) throws Exception {
	    Visibility visibility = new Visibility();
	    new Thread(visibility,"visibility").start();
	    Thread.sleep(1000);
        //當主線程直到主線程調用stop方法,改變了v線程中的stop變量的值使循環停止。
	    visibility.stopFunction();
	    Thread.sleep(1000);
	    System.out.println("finish main");
	    System.out.println(visibility.getStopStatus());
	}
}

        運行結果:

           

       爲什麼循環沒停止,且沒有打印System.out.println("end and print,i=" + i);這句話?原因是主線程調用把isStoped值修改爲true對visibility線程並不可見,所以主線程走完了,而visibility線程一直在做i++操作。解決上述變量不可見性的方法:用volatile關鍵字修飾isStoped變量,它會強制性的從主內存中取值,從而避免本地內存變量不可見問題。

3.有序性

  •  即程序執行的順序按照代碼的先後順序執行。

       很奇怪?其實我們寫代碼時只是看上去有序,代碼在編譯成指令執行時會進行重排序保證程序運行的高效率。 因爲下面的語句1和語句2沒有什麼數據依賴性,可能會打亂順序執行:

a=b+c;    //語句1
d=e-f;    //語句2
g=a+3;    //語句3

       CPU執行一條指令的時候一般有:取指IF-->譯碼和讀取寄存器操作數ID-->執行或者有效地址計算EX(用到CPU中邏輯運算單元)-->存儲器訪問MEM-->寫回WB(用到寄存器)

       比如兩條指令執行順序由上到下:

               

       當我們運行語句1和語句2時有:

                    

       上圖產生氣泡X會嚴重影響效率,所以會把沒有相關性的操作加入到有氣泡操作裏,這就是處理器爲了提高程序運行效率,對輸入代碼進行優化:

          

       一般認爲CPU的指令都是原子操作,雖然重排序不會影響單個線程內程序執行的結果,但是多線程會有影響:

public class VolatileSort {
	int a =0;
	boolean flag = false;
	public void write(){
		a=1;
		flag =true;
	}
	public void read(){
		if(flag){
			int i=a+1;
			//...
		}
	}
}

       一個線程執行write方法,另一個線程檢查flag=true時,a=1還未執行,會導致程序運算錯誤。解決方法還是使用volatile關鍵字修飾變量。

4.原子性

  • 原子性是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一旦開始,就不會被其它線程干擾,要麼直接就不執行。

       volatile關鍵字最致命的缺點是不支持原子性。比如count++這樣的操作就不是原子性的:

public class VolatileTest {
    private volatile int count= 0;
       //解決方法之一
//    Lock lock = new ReentrantLock();
//    public  void increase() {
//        lock.lock();
//        try {
//            count++;
//        } finally{
//            lock.unlock();
//        }
//    }
    //解決方法之二
//  public synchronized  void increase() {
    public void increase() {
        count++;
    }
    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
       //保證前面的線程都執行完
        while(Thread.activeCount()>1){  
            Thread.yield();
        }
        //執行結果並不是10000
        System.out.println(test.count);
    }
}

       count++其實有三個操作:讀取count-->做count+1操作-->然後把count+1後的值寫回到count裏。

       假設有兩個線程,當第一個線程讀取count=1時,還沒進行+1操作,切換到第二個線程,此時第二個線程也讀取的是count=1。隨後兩個線程進行後續+1操作,再賦值回去以後,count不是3,而是2。顯然數據出現了不一致性,可以使用上面代碼的方法一和方法二加鎖或原子類操作:

public class VolatileTest2 {
    private AtomicInteger count= new AtomicInteger();
    public  void increase() {
        count.getAndIncrement();
    }
    public static void main(String[] args) {
        final VolatileTest2 test = new VolatileTest2();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1){  
            Thread.yield();
        }
        System.out.println(test.count.get());
    }
}

其實前面或多或少提到了volatile關鍵字,這裏做一些擴展,volatile是如何保證可見性的呢?

volatile User user =new User();

上述代碼在x86處理器通過工具獲取JIT編譯器生成的彙編指令如下:

       0x01a3deld:movb 》$0×0,0×1104800(%esi);0x01a3de4>:lock add1 $0×0,(%esp);

lock指令在多核處理器會引發兩件事:

       1.將當前緩存行的數據寫回到系統內存。

       2.這個寫回內存操作會使其他CPU裏緩存了該內存地址的數據無效。

參考:

       《深入理解Java虛擬機》

       《Java併發編程的藝術》

       《Java高併發程序設計》

系列:

【JDK併發包基礎】線程池詳解

【JDK併發包基礎】併發容器詳解

【JDK併發包基礎】工具類詳解

【JDK併發基礎】Java內存模型詳解

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