java內存模型(JMM)和happens-before

java內存模型(JMM)和happens-beforejava

咱們知道java程序是運行在JVM中的,而JVM就是構建在內存上的虛擬機,那麼內存模型JMM是作什麼用的呢?git

咱們考慮一個簡單的賦值問題:github

int a=100;

JMM考慮的就是什麼狀況下讀取變量a的線程能夠看到值爲100。看起來這是一個很簡單的問題,賦值以後不就能夠讀到值了嗎? 數組

可是上面的只是咱們源碼的編寫順序,當把源碼編譯以後,在編譯器中生成的指令的順序跟源碼的順序並非徹底一致的。處理器可能採用亂序或者並行的方式來執行指令(在JVM中只要程序的最終執行結果和在嚴格串行環境中執行結果一致,這種重排序是容許的)。而且處理器還有本地緩存,當將結果存儲在本地緩存中,其餘線程是沒法看到結果的。除此以外緩存提交到主內存的順序也肯能會變化。緩存

上面提到的種種可能都會致使在多線程環境中產生不一樣的結果。在多線程環境中,大部分時間多線程都是在執行各自的任務,只有在多個線程須要共享數據的時候,才須要協調線程之間的操做。安全

而JMM就是JVM中必須遵照的一組最小保證,它規定了對於變量的寫入操做在何時對其餘線程是可見的。多線程

重排序

上面講了JVM中的重排序,這裏咱們舉個例子,以便你們對重排序有一個更深刻的理解:併發

@Slf4j
public class Reorder {

    int x=0, y=0;
    int a=0, b=0;

    private  void reorderMethod() throws InterruptedException {

        Thread one = new Thread(()->{
            a=1;
            x=b;
        });

        Thread two = new Thread(()->{
            b=1;
            y=a;
        });
        one.start();
        two.start();
        one.join();
        two.join();
        log.info("{},{}", x, y);
    }

    public static void main(String[] args) throws InterruptedException {

        for (int i=0; i< 100; i++){
            new Reorder().reorderMethod();
        }
    }
}

上面的例子是一個很簡單的併發程序。因爲咱們沒有使用同步限制,因此線程one和two的執行順序是不定的。有可能one在two以前執行,也有可能在two以後執行,也可能二者同時執行。不一樣的執行順序可能會致使不一樣的輸出結果。app

同時雖然咱們在代碼中指定了先執行a=1, 再執行x=b,可是這兩條語句其實是沒有關係的,在JVM中徹底可能將兩條語句重排序成x=b在前,a=1在後,從而致使輸出更多意想不到的結果。函數

Happens-Before

爲了保證java內存模型中的操做順序,JMM爲程序中的全部操做定義了一個順序關係,這個順序叫作Happens-Before。要想保證操做B看到操做A的結果,無論A和B是在同一線程仍是不一樣線程,那麼A和B必須知足Happens-Before的關係。若是兩個操做不知足happens-before的關係,那麼JVM能夠對他們任意重排序。

咱們看一下happens-before的規則:

  1. 程序順序規則: 若是在程序中操做A在操做B以前,那麼在同一個線程中操做A將會在操做B以前執行。
注意,這裏的操做A在操做B以前執行是指在單線程環境中,雖然虛擬機會對相應的指令進行重排序,可是最終的執行結果跟按照代碼順序執行是同樣的。虛擬機只會對不存在依賴的代碼進行重排序。
  1. 監視器鎖規則: 監視器上的解鎖操做必須在同一個監視器上面的加鎖操做以前執行。
鎖咱們你們都很清楚了,這裏的順序必須指的是同一個鎖,若是是在不一樣的鎖上面,那麼其執行順序也不能獲得保證。
  1. volatile變量規則: 對volatile變量的寫入操做必須在對該變量的讀操做以前執行。
原子變量和volatile變量在讀寫操做上面有着相同的語義。
  1. 線程啓動規則: 線程上對Thread.start的操做必需要在該線程中執行任何操做以前執行。
  2. 線程結束規則: 線程中的任何操做都必須在其餘線程檢測到該線程結束以前執行。
  3. 中斷規則: 當一個線程再另外一個線程上調用interrupt時,必須在被中斷線程檢測到interrupt調用以前執行。
  4. 終結器規則: 對象的構造函數必須在啓動該對象的終結器以前執行完畢。
  5. 傳遞性: 若是操做A在操做B以前執行,而且操做B在操做C以前執行,那麼操做A必須在操做C以前執行。

上面的規則2很好理解,在加鎖的過程當中,不容許其餘的線程得到該鎖,也意味着其餘的線程必須等待鎖釋放以後才能加鎖和執行其業務邏輯。

4,5,6,7規則也很好理解,只有開始,才能結束。這符合咱們對程序的通常認識。

8的傳遞性相信學過數學的人應該也不難理解。

