Java中的volatile實(shí)現(xiàn)機(jī)制詳細(xì)解析
前言
在任何編程語(yǔ)言中,多線程操作同一個(gè)數(shù)據(jù)都會(huì)帶來(lái)數(shù)據(jù)不一致的問(wèn)題,這是由于在多線程的情況下CPU分配時(shí)間片并不是按照線程創(chuàng)建順序去分配的,具有一定的隨機(jī)性。
一個(gè)任務(wù)被首先創(chuàng)建出來(lái),并不意味著這個(gè)特定的任務(wù)一定會(huì)首先執(zhí)行,為了解決并發(fā)狀態(tài)下數(shù)據(jù)不一致的問(wèn)題,就有了Lock、synchronized、volatile等等一系列的解決方法。
線程對(duì)資源的感知
我們現(xiàn)在有一個(gè)游戲的充錢(qián)系統(tǒng),這個(gè)系統(tǒng)只有兩種功能:充錢(qián)、顯示余額。我們希望這個(gè)系統(tǒng)的功能是這樣的,如果玩家一旦充錢(qián),賬戶變化立刻會(huì)被感知到并輸出出來(lái)。假設(shè)賬戶里有0元:
public class VolatileTest {
final static int MAX = 500; //最多500元作為退出條件
static int deposit = 0; //初始余額
public static void main(String[] args) {
//顯示賬戶余額線程
new Thread(() -> {
int calculate = deposit;
while (calculate < MAX) {
if(calculate!=deposit){ //當(dāng)發(fā)現(xiàn)本地變量和全局變量不一致時(shí)輸出
System.out.println("當(dāng)前余額" + deposit);
calculate = deposit;
}
}
}).start();
//充錢(qián)線程,每次充錢(qián)100元
new Thread(() -> {
int calculate = deposit;
while (calculate < MAX) {
calculate += 100; //改變金額
System.out.println("充錢(qián)100元,當(dāng)前總額" + calculate);
deposit = calculate; //回寫(xiě)給全局變量
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}運(yùn)行程序,拿到下面的結(jié)果:

結(jié)果發(fā)現(xiàn),我們的感知線程除了第一次感知到變化外,后續(xù)的充錢(qián)都沒(méi)有感知到。但是線程一直再運(yùn)行并沒(méi)有停下。
我們接著分析代碼邏輯,充錢(qián)線程之所以不再打印了是因?yàn)槌隽顺溴X(qián)最大限制while (calculate < MAX),那么理論上來(lái)說(shuō)deposit現(xiàn)在已經(jīng)是500了。
但是在顯示余額的線程上,并沒(méi)有感知到deposit被修改,仍然認(rèn)為while (calculate < MAX)條件依然成立。
在這個(gè)顯示線程中calculate==deposit==100,所以顯示線程會(huì)一直空轉(zhuǎn),直到把CPU資源消耗完畢崩潰才能停下來(lái),這顯然是一個(gè)非常壞的結(jié)果。
如何修改程序呢,其實(shí)也非常簡(jiǎn)單,只要在初始余額變量上加上volatile關(guān)鍵字即可。
static volatile int deposit = 0; //初始余額,加上關(guān)鍵字
重新運(yùn)行輸出結(jié)果,這次不僅程序運(yùn)行符合預(yù)期,而且程序順利執(zhí)行完畢。每次充錢(qián)的線程執(zhí)行完畢,查詢余額的線程立刻感知到并且執(zhí)行了相應(yīng)的邏輯,整個(gè)程序邏輯上執(zhí)行順利,結(jié)果符合預(yù)期。

問(wèn)題分析
之所以會(huì)發(fā)生這樣的問(wèn)題,其實(shí)和目前的Java的內(nèi)存模型有關(guān)系。我們知道現(xiàn)在的主機(jī)一般都會(huì)在CPU和主存之間方置緩存,用來(lái)緩沖CPU過(guò)快的執(zhí)行速度與主存相對(duì)較慢的IO速度。其實(shí)Java的線程也有類似的結(jié)果,只不過(guò)緩存被本地工作空間代替了,類似于下圖。但是要說(shuō)明一點(diǎn),我們可以看作每個(gè)線程有自己的私有空間(local workspace)和共享空間(main memory) ,實(shí)際上Java并沒(méi)有在物理內(nèi)存上這樣劃出來(lái)一塊,這只是Java執(zhí)行中的一個(gè)概念模型。物理上仍然是CPU – Cache – Memory這樣的結(jié)構(gòu)。

在這種結(jié)構(gòu)中,主存中的數(shù)據(jù)每個(gè)線程都可以訪問(wèn),但是本地工作空間只有本地線程可以訪問(wèn)。
而本地工作空間中方置的東西就是本地變量和主存資源副本,因此線程并不能直接修改修改主存的數(shù)據(jù),只能讀取到工作空間中,然后由線程拿到CPU中修改。修改完成后,再?gòu)乃接泄ぷ骺臻g刷新回主存。這樣所有的線程就可以拿到最新的數(shù)據(jù)到自己的工作空間里進(jìn)行操作,這個(gè)邏輯在單線程下自然沒(méi)有問(wèn)題。
但是多線程的時(shí)候,就會(huì)出現(xiàn)數(shù)據(jù)不一致的問(wèn)題,比如Thread 0在私有工作空間中做了deposit變量修改,但是還沒(méi)有刷新到主存中的時(shí)候。
Thread 1就把主存中的deposit變量copy到了自己的工作空間進(jìn)行操作,那么Thread 1用的就是舊的數(shù)據(jù),計(jì)算的結(jié)果也就出現(xiàn)了偏差,后來(lái)再經(jīng)過(guò)各個(gè)線程的互相刷新共享空間的數(shù)據(jù),偏差就會(huì)越來(lái)越大。
volatile的原理
根據(jù)我們寫(xiě)的例子來(lái)看,volatile關(guān)鍵字加上以后,就可以保證當(dāng)某個(gè)線程對(duì)主存中數(shù)據(jù)修改的時(shí)候,其他線程能夠感知到這種修改,因此可以基于最新的版本進(jìn)行后續(xù)的操作。
所以volatile關(guān)鍵字應(yīng)該具有以下用作:
保證數(shù)據(jù)的可見(jiàn)性:
某個(gè)線程對(duì)共享數(shù)據(jù)的修改,其他數(shù)據(jù)能夠立刻感知到,這點(diǎn)是如何做到的呢?首先要先引入一個(gè)知識(shí)點(diǎn):緩存的一致性協(xié)議(MESI)。
這個(gè)協(xié)議會(huì)使得:
讀操作,CPU讀取cache中的數(shù)據(jù)時(shí),不做任何鎖操作。
寫(xiě)操作,當(dāng)CPU將要修改某個(gè)共享變量的時(shí)候,CPU會(huì)發(fā)出信號(hào),通知其他的CPU將該變量在其他中緩存中副本所對(duì)應(yīng)的cache line置為無(wú)效,這也就導(dǎo)致了其他CPU的緩存中該變量失效,只能再次從主存中讀取。

有了這個(gè)前提知識(shí)以后,有些讀者一定想到了:一旦給某個(gè)共享變量加上volatile關(guān)鍵字以后,當(dāng)某個(gè)線程要修改共享變量的時(shí)候,會(huì)通知其他線程,來(lái)把其他線程的私有空間中的該共享變量的副本的cache line置為無(wú)效,使得其他線程再次去主存中讀取最新的值,其對(duì)應(yīng)的硬件原理就是上面所說(shuō)的MESI協(xié)議。通過(guò)這樣的辦法,保證了共享變量在各個(gè)線程中的修改可見(jiàn)性,使得所有的線程對(duì)共享變量的修改具有感知。但是重新讀取并不能可以保證一定可以讀取到最新的數(shù)據(jù),因此volatile還必須要有更多的功能。
保證線程的有序性:
程序在編譯階段和指令優(yōu)化階段會(huì)對(duì)執(zhí)行的指令進(jìn)行重排序,也就是說(shuō)我們寫(xiě)的代碼順序,并不是程序的執(zhí)行順序。這樣做的目的是為了提高CPU的執(zhí)行效率和吞吐量,比如賦值指令的執(zhí)行效率明顯會(huì)遠(yuǎn)遠(yuǎn)高于運(yùn)算指令,那么在重排序階段就會(huì)把賦值指令放在一起,運(yùn)算指令放在一起,以提高總體的效率。這樣做在單線程狀態(tài)下沒(méi)有問(wèn)題,但是在多線程狀態(tài)下,一個(gè)變量的先賦值后運(yùn)算和先運(yùn)算后賦值就可能會(huì)產(chǎn)生很明顯的區(qū)別。但是對(duì)于volatile修飾的變量有這樣一個(gè)規(guī)則來(lái)保證程序執(zhí)行的順序:
- volatile之前的代碼不能調(diào)整到它的后面。
- volatile之后的代碼不能調(diào)整到它的前面。
- volatile修飾的代碼,不可以調(diào)整順序。
最終是如何實(shí)現(xiàn)這個(gè)功能的呢?當(dāng)解析到被volatile修飾的變量的時(shí)候,在匯編代碼上該變量會(huì)有一個(gè)Lock標(biāo)記,表示該變量被鎖住。也就是說(shuō)想某一個(gè)線程使用共享變量的時(shí)候,該變量就會(huì)被鎖住。其他線程由于MESI導(dǎo)致必須去主存哪取新數(shù)據(jù)的時(shí)候,會(huì)因?yàn)檫@里有Lock而阻塞,直到這個(gè)線程釋放該共享變量。分析到最后其實(shí)volatile依然是由鎖構(gòu)建的功能,但是這個(gè)鎖也是一個(gè)輕量級(jí)的鎖。
注意:volatile即使具有上述這些作用,但是并不具有原子性。
volatile的應(yīng)用
說(shuō)了半天volatile關(guān)鍵字的原理,這里列舉幾個(gè)常用的場(chǎng)景。
Flag標(biāo)志:作為控制某個(gè)功能或者分支的flag標(biāo)志使用。
public class VolatileTest implements Runnable{
private volatile boolean flag=false;
@Override
public void run() {
if (flag){
//...code
}else{
//...code
}
}
public void close(){
flag=false;
}
}雙重檢查鎖定:Double Checked Locking(DCL),一般用在單例模式上。
public class VolatileTest2 {
private volatile static VolatileTest2 vt;
public static VolatileTest2 getInstance(){
if(Objects.isNull(vt)){
vt=new VolatileTest2();
}
return vt;
}
}程序的執(zhí)行順序必須保證:如果有場(chǎng)景中必須要求某些變量在程序的限定位置出現(xiàn),而且不能隨意變更執(zhí)行順序,那么可以對(duì)這些變量加上volatile去保證,這些代碼的執(zhí)行順序是完全按照代碼所寫(xiě)的順序執(zhí)行的。
volatile與synchronized的區(qū)別
之前的博客已經(jīng)對(duì)synchronized做了講解,我們知道synchronized是加鎖用的,那么volatile和synchronized有什么區(qū)別呢?我們用一個(gè)表格做個(gè)對(duì)比。
| 區(qū)別 | volatile | synchronized |
| 語(yǔ)法上 | 只能修飾變量 | 只能修飾方法和語(yǔ)句塊 |
| 原子性 | 不能保證原子性 | 可以保證原子性 |
| 可見(jiàn)性 | 通過(guò)對(duì)變量加lock,使用緩存的一致性協(xié)議保證可見(jiàn)性 | 使用對(duì)象監(jiān)視器monitor保證可見(jiàn)性,monitorenter,monitorexit,ACC_SYNCHRONIZED |
| 有序性 | 可以保證有序性 | 可以保證有序性,但是加鎖部分變?yōu)閱尉€程執(zhí)行 |
| 阻塞 | 輕量級(jí)鎖不會(huì)阻塞 | 偏向鎖,可能會(huì)引發(fā)阻塞; 重量級(jí)鎖,會(huì)引起阻塞 |
總結(jié)
本文的主要內(nèi)容就在于要理解volatile的緩存的一致性協(xié)議導(dǎo)致的共享變量可見(jiàn)性,以及volatile在解析成為匯編語(yǔ)言的時(shí)候?qū)ψ兞考渔i兩塊理論內(nèi)容。
到此volatile關(guān)鍵字的基本內(nèi)容告一段落,謝謝大家。
到此這篇關(guān)于Java中的volatile實(shí)現(xiàn)機(jī)制詳細(xì)解析的文章就介紹到這了,更多相關(guān)volatile實(shí)現(xiàn)機(jī)制內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
基于獲取JAVA路徑,包括CLASSPATH外的路徑的方法詳解
本篇文章是對(duì)獲取JAVA路徑,包括CLASSPATH外的路徑的方法進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-05-05
spring事務(wù)之事務(wù)掛起和事務(wù)恢復(fù)源碼解讀
這篇文章主要介紹了spring事務(wù)之事務(wù)掛起和事務(wù)恢復(fù)源碼解讀,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-11-11
Mybatis框架之工廠模式(Factory Pattern)
MyBatis中使用工廠模式來(lái)管理和創(chuàng)建SqlSession對(duì)象,從而簡(jiǎn)化數(shù)據(jù)庫(kù)訪問(wèn)的配置和管理過(guò)程,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2024-11-11
Java?深入理解創(chuàng)建型設(shè)計(jì)模式之抽象工廠模式
當(dāng)系統(tǒng)所提供的工廠所需生產(chǎn)的具體產(chǎn)品并不是一個(gè)簡(jiǎn)單的對(duì)象,而是多個(gè)位于不同產(chǎn)品等級(jí)結(jié)構(gòu)中屬于不同類型的具體產(chǎn)品時(shí)需要使用抽象工廠模式,抽象工廠模式是所有形式的工廠模式中最為抽象和最具一般性的一種形態(tài)2022-02-02
Java后端SSM框架圖片上傳功能實(shí)現(xiàn)方法解析
這篇文章主要介紹了Java后端SSM框架圖片上傳功能實(shí)現(xiàn)方法解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-06-06
關(guān)于Springboot在新增和修改下上傳圖片并顯示的問(wèn)題
這篇文章主要介紹了關(guān)于Springboot在新增和修改下上傳圖片并顯示的問(wèn)題及解決方法,在這里 springboot中已經(jīng)內(nèi)嵌了上傳圖片的依賴包,因此不需要再添加額外依賴,具體實(shí)現(xiàn)代碼跟隨小編一起看看吧2021-04-04
SpringCloud使用Zookeeper作為注冊(cè)中心
這篇文章主要介紹了SpringCloud如何使用Zookeeper作為注冊(cè)中心,幫助大家更好的理解和學(xué)習(xí)使用Zookeeper,感興趣的朋友可以了解下2021-04-04
Java設(shè)計(jì)模式之外觀模式(Facade模式)介紹
這篇文章主要介紹了Java設(shè)計(jì)模式之外觀模式(Facade模式)介紹,外觀模式(Facade)的定義:為子系統(tǒng)中的一組接口提供一個(gè)一致的界面,需要的朋友可以參考下2015-03-03
springboot接收日期字符串參數(shù)與返回日期字符串類型格式化
這篇文章主要介紹了springboot接收日期字符串參數(shù)與返回日期字符串類型格式化,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-01-01

