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

佔小狼 轉載請註明原創出處,謝謝!html

前言

Java中volatile這個熱門的關鍵字,在面試中常常會被說起,在各類技術交流羣中也常常被討論,但彷佛討論不出一個完美的結果,帶着種種疑惑,準備從JVM、C++、彙編的角度從新梳理一遍。java

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

概念是知道了,但仍是很迷糊,它們究竟是如何實現的?c++

本文會涉及到一些彙編方面的內容,若是多看幾遍,應該能看懂。面試

重排序

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

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方法,會獲得什麼結果?bash

答案是不肯定,由於這裏涉及到了編譯器的重排序和CPU指令的重排序。異步

編譯器重排序

編譯器在不改變單線程語義的前提下,爲了提升程序的運行速度,能夠對字節碼指令進行從新排序,因此代碼中a、b的賦值順序,被編譯以後可能就變成了先設置b,再設置a。ide

由於對於線程A來講,先設置哪一個,都不影響自身的結果。函數

CPU指令重排序

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

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

固然了,還有平時熟知的DRAM,訪問內存通常須要65ns,因此CPU訪問一次內存和緩存比較起來顯得很慢。

對於不一樣插槽的CPU,L1和L2的數據並不共享,通常經過MESI協議保證Cache的一致性,但須要付出代價。

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

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

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

三、S(Shared) 這行數據有效,和內存中的數據一致,數據分佈在不少Cache中

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

每一個Core的Cache控制器不只知道本身的讀寫操做,也監聽其它Cache的讀寫操做,假若有4個Core: 一、Core1從內存中加載了變量X,值爲10,這時Core1中緩存變量X的cache line的狀態是E; 二、Core2也從內存中加載了變量X,這時Core1和Core2緩存變量X的cache line狀態轉化成S; 三、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無須等待。

一、CPU執行load讀數據時,把讀請求放到LoadBuffer,這樣就不用等待其它CPU響應,先進行下面操做,稍後再處理這個讀請求的結果。 二、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;
}
複製代碼

再次生成彙編代碼以下:

和第一次比較,有如下不一樣:

一、對變量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;
}
複製代碼

編譯以後,和上面相似

二、其中_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的實現

一、loadload屏障(load1,loadload, load2) 二、loadstore屏障(load,loadstore, store)

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

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

在LoadBuffer中插入該屏障,清空屏障以前的load操做,而後才能執行屏障以後的操做,能夠保證load操做的數據在下個store指令以前準備好

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

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

四、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自帶了編譯器屏障的功能,總能拿到內存中的最新值。

相關文章
相關標籤/搜索