面試必問的volatile践磅,你了解多少单刁?

前言

Java中volatile這個(gè)熱門的關(guān)鍵字,在面試中經(jīng)常會(huì)被提及府适,在各種技術(shù)交流群中也經(jīng)常被討論羔飞,但似乎討論不出一個(gè)完美的結(jié)果,帶著種種疑惑檐春,準(zhǔn)備從JVM逻淌、C++、匯編的角度重新梳理一遍喇聊。

volatile的兩大特性:禁止重排序恍风、內(nèi)存可見性,這兩個(gè)概念誓篱,不太清楚的同學(xué)可以看這篇文章 -> java volatile關(guān)鍵字解惑

概念是知道了朋贬,但還是很迷糊,它們到底是如何實(shí)現(xiàn)的窜骄?

本文會(huì)涉及到一些匯編方面的內(nèi)容锦募,如果多看幾遍,應(yīng)該能看懂邻遏。

重排序

為了理解重排序糠亩,先看一段簡(jiǎn)單的代碼

public class VolatileTest {

    int a = 0;
    int b = 0;

    public void set() {
        a = 1;
        b = 1;
    }

    public void loop() {
        while (b == 0) continue;
        if (a == 1) {
            System.out.println("i'm here");
        } else {
            System.out.println("what's wrong");
        }
    }
}

VolatileTest類有兩個(gè)方法虐骑,分別是set()和loop(),假設(shè)線程B執(zhí)行l(wèi)oop方法赎线,線程A執(zhí)行set方法廷没,會(huì)得到什么結(jié)果?

答案是不確定垂寥,因?yàn)檫@里涉及到了編譯器的重排序和CPU指令的重排序颠黎。

編譯器重排序

編譯器在不改變單線程語(yǔ)義的前提下,為了提高程序的運(yùn)行速度滞项,可以對(duì)字節(jié)碼指令進(jìn)行重新排序狭归,所以代碼中a、b的賦值順序文判,被編譯之后可能就變成了先設(shè)置b过椎,再設(shè)置a。

因?yàn)閷?duì)于線程A來說戏仓,先設(shè)置哪個(gè)疚宇,都不影響自身的結(jié)果。

CPU指令重排序

CPU指令重排序又是怎么回事赏殃?
在深入理解之前灰嫉,先看看x86的cpu緩存結(jié)構(gòu)。

1嗓奢、各種寄存器讼撒,用來存儲(chǔ)本地變量和函數(shù)參數(shù),訪問一次需要1cycle股耽,耗時(shí)小于1ns根盒;
2、L1 Cache物蝙,一級(jí)緩存炎滞,本地core的緩存,分成32K的數(shù)據(jù)緩存L1d和32k指令緩存L1i诬乞,訪問L1需要3cycles册赛,耗時(shí)大約1ns;
3震嫉、L2 Cache森瘪,二級(jí)緩存,本地core的緩存票堵,被設(shè)計(jì)為L(zhǎng)1緩存與共享的L3緩存之間的緩沖扼睬,大小為256K,訪問L2需要12cycles悴势,耗時(shí)大約3ns窗宇;
4措伐、L3 Cache,三級(jí)緩存军俊,在同插槽的所有core共享L3緩存侥加,分為多個(gè)2M的段,訪問L3需要38cycles粪躬,耗時(shí)大約12ns官硝;

當(dāng)然了,還有平時(shí)熟知的DRAM短蜕,訪問內(nèi)存一般需要65ns,所以CPU訪問一次內(nèi)存和緩存比較起來顯得很慢傻咖。

對(duì)于不同插槽的CPU朋魔,L1和L2的數(shù)據(jù)并不共享,一般通過MESI協(xié)議保證Cache的一致性卿操,但需要付出代價(jià)警检。

在MESI協(xié)議中,每個(gè)Cache line有4種狀態(tài)害淤,分別是:

1扇雕、M(Modified)
這行數(shù)據(jù)有效,但是被修改了窥摄,和內(nèi)存中的數(shù)據(jù)不一致镶奉,數(shù)據(jù)只存在于本Cache中

