天天看點

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

前言

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

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修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序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修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

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

再來看看release_int_field_put方法

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定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 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修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

可以發現,在生成的彙編代碼中,對變量a的一些無效負責操作果然都被優化掉了,如果在聲明變量a時加上volatile

#include 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修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

1、對變量a指派2的語句,也保留了下來,雖然是無效的動作,是以volatile關鍵字可以禁止指令優化,其實這裡發揮了編譯器屏障的作用;

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

#include 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修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

是以,每次對變量a的指派,都會寫入到記憶體中;每次對變量的讀取,都會從記憶體中重新加載。

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

執行完指派操作後,緊接着執行OrderAccess::storeload(),這又是啥?

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

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

1、loadload屏障(load1,loadload, load2)

2、loadstore屏障(load,loadstore, store)

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

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

其中__asm__,表示彙編代碼的開始。

volatile,之前分析過了,禁止編譯器對代碼進行優化。

把這段指令編譯之後,發現沒有看懂....最後的"memory"是編譯器屏障的作用。

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

3、storestore屏障(store1,storestore, store2)

通過"release()"方法實作:

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

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

4、storeload屏障(store,storeload, load)

對java中的volatile變量進行指派之後,插入的就是這個屏障,通過"fence()"方法實作:

volatile修飾的變量_看了這篇volatile詳細介紹,面試你會害怕?前言重排序編譯器重排序CPU指令重排序記憶體可見性如何實作設定volatile變量

看到這個有沒有很興奮?

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

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

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