前言
線程并發(fā)系列文章:
Java 線程基礎
Java 線程狀態(tài)
Java “優(yōu)雅”地中斷線程-實踐篇
Java “優(yōu)雅”地中斷線程-原理篇
真正理解Java Volatile的妙用
Java ThreadLocal你之前了解的可能有誤
Java Unsafe/CAS/LockSupport 應用與原理
Java 并發(fā)"鎖"的本質(一步步實現(xiàn)鎖)
Java Synchronized實現(xiàn)互斥之應用與源碼初探
Java 對象頭分析與使用(Synchronized相關)
Java Synchronized 偏向鎖/輕量級鎖/重量級鎖的演變過程
Java Synchronized 重量級鎖原理深入剖析上(互斥篇)
Java Synchronized 重量級鎖原理深入剖析下(同步篇)
Java并發(fā)之 AQS 深入解析(上)
Java并發(fā)之 AQS 深入解析(下)
Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 詳解
Java 并發(fā)之 ReentrantLock 深入分析(與Synchronized區(qū)別)
Java 并發(fā)之 ReentrantReadWriteLock 深入分析
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(應用篇)
最詳細的圖文解析Java各種鎖(終極篇)
線程池必懂系列
通過這篇文章你將知道:
1、什么是線程同步以及為什么需要線程同步
2、加一條打印語句可退出循環(huán)原因
3、volatile為什么不能保證原子性
4、cpu 緩存一致性協(xié)議(MESI)
5、內存屏障的作用
6、有了MESI,為啥還需要volatile
7、volatile可見性、順序性原理
8、volatile運用在哪些場景
概念解析
看到volatile就會想到線程同步,那么volatile能夠實現(xiàn)線程同步嗎?
兩個前提:什么是線程同步?為什么要進行線程同步?
-
我們知道,在現(xiàn)代計算機原理中,中央處理器(cpu)負責計算,內存(mem)負責存儲數(shù)據(jù),兩者交互可以簡單理解為程序執(zhí)行的核心(當然實際比這復雜得多)。
image.png
線程則可認為是cpu上運行的一段代碼,在單核cpu中,任何時刻只有一個線程被執(zhí)行

如上圖所示,有5個線程在等待cpu輪轉執(zhí)行,mem里有個變量x,每個線程的功能是使x增1,假設x初始值x=1,那么線程執(zhí)行結果如下:
線程1從mem取出x=1 送到cpu運算x = x+1 -> x = 2;
線程2從mem取出x=2 送到cpu運算x = x+1 -> x= 3;
...
線程5從mem取出x=4 送到cpu運算x = x+1 -> x= 5;
可以看出,在單核cpu中,多個線程共享同一個變量x,并且都對其進行了操作,當其中一個線程修改了x,其它線程能夠知道該值已經發(fā)生了變化。我們可以理解為對于變量x的,線程間達到了線程同步。
2、我們知道cpu運算速度遠遠高于內存讀寫速度,當線程1計算x完成并且寫入內存時,此時cpu沒事干了。為了充分利用cpu資源,在cpu和mem之間加了個存儲器,速度比mem快很多,稱之為cache。當從mem中讀取x后,變將x更新到cache里,下次再讀取x時,先去cache里查找x,找到就不用到mem里找了,顯著提高了讀寫效率。然而單核cpu同一時間只能執(zhí)行一個線程,為了能夠實現(xiàn)真正的并行,多核cpu應運而生,如下圖。

線程1運行在cpu1上,線程2運行在cpu2上,x變量存儲在mem中。
可以看出,cpu與mem之間多了一層cache,那么現(xiàn)在線程1、線程2如何更新變量x呢?
1、假設x的初始值x=1
2、線程1和線程2同時對x進行自增操作x=x+1
3、線程1將x值加載到cache1,線程2將x值加載到cache2
4、線程1修改x=x+1->x = 2,并寫入cache1
5、線程2修改x=x+1->x = 2,并寫入cache2
6、將cache1里x寫入mem,此時x=2。將cache2里x寫入mem,此時x=2
7、最終mem里的x=2
由于是線程1和線程2同時修改x,因此就會存在一個風險:當線程2修改x時并不知道線程1在修改x,當然線程1也知道線程2在修改x,兩個線程自顧自的干自己的活,最終反饋到mem里的x=2,并不是預期的x=3。
舉個通俗的例子:爸爸(線程1)媽媽(線程2)規(guī)定小明1天只能有1元的零花錢,某一天爸爸給了小明1元,而媽媽并不知道爸爸已經給了1元,于是也給了小明1元,至此小明擁有了2元,就與爸爸媽媽的要求不相符了。這就是需要進行線程同步的現(xiàn)實需求。
上面解釋了什么是線程同步以及為什么要線程同步,接下來我們來看看volatile能否進行線程同步。
private volatile static int x = 0;
public static void main(String args[]) {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
x++;
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
System.out.println("x=" + x);
} catch (Exception e) {
}
}
thread1和thead2分別對x自增,x使用volatile修飾,預期結果x = 2000,實際結果:


可以看出結果是隨機的,不符合預期,說明此種場景下,volatile并不能保證線程同步。
線程并發(fā)三要素
原子性
一個或者多個操作,要么全部執(zhí)行并且中途不能被打斷,要么都不執(zhí)行。
可見性
同一個線程里,先執(zhí)行的代碼結果對后執(zhí)行的代碼可見,不同線程里任意線程對某個變量修改后,其它線程能夠及時知道修改后的結果。
有序性
同一線程里,程序的執(zhí)行順序按照代碼的先后順序執(zhí)行。
可以看出,如果不作其它的操作,在多線程環(huán)境下可能就會遇到原子性、可見性的問題,因為各個線程之間并不知道其中某個線程正在對共享變量進行操作,也不能立即知道操作后的結果。要保證多線程安全,就需要滿足上述三個條件,那么volatile滿足哪些條件呢?
驗證volatile可見性
看下邊網絡上摘抄一個例子:
【注1】https://juejin.im/post/5c6b99e66fb9a049d51a1094
private static int sharedVariable = 0;
private static final int MAX = 10;
public static void main(String[] args) {
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
}
}
System.out.println(Thread.currentThread().getName() + " stop run");
}, "t1").start();
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
sharedVariable = oldValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + " stop run");
}, "t2").start();
}
上面代碼目的是:有個共享變量sharedVariable,分別啟動兩個線程t1,t2。t2每次對sharedVariable自增操作,t1不停檢測sharedVariable變化,如果發(fā)生變化則打印。根據(jù)之前的了解,我們很快就能猜出t1可能無法實時獲知sharedVariable的變化,運行代碼來驗證一下我們的猜測。

上圖傳達出兩個信息:
1、t2將sharedVariable改變?yōu)?0,t1只有一次打印
2、t2線程停止,t1未停止,程序沒有停止運行
和我們預想的一致,t2對共享變量的修改,t1沒能夠及時獲知,導致t1一直在檢測。好了,現(xiàn)在我們加上volatile修飾sharedVariable,結果會如何呢?

上圖傳達出兩個信息:
1、t1能夠及時獲知t2每次對sharedVariable的修改。
2、t1、t2均已停止,程序停止運行。
通過上面的正反例子(有無volatile的情況下),似乎能夠證明volatile修飾的變量能在各個線程間可見,但此種證明方式合適嗎?我們再來看看t1的代碼
while (sharedVariable < MAX) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
}
}
當sharedVariable != oldValue條件成立時才會打印sharedVariable的值,那我們想直接知道sharedVariable值的變化呢,因此在改造將代碼做兩個改動:
1、將volatile修飾符去掉。
2、在t1循環(huán)里增加一行打印。
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
System.out.println("sharedVariable:" + sharedVariable + " oldValue:" + oldValue);
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
}
}
System.out.println(Thread.currentThread().getName() + " stop run");
}, "t1").start();
結果:

結果出乎我們的意料,在沒有volatile修飾sharedVariable變量的情況下,t1仍然能夠監(jiān)測到sharedVariable的變化,而我們僅僅只增加了1行打印語句而已。
既然t1能夠監(jiān)測到sharedVariable變化,那為啥之前if (sharedVariable != oldValue)條件沒成立呢,因此我們想在else里打印驗證一下:
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
} else {
System.out.println("sharedVariable:" + sharedVariable + " oldValue:" + oldValue);
}
}
System.out.println(Thread.currentThread().getName() + " stop run");
}, "t1").start();
結果如下:

t1還是能夠監(jiān)測到sharedVariable變化。為了能夠看清t1里的全部打印,我們加sleep(200),再來看看打印結果:

我們看到t1能夠感知到t2每次對sharedVariable的改變。這里面的可變因素是增加了一行打印,因此我們現(xiàn)在將else里的打印去掉
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
} else {
try {
// System.out.println("sharedVariable:" + sharedVariable + " oldValue:" + oldValue);
Thread.sleep(200);
} catch (Exception e) {
}
}
}
System.out.println(Thread.currentThread().getName() + " stop run");
}, "t1").start();
結果:

結果依舊,這下可變因素只有一句sleep(200)代碼了,我們將此行代碼注釋
結果:

打印結果回到了我們最開始時的表現(xiàn),t1不能夠監(jiān)測到sharedVariable的變化。
由現(xiàn)象推導出的結論:
在t1里增加打印或者sleep,能夠讓t1監(jiān)測到sharedVariable的變化
也許你會說:“既然如此,我還需要volatile干嘛”。println和sleep真能達到可見性嗎?查看了println和sleep方法,本身沒什么特殊的。既然確定我們代碼沒啥問題,不禁會想到是不是編譯器和JVM在搞事呢?看看沒有加printltn和sleep時,它們對代碼做了怎樣的優(yōu)化。
【注2】https://www.cnblogs.com/dzhou/p/9549839.html
引用此文章一張圖

有可能對代碼進行優(yōu)化部分包括:
1、一是編譯器編譯為.class文件
2、解釋器&JIT代碼生成器部分
查看.class文件
編譯前
[圖片上傳失敗...(image-f54518-1634220567276)]
編譯后
[圖片上傳失敗...(image-920fdb-1634220567276)]
可以看出,編譯前后沒啥區(qū)別。
查看JVM優(yōu)化
解釋器是將字節(jié)碼解釋為機器語言;JIT是為了讓重復執(zhí)行的代碼(熱點代碼)避免重復解釋,將之編譯為本地機器碼,用到時直接執(zhí)行機器碼,達到了節(jié)約時間的目的。
既然編譯器沒有做優(yōu)化,那么猜測就是JVM做了優(yōu)化,實際上還真是JIT做了這事。
【注3】https://hllvm-group.iteye.com/group/topic/34932#post-232535
這篇文章解釋了JIT對循環(huán)做了怎樣的優(yōu)化。
這里簡單說一下:
依然是以t1的代碼為例,我們的初衷是如果sharedVariable<10,那么不斷監(jiān)測sharedVariable的變化,如果發(fā)生了變化則打印出來。
while(sharedVariable < 10) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
}
}
JIT發(fā)現(xiàn)這里是重復判斷“sharedVariable != oldValue”,因此優(yōu)化了代碼:只在第一次取sharedVariable的值,后續(xù)不再取最新值,然后一直死循環(huán),最終導致程序沒退出。而增加了println或者sleep后,JIT取消了優(yōu)化。(注:此處結論根據(jù)鏈接里的例子進行猜測,沒有去了解匯編后的代碼,若有不同看法,請評論指正)。
緩存一致性協(xié)議
上面我們發(fā)現(xiàn)了一個現(xiàn)象:沒有volatile修飾共享變量,增加println/sleep取消JIT優(yōu)化,使得t1能夠監(jiān)測到sharedVariable的變化,這是怎么做到的呢?記得我們最開始時提到的多核cpu下,每個cpu有多級cache緩存,cpu每次優(yōu)先從cache里取數(shù)據(jù),為了能夠讓不同cpu cache之間數(shù)據(jù)盡量一致,cpu實現(xiàn)了緩存一致性協(xié)議(Cache-Coherence Protocols )