2、E(Exclusive)
這行數(shù)據(jù)有效崭放,和內(nèi)存中的數(shù)據(jù)一致哨苛,數(shù)據(jù)只存在于本Cache中

3、S(Shared)
這行數(shù)據(jù)有效币砂,和內(nèi)存中的數(shù)據(jù)一致建峭,數(shù)據(jù)分布在很多Cache中

4、I(Invalid)
這行數(shù)據(jù)無效

每個(gè)Core的Cache控制器不僅知道自己的讀寫操作决摧,也監(jiān)聽其它Cache的讀寫操作亿蒸,假如有4個(gè)Core:
1、Core1從內(nèi)存中加載了變量X掌桩,值為10边锁,這時(shí)Core1中緩存變量X的cache line的狀態(tài)是E;
2波岛、Core2也從內(nèi)存中加載了變量X砚蓬,這時(shí)Core1和Core2緩存變量X的cache line狀態(tài)轉(zhuǎn)化成S;
3盆色、Core3也從內(nèi)存中加載了變量X灰蛙,然后把X設(shè)置成了20祟剔,這時(shí)Core3中緩存變量X的cache line狀態(tài)轉(zhuǎn)化成M,其它Core對(duì)應(yīng)的cache line變成I(無效)

當(dāng)然了摩梧,不同的處理器內(nèi)部細(xì)節(jié)也是不一樣的物延,比如Intel的core i7處理器使用從MESI中演化出的MESIF協(xié)議,F(xiàn)(Forward)從Share中演化而來仅父,一個(gè)cache line如果是F狀態(tài)叛薯,可以把數(shù)據(jù)直接傳給其它內(nèi)核,這里就不糾結(jié)了笙纤。

CPU在cache line狀態(tài)的轉(zhuǎn)化期間是阻塞的耗溜,經(jīng)過長(zhǎng)時(shí)間的優(yōu)化,在寄存器和L1緩存之間添加了LoadBuffer省容、StoreBuffer來降低阻塞時(shí)間抖拴,LoadBuffer、StoreBuffer腥椒,合稱排序緩沖(Memoryordering Buffers (MOB))阿宅,Load緩沖64長(zhǎng)度,store緩沖36長(zhǎng)度笼蛛,Buffer與L1進(jìn)行數(shù)據(jù)傳輸時(shí)洒放,CPU無須等待。

1滨砍、CPU執(zhí)行l(wèi)oad讀數(shù)據(jù)時(shí)往湿,把讀請(qǐng)求放到LoadBuffer,這樣就不用等待其它CPU響應(yīng)惋戏,先進(jìn)行下面操作煌茴,稍后再處理這個(gè)讀請(qǐng)求的結(jié)果。
2日川、CPU執(zhí)行store寫數(shù)據(jù)時(shí)蔓腐,把數(shù)據(jù)寫到StoreBuffer中,待到某個(gè)適合的時(shí)間點(diǎn)龄句,把StoreBuffer的數(shù)據(jù)刷到主存中回论。

因?yàn)镾toreBuffer的存在,CPU在寫數(shù)據(jù)時(shí)分歇,真實(shí)數(shù)據(jù)并不會(huì)立即表現(xiàn)到內(nèi)存中傀蓉,所以對(duì)于其它CPU是不可見的;同樣的道理职抡,LoadBuffer中的請(qǐng)求也無法拿到其它CPU設(shè)置的最新數(shù)據(jù)葬燎;

由于StoreBuffer和LoadBuffer是異步執(zhí)行的,所以在外面看來,先寫后讀谱净,還是先讀后寫窑邦,沒有嚴(yán)格的固定順序。

內(nèi)存可見性如何實(shí)現(xiàn)

從上面的分析可以看出壕探,其實(shí)是CPU執(zhí)行l(wèi)oad冈钦、store數(shù)據(jù)時(shí)的異步性,造成了不同CPU之間的內(nèi)存不可見李请,那么如何做到CPU在load的時(shí)候可以拿到最新數(shù)據(jù)呢瞧筛?

設(shè)置volatile變量

