1. 程式人生 > >Java並發編程(四)-- Java內存模型

Java並發編程(四)-- Java內存模型

ron 展示 共享內存模型 article oid 示意圖 緩沖 訪問共享 解決

Java內存模型

前面講到了Java線程之間的通信采用的是共享內存模型,這裏提到的共享內存模型指的就是Java內存模型(簡稱JMM),JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。

Java內存模型即Java Memory Model,簡稱JMM。JMM規範了Java 虛擬機(JVM)在計算機內存(RAM)是如何協同工作的。Java虛擬機是一個完整的計算機虛擬模型,因此這個模型自然也包含一個內存模型——稱為Java內存模型。也就是說JMM是隸屬於JVM的。原始的Java內存模型存在一些不足,因此Java內存模型在Java1.5時被重新修訂,現在的Java8仍沿用了Java1.5的版本。

如果你想設計表現良好的並發程序,理解Java內存模型是非常重要的。Java內存模型規定了如何和何時可以看到由其他線程修改過後的共享變量的值,以及在必須時如何同步的訪問共享變量。例如

技術分享圖片

從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:

  • 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  • 然後,線程B到主內存中去讀取線程A之前已更新過的共享變量。

下面通過示意圖來說明這兩個步驟:

技術分享圖片

本地內存A和B有主內存中共享變量x的副本,假設初始時,這三個內存中的x值都為0。線程A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變為了1。隨後,線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變為了1。

從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存,因為JMM通過控制主內存與每個線程的本地內存之間的交互,來為Java程序員提供內存可見性保證。

Java內存模型內部原理

Java內存模型只是一個抽象概念,那麽它在Java中具體是怎麽工作的呢?為了更好的理解上Java內存模型工作方式,下面就JVM對Java內存模型的實現、硬件內存模型及它們之間的橋接做詳細介紹。

Java內存模型把Java虛擬機內部劃分為線程棧和堆。下圖演示了Java內存模型的邏輯視圖。

技術分享圖片

每一個運行在Java虛擬機裏的線程都擁有自己的線程棧。這個線程棧包含了這個線程調用的方法當前執行點相關的信息。一個線程僅能訪問自己的線程棧。一個線程創建的本地變量對其它線程不可見,僅自己可見。即使兩個線程執行同樣的代碼,這兩個線程任然在在自己的線程棧中的代碼來創建本地變量。因此,每個線程擁有每個本地變量的獨有版本。

所有原始類型的本地變量都存放在線程棧上,因此對其它線程不可見。一個線程可能向另一個線程傳遞一個原始類型變量的拷貝,但是它不能共享這個原始類型變量自身。

堆上包含在Java程序中創建的所有對象,無論是哪一個對象創建的。這包括原始類型的對象版本。如果一個對象被創建然後賦值給一個局部變量,或者用來作為另一個對象的成員變量,這個對象任然是存放在堆上。

下面這張圖演示了調用棧和本地變量存放在線程棧上,對象存放在堆上。

技術分享圖片

一個本地變量可能是原始類型,在這種情況下,它總是“呆在”線程棧上。

一個本地變量也可能是指向一個對象的一個引用。在這種情況下,引用(這個本地變量)存放在線程棧上,但是對象本身存放在堆上。

一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量任然存放在線程棧上,即使這些方法所屬的對象存放在堆上。

一個對象的成員變量可能隨著這個對象自身存放在堆上。不管這個成員變量是原始類型還是引用類型。

靜態成員變量跟隨著類定義一起也存放在堆上。

存放在堆上的對象可以被所有持有對這個對象引用的線程訪問。當一個線程可以訪問一個對象時,它也可以訪問這個對象的成員變量。如果兩個線程同時調用同一個對象上的同一個方法,它們將會都訪問這個對象的成員變量,但是每一個線程都擁有這個本地變量的私有拷貝。如下圖:

技術分享圖片

兩個線程擁有一些列的本地變量。其中一個本地變量(Local Variable 2)執行堆上的一個共享對象(Object 3)。這兩個線程分別擁有同一個對象的不同引用。這些引用都是本地變量,因此存放在各自線程的線程棧上。這兩個不同的引用指向堆上同一個對象。

註意,這個共享對象(Object 3)持有Object2和Object4一個引用作為其成員變量(如圖中Object3指向Object2和Object4的箭頭)。通過在Object3中這些成員變量引用,這兩個線程就可以訪問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()調用methodTwo()方法。

methodOne()聲明了一個原始類型的本地變量和一個引用類型的本地變量。

每個線程執行methodOne()都會在它們對應的線程棧上創建localVariable1和localVariable2的私有拷貝。localVariable1變量彼此完全獨立,僅“生活”在每個線程的線程棧上。一個線程看不到另一個線程對它的localVariable1私有拷貝做出的修改。

每個線程執行methodOne()時也將會創建它們各自的localVariable2拷貝。然而,兩個localVariable2的不同拷貝都指向堆上的同一個對象。代碼中通過一個靜態變量設置localVariable2指向一個對象引用。僅存在一個靜態變量的一份拷貝,這份拷貝存放在堆上。因此,localVariable2的兩份拷貝都指向由MySharedObject指向的靜態變量的同一個實例。MySharedObject實例也存放在堆上。它對應於上圖中的Object3。

MySharedObject類也包含兩個成員變量。這些成員變量隨著這個對象存放在堆上。這兩個成員變量指向另外兩個Integer對象。這些Integer對象對應於上圖中的Object2和Object4.

