首頁  >  文章  >  Java  >  Java記憶體模型的詳細介紹

Java記憶體模型的詳細介紹

黄舟
黄舟原創
2017-02-28 10:41:421402瀏覽

這個Java記憶體模型指定的是Java虛擬機器如何跟電腦記憶體(RAM)一起運作。這個Java虛擬機是整個電腦的模型,以至於這個模型自然的包含的一個記憶體模型----也叫作Java記憶體模型。

理解Java記憶體模型是很重要的,如果你想正確的設計並發程式。這個Java記憶體模型指的是如何以及什麼時間不同的執行緒可以看到被其他執行緒寫入的共享變數的值,以及如何同步的存取共享變數。

最初的Java記憶體模型是不足的,以至於在Java1.5版本中Java記憶體模型被改進了。這個Java記憶體模型的版本在Java8中仍然被使用。

內部的Java記憶體模型

Java記憶體模型在JVM內部的使用,是透過分割為執行緒堆疊和堆疊來使用的。這個圖示是透過邏輯的角度來看記憶體模型的:


每一個運行在Java虛擬機器的執行緒都有它自己的執行緒堆疊。這個執行緒棧包含了關於這個執行緒已經呼叫達到目前執行的點的方法的資訊。我們也會稱之為「呼叫棧」。隨著執行緒執行它的程式碼,這個呼叫堆疊就會改變。

這個執行緒堆疊也會包含對於正在執行的每一個方法的所有的本地變數(在呼叫堆疊上的所有方法)。一個執行緒只能存取它自己的執行緒棧。被一個線程創建的本地變數對於其他所有的線程是不可見的。甚至如果兩個線程正在執行完全相同的程式碼,這兩個線程仍然是創建他們各自的本地變數。因此,每一個執行緒都有它們自己的本地變數的版本。

所有基本類型的本機變數(boolean,byte,short,char,int,long,float,double)是完全的儲存在執行緒堆疊中,因此對其他執行緒是不可見的。一個線程可能會傳遞一個基本類型變數的拷貝給另一個線程,但是它仍然不能共享這個基礎類型的本地變數。

這個堆包含了你的應用程式中創建的所有的對象,不管是什麼線程創建了這個對象。這個包括了基本類型的物件版本(例如,Byte,Integer,Long等等)。不管是否一個物件被創建,並且分配給一個本地變量,或者創建一個另一個物件的成員變量,這個物件仍然存儲在堆中。

這裡有一個圖示顯示這個呼叫堆疊和本機變數儲存在執行緒棧中,以及物件儲存在堆中:


一個本地變數可能是基本型,這樣的話它就會完全的保存在執行緒棧中。

一個本地變數可能是一個物件參考。在這種場景下這個引用(本地變數)儲存在線程棧中,但是這個物件自己儲存在堆中。

一個物件可能包含方法,以及這些方法包含本地變數。這些本地變數也是儲存在執行緒棧中,甚至如果這個方法屬於的物件儲存在堆中。

一個物件的成員變數伴隨著物件自己儲存在堆中。不僅這個成員變數是基本類型的時候,而且如果它是一個物件的參考。

靜態類別變數也會儲存在堆中。

在堆中的物件可以被有這個物件引用的所有執行緒存取。當一個執行緒存取一個物件的時候,它也可以存取這個物件的成員變數。如果兩個執行緒同時呼叫相同物件的一個方法,他們將會同時存取這個物件的成員變量,但是每個執行緒都會有他們自己的本地變數的拷貝。

這裡有一個基於上面說明的一個圖示:


#兩個執行緒有一個本地變數集。本地變數中的一個(Locale Variable 2)指向了堆中的共同的物件(Object 3)。這兩個線程每一個都有一個相同物件的不同的引用。他們引用的本地變數都是儲存在線程棧中,但是這兩個不同的引用指向的相同物件是在堆中。

注意這個共享物件(Object 3)是怎麼引用物件2和物件4作為成員變數(圖示中的箭頭所示)。透過Object3中的這些變數的引用,兩個執行緒也可以存取物件2和物件4。

