前言
Java中volatile這個(gè)熱門的關(guān)鍵字,在面試中經(jīng)常會(huì)被提及府适,在各種技術(shù)交流群中也經(jīng)常被討論羔飞,但似乎討論不出一個(gè)完美的結(jié)果,帶著種種疑惑檐春,準(zhǔn)備從JVM逻淌、C++、匯編的角度重新梳理一遍喇聊。
volatile的兩大特性:禁止重排序恍风、內(nèi)存可見性,這兩個(gè)概念誓篱,不太清楚的同學(xué)可以看這篇文章 -> java volatile關(guān)鍵字解惑
概念是知道了朋贬,但還是很迷糊,它們到底是如何實(shí)現(xiàn)的窜骄?
本文會(huì)涉及到一些匯編方面的內(nèi)容锦募,如果多看幾遍,應(yīng)該能看懂邻遏。
重排序
為了理解重排序糠亩,先看一段簡(jiǎn)單的代碼
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類有兩個(gè)方法虐骑,分別是set()和loop(),假設(shè)線程B執(zhí)行l(wèi)oop方法赎线,線程A執(zhí)行set方法廷没,會(huì)得到什么結(jié)果?
答案是不確定垂寥,因?yàn)檫@里涉及到了編譯器的重排序和CPU指令的重排序颠黎。
編譯器重排序
編譯器在不改變單線程語(yǔ)義的前提下,為了提高程序的運(yùn)行速度滞项,可以對(duì)字節(jié)碼指令進(jìn)行重新排序狭归,所以代碼中a、b的賦值順序文判,被編譯之后可能就變成了先設(shè)置b过椎,再設(shè)置a。
因?yàn)閷?duì)于線程A來說戏仓,先設(shè)置哪個(gè)疚宇,都不影響自身的結(jié)果。
CPU指令重排序
CPU指令重排序又是怎么回事赏殃?
在深入理解之前灰嫉,先看看x86的cpu緩存結(jié)構(gòu)。
1嗓奢、各種寄存器讼撒,用來存儲(chǔ)本地變量和函數(shù)參數(shù),訪問一次需要1cycle股耽,耗時(shí)小于1ns根盒;
2、L1 Cache物蝙,一級(jí)緩存炎滞,本地core的緩存,分成32K的數(shù)據(jù)緩存L1d和32k指令緩存L1i诬乞,訪問L1需要3cycles册赛,耗時(shí)大約1ns;
3震嫉、L2 Cache森瘪,二級(jí)緩存,本地core的緩存票堵,被設(shè)計(jì)為L(zhǎng)1緩存與共享的L3緩存之間的緩沖扼睬,大小為256K,訪問L2需要12cycles悴势,耗時(shí)大約3ns窗宇;
4措伐、L3 Cache,三級(jí)緩存军俊,在同插槽的所有core共享L3緩存侥加,分為多個(gè)2M的段,訪問L3需要38cycles粪躬,耗時(shí)大約12ns官硝;
當(dāng)然了,還有平時(shí)熟知的DRAM短蜕,訪問內(nèi)存一般需要65ns,所以CPU訪問一次內(nèi)存和緩存比較起來顯得很慢傻咖。
對(duì)于不同插槽的CPU朋魔,L1和L2的數(shù)據(jù)并不共享,一般通過MESI協(xié)議保證Cache的一致性卿操,但需要付出代價(jià)警检。
在MESI協(xié)議中,每個(gè)Cache line有4種狀態(tài)害淤,分別是:
1扇雕、M(Modified)
這行數(shù)據(jù)有效,但是被修改了窥摄,和內(nèi)存中的數(shù)據(jù)不一致镶奉,數(shù)據(jù)只存在于本Cache中
2、E(Exclusive)
這行數(shù)據(jù)有效崭放,和內(nèi)存中的數(shù)據(jù)一致哨苛,數(shù)據(jù)只存在于本Cache中
3、S(Shared)
這行數(shù)據(jù)有效币砂,和內(nèi)存中的數(shù)據(jù)一致建峭,數(shù)據(jù)分布在很多Cache中
4、I(Invalid)
這行數(shù)據(jù)無效
每個(gè)Core的Cache控制器不僅知道自己的讀寫操作决摧,也監(jiān)聽其它Cache的讀寫操作亿蒸,假如有4個(gè)Core:
1、Core1從內(nèi)存中加載了變量X掌桩,值為10边锁,這時(shí)Core1中緩存變量X的cache line的狀態(tài)是E;
2波岛、Core2也從內(nèi)存中加載了變量X砚蓬,這時(shí)Core1和Core2緩存變量X的cache line狀態(tài)轉(zhuǎn)化成S;
3盆色、Core3也從內(nèi)存中加載了變量X灰蛙,然后把X設(shè)置成了20祟剔,這時(shí)Core3中緩存變量X的cache line狀態(tài)轉(zhuǎn)化成M,其它Core對(duì)應(yīng)的cache line變成I(無效)
當(dāng)然了摩梧,不同的處理器內(nèi)部細(xì)節(jié)也是不一樣的物延,比如Intel的core i7處理器使用從MESI中演化出的MESIF協(xié)議,F(xiàn)(Forward)從Share中演化而來仅父,一個(gè)cache line如果是F狀態(tài)叛薯,可以把數(shù)據(jù)直接傳給其它內(nèi)核,這里就不糾結(jié)了笙纤。
CPU在cache line狀態(tài)的轉(zhuǎn)化期間是阻塞的耗溜,經(jīng)過長(zhǎng)時(shí)間的優(yōu)化,在寄存器和L1緩存之間添加了LoadBuffer省容、StoreBuffer來降低阻塞時(shí)間抖拴,LoadBuffer、StoreBuffer腥椒,合稱排序緩沖(Memoryordering Buffers (MOB))阿宅,Load緩沖64長(zhǎng)度,store緩沖36長(zhǎng)度笼蛛,Buffer與L1進(jìn)行數(shù)據(jù)傳輸時(shí)洒放,CPU無須等待。
1滨砍、CPU執(zhí)行l(wèi)oad讀數(shù)據(jù)時(shí)往湿,把讀請(qǐng)求放到LoadBuffer,這樣就不用等待其它CPU響應(yīng)惋戏,先進(jìn)行下面操作煌茴,稍后再處理這個(gè)讀請(qǐng)求的結(jié)果。
2日川、CPU執(zhí)行store寫數(shù)據(jù)時(shí)蔓腐,把數(shù)據(jù)寫到StoreBuffer中,待到某個(gè)適合的時(shí)間點(diǎn)龄句,把StoreBuffer的數(shù)據(jù)刷到主存中回论。
因?yàn)镾toreBuffer的存在,CPU在寫數(shù)據(jù)時(shí)分歇,真實(shí)數(shù)據(jù)并不會(huì)立即表現(xiàn)到內(nèi)存中傀蓉,所以對(duì)于其它CPU是不可見的;同樣的道理职抡,LoadBuffer中的請(qǐng)求也無法拿到其它CPU設(shè)置的最新數(shù)據(jù)葬燎;
由于StoreBuffer和LoadBuffer是異步執(zhí)行的,所以在外面看來,先寫后讀谱净,還是先讀后寫窑邦,沒有嚴(yán)格的固定順序。
內(nèi)存可見性如何實(shí)現(xiàn)
從上面的分析可以看出壕探,其實(shí)是CPU執(zhí)行l(wèi)oad冈钦、store數(shù)據(jù)時(shí)的異步性,造成了不同CPU之間的內(nèi)存不可見李请,那么如何做到CPU在load的時(shí)候可以拿到最新數(shù)據(jù)呢瞧筛?
設(shè)置volatile變量
寫一段簡(jiǎn)單的java代碼,聲明一個(gè)volatile變量导盅,并賦值
public class VolatileTest {
static volatile int i;
public static void main(String[] args){
i = 10;
}
}
這段代碼本身沒什么意義较幌,只是想看看加了volatile之后,編譯出來的字節(jié)碼有什么不同白翻,執(zhí)行 javap -verbose VolatileTest
之后乍炉,結(jié)果如下:
讓人很失望,沒有找類似關(guān)鍵字synchronize編譯之后的字節(jié)碼指令(monitorenter嘁字、monitorexit),volatile編譯之后的賦值指令putstatic沒有什么不同杉畜,唯一不同是變量i的修飾flags多了一個(gè)ACC_VOLATILE
標(biāo)識(shí)纪蜒。
不過,我覺得可以從這個(gè)標(biāo)識(shí)入手此叠,先全局搜下ACC_VOLATILE
纯续,無從下手的時(shí)候,先看看關(guān)鍵字在哪里被使用了灭袁,果然在accessFlags.hpp文件中找到類似的名字猬错。
通過is_volatile()
可以判斷一個(gè)變量是否被volatile修飾,然后再全局搜"is_volatile"被使用的地方茸歧,最后在bytecodeInterpreter.cpp
文件中倦炒,找到putstatic字節(jié)碼指令的解釋器實(shí)現(xiàn),里面有is_volatile()
方法软瞎。
當(dāng)然了逢唤,在正常執(zhí)行時(shí),并不會(huì)走這段邏輯涤浇,都是直接執(zhí)行字節(jié)碼對(duì)應(yīng)的機(jī)器碼指令鳖藕,這段代碼可以在debug的時(shí)候使用,不過最終邏輯是一樣的只锭。
其中cache變量是java代碼中變量i在常量池緩存中的一個(gè)實(shí)例著恩,因?yàn)樽兞縤被volatile修飾,所以cache->is_volatile()
為真,給變量i的賦值操作由release_int_field_put
方法實(shí)現(xiàn)喉誊。
再來看看release_int_field_put
方法
內(nèi)部的賦值動(dòng)作被包了一層邀摆,OrderAccess::release_store
究竟做了魔法,可以讓其它線程讀到變量i的最新值裹驰。
奇怪隧熙,在OrderAccess::release_store的實(shí)現(xiàn)中,第一個(gè)參數(shù)強(qiáng)制加了一個(gè)volatile幻林,很明顯贞盯,這是c/c++的關(guān)鍵字。
c/c++中的volatile關(guān)鍵字沪饺,用來修飾變量躏敢,通常用于語(yǔ)言級(jí)別的 memory barrier,在"The C++ Programming Language"中整葡,對(duì)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聲明的變量表示隨時(shí)可能發(fā)生變化,每次使用時(shí)遭居,都必須從變量i對(duì)應(yīng)的內(nèi)存地址讀取啼器,編譯器對(duì)操作該變量的代碼不再進(jìn)行優(yōu)化,下面寫兩段簡(jiǎn)單的c/c++代碼驗(yàn)證一下
#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其實(shí)是無效的俱萍,執(zhí)行g++ -S -O2 main.cpp
得到編譯之后的匯編代碼如下:
可以發(fā)現(xiàn)端壳,在生成的匯編代碼中,對(duì)變量a的一些無效負(fù)責(zé)操作果然都被優(yōu)化掉了枪蘑,如果在聲明變量a時(shí)加上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、對(duì)變量a賦值2的語(yǔ)句岳颇,也保留了下來照捡,雖然是無效的動(dòng)作,所以volatile關(guān)鍵字可以禁止指令優(yōu)化话侧,其實(shí)這里發(fā)揮了編譯器屏障的作用栗精;
編譯器屏障可以避免編譯器優(yōu)化帶來的內(nèi)存亂序訪問的問題,也可以手動(dòng)在代碼中插入編譯器屏障瞻鹏,比如下面的代碼和加volatile關(guān)鍵字之后的效果是一樣
#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所在的內(nèi)存設(shè)置成2级历,關(guān)于RIP,可以查看 x64下PIC的新尋址方式:RIP相對(duì)尋址
所以叭披,每次對(duì)變量a的賦值寥殖,都會(huì)寫入到內(nèi)存中玩讳;每次對(duì)變量的讀取,都會(huì)從內(nèi)存中重新加載嚼贡。
感覺有點(diǎn)跑偏了熏纯,讓我們回到JVM的代碼中來。
執(zhí)行完賦值操作后粤策,緊接著執(zhí)行OrderAccess::storeload()
樟澜,這又是啥?
其實(shí)這就是經(jīng)常會(huì)念叨的內(nèi)存屏障叮盘,之前只知道念秩贰,卻不知道是如何實(shí)現(xiàn)的。從CPU緩存結(jié)構(gòu)分析中已經(jīng)知道:一個(gè)load操作需要進(jìn)入LoadBuffer柔吼,然后再去內(nèi)存加載毒费;一個(gè)store操作需要進(jìn)入StoreBuffer,然后再寫入緩存愈魏,這兩個(gè)操作都是異步的觅玻,會(huì)導(dǎo)致不正確的指令重排序,所以在JVM中定義了一系列的內(nèi)存屏障來指定指令的執(zhí)行順序培漏。
JVM中定義的內(nèi)存屏障如下溪厘,JDK1.7的實(shí)現(xiàn)
1、loadload屏障(load1牌柄,loadload畸悬, load2)
2、loadstore屏障(load友鼻,loadstore傻昙, store)
這兩個(gè)屏障都通過acquire()
方法實(shí)現(xiàn)
其中__asm__
闺骚,表示匯編代碼的開始彩扔。
volatile,之前分析過了僻爽,禁止編譯器對(duì)代碼進(jìn)行優(yōu)化虫碉。
把這段指令編譯之后,發(fā)現(xiàn)沒有看懂....最后的"memory"是編譯器屏障的作用胸梆。
在LoadBuffer中插入該屏障敦捧,清空屏障之前的load操作,然后才能執(zhí)行屏障之后的操作碰镜,可以保證load操作的數(shù)據(jù)在下個(gè)store指令之前準(zhǔn)備好
3兢卵、storestore屏障(store1,storestore绪颖, store2)
通過"release()"方法實(shí)現(xiàn):
在StoreBuffer中插入該屏障秽荤,清空屏障之前的store操作,然后才能執(zhí)行屏障之后的store操作,保證store1寫入的數(shù)據(jù)在執(zhí)行store2時(shí)對(duì)其它CPU可見窃款。
4课兄、storeload屏障(store,storeload晨继, load)
對(duì)java中的volatile變量進(jìn)行賦值之后烟阐,插入的就是這個(gè)屏障,通過"fence()"方法實(shí)現(xiàn):
看到這個(gè)有沒有很興奮紊扬?
通過os::is_MP()
先判斷是不是多核蜒茄,如果只有一個(gè)CPU的話,就不存在這些問題了珠月。
storeload屏障扩淀,完全由下面這些指令實(shí)現(xiàn)
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
為了試驗(yàn)這些指令到底有什么用,我們?cè)賹扅c(diǎn)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不被編譯器優(yōu)化掉啤挎,這里使用了volatile進(jìn)行修飾驻谆,編譯后的匯編指令如下:
從編譯后的代碼可以發(fā)現(xiàn),第二次使用foo變量時(shí)庆聘,沒有從內(nèi)存重新加載胜臊,使用了寄存器的值。
把__asm__ volatile ***
指令加上之后重新編譯
相比之前伙判,這里多了兩個(gè)指令象对,一個(gè)lock,一個(gè)addl宴抚。
lock指令的作用是:在執(zhí)行l(wèi)ock后面指令時(shí)勒魔,會(huì)設(shè)置處理器的LOCK#信號(hào)(這個(gè)信號(hào)會(huì)鎖定總線,阻止其它CPU通過總線訪問內(nèi)存菇曲,直到這些指令執(zhí)行結(jié)束)冠绢,這條指令的執(zhí)行變成原子操作,之前的讀寫請(qǐng)求都不能越過lock指令進(jìn)行重排常潮,相當(dāng)于一個(gè)內(nèi)存屏障弟胀。
還有一個(gè):第二次使用foo變量時(shí),從內(nèi)存中重新加載喊式,保證可以拿到foo變量的最新值孵户,這是由如下指令實(shí)現(xiàn)
__asm__ volatile ( : : : "cc", "memory");
同樣是編譯器屏障,通知編譯器重新生成加載指令(不可以從緩存寄存器中取)岔留。
讀取volatile變量
同樣在bytecodeInterpreter.cpp
文件中夏哭,找到getstatic字節(jié)碼指令的解釋器實(shí)現(xiàn)。
通過obj->obj_field_acquire(field_offset)
獲取變量值
最終通過OrderAccess::load_acquire
實(shí)現(xiàn)
inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
底層基于C++的volatile實(shí)現(xiàn)献联,因?yàn)関olatile自帶了編譯器屏障的功能竖配,總能拿到內(nèi)存中的最新值厕吉。