Java併發編程:JMM (Java內存模型) 以及與volatile關鍵字詳解

計算機系統的一致性

在現代計算機操做系統中,多任務處理幾乎是一項必備的功能,由於嵌入了多核處理器,計算機系統真正作到了同一時間執行若干個任務,是名副其實的多核系統。在多核系統中,爲了提高CPU與內存的交互效率,通常都設置了一層 「高速緩存區」 做爲內存與處理器之間的緩衝,使得CPU在運算的過程當中直接從高速緩存區讀取數據,必定程度上解決了性能的問題。可是,這樣也帶來了一個新問題,就是「緩存一致性」的問題。好比,多核的狀況下,每一個處理器都有本身的緩存區,數據如何保持一致性。針對這個問題,現代的計算機系統引入多處理器的數據一致性的協議,包括MOSI、Synapse、Firely、DragonProtocol等。java

當處理器經過高速緩存區與主內存發生交互時,對數據的讀寫必須遵循協議規定的標準,用一張關係圖表示的話大概以下:編程

而Java的內存模型 (JMM) 能夠說與硬件的一致性模型很類似,採用的是共享內存的線程通訊機制。

Java內存模型

Java內存模型規定了全部的變量都存儲在主內存中,每一個線程擁有本身的工做內存,工做內存中保存了被該線程使用的變量的主內存副本拷貝,線程只能操做本身工做內存的變量副本,操做完變量後會更新到主內存,經過主內存來完成與其餘線程間變量值的傳遞。此模型的交互關係以下圖所示:緩存

然而,Java的內存模型只是反映了虛擬機內部的線程處理機制,並不保證程序自己的併發安全性。

舉一個例子,在程序中對一個共享變量作自增操做:安全

i++;
複製代碼

假設初始化的時候i=0,當跑到此程序時,線程首先從主內存讀取i的值,而後複製到本身的工做內存,進行i++操做,最後將操做後的結果從工做內存複製到主內存中。若是是兩個線程執行i++的程序,預期的結果是2。但真的是這樣嗎?答案是否認的。bash

假設線程1讀取主內存的i=0,複製到本身的工做內存,在進行i++的操做後還沒來得及更新到主內存,這時線程2也讀取i=0,作了一樣的操做,那麼最終獲得的結果爲1,而不是2。多線程

這是典型的關於多線程併發安全例子,也是Java併發編程中最值得探討的話題之一,通常來講,處理這種問題有兩種手段:併發

  • 加鎖,好比同步代碼塊的方式。保證同一時間只能有一個線程能執行i++這條程序。
  • 利用線程間的通訊,好比使用對象的wait和notify方法來。

由於本文主要是探究 JMM 和 volatile 關鍵字的知識,具體怎麼實現併發處理就不作深刻探討了,改天看看抽個時間再寫篇博文專門講解好了。ide

內存模型的3個重要特徵

初步瞭解完什麼是JMM後,咱們來進一步瞭解它的重要特徵。值得說明的是,在Java多線程開發中,遵循着三個基本特性,分別是原子性、可見性和有序性,而Java的內存模型正是圍繞着在併發過程當中如何處理這三個特徵創建的。函數

原子性

原子性是指操做是原子性的,不可中斷的。舉個例子:高併發

String s="abc";
複製代碼

這個操做是直接賦值,是原子性操做。而相似下面這段代碼就不是原子性了:

i++;
複製代碼

當執行i++時,須要先獲取i的值,而後再執行i+1,至關於包含了兩個操做,因此不是原子性。

可見性

可見性是指共享數據的時候,一個線程修改了數據,其餘線程知道數據被修改,會從新讀取最新的主存的數據。就像前面說的兩個線程處理i++的問題,線程1改完後沒有更新到主內存,因此線程2是不知道的。

有序性

是指代碼執行的有序性,對於一個線程執行的代碼,咱們能夠認爲代碼是依次執行的,但併發中可能就會出現亂序,由於代碼有可能發生指令重排序(Instruction Reorder),重排後的指令與原指令的順序未必一致。

指令重排序

編譯器可以自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。是爲指令的重排序,尤爲是併發的狀況下。

java提供了volatile和synchronized來保證線程之間操做的有序性。volatile含有禁止指令重排序的語義(即它的第二個語義),synchronized規定一個變量在同一時刻只容許一條線程對其lock操做,也就是說同一個鎖的兩個同步塊只能串行進入。禁止了指令的重排序。

volatile關鍵字

說到了volatile,咱們就有必要了解一下這個關鍵字是作什麼的。

準確來講,volatile是java提供的輕量的同步機制。它有兩個特性:

  1. 保證修飾的變量對全部線程的可見性。
  2. 禁止指令的重排序優化。

保證可見性和防止指令重排

簡單寫段代碼說明一下:

public class VolatileDemo {
    
    private static boolean isReady;
    private static int number;
    
    private static class ReaderThread extends Thread{
        @Override
        public void run() {
            while (!isReady);
            System.out.println("number = "+number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        try {
            Thread.sleep(1000);
            number = 42;
            isReady = true;
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製代碼

在上面的代碼中,ReaderThread只有在isReady 爲 true 時纔會打印出 number 的值,然而,真實的狀況有多是打印不出來(可能性比較小,但仍是有),由於線程ReaderThread線程沒法看到主線程中對isReady的修改,致使while循環永遠沒法退出,同時,由於有可能發生指令重排,致使下面的代碼不能按順序執行:

number = 42;
isReady = true;
複製代碼

也就是能打印的話,number值多是0,不是42。若是在變量加上volatile關鍵字,告訴Java虛擬機這兩個變量可能會被不一樣的線程修改,那麼就能夠防止上述兩種不正常的狀況的發生。

不能保證原子性

volatile能保證可見性和有序性,但沒法保證原子性,好比下面的例子:

public class VolatileDemo {

    public static volatile int i = 0;

    public static void increase() {
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo test = new VolatileDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    test.increase();
            }).start();
        }      
        Thread.sleep(1000);
        System.out.println(test.i);
    }
}
複製代碼

正常狀況下,咱們指望上面的main函數執行完後輸出的結果是10000,但你會發現,結果老是會小於10000,由於increase()方法中的i++操做不是原子性的,分紅了讀和寫兩個操做。假設當線程1讀取了 i 的值,尚未修改,線程2這時也進行了讀取。而後,線程1修改完了,通知線程2從新讀取 i 的值,可這時它不須要讀取 i,它仍執行寫操做,而後賦值給主線程,這時數據就會出現問題。

因此,通常針對共享變量的讀寫操做,仍是須要用鎖來保證結果,例如加上 synchronized關鍵字。

參考:

《Java高併發程序設計》

《深刻理解Java虛擬機》

相關文章
相關標籤/搜索