第一次理解:
剛學(xué)java時(shí),對(duì)于volatile的記憶就是:
- volatile保證可見性
- volatile防止指令重排序
- volatile不保證原子性
沒過腦的背了一下,寫代碼的時(shí)候也沒用到過,以為不重要,然后就不了了之。
第二次理解
一段代碼引起好奇
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
上圖為比較經(jīng)典的dcl(dubbo check lock)單例模式,雙重if判斷是為了防止多線程多次創(chuàng)建,但是instance屬性為什么還要加個(gè)volatile關(guān)鍵字呢,有什么作用么?
其實(shí)它的作用主要體現(xiàn)在禁止指令重排序。
首先先理解下什么叫指令重排序?
指令重排序可以說是jvm對(duì)程序執(zhí)行的一個(gè)優(yōu)化,他可以保證普通的變量在方法執(zhí)行的過程中所有依賴賦值結(jié)果的地方都能獲取到正確的結(jié)果,而不能保證變量賦值操作的順序與程序代碼中寫的順序保持一致。如
x = 1;
y = 2;
這兩條賦值語句之間沒有依賴關(guān)系,所以在具體執(zhí)行時(shí)可能會(huì)先賦值y在賦值x,發(fā)生了指令重排。
而上述DCL代碼中雖然表面只有這instance = new Singleton();一條語句,但是這個(gè)賦值操作編譯成字節(jié)碼文件后是分為3個(gè)步驟來完成的:
- 為對(duì)象開辟內(nèi)存空間并賦默認(rèn)值
- 調(diào)用構(gòu)造函數(shù)為對(duì)象賦初始值
- 將instance引用指向剛開辟的內(nèi)存地址
而程序在執(zhí)行這三步時(shí),會(huì)有可能先執(zhí)行3再執(zhí)行2,如果發(fā)生這種情況,線程一先將引用指向地址,還沒來得及執(zhí)行構(gòu)造方法,線程二進(jìn)來判斷instance!=null 直接拿這半初始化的對(duì)象去使用,就出現(xiàn)了問題。
所以此處需要用volatile關(guān)鍵字來修飾變量,禁止指令重排序情況的發(fā)生。那么volatile是如何做到禁止重排序的呢?
《深入理解java虛擬機(jī)》中這樣寫道:
我們對(duì)volatile修飾的變量進(jìn)行編譯后發(fā)現(xiàn),在賦值操作后多執(zhí)行了一個(gè)“l(fā)ock addl $0x0,(%esp)”,這個(gè)操作相當(dāng)于一個(gè)內(nèi)存屏障(Memory Barrier 或 Memory Fence,指重排序時(shí)不能把后面的指令重排序到內(nèi)存屏障之前的位置)
也有別的博主這樣寫道:
JMM為volatile加內(nèi)存屏障有以下4種情況:
在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障,防止寫volatile與后面的寫操作重排序。
在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障,防止寫volatile與后面的讀操作重排序。
在每個(gè)volatile讀操作的后面插入一個(gè)LoadLoad屏障,防止讀volatile與后面的讀操作重排序。
在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障,防止讀volatile與后面的寫操作重排序。
第三次理解
那么保證可見性又是指什么東東?
要想理解這可見性,需要先了解java內(nèi)存模型(jmm)。學(xué)過計(jì)算機(jī)的同學(xué)都知道多核cpu中每個(gè)cpu都有自己的高速緩存,如L1,L2,L3,且每個(gè)cpu之間的緩存是隔離的,即數(shù)據(jù)不可見。而多個(gè)cpu又共享一個(gè)主內(nèi)存,數(shù)據(jù)一般會(huì)從磁盤讀取到主內(nèi)存當(dāng)中,當(dāng)cpu需要處理數(shù)據(jù)時(shí),需要從主內(nèi)存讀取數(shù)據(jù)到自己的緩存當(dāng)中然后進(jìn)行運(yùn)算,運(yùn)算結(jié)束后將最新數(shù)據(jù)同步回內(nèi)存之中。當(dāng)然這種模型也伴隨這緩存一致性問題的出現(xiàn)。
其實(shí)java內(nèi)存模型和cpu模型非常的類似:
每個(gè)線程擁有自己的工作內(nèi)存,然后共享的變量會(huì)存放在主內(nèi)存(jvm的內(nèi)存)當(dāng)中,線程之間工作內(nèi)存互相隔離。如圖:

上圖來源于《深入理解java虛擬機(jī)363頁》
我們?cè)賮砜磦€(gè)容易理解的圖:

