前言
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ù)性能十分快
- 當(dāng)CPU1需要讀取共享變量的值a時(shí),首先會(huì)找緩存(即L1、L2、L3三級(jí)高速緩存),看看這個(gè)值是不是在L1。
- 很明顯,緩存沒(méi)辦法給CPU1它想要的數(shù)據(jù),于是只能去主內(nèi)存讀取共享變量的值
- 緩存得到共享變量的值之后,把數(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è)流程:
- CPU1讀取數(shù)據(jù)a=1,CPU1的緩存中都有數(shù)據(jù)a的副本
- CPU2也執(zhí)行讀取操作,同樣CPU2也有數(shù)據(jù)a=1的副本
- CPU1修改數(shù)據(jù)a=2,同時(shí)CPU1的緩存以及主內(nèi)存a=2
- 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è)緩存中使用的共享變量的副本是一致的。
- Modify(修改):當(dāng)緩存行中的數(shù)據(jù)被修改時(shí),該緩存行置為M狀態(tài)
- Exclusive(獨(dú)占):當(dāng)只有一個(gè)緩存行使用某個(gè)數(shù)據(jù)時(shí),置為E狀態(tài)
- Shared(共享):當(dāng)其他CPU中也讀取某數(shù)據(jù)到緩存行時(shí),所有持有該數(shù)據(jù)的緩存行置為S狀態(tài)
- 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ī)制之后:
- CPU1讀取數(shù)據(jù)a=1,CPU1的緩存中都有數(shù)據(jù)a的副本,該緩存行置為(E)狀態(tài)
- CPU2也執(zhí)行讀取操作,同樣CPU2也有數(shù)據(jù)a=1的副本,此時(shí)總線嗅探到CPU1也有該數(shù)據(jù),則CPU1、CPU2兩個(gè)緩存行都置為(S)狀態(tài)
- CPU1修改數(shù)據(jù)a=2,CPU1的緩存以及主內(nèi)存a=2,同時(shí)CPU1的緩存行置為(S)狀態(tài),總線發(fā)出通知,CPU2的緩存行置為(I)狀態(tài)
- 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)多多指正。