接下來咱們重點討論一下規則3和規則1的結合。討論以前咱們再總結一下happens-before究竟是作什麼的。

由於JVM會對接收到的指令進行重排序,爲了保證指令的執行順序,咱們纔有了happens-before規則。上面講到的2,3,4,5,6,7規則能夠看作是重排序的節點,這些節點是不容許重排序的,只有在這些節點之間的指令才容許重排序。

結合規則1程序順序規則,咱們獲得其真正的含義:代碼中寫在重排序節點以前的指令,必定會在重排序節點執行以前執行。

重排序節點就是一個分界點,它的位置是不可以移動的。看一下下面的直觀例子:

線程1中有兩個指令:set i=1, set volatile a=2。
線程2中也有兩個指令:get volatile a, get i。

按照上面的理論,set和get volatile是兩個重排序節點,set必須排在get以前。而依據規則1,代碼中set i=1 在set volatile a=2以前,由於set volatile是重排序節點,因此須要遵照程序順序執行規則,從而set i=1要在set volatile a=2以前執行。一樣的道理get volatile a在get i以前執行。最後致使i=1在get i以前執行。

這個操做叫作藉助同步。

安全發佈

咱們常常會用到單例模式來建立一個單的對象,咱們看下下面的方法有什麼不妥:

public class Book {

    private static Book book;

    public static Book getBook(){
        if(book==null){
            book = new Book();
        }
        return book;
    }
}

上面的類中定義了一個getBook方法來返回一個新的book對象,返回對象以前,咱們先判斷了book是否爲空,若是不爲空的話就new一個book對象。

初看起來,好像沒什麼問題,可是若是仔細考慮JMM的重排規則,就會發現問題所在。
book=new Book()其實一個複雜的命令,並非原子性操做。它大概能夠分解爲1.分配內存,2.實例化對象,3.將對象和內存地址創建關聯。

其中2和3有可能會被重排序,而後就有可能出現book返回了,可是尚未初始化完畢的狀況。從而出現不能夠預見的錯誤。

根據上面咱們講到的happens-before規則, 最簡單的辦法就是給方法前面加上synchronized關鍵字:

public class Book {

    private static Book book;

    public synchronized static Book getBook(){
        if(book==null){
            book = new Book();
        }
        return book;
    }
}

咱們再看下面一種靜態域的實現:

public class BookStatic {
    private static BookStatic bookStatic= new BookStatic();

    public static BookStatic getBookStatic(){
        return bookStatic;
    }
}

JVM在類被加載以後和被線程使用以前,會進行靜態初始化,而在這個初始化階段將會得到一個鎖,從而保證在靜態初始化階段內存寫入操做將對全部的線程可見。

上面的例子定義了static變量,在靜態初始化階段將會被實例化。這種方式叫作提早初始化。

下面咱們再看一個延遲初始化佔位類的模式:

public class BookStaticLazy {

    private static class BookStaticHolder{
        private static BookStaticLazy bookStatic= new BookStaticLazy();
    }

    public static BookStaticLazy getBookStatic(){
        return BookStaticHolder.bookStatic;
    }
}

上面的類中,只有在調用getBookStatic方法的時候纔會去初始化類。

接下來咱們再介紹一下雙重檢查加鎖。

public class BookDLC {
    private volatile static BookDLC bookDLC;

    public static BookDLC getBookDLC(){
        if(bookDLC == null ){
            synchronized (BookDLC.class){
                if(bookDLC ==null){
                    bookDLC=new BookDLC();
                }
            }
        }
        return bookDLC;
    }
}

上面的類中檢測了兩次bookDLC的值,只有bookDLC爲空的時候才進行加鎖操做。看起來一切都很完美,可是咱們要注意一點,這裏bookDLC必定要是volatile。

由於bookDLC的賦值操做和返回操做並無happens-before,因此可能會出現獲取到一個僅部分構造的實例。這也是爲何咱們要加上volatile關鍵詞。

初始化安全性

本文的最後,咱們將討論一下在構造函數中含有final域的對象初始化。

對於正確構造的對象,初始化對象保證了全部的線程都可以正確的看到由構造函數爲對象給各個final域設置的正確值,包括final域能夠到達的任何變量(好比final數組中的元素,final的hashMap等)。

public class FinalSafe {
    private final HashMap<String,String> hashMap;

    public FinalSafe(){
        hashMap= new HashMap<>();
        hashMap.put("key1","value1");
    }
}

上面的例子中,咱們定義了一個final對象,而且在構造函數中初始化了這個對象。那麼這個final對象是將不會跟構造函數以後的其餘操做重排序。

本文的例子能夠參考https://github.com/ddean2009/learn-java-concurrency/tree/master/reorder

更多內容請訪問 flydean的博客

相關文章
相關標籤/搜索