吃透Java并發(fā):volatile是怎么保證可見(jiàn)性的

前言

volatile關(guān)鍵字能夠保證可見(jiàn)性和有序性,但是volatile為什么能夠保證可見(jiàn)性和有序性?為什么volatile又不能保證原子性?
今天,我們從CPU多核緩存架構(gòu)出發(fā),結(jié)合MESI緩存一致性協(xié)議來(lái)深入剖析一下,volatile的原理。

問(wèn)題的出現(xiàn)

我們先通過(guò)一個(gè)例子來(lái)看看,可見(jiàn)性導(dǎo)致的線程安全問(wèn)題:

public class Main {

    static int a = 0;

    public static void main(String[] args)  throws Exception {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (a == 0) {

                }
                System.out.println("T1得知a = 1");
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    a = 1;
                    System.out.println("T2修改a = 1");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        t2.start();
    }
}

線程T2再休眠1秒之后,修改了a的值為1,此時(shí)T1應(yīng)該退出while循環(huán)并打印,但是結(jié)果并非如此:

T1沒(méi)有退出循環(huán),程序也就不會(huì)結(jié)束。但是如果對(duì)a使用volatile關(guān)鍵字修飾就會(huì)解決該問(wèn)題。

這個(gè)問(wèn)題的源頭就在于可見(jiàn)性問(wèn)題。為什么會(huì)出現(xiàn)這種問(wèn)題呢?這里我們需要從CPU多核緩存架構(gòu)講起。

CPU多核緩存架構(gòu)

一個(gè)雙核CPU架構(gòu)可以如下圖所示:

首先需要明確的一點(diǎn)是,計(jì)算機(jī)實(shí)際上是分為多級(jí)緩存的,因?yàn)樽x取緩存的數(shù)據(jù)性能十分快

  1. 當(dāng)CPU1需要讀取共享變量的值a時(shí),首先會(huì)找緩存(即L1、L2、L3三級(jí)高速緩存),看看這個(gè)值是不是在L1。
  2. 很明顯,緩存沒(méi)辦法給CPU1它想要的數(shù)據(jù),于是只能去主內(nèi)存讀取共享變量的值
  3. 緩存得到共享變量的值之后,把數(shù)據(jù)交給寄存器,但是緩存留了個(gè)心眼,它把a(bǔ)的值存了起來(lái),這樣下次別的線程再需要a的值時(shí),就不用再去主內(nèi)存問(wèn)了

至此,一次完整的數(shù)據(jù)訪問(wèn)流程走完了。L1和L2、L3都是高速緩存,從高速緩存和主內(nèi)存讀取數(shù)據(jù)的速度完全是兩個(gè)概念。所以才會(huì)有主內(nèi)存和緩存的設(shè)計(jì)。

寫(xiě)數(shù)據(jù)時(shí)刷新內(nèi)存

針對(duì)上述模型,當(dāng)CPU1讀取完數(shù)據(jù)后,假如對(duì)數(shù)據(jù)進(jìn)行了修改,那么它會(huì)將緩存 —> 主內(nèi)存的順序?qū)⑿薷暮蟮臄?shù)據(jù)刷新一遍,完成對(duì)數(shù)據(jù)的更新。

從讀到寫(xiě)這一整個(gè)流程看起來(lái)似乎都是完美的,而且每次修改都把數(shù)據(jù)重新寫(xiě)回到主內(nèi)存,講道理不會(huì)有問(wèn)題啊?

實(shí)際上問(wèn)題正是出在這個(gè)看似完美的讀寫(xiě)操作中:對(duì)于CPU1來(lái)說(shuō)的確是完美的,但如果這時(shí)候CPU2來(lái)插一腳呢?我們思考下面這個(gè)流程:

  1. CPU1讀取數(shù)據(jù)a=1,CPU1的緩存中都有數(shù)據(jù)a的副本
  2. CPU2也執(zhí)行讀取操作,同樣CPU2也有數(shù)據(jù)a=1的副本
  3. CPU1修改數(shù)據(jù)a=2,同時(shí)CPU1的緩存以及主內(nèi)存a=2
  4. CPU2再次讀取a,但是CPU2在緩存中命中數(shù)據(jù),此時(shí)a=1

