用volatile的視角,來打開JMM內存模型

【引言】

這一切的一切,還得從一個叫volatile的關鍵字提及..... java

在這裏插入圖片描述

【靈魂拷問開始】面試

  1. 面試官:Java併發這塊瞭解的怎麼樣?說說你對volatile關鍵字的理解?編程

  2. 面試官:能不能詳細說下什麼是內存可見性,什麼又是指令重排序呢?數組

  3. 面試官:volatile怎麼保證可見性的?多個線程之間的可見性,你能講一下底層原理是怎麼實現的嗎?緩存

  4. 面試官:volatile關鍵字是怎麼保證有序性的?安全

  5. 面試官:volatile能保證可見性和有序性,可是能保證原子性嗎?爲何?網絡

  6. 面試官:瞭解過JMM內存模型嗎?簡單的講講數據結構

這些題目,相信你認真看完此文,會有本身的理解和認識。文末見問題回答👇多線程

到這裏,個人眼裏已經是常含淚水了。不是由於我對代碼愛的深沉,而是由於我菜的真誠! 架構

在這裏插入圖片描述

沒事,不就是個破volatile嗎?別念了,我學習還不行嗎!

PS: 文章的內容是我看視頻,博客,查資料的理解。在這一塊可能不少人的理解有所不一樣,小編我尚無工做經驗,只是總結研究來學習,作個面試題的記錄。文章內容從理解到查資料學習再到畫圖寫出來,肝了挺長時間的吧。你們當作一篇麪筋來看就好,主要是回答面試問題,至於深刻到底層經過字節碼彙編等來經過代碼說明,俺還在研究中。本文只是比較淺顯的發現問題,解決問題的。不作實際的工做開發。若有不正請當即指出。


1. 多核併發緩存架構

緩存Cache設置的目的是爲了解決磁盤和CPU速度不匹配的問題。可是,對於CPU來講,Cache仍是不夠快,緩存的概念再次被擴充,不只在內存和磁盤之間也有Cache(磁盤緩存),並且在CPU和主內存之間有Cache(CPU緩存),乃至在硬盤與網絡之間也有某種意義上的Cache──稱爲Internet臨時文件夾或網絡內容緩存等。凡是位於速度相差較大的兩種硬件之間,用於協調二者數據傳輸速度差別的結構,都可稱之爲Cache。

CPU緩存

CPU緩存(Cache Memory)是位於CPU與內存之間的臨時存儲器,它的容量比內存小的多。可是交換速度卻比內存要快得多。緩存大小是CPU的重要指標之一,並且緩存的結構和大小對CPU速度的影響很是大,CPU內緩存的運行頻率極高,通常是和處理器同頻運做,工做效率遠遠大於系統內存和硬盤。

CPU緩存能夠分爲三級:

一級緩存L1

一級緩存(Level 1 Cache)簡稱L1 Cache,位於CPU內核的旁邊,是與CPU結合最爲緊密的CPU緩存。通常來講,一級緩存能夠分爲一級數據緩存(Data Cache,D-Cache)和一級指令緩存(Instruction Cache,I-Cache)

二級緩存L2

L2 Cache(二級緩存)是CPU的第二層高速緩存,份內部和外部兩種芯片。內部的芯片二級緩存運行速度與主頻相同,而外部的二級緩存則只有主頻的一半。L2高速緩存容量也會影響CPU的性能,原則是越大越好。

三級緩存L3

三級緩存是爲讀取二級緩存後未命中的數據設計的—種緩存,在擁有三級緩存的CPU中,只有約5%的數據須要從內存中調用,這進一步提升了CPU的效率。

任務管理器查看CPU緩存使用狀況:

因此說,在咱們的程序執行時,在CPU和Cache之間,是經過CPU緩存來作交互的。CPU從CPU緩衝讀取數據,CPU緩存從內存中讀取數據;CPU將計算完的數據寫回到CPU緩存中,而後CPU緩存再同步回內存中,內存再寫回到磁盤中。

JMM內存模型簡介

JMM(Java Memory Model), 是Java虛擬機平臺對開發者提供的多線程環境下的內存可見性、是否能夠重排序等問題的無關具體平臺的統一的保證。JMM定義了一個線程與主存之間的抽象關係,它就像咱們的數據結構中的邏輯結構同樣,只是概念性的東西,並非真實存在的,可是可以讓咱們更好的理解多線程的底層原理。

首先,必定要先明確一個概念:CPU的運算是很是很是快的,和其餘硬件不在一個量級上。

Java內存模型類比於上面硬件的內存模型,它是基於CPU緩存模型來構建的。