cache基本數(shù)據(jù)單位稱之為cache line(大小可能為64byte 128byte等,不同cache設計不一樣),cache與mem和cache之間的數(shù)據(jù)交換都是以cache line為基本單位。既然cache1、mem、cache2之間要保證數(shù)據(jù)一致性,那么需要有效的協(xié)同。cache line設計了四種狀態(tài):
Modified(M),Exclusive(E),Shared(S)和Invalid(I)
因此緩存一致性協(xié)議也稱作MESI協(xié)議。那么cache1、mem、cache2是怎么通信的呢?它們之間定義了6種消息:
1、Read (包含要讀取變量在mem里的地址)
2、Read Response (對Read消息的回復,包含對應變量的cache line)
3、Invalidate (包含待失效的變量地址)
4、Invalidate Acknowledge (對Invalidate消息的回復)
5、Read Invalidate (Read消息和Invalidate消息的結合)
6、WriteBack (將cache 刷新到mem里)
簡單化的通信過程
我們分別從讀和寫一個共享變量x來觀察MESI如何工作的。
讀寫x:
x存在mem里,不在cache1、cache2里,初始值x=0
cpu1讀x:
1、cpu1發(fā)出Read消息,mem和cache2收到該消息,此時mem發(fā)送Read Response(包含x)
2、cpu1收到Read Response后,將數(shù)據(jù)放入對應的cache line
cpu2讀取x:
1、cpu2發(fā)出Read消息,mem和cache1收到該消息,此時cache1發(fā)送Read Response(包含x)
2、cpu2收到Read Response后,將數(shù)據(jù)放入對應的cache line
此時,cache1 和cache2都擁有了x,狀態(tài)為S
現(xiàn)在cpu1要修改x=1
1、cpu1發(fā)送Invalidate消息
2、cpu2收到后,找到對應的cache line,將之移除(置為I狀態(tài)),并發(fā)送Invalidate Acknowledge
3、cpu1收到Invalidate Acknowledge,將x=1更新到cache line,此時狀態(tài)為(M)
cpu2讀取x=1
1、cpu2讀取x時,發(fā)現(xiàn)cache line狀態(tài)為I,因此會發(fā)送Read消息
2、cpu1收到Read消息,發(fā)現(xiàn)此時處在M狀態(tài),發(fā)出WriteBack將cache line刷新到mem,并發(fā)送Read Response給cpu2
3、cpu2收到Read Response,更新對應cache line
此時x=1已經會寫到mem,并且cpu2 cache里 x =1,cache1 、mem、cache2 三者x=1,完成一致性通信。
榨取cpu性能
如果cpu參照上述協(xié)議運行,那么可想而知效率是比較低的。
Store Buffer
假設x在cpu2里,不在cpu1里,x初始值x=0
cpu1執(zhí)行x=1
1、cpu1發(fā)送Read Invalidate消息
2、cpu2收到后先移除對應的cache line,并將Read Response和Invalidate Acknowledge返回給cpu1
3、cpu1收到后放入對應的cache line
4、cpu1將x=1寫入對應的cache line
從上面步驟可以看出,cpu1需要等待cpu2的Response才能進行下一步操作。等待過程比較耗時,因此cpu1和cache 之間增加了Store Buffer暫存數(shù)據(jù),流程變?yōu)槿缦拢?/p>
1、cpu1發(fā)送Read Invalidate消息
2、cpu1將x=1放入Store Buffer
3、cpu2收到后先移除對應的cache line,并將Read Response和Invalidate Acknowledge返回給cpu1
3、cpu1收到后放入對應的cache line
5、cpu1將Store Buffer寫入對應的cache line
增加了Store Buffer,看似沒啥用處。但想象一下,如果cpu1同時需要更新多個變量如x、y、z的值,那么先將x、y、z寫入到Store Buffer,當某個時機成熟時再一并寫入cache,這個時候效率就體現(xiàn)出來了。
Invalidate Queue
cpu1 發(fā)送Read Invalidate,需要等待cpu2發(fā)送Invalidate Acknowledge,假設此時cpu2忙于將Store Buffer寫入cache line,沒有及時將cache line移除,那么就不會發(fā)送Invalidate Acknowledge,cpu1就需要等待。此時就引入了Invalidate Queue,cpu2收到Invalidate消息后,將之放入Invalidate Queue,并立即發(fā)送Invalidate Acknowledge,這個時候cpu1及時收到確認消息,并沒有被耽擱。
加入Store Buffer和Invalidate Queue后示意圖