問(wèn)題到這里已經(jīng)很明顯了,CPU2并不知道CPU1改變了共享變量的值,因此造成了不可見(jiàn)問(wèn)題。

緩存一致性協(xié)議

為了解決這個(gè)問(wèn)題,在早期的CPU當(dāng)中,是通過(guò)在總線上直接加鎖的形式來(lái)解決緩存不一致的問(wèn)題。

但是正如Java中Synchronized一樣,直接加鎖太粗暴了,由于在鎖住總線期間,其他CPU無(wú)法訪問(wèn)內(nèi)存,導(dǎo)致效率低下。很明顯這樣做是不可取的。

所以就出現(xiàn)了緩存一致性協(xié)議。緩存一致性協(xié)議有MSI,MESI,MOSI,Synapse,F(xiàn)irefly及DragonProtocol等等。

MESI協(xié)議

最出名的就是Intel 的MESI協(xié)議,MESI協(xié)議保證了每個(gè)緩存中使用的共享變量的副本是一致的。

  1. Modify(修改):當(dāng)緩存行中的數(shù)據(jù)被修改時(shí),該緩存行置為M狀態(tài)
  2. Exclusive(獨(dú)占):當(dāng)只有一個(gè)緩存行使用某個(gè)數(shù)據(jù)時(shí),置為E狀態(tài)
  3. Shared(共享):當(dāng)其他CPU中也讀取某數(shù)據(jù)到緩存行時(shí),所有持有該數(shù)據(jù)的緩存行置為S狀態(tài)
  4. Invalid(無(wú)效):當(dāng)某個(gè)緩存行數(shù)據(jù)修改時(shí),其他持有該數(shù)據(jù)的緩存行置為I狀態(tài)

它核心的思想是:當(dāng)CPU寫(xiě)數(shù)據(jù)時(shí),如果發(fā)現(xiàn)操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會(huì)發(fā)出信號(hào)通知其他CPU將該變量的緩存行置為無(wú)效狀態(tài),因此當(dāng)其他CPU需要讀取這個(gè)變量時(shí),發(fā)現(xiàn)自己緩存中緩存該變量的緩存行是無(wú)效的,那么它就會(huì)從內(nèi)存重新讀取。

而這其中,監(jiān)聽(tīng)和通知又基于總線嗅探機(jī)制來(lái)完成。

總線嗅探機(jī)制

嗅探機(jī)制其實(shí)就是一個(gè)監(jiān)聽(tīng)器,回到我們剛才的流程,如果是加入MESI緩存一致性協(xié)議和總線嗅探機(jī)制之后:

  1. CPU1讀取數(shù)據(jù)a=1,CPU1的緩存中都有數(shù)據(jù)a的副本,該緩存行置為(E)狀態(tài)
  2. CPU2也執(zhí)行讀取操作,同樣CPU2也有數(shù)據(jù)a=1的副本,此時(shí)總線嗅探到CPU1也有該數(shù)據(jù),則CPU1、CPU2兩個(gè)緩存行都置為(S)狀態(tài)
  3. CPU1修改數(shù)據(jù)a=2,CPU1的緩存以及主內(nèi)存a=2,同時(shí)CPU1的緩存行置為(S)狀態(tài),總線發(fā)出通知,CPU2的緩存行置為(I)狀態(tài)
  4. CPU2再次讀取a,雖然CPU2在緩存中命中數(shù)據(jù)a=1,但是發(fā)現(xiàn)狀態(tài)為(I),因此直接丟棄該數(shù)據(jù),去主內(nèi)存獲取最新數(shù)據(jù)

當(dāng)我們使用volatile關(guān)鍵字修飾某個(gè)變量之后,就相當(dāng)于告訴CPU:我這個(gè)變量需要使用MESI和總線嗅探機(jī)制處理。從而也就保證了可見(jiàn)性。

指令重排序

在加入MESI和總線嗅探機(jī)制后,當(dāng)CPU2發(fā)現(xiàn)當(dāng)前緩存行數(shù)據(jù)無(wú)效時(shí),會(huì)丟棄該數(shù)據(jù),并前往主內(nèi)存獲取最新數(shù)據(jù)。