每個線程在操做共享變量的時候,都將共享變量拷貝一份到本身的工做區間中(由於若是多個線程同時在CPU中操做數據,就像CPU與內存直接交互同樣,速度很是慢),等到當前線程的CPU運算完以後,在寫回主內存。

若是此時一個共享變量發生了改變,爲了保證數據一致性,就必須馬上通知其餘線程這個共享變量的值發生了改變,讓其餘線程工做內存中的副本更新,保證拿到的數據是一致的。在這通知之間,線程之間就必然會有聯繫和溝通

就比如兩我的同時拿着同一張銀行卡到銀行取錢,卡里有100塊,一我的取了50,帳戶餘額當即就變成了50。第二我的是在這50的基礎上來取錢的,不可能還在100的基礎上取錢。

那麼,Java是怎麼保證銀行卡的餘額當即變爲50,而且是作了什麼操做來保證餘額的正確性呢?

<>

2. JMM內存模型驗證

volatile驗證內存模型

來,整一段代碼再嘮......

/** * @Author: Mr.Q * @Date: 2020-06-10 09:47 * @Description:JMM內存模型驗證--volatile保證可見性測試 */
public class VolatileVisibilityTest {

    //此處是否添加volatile,來驗證內存模型
    private static boolean initFlag = false; 

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("等待數據中....");
            while (!initFlag) {

            }
            System.out.println("--------------success-----------");
        }).start();

        Thread.sleep(3000);

        new Thread(() -> {
            prepareData();
        }).start();
    }

    public static void prepareData() {
        System.out.println("\n準備數據中....");
        initFlag = true; //此處爲第30行代碼
        System.out.println("initFlag = " + initFlag);
        System.out.println("數據準備完成!");
    }
}
複製代碼

首先,一個線程在等待數據,initFlag初始值爲false,!initFlag進入到死循環中卡在此處。

另外一個線程準備數據,將initFlag置爲true。因爲是靜態的成員共享變量,修改以後等待的線程可以感知到,此時跳出死循環,打印信息,程序運行結束。

可是,真的是這樣嗎?

咱們發現並無,此時程序依然處於死循環中,即initFlag依然爲false

咦,這是怎麼肥四呢?

在這裏插入圖片描述

單線程下跑,是沒有問題的。可這是在多線程中,問題就來了。

這也就間接驗證了JMM的存在,即每一個線程在工做時,都會將共享數據拷貝到本身的工做內存來操做。若是不是的話,此處多線程下執行也不會出現問題。

這時,那個男人,那個叫volatile的藍人,它基情滿滿的向咱們走來了!

共享變量不一致是吧?操做沒有可見性是吧?來吧,這種小事就交給我吧寶貝,麼麼噠😘

咱們想要達到這樣的效果:

private volatile static boolean initFlag = false;
複製代碼

volatile修飾initFlag變量,只要有線程作了修改,其餘線程當即能夠感知。

正確的運行結果,讓打印出成功信息。

問題是解決了。這時,面試官不厚道的笑了🙃,這場戰鬥,纔剛剛開始!

JMM內存模型8大原子操做

8大原子操做你們可能都有了解,可是具體到在底層是怎麼交互的?每一個原子操做之間的關係是怎樣的?

那麼,咱們經過上面的程序來具體作個底層原理的分析,這也是可以講清楚volatile關鍵字保證可見性最直觀的說明了!

【JMM內存模型8大原子操做】

  • read讀取: 從主內存中讀取數據

  • load載入: 將主內存讀取到的數據寫入工做內存

  • use使用: 從工做內存讀取出數據來計算

  • assign賦值: 將CPU計算出的值從新賦值到工做內存中

  • store存儲: 將工做內存中更改後的值寫入到主存

  • write寫入: 將store回去的變量賦值給主存中的變量

  • lock鎖定: 將主內存變量加鎖,標識爲線程獨佔狀態

  • unlock解鎖: 將主內存變量解鎖,解鎖後其餘線程才能再次鎖定該變量

仍是上面程序的代碼,針對上述程序出現的問題,咱們來作個深刻的分析瞭解:有圖有真相😒

咱們先來分析【線程1】:

  1. 首先,線程1將主內存中的initFlag = false read出來;

  2. 其次,將initFlag = false 拷貝一份到線程的工做內存中;

  3. 而後,CPU將線程工做內存(CPU緩存)中的數據拿到本身的寄存器中來計算。

此時,!initFlag爲真,線程1阻塞在死循環中,等待數據中......