內存屏障
通過Store Buffer 和Invalidate Queue成功提升了cpu效率,會有副作用嗎?
先來看看Store Buffer引入
x在cache2里,y在cache1里,cpu1執(zhí)行performByCpu1(),cpu2執(zhí)行performByCpu2
int x = 0;
boolean y = false;
void performByCpu1() {
x = 1;
y = true;
}
void performByCpu2() {
while(!y)
continue;
assert x == 1;
}
1、cpu1將x=1放入Store Buffer
2、cpu2發(fā)現(xiàn)y=false,則一直循環(huán)
3、cpu1將y=true放入寫入cache
4、cpu2發(fā)現(xiàn)y失效,則重新讀取發(fā)現(xiàn)y=true
5、cpu2從cpu1獲取x,發(fā)現(xiàn)x=0,assert fail
6、cpu1將Store Buffer里的x=1 刷新到cache line
問題出現(xiàn)了,x=1并沒有被cpu1及時寫入cache line,導致cpu2無法及時獲取x的值。為什么會出現(xiàn)此種問題呢?
- 當cpu1發(fā)送Read Invalidate之后,將x=1放入Store Buffer,此時繼續(xù)執(zhí)行y=1操作,而cpu2發(fā)送Read請求y的值,cpu2可能會先收到cpu1的Response,因此執(zhí)行assert x== 1操作,但是此時cpu1還沒收到Read Response和Invalidate Acknowledge,因此Store Buffer里的數(shù)據(jù)無法寫入cache,cpu2收到的是更改之前的數(shù)據(jù)。
如何解決此種問題?
通過加入內存屏障告訴CPU在進行下一步之前保證Store Buffer已刷新進cache
int x = 0;
boolean y = false;
void performByCpu1() {
x = 1;
smp_mb
y = true;
}
void performByCpu2() {
while(!y)
continue;
assert x == 1;
}
- cpu1執(zhí)行到smp_mb指令時,發(fā)現(xiàn)是內存屏障指令,先將x=1從Store Buffer寫入cache,當cpu2獲取y值時,收到的Read Response里x=1,因此asser x==1 為true。成功解決了引入Store Buffer的問題。
再來看看Invalidate Queue引入
1、cpu1發(fā)送Read Invalidate消息
2、cpu2收到后將之放入Invalidate Queue,并發(fā)送InvalidateAcknowledge消息
3、cpu1將x=1、y=true更新到cache里
4、cpu2將y更新到cache line并跳出循環(huán)
5、cpu2讀取x,發(fā)現(xiàn)x就在自己的cache line,x=0 assert fail
6、cpu2將Invalidate Queue取出,失效自身的cache
問題出現(xiàn)了,x=1并沒有被cpu2獲取到。出現(xiàn)該問題原因如下:
- cpu2執(zhí)行Invalidate Queue里的消息時間不確定
如何解決此種問題?
通過加入內存屏障告訴CPU在進行下一步之前保證Invalidate Queue里的消息已經執(zhí)行
int x = 0;
boolean y = false;
void performByCpu1() {
x = 1;
smp_mb
y = true;
}
void performByCpu2() {
while(!y)
continue;
smp_mb
assert x == 1;
}
- 當cpu2執(zhí)行到smp_mb時,強制將Invalidate Queue消息取出,將x所在的cache line移除,然后cpu2訪問x時,會從cpu1獲取,此時cpu1的x=1,cpu2收到將cache line 寫入x=1,assert x== 1 為true。成功解決了引入Invalidate Queue的問題。
【注4】內存屏障消息流轉可參考:https://zhuanlan.zhihu.com/p/55767485
小結
cpu實現(xiàn)了緩存一致性協(xié)議,盡可能保證cache之間數(shù)據(jù)一致性。但是為了充分利用cpu性能,增加了Store Buffer和Invalidate Queue緩存,導致cpu可能產生指令順序不一致問題,看起來像是指令重排了,通過增加內存讀寫屏障來規(guī)避此問題。
回到我們之前的問題:“沒有volatile修飾共享變量,增加println/sleep取消JIT優(yōu)化,使得t1能夠監(jiān)測到sharedVariable的變化”。這個能夠實現(xiàn)是因為cpu實現(xiàn)了緩存一致性協(xié)議。
再回到我們最初的問題,怎樣驗證volatile修飾的變量具有線程間可見性呢?我們原本想通過t1的死循環(huán)來證明,發(fā)現(xiàn)死循環(huán)并不是因為t1監(jiān)測不到sharedVariable變化,而是JIT對代碼進行了優(yōu)化。因此我們增加了打印語句,取消JIT優(yōu)化,此時無法有效的觀測到t1監(jiān)測不到sharedVariable變化現(xiàn)象,因為緩存一致性盡可能保證cache數(shù)據(jù)一致性。
volatile作用
可見性
MESI通過加入內存屏障指令來確保數(shù)據(jù)順序一致性,那么這個內存屏障是什么時候插入呢?實際上在編譯為匯編語句的時候,當JVM發(fā)現(xiàn)有變量被volatile修飾時,會加入LOCK前綴指令,這個指令的作用就是增加內存屏障。這就是為什么有了MESI協(xié)議,我們還需要volatile的原因之一。因為volatile變量讀寫前后插入內存屏障指令,所以cache之間的數(shù)據(jù)能夠及時感知到,共享變量就達到了線程間可見性,也就是說volatile修飾的變量具有線程間可見。
有序性
我們知道單個線程里,程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。引用上面的例子。
int x = 0;
boolean y = false;
void performByCpu1() {
x = 1;
y = true;
}
void performByCpu2() {
while(!y)
continue;
assert x == 1;
}
先來看performByCpu1()方法,x、y沒有什么關系,編譯器為了優(yōu)化,可能進行指令重排,將y=true 排在x=1前面執(zhí)行,在單線程里沒有影響,但當我們再看performByCpu2()方法時,因為指令重排的問題,導致多線程下程序執(zhí)行結果不正確了。這是編譯器層面的指令重排,加了volatile修飾后,取消這種編譯優(yōu)化。這就是為什么有了MESI協(xié)議,我們還需要volatile的原因之二。
volatile能保證原子性嗎
前面的例子我們有驗證過,volatile不能保證原子性,為什么不能保證呢?
t1、t2線程同時對x執(zhí)行x++操作,假設t1在cpu1上執(zhí)行,t2在cpu2上執(zhí)行。x++是復合操作,其包含如下三步驟:
1、讀取x的值
2、計算x+1的值
3、用x+1的結果給x賦值
指令執(zhí)行簡單化如下:
1、cpu1讀取x的值,初始值x=0
2、cpu2讀取x的值,初始值x=0
3、cpu1執(zhí)行x+1,此時tmp=1
4、cpu1將x=1寫入cache
5、cpu2收到Invalidate消息,失效其cache,并使用cpu1 cache的值,此時cpu2 cache x=1
6、cpu2執(zhí)行x+1,因為之前x=0已經在cpu2 寄存器里邊,因此直接執(zhí)行x+1,并將x=1寫入cache
7、最終寫入mem x=1 與預期結果x=2不符
因此volatile不能保證原子性。
volatile使用場景
綜上所述,volatile能夠保證可見性、有序性、不能保證原子性,那么volatile在哪些場景下會用到呢?那就針對其可見性、有序性特點來分析。
針對有序性,我們可以通過volatile禁止指令重排達到有序性。典型用處是在單例雙重檢查鎖 DCL(double-checked locking)
static volatile CheckManager instance;
public static CheckManager getInstance() {
if (instance == null) {
synchronized (CheckManager.class) {
if (instance == null) {
instance = new CheckManager();
}
}
}
return instance;
}
instance = new CheckManager() 是復合運算,正常步驟分解如下:
1、分配對象內存空間
2、初始化CheckManager
3、返回對象內存空間首地址
由于存在指令重排優(yōu)化,3可能在2之前執(zhí)行,當另一個線程獲取到instance時,由于沒有正常初始化完成,可能會導致問題。通過使用volatile 修飾instance,禁止指令重排,避免此種情況。
針對可見性,當一個線程在修改共享變量,而其它線程只是讀取變量時,使用volatile修飾共享變量,讀線程能夠及時獲知共享變量的變化。例子可參考開篇的t1、t2讀寫x做法。
實際上我們發(fā)現(xiàn)CAS、Lock等鎖機制內部使用了volatile保證可見性,有興趣的可以去看看源碼。
有疑惑、指正的請評論留言~
參考鏈接
https://juejin.im/post/5c6b99e66fb9a049d51a1094
https://www.cnblogs.com/dzhou/p/9549839.html
https://hllvm-group.iteye.com/group/topic/34932#post-232535
https://zhuanlan.zhihu.com/p/55767485
本文基于JDK 1.8,運行環(huán)境也是jdk1.8。
