Java內(nèi)存模型-volatile的應(yīng)用(實(shí)例講解)

前言

1、并發(fā)編程三要素

????在并發(fā)編程的世界里,下面三要素你必須清楚:

  • 可見(jiàn)性:可見(jiàn)性指多個(gè)線程操作一個(gè)共享變量時(shí),其中一個(gè)線程對(duì)變量進(jìn)行修改后,其他的線程可以立即看到修改的結(jié)果。
  • 原子性:原子性指的是一個(gè)或多個(gè)操作,要么全部執(zhí)行,并且執(zhí)行過(guò)程中不被其它操作打斷,要么就全部不執(zhí)行。
  • 有序性:程序的執(zhí)行順序按照代碼的先后順序來(lái)執(zhí)行。



2、并發(fā)編程的三大問(wèn)題

????我們知道CPU、內(nèi)存、IO三者的速度存在很大的差異,為了平衡三者之間的速度差異,做了如下改變:

  • CPU 增加了緩存,以均衡與內(nèi)存的速度差異;
  • 操作系統(tǒng)增加了進(jìn)程、線程,以分時(shí)復(fù)用 CPU,進(jìn)而均衡 CPU 與 I/O 設(shè)備的速度差異;
  • 編譯程序優(yōu)化指令執(zhí)行次序,使得緩存能夠得到更加合理地利用。
    ????為了使處理器內(nèi)部運(yùn)算單元盡可能被充分利用,處理器還會(huì)對(duì)輸入的代碼進(jìn)行亂序執(zhí)行(Out-Of-Order Execution)優(yōu)化,處理器會(huì)在亂序執(zhí)行之后的結(jié)果進(jìn)行重組,保證結(jié)果的正確性,也就是保證結(jié)果與順序執(zhí)行的結(jié)果一致。但是在真正的執(zhí)行過(guò)程中,代碼執(zhí)行的順序并不一定按照代碼的書(shū)寫(xiě)順序來(lái)執(zhí)行,可能和代碼的書(shū)寫(xiě)順序不同。

????但這些改變可能導(dǎo)致詭異性的bug:

  • 緩存導(dǎo)致可見(jiàn)性問(wèn)題;
  • 編譯優(yōu)化帶來(lái)的有序性問(wèn)題;

????那如何解決上面的2個(gè)問(wèn)題呢?那就是java內(nèi)存模型。

ps:還有一個(gè)問(wèn)題是:線程切換帶來(lái)的原子性問(wèn)題(后面會(huì)講到)



3、內(nèi)存模型概念

????Java 內(nèi)存模型規(guī)范了JVM 如何提供按需禁用緩存和編譯優(yōu)化的方法。具體來(lái)說(shuō),這些方法包括 volatilesynchronizedfinal 三個(gè)關(guān)鍵字,以及六項(xiàng) Happens-Before 規(guī)則。本文只講volatile。

????關(guān)于Java內(nèi)存模型網(wǎng)上講的概念太多了 ,感覺(jué)文縐縐的,反正我是看的比較煩,看完也記不住。

ps:網(wǎng)上很多內(nèi)容都來(lái)自于《深入理解Java虛擬機(jī)》,這本書(shū)也建議你看看。

????我這里就簡(jiǎn)單提取下幾個(gè)重要的概念,然后整整實(shí)例。

????Java內(nèi)存模型的主要目標(biāo)是定義程序中變量的訪問(wèn)規(guī)則:即在虛擬機(jī)中將變量存儲(chǔ)到主內(nèi)存或者將變量從主內(nèi)存取出這樣的底層細(xì)節(jié)。

  • 主內(nèi)存:java虛擬機(jī)規(guī)定所有的變量(不是程序中的變量)都必須在主內(nèi)存中產(chǎn)生,為了方便理解,可以認(rèn)為是堆區(qū)。
  • 工作內(nèi)存:java虛擬機(jī)中每個(gè)線程都有自己的工作內(nèi)存,該內(nèi)存是線程私有的為了方便理解,可以認(rèn)為是虛擬機(jī)棧。線程的工作內(nèi)存保存了線程需要的變量在主內(nèi)存中的副本。虛擬機(jī)規(guī)定:線程對(duì)主內(nèi)存變量的修改必須在線程的工作內(nèi)存中進(jìn)行,不能直接讀寫(xiě)主內(nèi)存中的變量。不同的線程之間也不能相互訪問(wèn)對(duì)方的工作內(nèi)存。如果線程之間需要傳遞變量的值,必須通過(guò)主內(nèi)存來(lái)作為中介進(jìn)行傳遞。