但是這里又會(huì)產(chǎn)生一個(gè)問(wèn)題:CPU1把數(shù)據(jù)刷回主內(nèi)存是需要時(shí)間的,假如CPU2在主內(nèi)存拿數(shù)據(jù)時(shí),CPU1還沒(méi)有把數(shù)據(jù)刷回來(lái)呢?

很明顯,CPU2不會(huì)把資源浪費(fèi)在這里傻等。它會(huì)先跳過(guò)和該數(shù)據(jù)有關(guān)的語(yǔ)句,繼續(xù)處理后面的邏輯。

比如說(shuō)如下代碼:

a = 1;
b = 2;
b++;

假如第一條語(yǔ)句需要等待CPU1數(shù)據(jù)刷新,那么CPU2可能就會(huì)先回來(lái)執(zhí)行后面兩條語(yǔ)句。因?yàn)閷?duì)于CPU2來(lái)說(shuō),先執(zhí)行后面兩條語(yǔ)句不會(huì)對(duì)最終結(jié)果造成任何影響。

但是多線程環(huán)境下就會(huì)出現(xiàn)問(wèn)題。關(guān)于指令重排序,我們放到內(nèi)存屏障來(lái)講。

一些可能讓你困惑的問(wèn)題

依舊是一開(kāi)始的代碼,假如我們把TI線程循環(huán)的內(nèi)容改成如下:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            System.out.println(a);
        }
        System.out.println("T1得知a = 1");
    }
});

或者如下:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("T1得知a = 1");
    }
});

此時(shí)變量a沒(méi)有使用volatile修飾。

但是運(yùn)行結(jié)果會(huì)讓你匪夷所思:程序正常結(jié)束,a變量對(duì)T1居然可見(jiàn)了!

while在作怪?

這是為什么呢?難道是因?yàn)樵趙hile循環(huán)中加了代碼導(dǎo)致的?

那我們加個(gè)變量b再來(lái)試試:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            b++;
        }
        System.out.println("T1得知a = 1");
    }
});

這次運(yùn)行結(jié)果T1又沒(méi)辦法感知a的變化了,也就是說(shuō),并不是while中有代碼就會(huì)發(fā)生可見(jiàn)的現(xiàn)象。

那么真正的原因究竟是什么呢?

勤奮的CPU

這是一個(gè)很有趣的現(xiàn)象,有些人認(rèn)為是因?yàn)閜rintln方法加了synchronized的原因。的確,鎖機(jī)制保證了每次執(zhí)行都會(huì)把共享內(nèi)存中的數(shù)據(jù)同步到工作內(nèi)存中。

但Thread.sleep方法并沒(méi)有加呀?

真正的原因在于,CPU是很勤奮的,如果它發(fā)現(xiàn)自己有空閑的時(shí)間,就會(huì)主動(dòng)去主內(nèi)存里更新自己緩存中的數(shù)據(jù)。

而Thread.sleep方法對(duì)于CPU來(lái)說(shuō),會(huì)給它“喘息”的時(shí)間,讓它有空去把緩存里的數(shù)據(jù)去主內(nèi)存刷新一下。

而后面的b++操作幾乎沒(méi)有給CPU任何機(jī)會(huì)休息,也就沒(méi)辦法去刷新緩存中的數(shù)據(jù)信息。

總結(jié)

事實(shí)上,我們的JMM模型就是類(lèi)比CPU多核緩存架構(gòu)的,它的作用是屏蔽掉了底層不同計(jì)算機(jī)的區(qū)別
JMM不是真實(shí)存在的,只是一個(gè)抽象的概念。volatile也是借助MESI緩存一致性協(xié)議和總線嗅探機(jī)制才得以完成
此外,當(dāng)CPU不支持緩存一致性協(xié)議時(shí),還是需要依靠總線加鎖的形式來(lái)保證線程安全
本文到這里就結(jié)束了,感謝大家看到最后,記得點(diǎn)贊加關(guān)注哦,如有不對(duì)之處還請(qǐng)多多指正。

?著作權(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ù)。

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