再回到我們的保證可見性的探討:
如上圖所示,若線程A和B都操作主內(nèi)存的共享變量時(shí),AB會(huì)將共享變量先拷貝會(huì)自己的工作內(nèi)存,在A率先完成修改完之后再同步刷回到主內(nèi)存當(dāng)中,此時(shí)線程B本地內(nèi)存的數(shù)據(jù)還是最先拷貝的舊數(shù)據(jù),沒有及時(shí)的獲取到已修改的最新數(shù)據(jù),最后會(huì)造成數(shù)據(jù)不一致問題。
而volatile修飾變量時(shí),它會(huì)保證修改的值會(huì)立即被更新到主存,并通知其他線程當(dāng)前緩存的變量已失效,需要重新到主內(nèi)存中讀取。
底層也是通過內(nèi)存屏障來保證的。
針對(duì)這個(gè)特性,常見的使用的場(chǎng)景為狀態(tài)標(biāo)記量
public class VolatileTest1 {
volatile static boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
System.out.println("t1 start");
while (!flag){
System.out.println("doing something");
}
},"t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
}
和我們期望的一樣,1秒后,程序正常停止。
但是好奇的我開始思考,那是不是只要不加volatile,程序就不會(huì)停止?立即更新的反義詞是什么?正常情況下,線程會(huì)不會(huì),什么時(shí)候會(huì)把修改的值寫會(huì)主內(nèi)存,別的線程又會(huì)什么時(shí)候會(huì)去重新讀???
帶著好奇我把上訴代碼中的volatile去掉,運(yùn)行結(jié)果如圖:

沒錯(cuò) 程序居然正常停掉了!
然后我又把while循環(huán)里的system輸出去掉后,再次運(yùn)行:

這次又沒有停止?。?br> 難道就是因?yàn)橐痪漭敵稣Z句的問題么?我又嘗試換成i++試試:

這次也沒有停止?。?!
很神奇,搞得我也很懵逼!?。?br> 我不知道是不是因?yàn)榄h(huán)境的原因,我用的jdk11和8,idea2020.1.2,
個(gè)人初步猜測(cè):不加volatile,即正常情況下,本地線程更新值后,會(huì)很快的寫回主內(nèi)存,而其他線程什么時(shí)候重新從主內(nèi)存中讀取是不確定的。
上述while代碼里面執(zhí)行點(diǎn)稍微費(fèi)時(shí)的操作(如輸出,sleep 1s),都是可以停止的,如果循壞太快,它可能沒時(shí)間去重新讀取flag的值。
(希望有大佬看到小弟的這篇文章,并指點(diǎn)一二。)
第四次理解
那不保證原子性又是什么鬼?
原子性:保證指令不會(huì)受到線程上下文切換的影響,即一個(gè)操作不會(huì)被cpu切換所打斷。
我們舉一個(gè)最常見的案列來說明:
多個(gè)線程對(duì)同一個(gè)數(shù)字進(jìn)行++操作:
public class VolatileDemo {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final VolatileDemo test = new VolatileDemo();
System.out.println("start");
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
test.increase();
}
}).start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(test.inc);
}
}
我們啟動(dòng)了20個(gè)線程對(duì)inc進(jìn)行++操作,每個(gè)線程+10000,理想結(jié)果應(yīng)該為200000,但是實(shí)際運(yùn)行結(jié)果卻小于這個(gè)值,而且結(jié)果每次都不一樣(可以多運(yùn)行幾次觀察):

這是為什么呢,程序中inc已經(jīng)加了volatile修飾,保證了線程的可見性,但是為什么結(jié)果還是會(huì)比預(yù)想的小呢?
這是因?yàn)?+操作并不是簡單的一步操作,即他不是原子性的,查看編譯后的字節(jié)碼文件,++的實(shí)際操作為:
(實(shí)事求是地說,使用字節(jié)碼來分析并發(fā)問題仍然是不嚴(yán)謹(jǐn)?shù)模驗(yàn)榧词咕幾g出來只有一條字節(jié)碼指令,也并不意味執(zhí)行這條指令就是一個(gè)原子操作。一條字節(jié)碼指令在解釋執(zhí)行時(shí),解釋器要運(yùn) 行許多行代碼才能實(shí)現(xiàn)它的語義。如果是編譯執(zhí)行,一條字節(jié)碼指令也可能轉(zhuǎn)化成若干條本地機(jī)器碼 指令。)
public void increase();
Code:
0: aload_0
1: dup
2: getfield #2 // Field inc:I
5: iconst_1
6: iadd
7: putfield #2 // Field inc:I
10: return
inc++操作分成了2.獲取字段 5.準(zhǔn)備常數(shù)1 6.進(jìn)行加1操作 7.賦值 四步
不保證原子性,即無法確保這四步操作不會(huì)被cpu切換打斷:

如圖cpu在線程1修改完之后還未寫入內(nèi)存時(shí),切換到線程2,執(zhí)行完了++操作,此時(shí)cpu切換回線程1又把inc=1 寫回去,造成了inc的值被覆蓋。
我們?cè)倏聪缕胀ǖ馁x值操作的字節(jié)碼文件 如:x=1
public void fun1(){
inc = 1;
}
// 編譯后
public void fun1();
Code:
0: aload_0
1: iconst_1
2: putfield #2 // Field inc:I
5: return
他沒有g(shù)etfield和add的操作,直接賦值,所以賦值操作算是原子性的。
而synchronized是如何保證原子性的呢?
通過字節(jié)碼文件我們可以發(fā)現(xiàn),用synchronized修飾真的代碼塊在前后會(huì)執(zhí)行monitorenter和monitorexit指令,這minitor指令底層則是通過lock和unlock來滿足原子性的,他只允許同時(shí)只有一個(gè)線程來操作資源。
推薦一篇很詳細(xì)很全面的文章,此篇部分文字也有參考如下文章: