面試必問的volatile,你了解多少?

前言

Java中volatile這個熱門的關鍵字,在面試中經常會被提及,在各種技術交流群中也經常被討論,但似乎討論不出一個完美的結果,帶著種種疑惑,準備從JVM、C++、匯編的角度重新梳理一遍。

volatile的兩大特性:禁止重排序、內存可見性,這兩個概念,不太清楚的同學可以看這篇文章 -> java volatile關鍵字解惑

概念是知道了,但還是很迷糊,它們到底是如何實現的?

本文會涉及到一些匯編方面的內容,如果多看幾遍,應該能看懂。

重排序

為了理解重排序,先看一段簡單的代碼

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類有兩個方法,分別是set()和loop(),假設線程B執行loop方法,線程A執行set方法,會得到什么結果?

答案是不確定,因為這里涉及到了編譯器的重排序和CPU指令的重排序。

編譯器重排序

編譯器在不改變單線程語義的前提下,為了提高程序的運行速度,可以對字節碼指令進行重新排序,所以代碼中a、b的賦值順序,被編譯之后可能就變成了先設置b,再設置a。

因為對于線程A來說,先設置哪個,都不影響自身的結果。

CPU指令重排序

CPU指令重排序又是怎么回事?
在深入理解之前,先看看x86的cpu緩存結構。

1、各種寄存器,用來存儲本地變量和函數參數,訪問一次需要1cycle,耗時小于1ns;
2、L1 Cache,一級緩存,本地core的緩存,分成32K的數據緩存L1d和32k指令緩存L1i,訪問L1需要3cycles,耗時大約1ns;
3、L2 Cache,二級緩存,本地core的緩存,被設計為L1緩存與共享的L3緩存之間的緩沖,大小為256K,訪問L2需要12cycles,耗時大約3ns;
4、L3 Cache,三級緩存,在同插槽的所有core共享L3緩存,分為多個2M的段,訪問L3需要38cycles,耗時大約12ns;

當然了,還有平時熟知的DRAM,訪問內存一般需要65ns,所以CPU訪問一次內存和緩存比較起來顯得很慢。

對于不同插槽的CPU,L1和L2的數據并不共享,一般通過MESI協議保證Cache的一致性,但需要付出代價。

在MESI協議中,每個Cache line有4種狀態,分別是:

1、M(Modified)
這行數據有效,但是被修改了,和內存中的數據不一致,數據只存在于本Cache中

2、E(Exclusive)
這行數據有效,和內存中的數據一致,數據只存在于本Cache中

3、S(Shared)
這行數據有效,和內存中的數據一致,數據分布在很多Cache中

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

每個Core的Cache控制器不僅知道自己的讀寫操作,也監聽其它Cache的讀寫操作,假如有4個Core:
1、Core1從內存中加載了變量X,值為10,這時Core1中緩存變量X的cache line的狀態是E;
2、Core2也從內存中加載了變量X,這時Core1和Core2緩存變量X的cache line狀態轉化成S;
3、Core3也從內存中加載了變量X,然后把X設置成了20,這時Core3中緩存變量X的cache line狀態轉化成M,其它Core對應的cache line變成I(無效)

當然了,不同的處理器內部細節也是不一樣的,比如Intel的core i7處理器使用從MESI中演化出的MESIF協議,F(Forward)從Share中演化而來,一個cache line如果是F狀態,可以把數據直接傳給其它內核,這里就不糾結了。

CPU在cache line狀態的轉化期間是阻塞的,經過長時間的優化,在寄存器和L1緩存之間添加了LoadBuffer、StoreBuffer來降低阻塞時間,LoadBuffer、StoreBuffer,合稱排序緩沖(Memoryordering Buffers (MOB)),Load緩沖64長度,store緩沖36長度,Buffer與L1進行數據傳輸時,CPU無須等待。

1、CPU執行load讀數據時,把讀請求放到LoadBuffer,這樣就不用等待其它CPU響應,先進行下面操作,稍后再處理這個讀請求的結果。
2、CPU執行store寫數據時,把數據寫到StoreBuffer中,待到某個適合的時間點,把StoreBuffer的數據刷到主存中。

因為StoreBuffer的存在,CPU在寫數據時,真實數據并不會立即表現到內存中,所以對于其它CPU是不可見的;同樣的道理,LoadBuffer中的請求也無法拿到其它CPU設置的最新數據;

由于StoreBuffer和LoadBuffer是異步執行的,所以在外面看來,先寫后讀,還是先讀后寫,沒有嚴格的固定順序。

內存可見性如何實現

從上面的分析可以看出,其實是CPU執行load、store數據時的異步性,造成了不同CPU之間的內存不可見,那么如何做到CPU在load的時候可以拿到最新數據呢?

設置volatile變量

寫一段簡單的java代碼,聲明一個volatile變量,并賦值

public class VolatileTest {

    static volatile int i;

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

這段代碼本身沒什么意義,只是想看看加了volatile之后,編譯出來的字節碼有什么不同,執行 javap -verbose VolatileTest 之后,結果如下:

讓人很失望,沒有找類似關鍵字synchronize編譯之后的字節碼指令(monitorenter、monitorexit),volatile編譯之后的賦值指令putstatic沒有什么不同,唯一不同是變量i的修飾flags多了一個ACC_VOLATILE標識。

不過,我覺得可以從這個標識入手,先全局搜下ACC_VOLATILE,無從下手的時候,先看看關鍵字在哪里被使用了,果然在accessFlags.hpp文件中找到類似的名字。

通過is_volatile()可以判斷一個變量是否被volatile修飾,然后再全局搜"is_volatile"被使用的地方,最后在bytecodeInterpreter.cpp文件中,找到putstatic字節碼指令的解釋器實現,里面有is_volatile()方法。

當然了,在正常執行時,并不會走這段邏輯,都是直接執行字節碼對應的機器碼指令,這段代碼可以在debug的時候使用,不過最終邏輯是一樣的。

其中cache變量是java代碼中變量i在常量池緩存中的一個實例,因為變量i被volatile修飾,所以cache->is_volatile()為真,給變量i的賦值操作由release_int_field_put方法實現。

再來看看release_int_field_put方法

內部的賦值動作被包了一層,OrderAccess::release_store究竟做了魔法,可以讓其它線程讀到變量i的最新值。

奇怪,在OrderAccess::release_store的實現中,第一個參數強制加了一個volatile,很明顯,這是c/c++的關鍵字。

c/c++中的volatile關鍵字,用來修飾變量,通常用于語言級別的 memory barrier,在"The C++ Programming Language"中,對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聲明的變量表示隨時可能發生變化,每次使用時,都必須從變量i對應的內存地址讀取,編譯器對操作該變量的代碼不再進行優化,下面寫兩段簡單的c/c++代碼驗證一下

#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其實是無效的,執行g++ -S -O2 main.cpp得到編譯之后的匯編代碼如下:

可以發現,在生成的匯編代碼中,對變量a的一些無效負責操作果然都被優化掉了,如果在聲明變量a時加上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、對變量a賦值2的語句,也保留了下來,雖然是無效的動作,所以volatile關鍵字可以禁止指令優化,其實這里發揮了編譯器屏障的作用;

編譯器屏障可以避免編譯器優化帶來的內存亂序訪問的問題,也可以手動在代碼中插入編譯器屏障,比如下面的代碼和加volatile關鍵字之后的效果是一樣

#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所在的內存設置成2,關于RIP,可以查看 x64下PIC的新尋址方式:RIP相對尋址

所以,每次對變量a的賦值,都會寫入到內存中;每次對變量的讀取,都會從內存中重新加載。

感覺有點跑偏了,讓我們回到JVM的代碼中來。

執行完賦值操作后,緊接著執行OrderAccess::storeload(),這又是啥?

其實這就是經常會念叨的內存屏障,之前只知道念,卻不知道是如何實現的。從CPU緩存結構分析中已經知道:一個load操作需要進入LoadBuffer,然后再去內存加載;一個store操作需要進入StoreBuffer,然后再寫入緩存,這兩個操作都是異步的,會導致不正確的指令重排序,所以在JVM中定義了一系列的內存屏障來指定指令的執行順序。

JVM中定義的內存屏障如下,JDK1.7的實現

1、loadload屏障(load1,loadload, load2)
2、loadstore屏障(load,loadstore, store)

這兩個屏障都通過acquire()方法實現

其中__asm__,表示匯編代碼的開始。
volatile,之前分析過了,禁止編譯器對代碼進行優化。
把這段指令編譯之后,發現沒有看懂....最后的"memory"是編譯器屏障的作用。

在LoadBuffer中插入該屏障,清空屏障之前的load操作,然后才能執行屏障之后的操作,可以保證load操作的數據在下個store指令之前準備好

3、storestore屏障(store1,storestore, store2)
通過"release()"方法實現:

在StoreBuffer中插入該屏障,清空屏障之前的store操作,然后才能執行屏障之后的store操作,保證store1寫入的數據在執行store2時對其它CPU可見。

4、storeload屏障(store,storeload, load)
對java中的volatile變量進行賦值之后,插入的就是這個屏障,通過"fence()"方法實現:

看到這個有沒有很興奮?

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

storeload屏障,完全由下面這些指令實現

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

為了試驗這些指令到底有什么用,我們再寫點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不被編譯器優化掉,這里使用了volatile進行修飾,編譯后的匯編指令如下:

從編譯后的代碼可以發現,第二次使用foo變量時,沒有從內存重新加載,使用了寄存器的值。

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

相比之前,這里多了兩個指令,一個lock,一個addl。
lock指令的作用是:在執行lock后面指令時,會設置處理器的LOCK#信號(這個信號會鎖定總線,阻止其它CPU通過總線訪問內存,直到這些指令執行結束),這條指令的執行變成原子操作,之前的讀寫請求都不能越過lock指令進行重排,相當于一個內存屏障。

還有一個:第二次使用foo變量時,從內存中重新加載,保證可以拿到foo變量的最新值,這是由如下指令實現

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

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

讀取volatile變量

同樣在bytecodeInterpreter.cpp文件中,找到getstatic字節碼指令的解釋器實現。

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

最終通過OrderAccess::load_acquire實現

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

底層基于C++的volatile實現,因為volatile自帶了編譯器屏障的功能,總能拿到內存中的最新值。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容