java記憶體模型
-
-
- 這裡說的是JAVA如何解決其中的可見性和有序性問題。
-
- Happens-Before規則
- volatile關鍵字
- final關鍵字
- synchronized關鍵字
-
大家都知道java并發的三大根源性問題:可見性,有序性,原子性。那麼java是如何解決的呢?
這裡說的是JAVA如何解決其中的可見性和有序性問題。
導緻可見性的原因是緩存,導緻有序性的原因是編譯優化。那麼我們隻要按需禁用緩存和編譯優化就可以了。
JAVA推出了JAVA記憶體模型,JAVA記憶體模型規範了JVM提供按需禁用緩存和編譯優化的方法。具體來說,就是volatile,synchronized,final三個關鍵字,以及Happens-Before規則。
Happens-Before規則
happens-before僅僅要求前一個操作的執行結果對後一個操作是可見的,且前一個操作按順序排在後一個操作之前。
Happens-Before 限制了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化後一定遵守 Happens-Before 規則,下面我來介紹這8大規則:A操作happens-before于B操作 == A happens(發生) B before(之前)
- 程式順序規則:一個線程中的每個操作,happens-before于該線程中任意後續操作。
- 螢幕鎖規則:對于一個鎖的解鎖,happens-before于随後對于這個鎖的加鎖。
- volatie變量規則:對于一個volatile變量的寫操作,happens-before于後續對該變量的讀操作。
- 傳遞性規則:如果Ahappen-beford B,且B happen-before C,那麼A happen-before C;
- 線程start()規則:main主線程啟動子線程B後,子線程B能夠看到main線程啟動子線程B之前的操作。
- 線程join()規則:線程A調用線程B的join()方法,線程A等待線程B執行完join()成功傳回後,線程B的任意操作都隊線程A中B.join()之後可見。
- 線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程代碼檢測到中斷事件的發生,可以通過Thread.interrupted()檢測到是否發生中斷。
- 對象終結規則:這個也簡單的,就是一個對象的初始化的完成,也就是構造函數執行的結束一定 happens-before它的finalize()方法。
下面分别說說volatile,synchronized,final三個關鍵字。
volatile關鍵字
volatile可以保證可見性和有序性。
- volatile如何保證有序性
重排序分為編譯器重排序和處理器重排序,為了實作volatile的記憶體語義,JMM為了限制這兩種重排序,對以下情況不能重排序!
1.當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序;
2.當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序;
3.當第一個操作是volatile寫,第二個操作是volatile讀,不能重排序;
為了實作以上規則(volatile的記憶體語義),編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障禁止處理器重排序。但是對于編譯器,發現一個最優布置最小化插入記憶體屏障的數量是不可能的,是以JMM就在每個volatile讀寫前後都分别插入不同的記憶體屏障來實作有序性。
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的前面插入一個StoreLoad屏障。
在每個volatile讀操作的前面插入一個LoadLoad屏障。
在每個volatile讀操作的前面插入一個LoadStore屏障。
如果想了解四種屏障的作用,可以自己查閱資料。
- volatile 如何保證可見性
有volatile修飾的共享變量在進行寫操作時,會多出lock的彙編代碼,而lock字首的指令在多核處理器下會進行兩個操作
1.将目前緩存行中的資料寫回到系統記憶體中。
LOCK#信号會鎖定這塊記憶體區域的緩存,并寫回記憶體,并使用緩存一緻性,保證它的原子性。
2.這個寫回記憶體的操作會使其他cup裡緩存了該記憶體位址的緩存行無效。
詳情參考volatile原理
final關鍵字
final域的重排序規則,編譯器和處理器要遵循下面兩個規則:
首先用java代碼展示這兩種情況。
public class FinalDemo{
int i;//普通變量
final int j;//final變量
static FinalDemo demo;
public FinalDemo(){//構造函數
i=1;//寫普通域
j=2;//寫final域
}
public static void writer(){//寫線程A執行
demo=new FinalDemo();
}
public static void reader(){//讀線程B執行
FilalDemo object=demo;//讀對象引用
int a=object.i;//讀普通域
int b=object.j;//讀final域
}
}
- A在構造函數中對final域的寫入,與B随後把這個 被構造的對象的引用 給一個引用變量,這兩個操作不能重排序。
- 初次讀一個包含final域的對象的引用,與随後初次讀這個final域,這兩個操作不能重排序。
上面我們看到的final域是基礎資料類型,那麼如果final域是引用類型呢?
對于引用類型,寫final域的重排序規則對編譯器和處理器做了如下限制。
-
在構造函數中對一個final引用對象的成員域的寫入,與随後在構造函數外讀這個final域引用對象的成員域,這兩個操作不能發生重排序。
請看下面執行個體代碼。
public class FinalReferenceDemo {
final int[] arrays; //final是引用類型
static FinalReferenceDemo demo;
public FinalReferenceDemo() {//構造函數
arrays = new int[1];//1
arrays[0]=1;//2
}
public static void writeOne(){//寫線程A執行
demo=new FinalReferenceDemo();//3
}
public static void writeTwo(){//寫線程B執行
demo.arrays[0]=2;//4
}
public static void reader(){//讀線程C執行
if(demo!=null){
int temp=demo.arrays[0];
}
}
}
-
構造函數溢出問題
看如下代碼
public class FinalReferenceEscapeDemo {
final int i;
static FinalReferenceEscapeDemo obj;
public FinalReferenceEscapeDemo() {// 構造函數
i = 1;
obj = this;
}
public static void writer() {
new FinalReferenceEscapeDemo();
}
public static void reader() {// 讀線程C執行
if (obj != null) {
int temp = obj.i;
}
}
}
如果上面這個例子太複雜,你可以結合下面這個例子再了解下
我們通常new一個對象時,有三步
我們想象的是這樣的
- 配置設定一塊記憶體 M;
- 在記憶體 M 上初始化 Singleton 對象;
- 然後 M 的位址指派給 instance 變量。
其實編譯器優化後是這樣的
- 配置設定一塊記憶體 M;
- 然後 M 的位址指派給 instance 變量;
- 在記憶體 M 上初始化 Singleton 對象;
這樣在第2步之後,instance其實已經有了記憶體M的引用,但是值沒有初始化,這樣在另一個線程中可以擷取到該對象,但是時沒有初始化的,比如該對象中有 int i ; i沒有初始化,調用會出錯。
synchronized關鍵字
- synchronized的管程模型原理
- synchronized的實作
在其他文章中有講到,這裡就不多說了。
參考書籍:java并發程式設計的藝術