4、volatile解決緩存帶來(lái)的可見(jiàn)性問(wèn)題

先看一段程序:

public class TestAddCount {
    private long count = 0;

    public static void main(String[] args) throws InterruptedException {
        TestAddCount testAddCount = new TestAddCount();
        // 創(chuàng)建2個(gè)子線程,并執(zhí)行add操作
        Thread t1 = new Thread(() -> {
            testAddCount.add(10);
        });
        Thread t2 = new Thread(() -> {
            testAddCount.add(10);
        });
        // 啟動(dòng)2個(gè)子線程
        t1.start();
        t2.start();
        // main線程等待2個(gè)子線程執(zhí)行完
        t1.join();
        System.out.println("線程t1結(jié)束時(shí):" + testAddCount.count);
        t2.join();
        // 打印執(zhí)行后的結(jié)果count
        System.out.println("線程t2結(jié)束時(shí):" + testAddCount.count);
    }

    private void add(int n) {
        for (int i = 0; i < n; i++) {
            try {
                // 【注意:2個(gè)子線程并不是同時(shí)啟動(dòng)的,是有先后順序的,為了盡可能保證2個(gè)線程啟動(dòng)時(shí)count=0,所以在add方法中讓它睡眠0.1秒】
                // 當(dāng)然,為了讓參數(shù)n小的情況下效果更加明顯,add方法中讓它睡眠了0.1秒
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.count++;
        }
    }
}

????我們想要的運(yùn)行后count的最終結(jié)果是不是20?但是實(shí)際結(jié)果總是小于等于20的。為什么呢?

????在單核時(shí)代,上面的程序運(yùn)行結(jié)果總是20。(不要告訴我你的電腦是單核的哦)

????在多核時(shí)代就不一樣了。count變量是屬于對(duì)象TestAddCount的實(shí)例變量,存儲(chǔ)于堆區(qū),是線程共享的。

????CPU為了平衡和內(nèi)存之間的速度差異,增加了緩存技術(shù)。
????如下圖:線程在讀取變量count的時(shí)候,會(huì)先將count的值讀入線程私有的緩存中,待處理結(jié)束后,再將緩存中的值寫(xiě)入內(nèi)存中。比如線程t1和t2剛開(kāi)始讀取到的count值都是0,然后分別進(jìn)行加1操作。假設(shè)t1先將結(jié)果1回寫(xiě)到內(nèi)存,然后t2再將結(jié)果1回寫(xiě)到內(nèi)存,進(jìn)行2次加1操作后,結(jié)果還是1。所以上面的例子得到的最終結(jié)果是小于等于20的。這就是緩存導(dǎo)致的可見(jiàn)性問(wèn)題。

可見(jiàn)性問(wèn)題.png

如何解決呢?
????那就是按需禁用緩存,使用關(guān)鍵字volatile來(lái)修飾變量count即可。volatile修飾的變量,能保證新值能立即同步到主內(nèi)存,以及每次使用前立即從主內(nèi)存刷新。

private volatile long count = 0;

認(rèn)真的朋友可能發(fā)現(xiàn) ,加了volatile還是不行哦。因?yàn)檫€有一個(gè)問(wèn)題,就是原子性問(wèn)題。

這也便涉及到一道經(jīng)典的面試題:i++是原子性的嘛?
i++并不是一個(gè)原子性的操作。i++做了三次指令操作:

  • 第一次,從內(nèi)存中讀取i變量的值到CPU的寄存器;
  • 第二次,在寄存器中的i自增1
  • 第三次,將寄存器中的值寫(xiě)入內(nèi)存。

????操作系統(tǒng)的任務(wù)切換是在CPU指令級(jí)別的,而不是高級(jí)語(yǔ)言里面的一條語(yǔ)句。如下圖:對(duì)于++i,在多核機(jī)器上,線程t1、t2在讀取內(nèi)存時(shí)也可能同時(shí)讀到同一個(gè)值,然后可能發(fā)生線程切換,這樣就會(huì)同一個(gè)值自增兩次,而實(shí)際上只自增了一次,所以++i也不是原子操作。這也就是線程切換帶來(lái)的原子性問(wèn)題。