寫一段簡(jiǎn)單的java代碼,聲明一個(gè)volatile變量导盅,并賦值

public class VolatileTest {

    static volatile int i;

    public static void main(String[] args){
        i = 10;
    }
}

這段代碼本身沒什么意義较幌,只是想看看加了volatile之后,編譯出來的字節(jié)碼有什么不同白翻,執(zhí)行 javap -verbose VolatileTest 之后乍炉,結(jié)果如下:

讓人很失望,沒有找類似關(guān)鍵字synchronize編譯之后的字節(jié)碼指令(monitorenter嘁字、monitorexit),volatile編譯之后的賦值指令putstatic沒有什么不同杉畜,唯一不同是變量i的修飾flags多了一個(gè)ACC_VOLATILE標(biāo)識(shí)纪蜒。

不過,我覺得可以從這個(gè)標(biāo)識(shí)入手此叠,先全局搜下ACC_VOLATILE纯续,無從下手的時(shí)候,先看看關(guān)鍵字在哪里被使用了灭袁,果然在accessFlags.hpp文件中找到類似的名字猬错。

通過is_volatile()可以判斷一個(gè)變量是否被volatile修飾,然后再全局搜"is_volatile"被使用的地方茸歧,最后在bytecodeInterpreter.cpp文件中倦炒,找到putstatic字節(jié)碼指令的解釋器實(shí)現(xiàn),里面有is_volatile()方法软瞎。

當(dāng)然了逢唤,在正常執(zhí)行時(shí),并不會(huì)走這段邏輯涤浇,都是直接執(zhí)行字節(jié)碼對(duì)應(yīng)的機(jī)器碼指令鳖藕,這段代碼可以在debug的時(shí)候使用,不過最終邏輯是一樣的只锭。

其中cache變量是java代碼中變量i在常量池緩存中的一個(gè)實(shí)例著恩,因?yàn)樽兞縤被volatile修飾,所以cache->is_volatile()為真,給變量i的賦值操作由release_int_field_put方法實(shí)現(xiàn)喉誊。

再來看看release_int_field_put方法

內(nèi)部的賦值動(dòng)作被包了一層邀摆,OrderAccess::release_store究竟做了魔法,可以讓其它線程讀到變量i的最新值裹驰。

奇怪隧熙,在OrderAccess::release_store的實(shí)現(xiàn)中,第一個(gè)參數(shù)強(qiáng)制加了一個(gè)volatile幻林,很明顯贞盯,這是c/c++的關(guān)鍵字。

c/c++中的volatile關(guān)鍵字沪饺,用來修飾變量躏敢,通常用于語(yǔ)言級(jí)別的 memory barrier,在"The C++ Programming Language"中整葡,對(duì)volatile的描述如下:

A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.

volatile是一種類型修飾符件余,被volatile聲明的變量表示隨時(shí)可能發(fā)生變化,每次使用時(shí)遭居,都必須從變量i對(duì)應(yīng)的內(nèi)存地址讀取啼器,編譯器對(duì)操作該變量的代碼不再進(jìn)行優(yōu)化,下面寫兩段簡(jiǎn)單的c/c++代碼驗(yàn)證一下

#include <iostream>

int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    a = foo + 10;
    int b = a + 20;
    return b;
}

代碼中的變量i其實(shí)是無效的俱萍,執(zhí)行g++ -S -O2 main.cpp得到編譯之后的匯編代碼如下:

可以發(fā)現(xiàn)端壳,在生成的匯編代碼中,對(duì)變量a的一些無效負(fù)責(zé)操作果然都被優(yōu)化掉了枪蘑,如果在聲明變量a時(shí)加上volatile

#include <iostream>

int foo = 10;
volatile int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    a = foo + 10;
    int b = a + 20;
    return b;
}

再次生成匯編代碼如下:

和第一次比較损谦,有以下不同:

1、對(duì)變量a賦值2的語(yǔ)句岳颇,也保留了下來照捡,雖然是無效的動(dòng)作,所以volatile關(guān)鍵字可以禁止指令優(yōu)化话侧,其實(shí)這里發(fā)揮了編譯器屏障的作用栗精;