對於【線程2】:

  1. 前三步徹底和線程1的操做同樣,每一個線程都是這麼幹的.

  2. 線程2中調用了prepareData方法使initFlag = true

  3. 而後CPU將改變後的值從新賦值到工做內存中,此時線程2的工做內存中initFlag = true

  4. 線程2的工做內存存儲了true,並準備更新回主存中

  5. 線程2執行write操做,將initFlag = true寫回到主存中

此時,主存中存放的是initFlag = true。而線程1的工做內存中任然是initFlag = false。就是線程2把initFalg改了,線程1還不知道,仍然拿的是原來的值,致使程序一直處在死循環中。

這就是程序爲何卡在了這裏的緣由!

那後來加上了volatile關鍵字,它是怎麼保證線程2改完initFlag後,線程1立馬就知道了呢?換句話來講,線程2更改完initFlag後,是怎麼讓線程1的工做內存中拷貝的副本也當即更新呢?

3. JMM緩存不一致問題

就像上面圖解的狀況同樣,JMM出現了緩存不一致新的問題,即線程2修改完initFlag以後,線程1工做內存中的副本和主存中不一致的問題。

那麼,大佬們是如何解決這個問題的呢?

8個原子操做,這不還剩lockunlock麼!他倆呀,就幹這事的!

總線加鎖

起初,是經過對數據在總線上加鎖來實現的:

一個線程在修改數據時,會加一把lock鎖到總線上。此時,其餘線程就不能再去讀取數據了,等到線程2將數據修改完寫回到主存,而後unlock釋放鎖,而後其餘線程纔可以讀取。

這樣,固然保證了其餘線程拿到了最新的數據,數據一致性獲得保證了,可是多核並行的操做,在加鎖以後變成了單核串型的了,效率低下。就這樣的速度,能叫併發嗎?這還怎麼過雙十一呀🤣!

在這裏插入圖片描述

MESI緩存一致性協議

MESI協議

多個CPU從主內存讀取同一個數據到各自的高速緩存,當其中某個CPU修改了緩存裏的數據,該數據會立刻同步回主內存,其它CPU經過總線偵聽機制能夠感知到數據的變化,從而將本身緩存裏的數據失效。

總線偵聽:

當幾個緩存共享特定數據而且處理器修改共享數據的值時,更改必須傳播到全部其餘具備數據副本的緩存中。這種變化的傳播能夠防止系統違反高速緩存一致性。能夠經過總線偵聽來完成數據更改的通知。全部偵聽器都會監視總線上的每筆交易。若是修改共享緩存塊的事務出如今總線上,則全部偵聽器都會檢查其緩存是否具備共享塊的相同副本。若是緩存具備共享塊的副本,則相應的窺探器將執行操做以確保緩存一致性。該動做能夠是刷新無效緩存塊。它還依賴於緩存一致性協議來改變緩存塊狀態。

MESI緩存一致性協議,經過對總線的偵聽機制,很好地解決了這個問題。

沒錯,硬件!就是這麼硬核且高效。

【簡單總結一下】:

總線上安裝了多個監聽器,發現有線程修改了內存中的數據,就會使其餘線程工做區間不一致的副本當即失效,而後讓他們從新並行讀取。


4. volatile可見性底層實現原理

上面講了硬件層面上的實現,那麼,軟件上是怎麼實現的呢?

有了總線監聽器,咱們能夠檢測到線程修改數據的行爲。可是,線程2修改了數據,監聽器也檢測到了,線程1是怎麼知道而且修改的呢?

咱們都知道,線程間各自工做都是獨立的,線程2修改了數據,並不會告訴線程1我修改了。數據都在內存上,你們共有的,我修改憑什麼要告訴你😒?換句話來講,他們都是經過主存來溝通交互的。

那麼,volatile關鍵字是怎麼保證修改的可見性的呢?

volatile的代碼是用更加底層的C/C++代碼來實現的

底層的實現,主要是經過彙編lock前綴指令,它會鎖定內存區域的緩存(緩存行鎖定),並寫回到主內存中。

保證可見性原理驗證

咱們對程序作反彙編,查看彙編代碼:

因爲彙編代碼比較長,雖然俺學了微機原理,但真的是看不懂😭。就挑最重要的一句摘錄出來解釋

0x0000000002c860bf:lock add dword ptr [rsp], Oh ; *putstatic initFlag 
iqqcode.jmm.VolatileVisibilityTest::prepareData@1 (line 30)
複製代碼

對應代碼爲

initFlag = true;
複製代碼

A-32架構軟件開發者手冊對lock指令的解釋:

  1. 會將當前處理器緩存行的數據當即寫回到系統內存

  2. 這個寫回內存的操做會引發在其餘CPU裏緩存了該內存地址的數據無效

