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

佔小狼發表於2019-03-02

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

面試必問的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快取結構。

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

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 之後,結果如下:

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

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

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

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

通過is_volatile()可以判斷一個變數是否被volatile修飾,然後再全域性搜”is_volatile”被使用的地方,最後在bytecodeInterpreter.cpp檔案中,找到putstatic位元組碼指令的直譯器實現,裡面有is_volatile()方法。

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

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

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

再來看看release_int_field_put方法

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

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

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

奇怪,在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得到編譯之後的彙編程式碼如下:

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

可以發現,在生成的彙編程式碼中,對變數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;
}
複製程式碼

再次生成彙編程式碼如下:

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

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

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;
}
複製程式碼

編譯之後,和上面類似

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

2、其中_a(%rip)是變數a的每次地址,通過movl $2, _a(%rip)可以把變數a所在的記憶體設定成2,關於RIP,可以檢視 x64下PIC的新定址方式:RIP相對定址

所以,每次對變數a的賦值,都會寫入到記憶體中;每次對變數的讀取,都會從記憶體中重新載入。

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

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

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

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

JVM中定義的記憶體屏障如下,JDK1.7的實現

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

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

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

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

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

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

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

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

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

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

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

看到這個有沒有很興奮?

通過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進行修飾,編譯後的彙編指令如下:

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

從編譯後的程式碼可以發現,第二次使用foo變數時,沒有從記憶體重新載入,使用了暫存器的值。

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

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

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

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

__asm__ volatile ( : : : "cc", "memory");
複製程式碼

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

讀取volatile變數

同樣在bytecodeInterpreter.cpp檔案中,找到getstatic位元組碼指令的直譯器實現。

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

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

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

最終通過OrderAccess::load_acquire實現

inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
複製程式碼

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

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

相關文章