編譯器屏障可以避免編譯器優(yōu)化帶來的內(nèi)存亂序訪問的問題,也可以手動(dòng)在代碼中插入編譯器屏障瞻鹏,比如下面的代碼和加volatile關(guān)鍵字之后的效果是一樣

#include <iostream>

int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    __asm__ volatile ("" : : : "memory"); //編譯器屏障
    a = foo + 10;
    __asm__ volatile ("" : : : "memory");
    int b = a + 20;
    return b;
}

編譯之后术羔,和上面類似

2、其中_a(%rip)是變量a的每次地址乙漓,通過movl $2, _a(%rip)可以把變量a所在的內(nèi)存設(shè)置成2级历,關(guān)于RIP,可以查看 x64下PIC的新尋址方式:RIP相對(duì)尋址

所以叭披,每次對(duì)變量a的賦值寥殖,都會(huì)寫入到內(nèi)存中玩讳;每次對(duì)變量的讀取,都會(huì)從內(nèi)存中重新加載嚼贡。

感覺有點(diǎn)跑偏了熏纯,讓我們回到JVM的代碼中來。

執(zhí)行完賦值操作后粤策,緊接著執(zhí)行OrderAccess::storeload()樟澜,這又是啥?

其實(shí)這就是經(jīng)常會(huì)念叨的內(nèi)存屏障叮盘,之前只知道念秩贰,卻不知道是如何實(shí)現(xiàn)的。從CPU緩存結(jié)構(gòu)分析中已經(jīng)知道:一個(gè)load操作需要進(jìn)入LoadBuffer柔吼,然后再去內(nèi)存加載毒费;一個(gè)store操作需要進(jìn)入StoreBuffer,然后再寫入緩存愈魏,這兩個(gè)操作都是異步的觅玻,會(huì)導(dǎo)致不正確的指令重排序,所以在JVM中定義了一系列的內(nèi)存屏障來指定指令的執(zhí)行順序培漏。

JVM中定義的內(nèi)存屏障如下溪厘,JDK1.7的實(shí)現(xiàn)

1、loadload屏障(load1牌柄,loadload畸悬, load2)
2、loadstore屏障(load友鼻,loadstore傻昙, store)

這兩個(gè)屏障都通過acquire()方法實(shí)現(xiàn)

其中__asm__闺骚,表示匯編代碼的開始彩扔。
volatile,之前分析過了僻爽,禁止編譯器對(duì)代碼進(jìn)行優(yōu)化虫碉。
把這段指令編譯之后,發(fā)現(xiàn)沒有看懂....最后的"memory"是編譯器屏障的作用胸梆。

在LoadBuffer中插入該屏障敦捧,清空屏障之前的load操作,然后才能執(zhí)行屏障之后的操作碰镜,可以保證load操作的數(shù)據(jù)在下個(gè)store指令之前準(zhǔn)備好

3兢卵、storestore屏障(store1,storestore绪颖, store2)
通過"release()"方法實(shí)現(xiàn):

在StoreBuffer中插入該屏障秽荤,清空屏障之前的store操作,然后才能執(zhí)行屏障之后的store操作,保證store1寫入的數(shù)據(jù)在執(zhí)行store2時(shí)對(duì)其它CPU可見窃款。

4课兄、storeload屏障(store,storeload晨继, load)
對(duì)java中的volatile變量進(jìn)行賦值之后烟阐,插入的就是這個(gè)屏障,通過"fence()"方法實(shí)現(xiàn):

看到這個(gè)有沒有很興奮紊扬?

通過os::is_MP()先判斷是不是多核蜒茄,如果只有一個(gè)CPU的話,就不存在這些問題了珠月。

storeload屏障扩淀,完全由下面這些指令實(shí)現(xiàn)

__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");

為了試驗(yàn)這些指令到底有什么用,我們?cè)賹扅c(diǎn)c++代碼編譯一下

#include <iostream>

int foo = 10;

int main(int argc, const char * argv[]) {
    // insert code here...
    volatile int a = foo + 10;
    // __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
    volatile int b = foo + 20;

    return 0;
}