這個圖示也顯示了一個本地變數指向了堆中的兩個不同的物件。在這種場景下這個引用就會指向兩個不同的物件(物件1和物件5),不是相同的物件。理論上兩個物件既能存取物件1,也能存取物件5,如果兩個執行緒都有這兩個物件的引用的話。但是在圖示中每一個執行緒只是有這兩個物件的一個引用。

所以什麼樣的程式碼會出現上圖的記憶體結構呢?好吧,就像下面的程式碼一樣簡答:


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方法呼叫methodTwo方法。

methodOne方法宣告了一個基本類型的本機變數(int型別),以及一個物件所引用的本機變數。

每個執行緒執行methodOne方法的時候,在他們各自的執行緒堆疊中創建它們自己的localVariable1和localVariable2的拷貝。這個localVariable1將會彼此完全的分離,只是存活在各自的線程棧中,一個線程不能看到另外一個線程對localVariable1的改變。

每個執行緒執行methodOne方法也會建立localVariable2它們自己的拷貝。然而,這兩個localVariable2的不同拷貝都是指向堆中相同的物件。這個程式碼設定localVariable2透過一個靜態變數去指向一個物件的引用。這裡只有一個靜態變數的拷貝,而這個拷貝是在堆中。因此,localVariable2中的兩個拷貝都是以指向相同實例而結束。這個MySharedObject也是儲存在堆中。它相當於上面圖中的物件3。

注意,這個MySharedObject類別也包含了兩個成員變數。這個成員變數他們自己伴隨著物件儲存在堆中。這兩個成員變數指向了兩個其他的Integer物件。這些Integer物件相當於上圖中的物件2和物件4。

也要注意methodTwo方法是如何建立了一個localVariable1的本地變數。這個本地變數是​​一個指向Integer物件的一個引用。這個方法設定這個localVariable1引用指向一個新的Integer實例。這個localVariable1引用將會儲存在正在執行的methodTwo方法中每一個執行緒的一個拷貝。這兩個實例化的Integer物件將會儲存在堆中,但是這個方法每次執行的時候都會建立一個新的Integer對象,執行這個方法的兩個執行緒將會建立分離的Integer實例。在methodTwo方法內部建立的Integer物件相當於上圖中的物件1和物件5。

也要注意在MySharedObject類別中的long類型的兩個成員變量,是基本類型的。因為這些變數是成員變量,他們仍然伴隨著物件儲存在堆中。只是本地變數會儲存在線程棧中。

硬體記憶體架構

現在的硬體記憶體架構跟內部的Java記憶體模型是稍微不同的。理解硬體記憶體體系結構也是重要的,去理解Java記憶體模型如何運作是有幫助的。這個部分描述公共的硬體記憶體框架,以及後面的部分介紹Java記憶體模型是如何跟著它運作的。

這裡有一個現代電腦硬體結構的簡化圖示:


#現在的電腦經常有兩個或更多的CPU。這些CPU中的一些可能有多核。重點是,有兩個或更多CPU的電腦可能有不只一個執行緒同時在運作。每一個CPU在任何給予的時間都能運行一個線程,在你的Java應用中可能一個CPU一個線程在同時運行。

每一個CPU包含一系列的暫存器,這個實質上就是CPU記憶體。這個CPU在暫存器上執行相對於在主記憶體上執行來說會更快一些。那是因為CPU存取暫存器比存取主記憶體更快一些。

每一個CPU可能也有一個CPU快取的記憶體層。事實上,大部分現在的CPU都有一定大小的快取記憶體層。這個CPU存取快取記憶體層比主記憶體快多了,但是不會和存取內部的暫存器一樣快。以至於,這個CPU快取記憶體的存取速度是介於內部暫存器和主記憶體之間的。有些CPU可能有多級快取(等級1和等級2),但是這個是不重要的去知道理解Java記憶體模型與記憶體的相互作用。重要的是知道CPU可能有一個快取記憶體層。

