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