1. 程式人生 > >Java記憶體模型與執行緒——Java記憶體模型

Java記憶體模型與執行緒——Java記憶體模型

文章目錄


不同架構的物理機,可以擁有不一樣的記憶體模型,而Java虛擬機器也有自己的記憶體模型。Java虛擬機器的記憶體模型是為了遮蔽硬體、作業系統

的記憶體訪問差異,讓java程式在各種平臺上都能達到一直的記憶體訪問效果。


一、主記憶體與工作記憶體

1.1 Java記憶體模型中的變數

Java記憶體模型的目標是定義程式中各個變數的訪問規則。然而這裡的變數並不與java語言中的變數一樣。這裡的變數(例項欄位、靜態欄位、構成陣列物件的元素)是指不在棧上的變數,因為棧上的變數是執行緒共享的。如果區域性變數是一個reference型別,它引用的物件在Java堆中,然而reference本身是在Java棧中的區域性變量表中的。

1.2 主記憶體與工作記憶體

主記憶體:

java記憶體模型規定所有的變數都儲存在主記憶體中。需要注意的是這個名字與物理硬體的主記憶體一樣,然而這裡說的記憶體只是虛擬機器記憶體的一部分,比如棧也是虛擬機器記憶體的一部分。虛擬機器記憶體也只是硬體的主記憶體的一部分。這裡可以將java虛擬機器的主記憶體類比與硬體的主記憶體。

工作記憶體:

Java虛擬機器的每個執行緒都有自己的工作記憶體,這個工作記憶體類比與快取記憶體。每個執行緒對變數的訪問修改都是發生在這個工作記憶體的,而不能之間作用於主記憶體。

工作記憶體中儲存了使用到的變數的主記憶體副本拷貝1

執行緒、主記憶體、工作記憶體三者的互動關係

二、主記憶體與工作記憶體間互動操作

主記憶體與工作記憶體之間的互動協議,即是怎樣將一個變數從主記憶體拷貝到工作記憶體中,將一個變數從工作記憶體中同步會主記憶體中。這些操作具有原子性(對於double、long在某些平臺某些命令上有例外)。

操作 作用於主記憶體還是工作記憶體中的變數 說明
lock(鎖定) 作用於主記憶體 把一個變數標識為一個執行緒獨佔狀態
unlock(解鎖) 作用於主記憶體 把一個被標識為執行緒獨佔的變數釋放出來
read(讀取) 作用於主記憶體 把一個變數的值從主記憶體中傳輸到執行緒的工作記憶體中
load(載入) 作用於工作記憶體 把read讀到的變數值放入工作記憶體的變數副本中
use(使用) 作用於工作記憶體 它把一個變數的值從工作記憶體傳遞到執行引擎中
assign(賦值) 作用於工作記憶體 把執行引擎收到的值賦值給工作記憶體中的變數
store(儲存) 作用於公共記憶體 把工作記憶體中一個變數的值傳送到主記憶體
write(寫人) 作用於主記憶體 將store從中從工作記憶體中得到的變數的值放入主記憶體的變數中

需要注意的是,read與load,store與write都是要順序執行,沒有要求要連續執行。