為了變量a和b不被編譯器優(yōu)化掉啤挎,這里使用了volatile進(jìn)行修飾驻谆,編譯后的匯編指令如下:

從編譯后的代碼可以發(fā)現(xiàn),第二次使用foo變量時(shí)庆聘,沒有從內(nèi)存重新加載胜臊,使用了寄存器的值。

__asm__ volatile ***指令加上之后重新編譯

相比之前伙判,這里多了兩個(gè)指令象对,一個(gè)lock,一個(gè)addl宴抚。
lock指令的作用是:在執(zhí)行l(wèi)ock后面指令時(shí)勒魔,會(huì)設(shè)置處理器的LOCK#信號(hào)(這個(gè)信號(hào)會(huì)鎖定總線,阻止其它CPU通過總線訪問內(nèi)存菇曲,直到這些指令執(zhí)行結(jié)束)冠绢,這條指令的執(zhí)行變成原子操作,之前的讀寫請(qǐng)求都不能越過lock指令進(jìn)行重排常潮,相當(dāng)于一個(gè)內(nèi)存屏障弟胀。

還有一個(gè):第二次使用foo變量時(shí),從內(nèi)存中重新加載喊式,保證可以拿到foo變量的最新值孵户,這是由如下指令實(shí)現(xiàn)

__asm__ volatile ( : : : "cc", "memory");

同樣是編譯器屏障,通知編譯器重新生成加載指令(不可以從緩存寄存器中取)岔留。

讀取volatile變量

同樣在bytecodeInterpreter.cpp文件中夏哭,找到getstatic字節(jié)碼指令的解釋器實(shí)現(xiàn)。

通過obj->obj_field_acquire(field_offset)獲取變量值

最終通過OrderAccess::load_acquire實(shí)現(xiàn)

inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }

底層基于C++的volatile實(shí)現(xiàn)献联,因?yàn)関olatile自帶了編譯器屏障的功能竖配,總能拿到內(nèi)存中的最新值厕吉。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市械念,隨后出現(xiàn)的幾起案子头朱,更是在濱河造成了極大的恐慌,老刑警劉巖龄减,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件项钮,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡希停,警方通過查閱死者的電腦和手機(jī)烁巫,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來宠能,“玉大人亚隙,你說我怎么就攤上這事∥コ纾” “怎么了阿弃?”我有些...
    開封第一講書人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)羞延。 經(jīng)常有香客問我渣淳,道長(zhǎng),這世上最難降的妖魔是什么伴箩? 我笑而不...
    開封第一講書人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任入愧,我火速辦了婚禮,結(jié)果婚禮上嗤谚,老公的妹妹穿的比我還像新娘棺蛛。我一直安慰自己,他們只是感情好巩步,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開白布旁赊。 她就那樣靜靜地躺著,像睡著了一般渗钉。 火紅的嫁衣襯著肌膚如雪彤恶。 梳的紋絲不亂的頭發(fā)上钞钙,一...
    開封第一講書人閱讀 49,144評(píng)論 1 285
  • 那天鳄橘,我揣著相機(jī)與錄音,去河邊找鬼芒炼。 笑死瘫怜,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的本刽。 我是一名探鬼主播鲸湃,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼赠涮,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了暗挑?” 一聲冷哼從身側(cè)響起笋除,我...
    開封第一講書人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎炸裆,沒想到半個(gè)月后垃它,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡烹看,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年国拇,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片惯殊。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡酱吝,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出土思,到底是詐尸還是另有隱情务热,我是刑警寧澤,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布己儒,位于F島的核電站陕习,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏址愿。R本人自食惡果不足惜该镣,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望响谓。 院中可真熱鬧损合,春花似錦、人聲如沸娘纷。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)赖晶。三九已至律适,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間遏插,已是汗流浹背捂贿。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留胳嘲,地道東北人厂僧。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像了牛,于是被迫代替她去往敵國(guó)和親颜屠。 傳聞我的和親對(duì)象是個(gè)殘疾皇子辰妙,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容