原子性問(wèn)題.png

????如何解決原子性的問(wèn)題,你可能想到了用synchronized來(lái)加鎖,保證其是原子性操作,synchronized的內(nèi)容比較多,回頭單獨(dú)整理一篇吧!?。?/p>



5、volatile解決編譯優(yōu)化帶來(lái)的有序性問(wèn)題

????上面說(shuō)了volatile還能解決編譯優(yōu)化帶來(lái)的有序性問(wèn)題,那么我們?cè)賮?lái)看下一個(gè)經(jīng)典的雙重校驗(yàn)來(lái)創(chuàng)建單例模式的例子:

public class Singleton {
    static Singleton instance;

    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

看下程序運(yùn)行流程:

  • 假設(shè)兩個(gè)線程 t1、t2 同時(shí)調(diào)用 getInstance(),同時(shí)發(fā)現(xiàn) instance == null ;
  • 于是同時(shí)對(duì) Singleton.class 加鎖,此時(shí) JVM 保證只有一個(gè)線程能夠加鎖成功(假設(shè)是線程 t1),另外一個(gè)線程則會(huì)處于等待狀態(tài)(假設(shè)是線程 t2);
  • 線程 t1 會(huì)創(chuàng)建一個(gè) Singleton 實(shí)例,之后釋放鎖,鎖釋放后,線程 t2 被喚醒,線程t2 再次嘗試加鎖,此時(shí)是可以加鎖成功的;
  • t2 加鎖成功后,線程 t2 再次檢查 instance == null 是否成立,發(fā)現(xiàn) instance!=null,不會(huì)再去實(shí)例化instance,直接返回線程t1實(shí)例化的instance。

????是不是覺(jué)得上面的程序沒(méi)有任何bug?但這個(gè) getInstance() 方法并不完美。問(wèn)題出在new操作上,我們以為的new操作是:

  • 指令1:分配一塊內(nèi)存 X;
  • 指令2:在內(nèi)存 X 上初始化 Singleton 對(duì)象;
  • 指令3:然后 X 的地址賦值給 instance 變量。

????但是實(shí)際上優(yōu)化后的new卻是這樣的:

  • 指令1:分配一塊內(nèi)存 X;
  • 指令2:將 X 的地址賦值給 instance 變量;
  • 指令3:最后在內(nèi)存 X 上初始化 Singleton 對(duì)象。

????優(yōu)化后會(huì)導(dǎo)致什么問(wèn)題呢?
????依然假設(shè)線程 t1 先去執(zhí)行 getInstance() 方法(和線程t2并不是同時(shí)去執(zhí)行),直接獲取到鎖,并執(zhí)行實(shí)例化new操作,當(dāng)執(zhí)行完指令 2 時(shí)恰好發(fā)生了線程切換,切換到了線程 t2 上;
如果此時(shí)線程 t2 也執(zhí)行 getInstance() 方法,那么線程 t2 在執(zhí)行第一個(gè)判斷時(shí)會(huì)發(fā)現(xiàn) instance != null ,所以直接返回 instance,而此時(shí)的 instance 是沒(méi)有初始化過(guò)的,如果我們這個(gè)時(shí)候訪問(wèn) instance 的成員變量就可能觸發(fā)空指針異常。這就是編譯優(yōu)化帶來(lái)的有序性問(wèn)題

這個(gè)時(shí)候你就可以使用volatile來(lái)修飾變量instance,按需禁用編譯優(yōu)化。上面的有序性也就解決了哈。

 static volatile Singleton instance;



總結(jié)

總結(jié).png



滬漂程序員一枚。
堅(jiān)持寫(xiě)博客,如果覺(jué)得還可以的話(huà),給個(gè)小星星哦,你的支持就是我創(chuàng)作的動(dòng)力。

個(gè)人微信公眾號(hào):“Java尖子生”,閱讀更多干貨。
<font color='red'>關(guān)注公眾號(hào),領(lǐng)取學(xué)習(xí)、面試資料。加技術(shù)討論群。</font>


最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

友情鏈接更多精彩內(nèi)容