methodTwo()創建一個名為localVariable的本地變量。這個成員變量是一個指向一個Integer對象的對象引用。這個方法設置localVariable1引用指向一個新的Integer實例。在執行methodTwo方法時,localVariable1引用將會在每個線程中存放一份拷貝。這兩個Integer對象實例化將會被存儲堆上,但是每次執行這個方法時,這個方法都會創建一個新的Integer對象,兩個線程執行這個方法將會創建兩個不同的Integer實例。methodTwo方法創建的Integer對象對應於上圖中的Object1和Object5。

還有一點,MySharedObject類中的兩個long類型的成員變量是原始類型的。因為,這些變量是成員變量,所以它們任然隨著該對象存放在堆上,僅有本地變量存放在線程棧上。

硬件內存架構

現代硬件內存模型與Java內存模型有一些不同。理解內存模型架構以及Java內存模型如何與它協同工作也是非常重要的。這部分描述了通用的硬件內存架構,下面的部分將會描述Java內存是如何與它“聯手”工作的。

技術分享圖片

一個現代計算機通常由兩個或者多個CPU。其中一些CPU還有多核。從這一點可以看出,在一個有兩個或者多個CPU的現代計算機上同時運行多個線程是可能的。每個CPU在某一時刻運行一個線程是沒有問題的。這意味著,如果你的Java程序是多線程的,在你的Java程序中每個CPU上一個線程可能同時(並發)執行。

每個CPU都包含一系列的寄存器,它們是CPU內內存的基礎。CPU在寄存器上執行操作的速度遠大於在主存上執行的速度。這是因為CPU訪問寄存器的速度遠大於主存。

每個CPU可能還有一個CPU緩存層。實際上,絕大多數的現代CPU都有一定大小的緩存層。CPU訪問緩存層的速度快於訪問主存的速度,但通常比訪問內部寄存器的速度還要慢一點。一些CPU還有多層緩存,但這些對理解Java內存模型如何和內存交互不是那麽重要。只要知道CPU中可以有一個緩存層就可以了。

一個計算機還包含一個主存。所有的CPU都可以訪問主存。主存通常比CPU中的緩存大得多。

通常情況下,當一個CPU需要讀取主存時,它會將主存的部分讀到CPU緩存中。它甚至可能將緩存中的部分內容讀到它的內部寄存器中,然後在寄存器中執行操作。當CPU需要將結果寫回到主存中去時,它會將內部寄存器的值刷新到緩存中,然後在某個時間點將值刷新回主存。

當CPU需要在緩存層存放一些東西的時候,存放在緩存中的內容通常會被刷新回主存。CPU緩存可以在某一時刻將數據局部寫到它的內存中,和在某一時刻局部刷新它的內存。它不會再某一時刻讀/寫整個緩存。通常,在一個被稱作“cache lines”的更小的內存塊中緩存被更新。一個或者多個緩存行可能被讀到緩存,一個或者多個緩存行可能再被刷新回主存。

Java內存模型和硬件內存架構之間的橋接

上面已經提到,Java內存模型與硬件內存架構之間存在差異。硬件內存架構沒有區分線程棧和堆。對於硬件,所有的線程棧和堆都分布在主內中。部分線程棧和堆可能有時候會出現在CPU緩存中和CPU內部的寄存器中。如下圖所示:

技術分享圖片

當對象和變量存儲到計算機的各個內存區域時,必然會面臨一些問題,其中最主要的兩個問題是:

  • 共享對象對各個線程的可見性
  • 共享對象的競爭現象

共享對象的可見性

當多個線程同時操作同一個共享對象時,如果沒有合理的使用volatile和synchronization關鍵字,一個線程對共享對象的更新有可能導致其它線程不可見。

想象一下我們的共享對象存儲在主存,一個CPU中的線程讀取主存數據到CPU緩存,然後對共享對象做了更改,但CPU緩存中的更改後的對象還沒有flush到主存,此時線程對共享對象的更改對其它CPU中的線程是不可見的。最終就是每個線程最終都會拷貝共享對象,而且拷貝的對象位於不同的CPU緩存中。

下圖展示了上面描述的過程。左邊CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存,把對象obj的count變量改為2。但這個變更對運行在右邊CPU中的線程不可見,因為這個更改還沒有flush到主存中:

技術分享圖片

要解決共享對象可見性這個問題,我們可以使用java volatile關鍵字。 Java’s volatile keyword. volatile 關鍵字可以保證變量會直接從主存讀取,而對變量的更新也會直接寫到主存。volatile原理是基於CPU內存屏障指令實現的,後面會講到。

競爭現象

如果多個線程共享一個對象,如果它們同時修改這個共享對象,這就產生了競爭現象。

如下圖所示,線程A和線程B共享一個對象obj。假設線程A從主存讀取Obj.count變量到自己的CPU緩存,同時,線程B也讀取了Obj.count變量到它的CPU緩存,並且這兩個線程都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU緩存中。

技術分享圖片

如果這兩個加1操作是串行執行的,那麽Obj.count變量便會在原始值上加2,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是並行的,不管是線程A還是線程B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,盡管一共有兩次加1操作。

要解決上面的問題我們可以使用java synchronized代碼塊。synchronized代碼塊可以保證同一個時刻只能有一個線程進入代碼競爭區,synchronized代碼塊也能保證代碼塊中所有變量都將會從主存中讀,當線程退出代碼塊時,對所有變量的更新將會flush到主存,不管這些變量是不是volatile類型的。

參考資料:

  http://ifeve.com/java-memory-model-6/

  http://www.infoq.com/cn/articles/java-memory-model-1

Java並發編程(四)-- Java內存模型