一個電腦也包含一個主記憶體區域(RAM)。所有的CPU都可以存取這個主記憶體。這個主記憶體典型的比CPU的快取記憶體更大。

作為代表性的,當CPU需要存取主記憶體的時候,它將會讀取主記憶體的部分進入CPU快取。它可能甚至會讀取快取的部分進去暫存器,然後在這裡執行操作。當這個CPU需要把結果寫回主記憶體的時候,他將會從內部的暫存器中沖刷到快取記憶體中,並且在某個點上把這個值沖刷到主記憶體中。

儲存在快取記憶體中的這些值當CPU需要在此儲存一些其他東西的時候會被沖刷到主記憶體。這個CPU快取有時候可能被寫到他的部分記憶體中,有的時候會沖刷他的部分記憶體。她不需要每次都去讀和寫這個完整快取。典型的,這個快取在更小的記憶體區塊中被更新稱之為「快取行」。一個或更多的快取行可能會讀取到快取記憶體中,並且一個或更多的快取行會再次刷新到主記憶體中。

在Java記憶體模型和硬體記憶體結構之間縮小差距

#如已經提到的,Java記憶體模型和硬體記憶體結構是不同的。這個硬體記憶體結構不會區分線程棧和堆。在硬體中,線程棧和堆都位於主記憶體中。執行緒棧和堆得部分可能有的時候會呈現在CPU快取中和內部的CPU暫存器中,如下圖所示:


##當物件和變量可以儲存在電腦中各種不同的記憶體區域的時候,某些問題可能會發生。主要的兩個問題是:


  • 執行緒對於共享變數更新的可見性

  • 當讀取,檢查,以及寫共享變數的競態條件

這些問題將會在下面的部分解釋到。


共享物件的可見性


如果兩個或更多的執行緒共享一個對象,沒有正確的使用volatile聲明或同步,被一個執行緒更新的共享變數可能對其它執行緒是不可見的。

想像下共享物件初始儲存在主記憶體中。運行在CPU上的一個執行緒讀取這個共享物件進入它的CPU快取中。這裡它做了一個共享物件的一個改變​​。只要CPU快取沒有刷新到主內存,這個共享物件的改變版本對於運行在其他CPU上的線程是不可見的。這種方式每一個執行緒可能都是以自己的共享物件的拷貝而結束,每一個拷貝都位於不同的CPU快取中。

下面的圖表說明了示意圖的情況。運行在左邊CPU的一個執行緒拷貝這個共享變數進入CPU緩存,並且改變他的值為2。這個改變對於運行在右邊的CPU的其他執行緒是不可見的,因為對於count的更新仍然還沒有刷回主記憶體。

為了解決這個問題,你可以使用Java的volatile關鍵字。這個關鍵字可以確保一個給予的變數直接從主記憶體讀取,並且當更新的時候直接寫會到主記憶體。

競態條件

如果兩個或更多的執行緒共享一個對象,並且不只一個執行緒更新這個共享對像中的變量,競態條件可能會發生。

想像下如果執行緒A讀取一個共享物件的count變數進入他的CPU快取。同時,線程B做相同的事情,但是進入不同的CPU快取。現在線程給count加一,並且線程B做同樣的事情。現在這個變數增加了兩次。

如果這些增量依序執行,這個count變數將會被增加兩次並且在原始值的基礎上加2寫會到主記憶體。

然後,這兩個增量沒有正確的同步,導致並發的執行。不管線程A還是線程B寫他們的更新到主記憶體中,這個更新的值只是加1,而不是加2。

這個圖表顯示了上面所描述的競態條件的問題:

#為了解決這個問題,你可以使用Java同步鎖定。一個同步鎖可以保證在任何時間內只能一個執行緒進入程式碼的臨界區域。同步鎖定也會保證所有的變數的存取都會從主記憶體中讀取,並且當執行緒離開同步程式碼區塊的時候,所有更新的變數將會再次刷回主內存,不管這個變數是否聲明為volatile。



 以上就是Java記憶體模型的詳細介紹的內容,更多相關內容請關注PHP中文網(www.php.cn)!



#

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn