你真的瞭解JMM嗎?

引言

在現代計算機中,cpu的指令速度遠超內存的存取速度,因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存(Cache)來做爲內存與處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。java

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,可是也爲計算機系統帶來更高的複雜度,由於它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每一個處理器都有本身的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致,舉例說明變量在多個CPU之間的共享。若是真的發生這種狀況,那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。編程

1、JMM(Java Memory Model)

java虛擬機規範定義java內存模型屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓java程序在各類平臺下都能達到一致的併發效果。緩存

java內存模型規定了一個線程如何和什麼時候能夠看到由其餘線程修改事後的共享變量的值,以及在必須時如何同步的訪問共享變量安全

注意:咱們這裏強調的是共享變量,不是私有變量。markdown

java內存模型規定了全部的變量都存儲在主內存中(JVM內存的一部分)。每條線程都有本身的工做內存,工做內存中保存了該線程使用的主內存中共享變量的副本,線程對變量的全部操做(讀取、賦值等)都必須在工做內存中進行,而不能直接讀寫主內存中的變量;工做內存在線程間是隔離的,不能直接訪問對方工做內存中的變量。因此在多線程操做共享變量時,就經過JMM來進行控制。多線程

咱們來看一看線程,工做內存、主內存三者的交互關係圖。併發

2、JMM的8種內存交互操做

9龍就疑問,JMM是如何保證併發下數據的一致性呢?app

內存交互操做有8種,虛擬機實現必須保證每個操做都是原子的,不可再分的(對於double和long類型的變量來講,load、store、read和write操做在某些平臺上容許例外)函數

  • lock (鎖定):做用於主內存的變量,把一個變量標識爲線程獨佔狀態。
  • read (讀取):做用於主內存變量,它把一個變量的值從主內存傳輸到線程的工做內存中,以便隨後的load動做使用。
  • load (載入):做用於工做內存的變量,它把read操做從主存中獲得變量放入工做內存的變量副本中。
  • use (使用):做用於工做內存中的變量,它把工做內存中的變量傳輸給執行引擎,每當虛擬機遇到一個須要使用到變量的值的字節碼指令時將會執行這個操做。
  • assign (賦值):做用於工做內存中的變量,它把一個從執行引擎中接受到的值賦值給工做內存的變量副本中,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操做。
  • store (存儲):做用於工做內存中的變量,它把一個從工做內存中一個變量的值傳送到主內存中,以便後續的write使用。
  • write  (寫入):做用於主內存中的變量,它把store操做從工做內存中獲得的變量的值放入主內存的變量中。
  • unlock (解鎖):做用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定。

若是是將變量從主內存複製到工做內存,必須先執行read,後執行load操做;若是是將變量從工做內存同步到主內存,必須先執行store,後執行write。JMM要求read和load, store和write必須按順序執行,但不是必須連續執行,中間能夠插入其餘的操做。性能

2.一、JMM指令使用規則
  • 不容許read和load、store和write操做之一單獨出現。即便用了read必須load,使用了store必須write
  • 不容許線程丟棄他最近的assign操做,即工做變量的數據改變了以後,必須告知主存
  • 不容許一個線程將沒有assign的數據從工做內存同步回主內存
  • 一個新的變量必須在主內存中誕生,不容許工做內存直接使用一個未被初始化的變量。就是對變量實施use、store操做以前,必須通過assign和load操做
  • 一個變量同一時間只有一個線程能對其進行lock。屢次lock後,必須執行相同次數的unlock才能解鎖
  • 若是對一個變量進行lock操做,會清空全部工做內存中此變量的值,在執行引擎使用這個變量前,必須從新load或assign操做初始化變量的值
  • 若是一個變量沒有被lock,就不能對其進行unlock操做。也不能unlock一個被其餘線程鎖住的變量
  • 對一個變量進行unlock操做以前,必須把此變量同步回主內存

3、volatile

不少併發編程中都使用了volatile,你知道爲何一個變量要使用volatile修飾嗎?

volatile有兩個語義:

  1. volatile能夠保證線程間變量的可見性。
  2. volatile禁止CPU進行指令重排序。

volatile修飾的變量,若是某個線程更改了變量值,其餘線程能夠當即觀察到這個值。而普通變量不能作到這一點,變量值在線程間傳遞均須要主內存來完成。若是線程修改了普通變量值,則須要刷新回主內存,另外一個線程須要從主內存從新讀取才能知道最新值。

3.一、volatile只能保證可見性,不能保證原子性

雖然volatile只能保證可見性,但不能認爲volatile修飾的變量能夠在併發下是線程安全的。

public class VolatileTest {
    /**
     * 進行自增操做的變量
     * 使用volatile修飾
     */

    private static volatile int count;

    public static void main(String[] args) {
        int threadNums = 2000;
        ExecutorService service = Executors.newCachedThreadPool();
        for (int i = 0; i < threadNums; i++) {
            service.execute(VolatileTest::addCount);
        }
        System.out.println(count);
        service.shutdown();
    }

    private static void addCount() {
        count++;
    }
}
//輸出結果
//1994
複製代碼

咱們能夠從例子中看出,共享變量使用了volatile修飾,啓動2000個線程對其進行自增操做,若是是線程安全的,結果應該是2000;但結果卻小於2000。證實volatile修飾的變量並不能保證原子性,若是想保證原子性,還須要額外加鎖。

3.二、volatile禁止指令重排序

雖然程序從表象上看到是按照咱們書寫的順序進行執行,但因爲CPU可能會因爲性能緣由,對執行指令進行重排序,以此提升性能。

好比咱們有一個方法是關於「談戀愛」的方法。僞代碼以下

{
    //線程A執行1,2,3
    //一、先認識某個女生,有好感
    //二、開展追求
    //三、追求成功

    //線程B,等待線程A追求成功後開始進入甜蜜的愛情
    while(!追求成功){
        sleep();
    }
    //一塊兒看電影,吃飯,牽手,接吻,xxx
}
複製代碼

咱們看到線程A須要執行3步,因爲cpu執行重排序優化,可能執行順序變爲一、三、2,亂套了,剛認識別人就成功了,接着就牽手,接吻,而後可能再執行追求的過程。。。。。。。。不敢想象,我還只是個孩子啊。這就是指令重排序可能在多線程環境下出現的問題。

若是咱們使用volatile修飾「追求成功」的變量,則能夠禁止CPU進行指令重排序,讓談戀愛是一件輕鬆而快樂的事情。

volatile使用內存屏障來禁止指令重排序。
在每一個volatile寫操做的前面插入一個StoreStore屏障,在每一個volatile寫操做的後面插入一個StoreLoad屏障。

在每一個volatile讀操做的後面插入一個LoadLoad屏障,在每一個volatile讀操做的後面插入一個LoadStore屏障。

4、原子性、可見性、順序性

咱們看到JMM圍繞這三個特徵來創建的。

4.一、原子性

JMM提供了read、load、use、assign、store、write六個指令直接提供原子操做,咱們能夠認爲java的基本變量的讀寫操做是原子的(long,double除外,由於有些虛擬機能夠將64位分爲高32位,低32位分開運算)。對於lock、unlock,虛擬機沒有將操做直接開放給用戶使用,但提供了更高層次的字節碼指令,monitorentermmonitorexit來隱式使用這兩個操做,對應於java的synchronized關鍵字,所以synchronized塊之間的操做也具備原子性

4.二、可見性

咱們上面說了線程之間的變量是隔離的,線程拿到的是主存變量的副本,更改變量,須要刷新回主存,其餘線程須要從主存從新獲取才能拿到變動的值。全部變量都要通過這個過程,包括被volatile修飾的變量;但volatile修飾的變量,能夠在修改後強制刷新到主存,並在使用時從主存獲取刷新,普通變量則不行。

