【譯】Java內存模型

翻譯鏈接:http://tutorials.jenkov.com/java-concurrency/java-memory-model.html


內容:

? Java內存模型

? 硬件內存架構

? 使以上兩者相互聯系的

? 共享對象的可見性

? 競爭狀態


Java內存模型指定了JVM如何與計算機內存RAM協作。

若你想正確地設計并發行為,那必須得好好理解Java的內存模型(以下簡稱JMM)。JMM指定了各個線程在需要時如何獲得其他線程的變量值,又怎樣去同步訪問這些共享的變量。

原始的JMM是并不滿足需求,因而在Java1.5中JMM被重新修訂,并仍在Java8中使用。


內部JMM

JVM在為線程棧(thread stacks)和堆(heap)分配內存時使用到了JMM。以下的圖表從表現了JMM的邏輯視角:


每一個在JVM中運行的線程擁有自己的線程棧。線程棧中包含了當前該線程擁有的、能夠被調用執行的方法。我將稱之為“調用棧”(call stack)。當線程執行這些代碼時,該調用棧也會相應地改變。

線程棧中包含了每個被執行的方法的所有的本地變量(local variable)(所有的方法都在調用棧中)。每個線程只能訪問它自己的線程棧。線程創建的本地變量對于其他線程來說是不可見的。即使兩個線程在執行相同的代碼,這兩個線程仍然會在各自的線程棧中創建這段代碼中的該本地變量。因此,每一個線程擁有自己版本的每個本地變量。

所有的原始數據類型(boolean, byte, short, char, int, long, float, double)的本地變量都被存儲在線程棧中,因而對于其他線程而言是不可見的。一個線程可能通過傳遞一份原始數據類型變量的拷貝給另一個線程,但不能共享它們給其他線程。

堆包含Java應用無論哪個線程創建的對象。它還包括一些原始數據類型(e.g. Byte, Integer, Long etc.)。不管這些對象賦值給本地變量,還是在另一個對象中作為成員變量被創建,它們都將被存儲在堆內存中。

下圖表示了調用棧和本地變量是存儲在線程棧中,而對象是在堆內存中。

一個本地變量可能是原始數據類型,在這種情況下它們被存儲在線程棧中。

一個本地變量也可能指向一個對象,這時本地變量仍然存儲在線程棧中,該對象也仍在堆內存中。

一個對象可能包含有本地變量的方法。方法中的變量還是在線程棧中,即使該對象是在堆內存中。

一個對象的成員變量和該對象一起存儲在堆內存中。即使成員變量是原始類型或指向一個對象。

靜態類變量和它的類定義一起存儲在堆中。

堆中的對象可以被所有線程訪問,當這些線程中有變量指向它,線程還可以訪問對象中的成員變量。若兩個線程同時調用同一個對象的同一方法,它們能夠訪問該對象的成員變量,每個線程將復制各自版本的本地變量。

以下是圖解:

兩個線程都擁有各自的一組線程棧中的成員變量,其中Local Variable2指向了一個堆中的共享對象Object 3.兩個線程每個都擁有不同的本地變量指向Object3,兩個本地變量都在各自的線程棧中,雖然它們指向了堆中的同一個對象。

注意共享對象Object3 同時被成員變量Object2和Object4指向,因此兩個線程還能訪問Object2和Object4。

這張圖表還展示了一個本地對象指向堆中的兩個不同的對象。在這種情況下了,按理論來說,兩個線程都能訪問Object1和Object5,只要兩個線程都用引用到這兩個對象。但是在這張圖表中,每個一線程都只有一個引用指向其中一個對象。

那么,怎樣的java代碼可以展現上面的內存邏輯圖?以下便是一個簡單的例子。


public class MyRunnable implements Runnable() {

public void run() {

methodOne();

}

public void methodOne() {

int localVariable1 = 45;

MySharedObject localVariable2 =

MySharedObject.sharedInstance;

//... do more with local variables.

methodTwo();

}

public void methodTwo() {

Integer localVariable1 = new Integer(99);

//... do more with local variable.

}

}

public class MySharedObject {

//static variable pointing to instance of MySharedObject

public static final MySharedObject sharedInstance =

new MySharedObject();

//member variables pointing to two objects on the heap

public Integer object2 = new Integer(22);

public Integer object4 = new Integer(44);

public long member1 = 12345;

public long member1 = 67890;

}


如果兩個線程都執行了run()方法,那么上面的圖表便是結果表示了。run()方法會調用methodOne(),接著methodOne會調用methosTwo()。methodOne()聲明了一個原始數據類型的本地變量(lovalVariable1是int類型)和一個指向了對象的的本地變量localVariable2。

每一個線程執行methodOne()都會創建一份它們自己的localVariable1和localVariable2在各自的線程棧中。其中兩者的localVariable1是完全無關的,只存在于各自的線程棧中。一個線程不能看見另一個線程對它的本地變量做了什么操作。

每一個線程執行methodOne方法還會創建一份localVariable2的復制,但是這兩份復制都最終指向了堆中的同一個對象。這塊代碼中的localVariable2都指向一個靜態變量對象。靜態變量在內存中只有一份復制,該復制存儲于堆內存中。因此,兩個localVariable2都指向同一個MySharedObject實例,并且該實例也是被存儲于堆內存中。它和圖中的Object3相一致。

