本系列譯自jakob jenkov的Java并發多線程教程,個人覺得很有收獲。由于個人水平有限,不對之處還望矯正!
? ? ? ? Java內存模型指定Java虛擬機如何與計算機的內存(RAM)一起工作。Java虛擬機是整個計算機的一個模型,所以這個模型自然包含了一個內存模型——也就是Java內存模型。
? ? ? ? 如果您想要設計正確的并發程序,那么理解Java內存模型是非常重要的。Java內存模型指定了不同線程如何以及何時可以看到由其他線程寫入共享變量的值,以及在必要時如何同步訪問共享變量。
? ? ? ? 原來的Java內存模型不夠用,所以Java內存模型在Java 1.5中被修改了。Java內存模型的這個版本仍然在Java 8中使用。
內部JAVA內存模型
? ? ? JVM內部使用的Java內存模型劃分了線程棧和堆之間的內存。這個圖表從邏輯的角度演示了Java內存模型
? ? ? ? 在Java虛擬機中運行的每個線程都有自己的線程棧。線程棧包含關于線程調用什么方法來達到當前執行點的信息。我將把它稱為“調用?!?。當線程執行其代碼時,調用堆棧會發生變化。
? ? ? ? 線程棧還包含每個正在執行的方法的所有本地變量(調用棧上的所有方法)。線程只能訪問它自己的線程棧。線程創建的局部變量對于所有其他線程都是不可見的,而不是創建線程的線程。即使兩個線程在執行完全相同的代碼,兩個線程仍然會在各自的線程棧中創建該代碼的本地變量。因此,每個線程都有自己的每個局部變量的版本。所有基本數據類型本地變量如(boolean、byte、short、char、int、long、float、double)都被完全存儲在線程棧中,因此對其他線程來說是不可見的。一個線程可能將pritimive變量的副本傳遞給另一個線程,但是它不能共享原始的局部變量本身。
? ? ? ? 堆包含在Java應用程序中創建的所有對象,而不管創建對象的線程是什么。這包括原始類型的對象版本(如字節、整數、Long等等)。如果一個對象被創建并分配給一個局部變量,或者作為另一個對象的成員變量創建,對象仍然存儲在堆中,這無關緊要。
? ? ? ? ? 下面是一個圖表,說明了在線程堆棧上存儲的調用堆棧和本地變量,以及存儲在堆上的對象:
? ? ? ? 局部變量可能是一種基本數據類型,在這種情況下,它完全被放在線程棧中。
? ? ? ? 局部變量也可能是一個對象的引用。在這種情況下,引用(本地變量)存儲在線程棧中,但是對象本身存儲在堆上。
? ? ? ? 對象可能包含方法,而這些方法可能包含局部變量。這些局部變量也存儲在線程棧中,即使方法所屬的對象存儲在堆上。
? ? ? ? 對象的成員變量和對象本身一起存儲在堆中.
? ? ? ? 靜態類變量也與類定義一起存儲在堆中。
? ? ? ? 堆上的對象可以被所有具有引用對象的線程訪問。當一個線程訪問一個對象 時,它也可以訪問該對象的成員變量。如果兩個線程同時調用同一個對象上的方法,那么它們都可以訪問對象的成員變量,但是每個線程都有自己的本地變量的副本。
? ? ? ? 以下的圖解說明上面的幾點
兩個線程有一組本地變量,局部變量(局部變量2)指向堆上的一個共享對象(對象3),這兩個線程對同一對象有不同的引用。
它們的引用是本地變量,因此存儲在每個線程的線程堆棧中(在每個線程堆棧中),不過,這兩個不同的引用指向堆上的同一個對象。注意,共享對象(對象3)是如何引用對象2和對象4作為成員變量的。
上圖也給我們展示了本地變量同時指向堆中的兩個不同對象(如variable1 指向堆上的兩個不同對象Object1,Object2),理論上如果線程都引用這兩個對象,他們都可以訪問這兩個對象的。但是在上圖中,每個線程只對這兩個對象的其中一個有引用。
因此,什么樣的代碼會出現上面的內存圖呢?下面的代碼非常簡單的展示了這個問題:
public class MyRunnable implements Runnable(){
? ? ? public void run(){
? ? ? ? ? ? methodOne();
? ? ? }
? ? ? public void methodOne(){
? ? ? ? ? int localVariablel = 45;
? ? ? ? ? MySharedObject localVariable2 = MySharedObject.sharedInstance;
? ? ? ? ? //... do more with local variables.
? ? ? ? ? methodTwo();
? ? ? }
? ? ? public void methodTwo(){
? ? ? ? ? Integer localVariablel = 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();
? ? ? ? // meber variables pointing to two objects on the heap
? ? ? ? public Integer object2 = new Integer(22);
? ? ? ? public Integer 4 = new Integer(44);
? ? ? ? public long member1 = 12345;
? ? ? ? public long member2 = 67890;
}
如果兩個線程執行run()方法,將會顯示前面的結果,run()方法調用methodOne()方法,methodOne()方法又調用methodTwo()方法。methodOne()聲明了一個私有的本地變量(int類型的localVariable1)和一個本地變量引用localVariable2。每個線程執行methodOne()將會在自己的線程棧中復制一份localVariable1和localVariable2,localVariable1將會與其他完全分離,只會生存在他們自己的線程棧上,一個線程不能看到另外的線程對localVariable1的更改。每個線程在執行methodOne()時也會復制一個localVariable2,但是最終這兩個復制變量都最終指向堆上的同一個對象。
? ? ? 代碼將localVariable2設置為指向一個靜態變量引用的對象。只有一個靜態變量的副本,這個副本存儲在堆中。因此,localVariable2的兩個副本都指向了靜態變量指向的MySharedObject的同一個實例。mysharedobtintinstance也被存儲在堆中。它對應于上面的圖中的對象3。
注意,MySharedObject類也包括兩個成員變量,成員變量和類一樣存儲在堆上。這兩個成員變量指向兩個Integer對象,這些整數對象對應上圖的Object2和Object2.注意method2()如何創建本地變量localVariable1,localVariable1是對一個Integer對象的引用。
methodTwo()方法創建了一個名為localVariable1的本地變量,這個變量引用一個Integer對象,這個方法把localVariable1的引用指向一個Integer實例,每個線程執行methodTwo()時都會存儲一個localVariable1的引用副本,實例化的兩個整數對象將被存儲在堆中,但是由于該方法每次執行該方法時都會創建一個新的整數對象,因此執行該方法的兩個線程將創建單獨的整數實例。
在method2()中創建的整數對象對應于上面的圖中的對象1和對象5。
還要注意MySharedObject中的類型為long的兩個成員變量,這是一個基本類型。由于這些變量是成員變量,所以它們仍然與對象一起存儲在堆中。只有本地變量存儲在線程堆棧中。
硬件內存架構
? ? ? 現代的硬件內存體系結構與內部Java內存模型有些不同。為了理解Java內存模型是如何工作的,理解硬件內存架構也是很重要的。本節描述通用的硬件內存架構,后面的部分將描述Java內存模型是如何工作的。
下面是現代計算機硬件架構的簡化圖
? ? ? 現代計算機通常有2個或更多的cpu。其中的一些cpu也可能有多個內核。需要指出的是,在一臺擁有2個或更多cpu的現代計算機上,可以同時運行多個線程。每個CPU都可以在任何給定的時間運行一個線程。這意味著,如果您的Java應用程序是多線程的,在你的Java應用程序中每個CPU都可以同時運行一個線程(并發)。
? ? ? 每個CPU都包含一組寄存器,它們本質上是CPU內存,CPU在這些寄存器上執行操作的速度要比在主內存中執行的速度快得多,這是因為CPU能夠訪問這些寄存器的速度比它訪問主存的速度快得多。
? ? ? ? 每個CPU也可能有一個CPU緩存。事實上,大多數現代的cpu都有一個一定大小的CPU緩存。CPU可以比主內存更快地訪問它的緩存內存,但是通常不像它能夠訪問它的內部寄存器那樣快。因此,CPU緩存在內部寄存器和主內存之間的速度之間。一些cpu可能有多個緩存層(1級緩存、2級緩存 ),但是這與了解Java內存模型如何與內存交互是不重要的。重要的是要知道,cpu可以有某種類型的緩存內存層。
? ? ? 計算機還包含一個主要的內存區域(RAM)。所有的cpu都可以訪問主內存。主內存區域通常比cpu的緩存內存大得多。
? ? ? 當一個CPU需要訪問主存時,它將把主內存的一部分讀到它的CPU緩存中。它甚至可以將緩存的一部分讀取到內部寄存器中,然后對其執行操作。當CPU需要將結果寫回主存時,它會將其內部寄存器中的值刷新到緩存內存中,并且在某個時候將值刷新到主內存中。
連接Java內存模型和硬件內存架構之間的差距
正如前面提到的,Java內存模型和硬件內存架構是不同的。硬件內存體系結構不區分線程堆棧和堆。在硬件上,線程堆棧和堆都位于主內存中。線程棧和堆的某些部分有時可能出現在CPU緩存和內部CPU寄存器中。這張圖中有這樣的例子:
當對象和變量可以存儲在計算機中不同的內存區域時,可能會出現某些問題。兩個主要問題是:
? ? ? 1、線程更新(寫)到共享變量的可見性
? ? ? 2、閱讀、檢查和寫入共享變量時的競態條件。
這兩個問題都將在下面的部分中解釋
共享對象的可見性
如果兩個或多個線程共享一個對象,如果不正確使用volatile聲明或同步,那么對一個線程所做的共享對象的更新可能對其他線程來說是不可見的。
假設共享對象最初存儲在主內存中。在CPU上運行的線程會將共享對象讀取到它的CPU緩存中。在那里,它對共享對象進行了更改。只要CPU緩存沒有被刷新到主存,那么共享對象的更改版本就不會被運行在其他CPU上的線程所看到。這樣,每個線程都可以使用自己的共享對象副本,每個副本都位于不同的CPU緩存中
下圖演示了所描繪的場景。在左側CPU上運行的一個線程將共享對象復制到它的CPU緩存中,并將它的count變量更改為2。對于在正確的CPU上運行的其他線程來說,這個更改是不可見的,因為更新計數還沒有被刷新到主內存中。
要解決這個問題,您可以使用Java的volatile關鍵字。volatile關鍵字可以確保從主內存直接讀取給定的變量,并在更新時將其寫入主內存。
競態條件
如果兩個或多個線程共享一個對象,并且多個線程在該共享對象中更新變量,那么可能會出現競態條件。想象一下,如果線程A讀取一個共享對象的變量計數到它的CPU緩存中。想象一下,線程B也一樣,但是進入不同的CPU緩存?,F在線程A添加了一個計數,而線程B也做了相同的工作?,F在,var1已經在每個CPU緩存中增加了兩次。
如果這些增量是按順序執行的,那么變量計數將會增加兩次,并將原來的值+2寫回主存,然而,這兩個增量在沒有適當同步的情況下同時進行。不管線程A和B將其更新后的計數寫回主存,更新后的值只會比原來的值高1,盡管有兩個增量。
這張圖說明了上面描述的競態條件的問題:
要解決這個問題,您可以使用Java同步塊。同步塊保證在任何給定的時間內只有一個線程可以進入給定的關鍵部分。同步塊也保證在同步塊中訪問的所有變量都將從主內存中讀取,當線程退出同步塊時,所有更新的變量將再次被刷新回主存,不管變量是否被聲明為volatile。