JVM可以看作是一個完整的計算機系統,自然會有自己的記憶體模型,就像物理機有RAM一樣.Java記憶體模型決定了Java是如何與物理機記憶體打交道的.如果你想編寫出具有確定行為的多執行緒併發程式,就需要對Java記憶體模型有一定的瞭解.jdk1.5之前的記憶體模型存在一定的缺陷,因此在jdk1.5之後重新發布了記憶體模型,這個記憶體模型一直沿用到jdk1.8.
JVM中的記憶體模型
在Java記憶體模型中,將記憶體區域劃分為執行緒棧和堆.如下圖展示了Java記憶體模型的邏輯平面圖:
執行緒棧
在java中,每建立一個執行緒,JVM就會在記憶體中建立一個執行緒棧,該執行緒棧中的資訊僅會被當前執行緒訪問,對其他執行緒是不可見的.且每當執行緒執行時,執行緒棧中的資訊都會得到更新.
執行緒棧用於儲存執行緒執行過程中全部方法的全部區域性變數以及執行緒當前執行方法的現場資訊,方便用於線上程切換後恢復執行.
執行緒棧中存放的資料型別有基礎資料型別(short, int, long, float, double, boolean, byte, char等)和引用型別,而引用型別引用的具體物件則存放在堆記憶體中.
儘管多個執行緒執行的是同一段程式碼,他們各自線上程棧中都有一份區域性變數的拷貝,互不影響,各自獨立.
儘管執行緒可以將自己的區域性變數傳遞給另一個執行緒,然而其他執行緒僅能得到這個執行緒區域性變數的拷貝,並不能訪問到這個執行緒區域性變數本身.
堆
在Java應用中建立的所有物件都會儲存在堆中,無論是哪個執行緒建立的物件.這些物件包含基礎資料型別的引用型別(Integer, Long等).
物件的成員變數會跟隨物件儲存在堆中,而在方法內建立和使用的物件也會儲存在堆中,而儲存線上程棧中的僅僅是該物件的引用.一個物件作為另一個物件的成員變數也一樣會儲存在堆中.
例項
- 區域性變數中的基礎資料型別, 全部儲存線上程棧中
- 區域性變數中的引用型別,引用一般儲存線上程棧中,而引用指向的物件將儲存在堆中
- 一個物件可以包含若干方法,一個方法可以包含若干區域性變數.區域性變數將全部儲存線上程棧中.儘管這些方法所屬的物件是儲存在堆中的.
- 一個物件包含若干成員變數,這些變數將跟隨物件一起儲存在堆中.無論這些變數是基礎資料型別還是指向物件的引用型別.
- 靜態物件和常量將跟隨類宣告一起被儲存在堆中.
- 只要執行緒中擁有堆內物件的引用就可以訪問到堆中的全部物件.只要能訪問到特定物件,就能訪問到該物件中的成員變數.若兩個執行緒同時呼叫一個物件的方法,那麼兩個執行緒能同時訪問到該物件的成員變數,但兩個執行緒都會持有各自的區域性變數拷貝.
下圖以邏輯平面圖的方式展示上文提及的情形:
圖中展示了兩個執行緒在java記憶體模型中的邏輯平面圖.兩個執行緒棧中各自有兩個方法,方法A和方法B,方法A中有兩個區域性變數,方法B中有一個區域性變數.其中區域性變數1為基礎型別,區域性變數2為引用型別,兩個執行緒棧的區域性變數2都指向了堆中的物件3,其中物件3中有兩個成員變數,成員變數都為引用型別,分別指向物件2和物件4.兩個執行緒棧中的區域性變數3都為引用型別,分別指向堆中的物件1和物件5.
通過編碼例項來覆蓋上文提及的情形,我們建立一個物件JMMExample類,用於模擬上圖中儲存例項的情形.
public class JMMExample {
public static class Object1OrObject5 {
private String str = "obj1 or obj5";
}
public static class Object2 {
private String str = "obj2";
}
public static class Object3 {
public static Object3 getInstance() {
return new Object3();
}
private Object2 obj2;
private Object4 obj4;
public Object3() {
this.obj2 = new Object2();
this.obj4 = new Object4();
}
public Object2 getObj2() {
return obj2;
}
public Object4 getObj4() {
return obj4;
}
}
public static class Object4 {
private String str = "obj4";
}
public void methodA(Object3 localVariable2) {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + " using localVariable2( " + localVariable2 + " )");
int localVariable1 = 10;
System.out.println(thread.getName() + " using localVariable1( " + localVariable1 + " )");
Object2 obj2 = localVariable2.getObj2();
Object4 obj4 = localVariable2.getObj4();
System.out.println(thread.getName() + " using localVariable2 point to ( " + obj2 + " )");
System.out.println(thread.getName() + " using localVariable2 point to ( " + obj4 + " )");
methodB();
}
public void methodB() {
Thread thread = Thread.currentThread();
Object1OrObject5 obj = new Object1OrObject5();
System.out.println(thread.getName() + " using localVariable3 point to ( " + obj + " )");
}
public static void main(String[] args) {
final Object3 obj3 = Object3.getInstance();
final JMMExample jmmExample = new JMMExample();
Runnable myRunnable = () -> jmmExample.methodA(obj3);
IntStream.range(1, 3)
.forEach(i -> new Thread(myRunnable, "Thread-" + i).start());
}
}
複製程式碼
執行結果:
Thread-1 using localVariable2( org.menfre.JMMExample$Object3@41d5399c
)
Thread-1 using localVariable1( 10 )
Thread-2 using localVariable2( org.menfre.JMMExample$Object3@41d5399c
)
Thread-2 using localVariable1( 10 )
Thread-2 using localVariable2 point to ( org.menfre.JMMExample$Object2@4f12cfd6
)
Thread-1 using localVariable2 point to ( org.menfre.JMMExample$Object2@4f12cfd6
)
Thread-1 using localVariable2 point to ( org.menfre.JMMExample$Object4@7c716752
)
Thread-2 using localVariable2 point to ( org.menfre.JMMExample$Object4@7c716752
)
Thread-1 using localVariable3 point to ( org.menfre.JMMExample$Object1OrObject5@4b2d52ec
)
Thread-2 using localVariable3 point to ( org.menfre.JMMExample$Object1OrObject5@72b696ac
)
從結果我們可以看出執行緒1和執行緒2各自將區域性變數1和2載入到執行緒棧中,其中區域性變數1為int型別,數值為10,區域性變數2為引用型別,指向物件Object3,從執行結果看出他們指向的物件為同一個.隨後執行緒1和執行緒2通過Object3的成員變數訪問到Object2和Object4,從執行結果可以看出Object2和Object4也為同一個物件.再然後執行緒1和2分別建立了各自的區域性變數3,從執行結果可以看出兩個執行緒的區域性變數3指向的物件是不同的,這符合上文提及的Object1和Object5.
物理機記憶體架構
物理機記憶體架構跟java記憶體模型不太一樣,但瞭解物理機記憶體架構有助於理解java記憶體模型與物理機記憶體之間的互動。
物理機記憶體架構邏輯平面圖如下:
現代計算機通常擁有兩個或兩個以上的cpu數量,有些cpu甚至擁有多個核心。這使得多個執行緒同時執行成為可能。在同一時間點,每個執行緒可以交由一個cpu進行排程,多個執行緒可以同時執行。若你的應用支援多執行緒,那麼你的程式將會在多個cpu中執行。
通常物理機記憶體會有三層架構,分別是主存,cpu快取(cpu快取可能會有多級,如1~3不等,但不影響理解),還有位於cpu中的多組暫存器。主存的容量一般比cpu快取和暫存器的容量大得多,而cpu快取容量會比暫存器大。
通常暫存器的讀寫速度會大於cpu快取,而cpu快取的讀寫速度會大於主存。cpu執行時會將主存的部分資料載入到cpu快取,同樣會將cpu快取中的部分資料載入到暫存器中,然後再對暫存器中的數值進行操作。
cpu執行結束後會將結果回寫到cpu快取,但cpu快取中的資料更新後不會立即寫回到主存中,而是當cpu需要將其他資料載入到cpu快取中時,才將cpu快取中更新後的資料回寫到主存。
cpu快取不會一次性的寫入和寫出整個快取區,而是分塊的進行寫入和寫出。cpu快取中分塊單位稱為“cache lines”。
Java記憶體模型與物理機記憶體架構互動
物理機記憶體並不會區分Java記憶體模型中的執行緒棧和堆。會將執行緒棧中的區域性變數和堆中的物件無差別的載入主存中。且無論是執行緒棧中的區域性變數還是堆中的物件都會出現在物理機記憶體三層架構中。如下邏輯平面圖所示:
一旦變數和物件被載入到多個儲存區域後,就會暴露出特定的問題,最主要的問題是如下兩種;
- 共享物件更改後對其他執行緒的可見性
- 多執行緒讀取和更改共享變數時產生的竟態條件
共享物件可見性
若一個執行緒將主存中的共享變數載入到cpu快取中操作,隨後另一個執行緒將主存中的共享變數載入到cpu快取中,此時第一個執行緒在cpu快取中對共享變數進行的更新對另一個執行緒不可見。如下邏輯平面圖所示:
圖中主存中儲存有共享變數count,count值等於1。在沒有volatile
修飾符修飾的情況下,此時兩個執行緒分別將共享變數載入到cpu快取中,第一個執行緒對count進行+1操作,此時count數值更新為2,但cpu快取並不會將更新後的數值立即寫回主存,此時執行緒2載入到cpu快取中的count並不是執行緒1更新後的數值。
java中,可以用volatile
修飾共享變數,來讓共享變數得到更新後立即寫回主存,以解決上述問題。
竟態條件
若兩個執行緒同時將主存中的共享變數載入到cpu快取中進行操作,同時更新共享變數,寫回主存後的預期變化是兩次更新都能對主存中的共享變數生效。但事實是,在沒有任何同步措施的情況下,兩個更新被回寫到主存後,僅會保留最後一次的更新。如下邏輯平面圖所示:
圖中主存中儲存有共享變數count,count值等於1。在沒有任何同步措施的情況下,此時兩個執行緒同時將共享變數載入到cpu快取中,兩個執行緒都對count進行+1操作,此時共享變數在cpu快取中有兩個版本。在兩個執行緒將更新後的count寫回主存時,主存中的共享變數應該被更新成3,但此時僅會有一個+1操作生效,即count會被更新為2.
java中,可以用synchronized
來修飾臨界區程式碼,生成同步塊。同步塊中的變數一次僅能被一個執行緒訪問載入到cpu快取中,且cpu快取中對同步塊中的共享變數的更新會被立即寫回主存,無論該共享變數有無volatile
修飾。