就是經過lock指令,讓initFlag當即寫回內存,且讓其餘線程中的副本失效。

相比於此前在總線上加的重量級鎖,lock指令只是在會寫主內存時加了鎖,就是從store操做開始才加鎖,而此前的總線上加鎖是從read就開始了。一旦寫回,當即unlock釋放鎖。因爲CPU的讀寫是很是快的,這個過程是很是很是之短的。因此volatile是輕量級的鎖,性能高。

Q:若是不加 lock - unlock 指令會怎樣?

線程2在store到write之間,這時initFlag = true被CPU修改了值可是尚未寫回主內存,總線監聽機制發現了數檢測的據被修改,當即使線程1工做內存的副本失效,線程1再次去讀取initFlag,但此時因爲沒有加鎖而且還沒來得及修改initFlag = false這個髒數據,線程1又將initFlag = false錯誤的數據拷貝到工做內存中,仍是處於死循環中,依然會存在問題。

因此,必需要在store和write之間加上lockunlock,防止時間差帶來的誤讀。

volatile保證可見性與有序性,可是不保證證原子性,保證原則性須要藉助synchronized這樣的鎖機制


5. volatile不保證原子性

不保證原子性驗證

仍是經過代碼來講明問題:

/** * @Author: Mr.Q * @Date: 2020-06-11 11:04 * @Description:volatile不保證原子性測試 */
public class VolatileAtomicityTest {

    public static volatile int num = 0;

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

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }

        //主線程阻塞,等待線程數組中的10個線程執行完再繼續執行
        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println(num); // num <= 1000 * 10
    }
}
複製代碼

結果:num <= 10000

在這裏插入圖片描述
此時此刻,我已對併發編程的代碼完全乾懵🙂,含着淚,繼續往下學習!

按道理來講結果是10000,可是運行下極可能是個小於10000的值。

咦?volatile不是保證了可見性啊,一個線程對num的修改,另一個線程應該馬上看到啊!

但是這裏的操做num++是個複合操做,包括讀取num的值,對其自增,而後再寫回主存。

  • 假設線程1,讀取了num的值爲0,線程2恰好和線程2是同步操做,也爲num=0;

  • 他倆都對num作了+1操做,同時準備write會主內存。

  • 看誰先經過總線(包括同時經過)

  • 假設是線程1先經過。MESI會將線程2工做內存中num = 1的副本馬上置位無效,此時線程1已將num = 0 --> 1修改,num = 1

  • 線程2只能再次從新讀取num = 1,而後執行加一再回寫主內存。num = 2,可是卻執行了三次循環,此時i = 3

若是線程1和線程2同時經過,因爲他們工做內存中num均爲1,因此仍是執行了3次循環而num自增了2次

這就是num < 10000的緣由。若是沒有出現上述狀況,num = 10000

【問題解決】

1. 同步加鎖解決volatile原子性問題

第一種補救措施很簡單,就是簡單粗暴的的加鎖,這樣能夠保證給num加1這個方法是同步的,這樣每一個線程就會井井有理的運行,而保證了最終的num數和預期值一致。

2. CAS解決volatile原子性問題

針對num++這類複合類的操做,可使用JUC併發包中的原子操做類,原子操做類是經過循環CAS的方式來保證其原子性的。

AtomicInteger這是個基於CAS的無鎖技術,它的主要原理就是經過比較預期值和實際值,當其沒有異常的之後,就進行增值操做。incrementAndGet這個方法實際上每次對num進行+1的過程都進行了比較,存在一個retry的過程。它在多線程處理中能夠防止這種屢次遞增而引起的線程不安全的問題


6. volatile保證有序性

volatile保證有序性,就是禁止編譯器在編譯階段對指令的重排序問題。

volatile禁止指令重排序

public class VolatileSeriaTest {

    private static int a = 0, b = 0; //此處a,b變量是否添加volatile來修飾

    public static void main(String[] args) throws InterruptedException {
        Set<String> set = new HashSet<>();
        Map<String,Integer> map = new HashMap<>();

        for (int i = 0; i < 1000000; i++) {
            a = 0;
            b = 0;
            map.clear();

            Thread one = new Thread(() -> {
                b = 1;
                int x = a;
                map.put("x", x);
            });

            Thread two = new Thread(() -> {
                a = 1;
                int y = b;
                map.put("y", y);
            });

            one.start();
            two.start();

            one.join();
            two.join();

            set.add("x=" + map.get("x") + "," + "y=" + map.get("y"));
            System.out.println(set + " --> i = " + i);
        }
    }
}
複製代碼

咱們能夠看到,程序一共跑出了四種狀況:

這三種狀況,咱們很容易想到

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-3rI0io9S-1592044179981)(iqqcode-blog.oss-cn-beijing.aliyuncs.com/img/2020061…)]

可是出現了x=0y=0就不正常了,緣由就是編譯器對程序做了指令重排序

當兩個線程以

  • x = a;

  • a = 1;

  • y = b;

  • b = 1;

順序來執行,就會出現x=0y=0這種特殊狀況,這是單線程下現象不到的情景。

CPU指令重排序的定義爲:CPU容許在某些條件下進行指令重排序,僅需保證重排序後單線程下的語義一致

保證的是單線程下的語義一致,多線程時是不保證的,因此就須要volatile來禁止指令重排序了。

那究竟是怎麼禁止的呢?

這裏只是簡單的說明問題,深刻的源碼分析研究,你們看看源碼查查資料吧。

這個涉及到內存屏障(Memory Barrier)

內存屏障簡介

內存屏障有兩個能力:

  1. 就像一套柵欄分割先後的代碼,阻止柵欄先後的沒有數據依賴性的代碼進行指令重排序,保證程序在必定程度上的有序性。

  2. 強制把寫緩衝區/高速緩存中的髒數據等寫回主內存,讓緩存中相應的數據失效,保證數據的可見性。

首先,指令並非代碼行,指令是原子的,經過javap命令能夠看到一行代碼編譯出來的指令,固然,像int i=1;這樣的代碼行也是原子操做。

在單例模式中,Instance ins = new Instance(); 就不是原子操做,它能夠分紅三步原子指令:

  1. 分配內存地址;

  2. new一個Instance對象;

  3. 將內存地址賦值給ins;

CPU爲了提升執行效率,這三步操做的順序能夠是123,也能夠是132。

若是是132順序的話,當把內存地址賦給inst後,ins指向的內存地址尚未new出來單例對象,這時候,若是拿到ins的話,其實就是空的,會報空指針異常。

這就是爲何雙重檢查單例模式(DCL) 中,單例對象要加上volatile關鍵字。

內存屏障有三種類型和一種僞類型:

  • lfence:即讀屏障(Load Barrier),在讀指令前插入讀屏障,可讓高速緩存中的數據失效,從新從主內存加載數據,以保證讀取的是最新的數據。

  • sfence:即寫屏障(Store Barrier),在寫指令以後插入寫屏障,能讓寫入緩存的最新數據寫回到主內存,以保證寫入的數據馬上對其餘線程可見。

  • mfence,即全能屏障,具有ifence和sfence的能力。

  • Lock前綴:Lock不是一種內存屏障,可是它能完成相似全能型內存屏障的功能。

volatile會給代碼添加一個內存屏障,指令重排序的時候不會把後面的指令重排序到屏障的位置以前

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-FssR5nnj-1592044179982)(iqqcode-blog.oss-cn-beijing.aliyuncs.com/img/2020061…)]

PS😐:只有一個CPU的時候,這種內存屏障是多餘的。只有多個CPUI訪問同一塊內存的時候,就須要內存屏障了。

JMM的Happens-Before原則

Happens-Before 是java內存模型中的語義規範,來闡述操做之間內存的可見性,能夠確保一條語句的全部「寫內存」操做對另外一條語句是可見的。

Happens-Before原則以下:

  1. 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操做先行發生於書寫在後 面的操做;

  2. 鎖定規則:一個unLock操做先行發生於後面對同一個鎖額lock操做;

  3. volatile變量規則:對一個變量的寫操做先行發生於後面對這個變量的讀操做;

  4. 傳遞規則:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C;

  5. 線程啓動規則:Thread對象的start()方法先行發生於此線程的每一個一個動做;

  6. 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;

  7. 線程終結規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;

  8. 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始;

以上的happens-before原則爲volatile關鍵字的可見性提供了強制保證。

併發編程三大特性:

  1. 可見性

  2. 原子性

  3. 有序性

併發三特性總結

特性 volatile synchronized Lock Atomic
原子性 沒法保障 能夠保障 能夠保障 能夠保障
可見性 能夠保障 能夠保障 能夠保障 能夠保障
有序性 能夠保障 能夠保障 能夠保障 沒法保障

爲了文章的可讀性,開篇面試題目的相關回答放到了這篇文章來解答.

戳👉知道這些,面試時volatile就穩了


【文章參考】

  1. CPU緩存 - 搜狗百科

  2. 緩存

  3. 面試官最愛的volatile關鍵字,你答對了嗎?

  4. Java指令重排序與volatile關鍵字

  5. Java Volatile關鍵字【公衆號:併發編程網】

  6. volatile的原理分析

相關文章
相關標籤/搜索