要注意MySharedObject也包含了兩個成員變量。成員變量和類對象一起被存儲在堆內存中。這兩個成員變量都指向了兩個Integer對象。這兩個Integer對象和圖中的Object2和Object4相一致。

注意methodTwo方法創建了一個名為localVariable1的本地變量。這個成員變量是對Integer對象的對象引用。這個方法使得localVariable1的引用指向一個新的Integer實例。localVariable1的引用會在每個執行methodTwo方法的線程中復制并存儲。這兩個Integer對象被實例化后會被存儲在堆內存中。但是每當這個方法被執行時,一個新的Integer對象就將會被創建。在方法methodTwo中創建的Integer對象對應上圖中的Object1和Object5。

注意MySharedObject類中的long類型成員變量是原始數據類型,因為它們是成員變量,因此仍然會被存儲在堆內存中,只有本地變量才會存儲在線程棧中。


硬件內存架構

為了理解JMM是如何與硬件內存架構合作,理解它也變的非常重要,因為現代硬件內存架構和java內部內存模型是不太一樣的。

以下是簡單化的現代計算機硬件架構。

現代計算機常常有兩個或以上的CPU,一些CPU還是多核的。關鍵是,這樣的多CPU硬件特性使得多個線程同時運行成為可能。每一個CPU都可以在任何時刻運行一個線程。這意味著如果你的Java應用是多線程的,每一個CPU都有一個線程同時在運行。

每一個CPU都包含一組寄存器,它們是CPU內存的基礎。CPU在寄存器上進行操作變量的速度遠遠快于在主存上,這是因為CPU訪問寄存器的速度遠快于訪問主存的速度。

每一個CPU可能還會有一個CPU緩存層。事實上,大部分現代的CPU都會有一定大小的CPU緩存。CPU訪問緩存層的速度大于訪問主存,一般而言小于寄存器。一些CPU可能會有還幾個級別的緩存(Level 1,Level2),JMM如何與之交互在這里并不是重點,關鍵是要知道CPU有一個緩存層。

計算機包含一個主存區(RAM),所有的CPU都可以訪問RAM,其大小往往遠大于CPU的緩存區。

通常,當CPU需要訪問主存,它會讀取一部分的主存內容到CPU緩存,甚至會讀取一些緩存到寄存器里,然后再進行操作。當CPU需要把結果送回到主存時,它會把結果流入到緩存,再從緩存流入到主存。當CPU需要在緩存數據的時候,之前在緩存中的數據往往會流回到主存中。緩存在一段時間內流入數據,在另一段時間內流出數據。每一次更新的時候它不需要把整個緩存讀或寫。通常,緩存更新的只是被稱為“緩存線”的小內存塊,只是一個或多個的內存線一遍遍被寫或被讀。


使兩者相聯系的橋梁

根據前面已經說明的,JMM和硬件內存架構是不一樣的。硬件內存架構并不區分線程棧和堆。在硬件中,線程棧和堆都在主存中。部分線程棧和堆可能會在CPU緩存中,或者CPU內部的寄存器中。如下圖所示。


當對象和變量存儲在計算機的不同存儲部位的時候,可能會產生以下兩個主要問題:

1.線程更新共享變量時,變量的可見性。

2.當讀寫共享變量時的競爭機制。

以上問題將會在下幾節中解釋。

共享對象的可見性

如果一個以上的線程共享同一個對象,而該對象又沒有適宜地使用volatile聲明,或者沒有使用同步方法,那么對于其他線程來說這個更新可能是不可見的。

想象一下剛開始共享對象實在主存中被初始化。CPU1上的一個線程讀取這個共享對象到CPU1緩存中,在那里改變了該對象值。只要CPU緩存沒有把數據流回到主存中,被改變的共享對象對于其他線程便是不可見的。這種情況下,每一個線程都有一份共享對象的最終值位于它們所在CPU的緩存中。

下圖便展現了上述情況。在左邊CPU的一個線程復制了一份共享對象于它的緩存中,并且把它的count變量改變成了2。這個改動對于其他在右邊CPU上的線程來說是不可見的,因為count的更新后的數值并沒有被流回主存。




為了解決這種情況,你可以使用

volatile

關鍵字,它可以確保給定的變量可以直接從主存中讀取,一旦被更新又能馬上回到主存。

競爭機制

如果兩個或以上的線程共享一個對象,那么會有多個線程更新的共享對象,競爭將會發生。

如果線程A讀取共享對象的變量count到它的緩存中,同時線程B也做了相同的事情,但是在另一個CPU緩存中。A對count進行了一次加1的操作,B也做了如此,那么現在var1已經被增加了兩次,在兩個不同的CPU緩存中。

如果這些操作是依次進行的,那么count變量是被增加兩次,并且以原值加2 后的數值返回主存。

但是如果這兩次操作是并發且沒有合適的同步,那么盡管兩個線程都對count進行了增加并返回了主存,那么主存里count的更新后數值還是比原值大1,盡管它被增加過兩次。

下圖就是說明了上述的主要意思。


為了解決這個問題,你可以使用Java的synchronized塊。同步塊確保任何時間只能有一個線程能夠訪問給定的代碼塊。同步塊還能保證代碼塊中的所有變量只能從主存中讀取,當線程退出同步塊的時候,所有已經更新的變量會流回到主存,無論它們是否被聲明volatile

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容