1. 程式人生 > >JVM之記憶體模型與執行緒

JVM之記憶體模型與執行緒

 由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體來作為記憶體與處理器之間的緩衝。
 基於高度快取的儲存互動很好地解決了處理器與記憶體的速度矛盾,但也引入了一個新的問題:快取一致性。在多處理系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體,如下圖所示。當多個處理器的運算任務都涉及同一主記憶體區域時,將可能導致各自的快取資料不一致,如果發生這種情況,就需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作。

 除了增加快取記憶體外,處理器還可能對輸入程式碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證各個語句計算的先後順序與輸入順序程式碼一致。與處理器的亂序執行優化類似,JVM中也有類似的指令重排序優化。

1、Java記憶體模型

 JVM規範定義了一種Java記憶體模型(JMM),可以遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現在各種平臺下都能達到一致的記憶體訪問效果。

1.1主記憶體與工作記憶體

 JMM規定了所有變數都儲存在主記憶體中。每條執行緒還有自己的工作記憶體,執行緒的工作記憶體儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問工作記憶體中的變數,執行緒間變數值得傳遞均需要通過主記憶體來完成,主記憶體、工作記憶體、執行緒之間的互動關係如下:

1.2記憶體間互動操作

 JMM中定義了8種操作來完成變數從主記憶體拷貝到工作記憶體及從工作記憶體同步回主記憶體之類的實現。虛擬機器實現時必須保證8種操作的每一種都是原子的、不可再分的,也就是保證每種操作必須具有原子性。

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  • unlock(解鎖):作用於主記憶體的變數,它把處於一個鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值得位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把從一個執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作。
  • write(寫入):作用於主記憶體的變數,他把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

 如果把變數從主記憶體複製到工作記憶體,就要順序的執行read與load操作。如果從工作記憶體同步回主記憶體,就要順序的執行store與write操作。注意,JMM主要求上述操作必須順序執行,但不保證連續執行。也就是說可以在這兩操作之間插入其他指令。JMM還規定了執行上述8種操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主執行緒不接受的情況出現。
  • 不允許一個執行緒弄丟它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個執行緒無原因地(沒有發生任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過assign和load操作。
  • 一個記憶體變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖(可重入鎖???)
  • 如果對一個變數執行lock操作,那就會清空工作記憶體中此變數的值,在執行引擎使用這個變數之前,需要重新執行load或assign操作初始化變數的值。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)

 由於這種定義相當嚴謹但又十分煩瑣,實踐起來很麻煩,所以就有了一個能與該定義等效判斷的原則——先行發生原則。這個原則非常重要,它是判斷資料是否存在競爭、執行緒是否安全的重要依據。
 先行發生是JMM中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,也就是說發生在操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。來看一段虛擬碼:

    //發生線上程A
    i=1;
    //發生線上程B
    j=i
    //發生線上程C
    i=2;

 根據先行發生原則,如果執行緒A的操作先行發生於執行緒B的操作,那麼就可以確定執行緒B中的j=1,但如果執行緒C加入到執行緒A與執行緒B操作之間且執行緒C與執行緒B沒有先行關係,那麼執行緒B的值是多少尼?不確定,1和2都有可能。注意:這裡的先行原則並不是時間上的先後順序,而必須是實際操作上的先後順序,因為時間上的先後順序在併發中也不靠譜。

public class MyExample {
    private int value;
    public void setValue(int value){
        this.value=value;
    }
    public int getValue(){
        return value;
    }
    public static void main(String args[]) throws InterruptedException {
        MyExample thread=new MyExample();
        new Thread(new Runnable() {
            @Override
            public void run() {
                thread.setValue(100);
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("value--->>>"+thread.getValue());
            }
        }).start();
    }
}

 上面就是一個時間上先行發生原則的示例,先在一個執行緒中給value賦值,再在另外一個執行緒中打印出這個值,這個值該是多少尼?不確定,0與100都有可能,所以時間上的先後順序是有問題的。必須要操作上的先後順序才可。

1.2對於volatile的特殊規則

volatile可以說是JVM提供的最輕量級的同步機制,但很多Java開發者都不習慣使用這個關鍵字,遇到同步問題時也是直接使用synchronize來解決,所以瞭解volation對於多執行緒操作來說很有意義。
 當一個變數定義為volation後,它將具備兩個特性。

  • 保證此變數對所有執行緒的可見性
  • 禁止指令重排序優化

 這裡的“可見性”是指當一個執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的,但這並不代表著“基於volatile變數的運算在併發下是安全的”,因為Java計算不具備原子性。下面來看一個示例。

public class MyExample {
    private volatile int value;
    public static void main(String args[]) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(10);
        MyExample example= new MyExample ();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i=0;i<10000;i++){
                        example.value++;
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
        countDownLatch.await();
        System.out.println("value--->>>" + example.value);
    }
}

 如果基於volatile變數的運算是安全的話,那麼列印的value值該是100000,但其實並不是,是一個低於100000的隨意值。這是為什麼尼?因為執行緒A對value操作後,在同步回主執行緒時候,執行緒B又對value進行了操作,這時候執行緒B中value的值比主記憶體中value的值要大,但由於基於volatile的值會立即將主記憶體的值同步給各個執行緒,所以執行緒B中的值就就會更新為主記憶體中的值,就會變小,這樣計算出來的值就不正確。也可以這麼理解,volatile寫入值不是執行緒安全的,但讀取值是執行緒安全的。

2、總結

 可以發現JMM都是圍繞著在併發過程中如何處理原子性、可見性和有序性來建立的。

  • 原子性:由JMM來直接保證的原子性變數操作包括read、load、assign、use、store和write,大致可以認為基本資料型別的訪問讀寫時具備原子性的
  • 可見性:指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改,但可見性並不代表原子性。
  • 有序性:指執行緒操作之間的先行發生原則,Java中提供了volatilesynchronize來保證執行緒之間操作的有序性。

【參考資料】
不一樣的視角:從JVM記憶體模型談執行緒安全