除了volatile修飾的變量,synchronized和final。synchronized在執行完畢後,進行unlock以前,必須將共享變量同步回主內存中(執行store和write操做)。前面規則其中一條。

而final修飾的字段,只要在構造函數中一旦初始化完成,而且沒有對象逃逸(指對象爲初始化完成就能夠被別的線程使用),那麼在其餘線程中就能夠看到final字段的值。

4.三、有序性

有序性在volatile已經詳細說明了。能夠總結爲,在本線程觀察到的結果,全部操做都是有序的;若是多線程環境下,一個線程觀察到另外一個線程的操做,就說雜亂無序的。

java提供了volatile和synchronized兩個關鍵字保證線程之間的有序性,volatile使用內存屏障,而synchronized基於lock以後,必須unlock後,其餘線程才能從新lock的規則,讓同步塊在在多線程間串行執行。

5、Happends-Before原則

先行發生是java內存模型中定義的兩個操做的順序,若是說操做A先行發生於線程B,就是說在發生操做B以前,操做A產生的影響能被操做B觀察到,「影響」包括修改了內存中共享變量的值,發送了消息,調用了方法等。

咱們舉個例子說一下。

//線程A執行
i = 1
//線程B執行
j = i
//線程C執行
i = 2
複製代碼

咱們仍是定義A線程執行 i = 1 先行發生於 線程B執行的 j = i;那麼咱們能夠肯定,在線程B執行以後,j的值是1。由於根據先行發生原則,線程A執行以後,i的值爲1,能夠被B觀察到;而且線程A執行以後,線程B執行以前,沒有線程對i的值進行變動。

這時候咱們考慮線程C,若是咱們仍是保證線程A先行發生於B,但線程C出如今A與B之間,那麼,你能夠肯定j的值是多少嗎?答案是否認的。由於線程C的結果也可能被B觀察到,這時候多是1,也多是2。這就存在線程安全問題。

在JMM下具備一些自然的先行發生關係,這些原則在無須任何同步協助下就已經存在,能夠直接使用。若是兩個操做之間的關係不在此列,而且沒法從如下先行發生原則推導出來,它們就沒有順序性保證,虛擬機就會進行隨意的重排序。

  • 程序次序規則(Program Order Rule):在一個線程內,程序的執行規則跟程序的書寫規則是一致的,從上往下執行。

  • 鎖定規則(Monitor Lock Rule):一個Unlock的操做確定先於下一次Lock的操做。這裏必須是同一個鎖。同理咱們能夠認爲在synchronized同步同一個鎖的時候,鎖內先行執行的代碼,對後續同步該鎖的線程來講是徹底可見的。

  • volatile變量規則(volatile Variable Rule):對同一個volatile的變量,先行發生的寫操做,確定早於後續發生的讀操做

  • 線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每個動做

  • 線程終止規則(Thread Termination Rule):Thread對象的停止檢測(如:Thread.join(),Thread.isAlive()等)操做,必晚於線程中全部操做

  • 線程中斷規則(Thread Interruption Rule):對線程的interruption()調用,先於被調用的線程檢測中斷事件(Thread.interrupted())的發生

  • 對象終止規則(Finalizer Rule):一個對象的初始化方法先於執行它的finalize()方法

  • 傳遞性(Transitivity):若是操做A先於操做B、操做B先於操做C,則操做A先於操做C

總結

本篇詳細總結了Java內存模型。再來品一品這句話。

java內存模型規定了一個線程如何和什麼時候能夠看到由其餘線程修改事後的共享變量的值,以及在必須時如何同步的訪問共享變量

各位看官,若是以爲9龍的文章對你有幫助,求點贊,求關注。若是轉載請註明出處。

本篇主要總結於:

深刻理解Java虛擬機++JVM高級特性與最佳實踐

相關文章
相關標籤/搜索