java記憶體模型還規定了在執行上述8種基本操作時必須滿足的規則:

  1. 不允許read和load,store和wirte單獨出現。
  2. 不允許一個執行緒丟棄它的最近的assign操作。
  3. 不執行一個執行緒無原因(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  4. 不執行在工作記憶體中直接使用一個未被初始化(load或assign)的變數。
  5. 一個變數在同一時刻只允許一個執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次
  6. 如果一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數之前,必需要新執行load或assign。
  7. 如果一個變數沒有被lock,那就不允許對它執行unlock操作,也不允許unlock一個其他執行緒lock的變數。
  8. 對一個變數執行unlock之前,必須先把此變數同步回主記憶體。

上述8訪問操作與8種規則,再加上對volatile的一些特殊規定,就已經晚期確定了Java程式中那些記憶體訪問操作在併發下是安全的。


三、對於volatile型變數的特殊規則

關鍵子volatile可以說是Java虛擬機器提供的最輕量級的同步機制。volatile有連個特性——可見性、禁止指令重排序優化

3.1 可見性

可見性:

一個執行緒物件volatile修飾的變數進行修改後,對於其他執行緒是立即可知的。

為什麼說volatile變數的運算在併發下是不安全的

因為java運算不具有原子性。具有原子性的是機器語言指令。

例子,自增量:



public class Main {
    public static  int   race=0;
    private static final   int THREAD_COUNT=20;//同時執行的執行緒數
    public static void increase(){
        race++;
    }
    public static void main(String[] args) {
        Thread[] threads=new Thread[THREAD_COUNT];
        for(int i=0;i<THREAD_COUNT;i++){
            threads[i]=new Thread(()->{
                for(int j=0;j<10000;j++){
                    increase();
                }
            });
            threads[i].start();
        }
        //讓所有執行緒都結束
        while (Thread.activeCount()>1){
            Thread.yield();//讓主執行緒讓步一下,是開啟的所有子執行緒都執行完
        }
        System.out.println(race);
    }

}

結果就有可能不是200000。
在這裡插入圖片描述
為什麼會這樣呢?原因在於自增量運算不具有原子性。我們需要檢視自增量的機器碼。不過這裡檢視位元組碼就可以解答這個問題了

 public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // 獲取的到rice結果是正確的
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field race:I
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8

自增量運算就由getstatic、iconst_1、idd、pustatic組成。由於在併發情況下,假如一個執行緒剛好在iconst_1或iadd時時間片用完,其他執行緒或得機會修改了race[1],而此時上一個執行緒有取得機會繼續執行,那麼[1]這次修改就被作廢了。

可以使用volatile的修飾的變數的場景

  1. 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值
  2. 變數不需要與其他的狀態變數共同參與不變約束

比如下面這個場景就很適合用,這樣就可以讓所有執行緒執行的doword都停止下來。


public class Test {
	volatile boolean shutdownRequested;
	public void shutdown() {
		shutdownRequested=true;
	}
	
	public void doWork() {
		//比如這裡,每一次訪問shutdownRequested都需要從主存中獲取;而普通變數就有可能只在工作記憶體中獲取
		while(!shutdownRequested) {
			//do stuff
		}
	}
}


3.2 禁止指令重排序優化

指令重排序

為了充分利用cpu的運算單元,當兩個機器指令沒有依賴關係時,就可以改變它們的執行順序,這就做亂序執行。在java虛擬機器層面,也有類似的機制——指令重排序優化。

例子

Map configOptions;
char[] configText;
volatile boolean initialized=false;

//假設以下程式碼線上程A中執行
configOptions=new HashMap();
configText=readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized=true;

//假設以下程式碼在B執行緒中執行
while(!initialized){
	sleep();
}
//使用A執行緒初始化好的配置資訊
doSomethingWitchConfig();

如果initiliazed沒有用volatile修飾,那麼如果A執行緒中發生指令重排序優化,initiallized=true在讀取配置檔案之間就執行了。那麼執行緒B豈不是就蹦了!!!!

使用volatile可以禁止指令重排序優化。

volatile與其他同步工具的比較

在某些情況下,volatile的同步機制的效能的確由於鎖。但是由於虛擬機器對鎖進行了很多的消除和優化,使得很難判斷volatile比synchronized快多少。

volatile自身比較

讀肯定快於寫啊。讀其實與普通變數的效能消耗沒多大區別。但寫操作就可能會慢一些了,因為它需要在程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

3.3 java記憶體模型對volatile變數定義的特殊規則

假設用T表示一個執行緒,用V和W表示兩個volatile的 變數,那麼在進行read、load、use、assign、store、write操作時,必須滿足如下規則:

  1. 對V/W,出現load/read/use時,必須滿足read,load,use是連續的。這條規則用於保證每次讀取volatile變數時,都是從主存中讀取到的最新值。
  2. 對V/W出現assign/store/write時,必須滿足assign,store,write是連續的。這條規則用於保證其他執行緒都可以看到當前執行緒對volatile變數的修改。就是改變了volatile變數就要裡面存到主存去。
  3. 如果一個執行緒對V,W進行了操作,V先進行的use/assign,那麼V就要先執行load/store。

四、8個操作中的例外

之前說8個基本操作都具有原子性,但是在對longdouble中兩個64位資料的訪問可能會訪問到半個資料。然而這種情況是非常罕見的。目前商用軟體中是不會出現的,所以不要太過在意,只要想其他基本資料型別一樣使用就行。

五、原子性、可見性、有序性

原子性:

就是一個操作要嘛做完,要嘛就不做

可見性:

就是在一個執行緒中對變數的修改,立馬反應到其他執行緒。可以實現可見性的關鍵字有volatile、final、schonyied。

有序性:

在一個執行緒中看,程式碼的執行是順序性的,在另外一個執行緒中看這個執行緒,程式碼的執行是無序的。可以用volatile、synchronized來保證執行緒間操作的有序性

六、happens-before與時間先後順序

先行發生:

如果A先行發生與B,說明在發生操作B之前,操作B能觀察到操作A對其產生的影響(如修改記憶體中共享變數的值、傳送了訊息、呼叫了方法)。

Java記憶體模型規定了一些天然的先行發生關係。如果兩個關係之間的操作不能從下面規則及其推倒中得出。則虛擬機器可以對他們隨意地進行重排序。

規則 說明
程式次序規則 在一個執行緒中,按控制流順序在前面的程式碼先行發生
管程鎖定規則 一個unlock操作先行發生於後面(時間上的先後順序)對同一個鎖的lock操作
volatile變數規則 對一個volatile變數的寫操作先行發生於後面(時間上的先後順序)這個變數的讀操作。
執行緒啟動規則 Thread物件的start()方法先行發生與此執行緒的每一個動作
執行緒終止規則 執行緒中的所有操作先行發生與此執行緒的終止檢測
執行緒中斷規則 對執行緒interrupt()方法呼叫先行發生與被中斷執行緒的程式碼檢測到中斷事件的發生。
物件終結規則 一個物件的初始化先行與物件的finlize
傳遞性 A先行於B,B先行與C,則A先行於C

A先行發生與B,並不意味者A在時間上先發生與B。同樣,A在時間上先發生與B,並不意味這A先行發生與B。即時間先後順序與先行發生原則之間並沒有太大的關係
參考
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5


  1. 需要注意,如果執行緒訪問的是一個10MB的物件,是不會把這個10MB的物件拷貝出來的,這個物件的引用、物件在某個執行緒訪問到的欄位是有可能存在拷貝的,但不會將整個物件拷貝一次。 ↩︎