淺分析Java volatile關(guān)鍵字
????大家好,前不久看了掘金一篇帖子原貼請點鏈接,那么今天就來給大家分享一下從這篇帖子中學(xué)到的volatile以及線程安全相關(guān)的知識點。
Java內(nèi)存模型
????在介紹volatile關(guān)鍵字之前,還是先給大家講講Java的內(nèi)存模型
????Java的內(nèi)存模型規(guī)定所有的變量都存儲在主內(nèi)存中,每條線程中還有屬于自己的工作內(nèi)存,現(xiàn)成的工作內(nèi)存中保存了被該線程所使用到的變量(這些變量是從主內(nèi)存中拷貝過來的),線程對變量的所有操作都必須在工作內(nèi)存中進行,不同線程之間也無法訪問到對方的工作內(nèi)存中的變量,線程間變量值的傳遞都需要通過主內(nèi)存來完成(從主內(nèi)存讀取共享變量到工作內(nèi)存->在工作內(nèi)存進行修改->寫回主內(nèi)存供其他線程訪問)
并發(fā)編程的三大概念
1. 可見性
????可見性是一種較為復(fù)雜的屬性,通常我們的直覺在這一部分很大程度來說都是錯的,并且通常我們沒有辦法保證執(zhí)行將共享變量讀取到工作內(nèi)存的線程讀取的一定是最新的共享變量值,也就是說我們不能保證一個線程在執(zhí)行讀操作的時候能適時看到其他線程剛剛寫入的值或者說已經(jīng)寫入的值,有的時候我們的確無法控制,為了確保多個線程之間對共享變量的可見性,我們必須使用一些同步的策略。
????可見性是指線程之間的可見性,具體的就是一個線程修改的結(jié)果對其他線程是可見的,如何實現(xiàn)這個可見性?上面說到了線程之間變量的傳遞需要通過主內(nèi)存來完成,那么可見性可以理解為一個線程讀取共享變量到自己的工作線程后,執(zhí)行完自己對該共享變量的操作后,立即將其寫回主內(nèi)存,這樣也就保證了對其他線程的可見性。
????比如,使用volatile關(guān)鍵字,就能保證線程之間具有可見性,volatile關(guān)鍵字修飾的變量不允許線程內(nèi)部緩存和重排序,即直接修改內(nèi)存。 但是volatile只能讓它修飾的內(nèi)容具有可見性,但是不能保證它的原子性。比如
volatile int a = 0;
//在線程中對a變量執(zhí)行自增操作
……
new Thread(() -> {
a++;
}).start();
……
????雖然volatile修飾了a變量,但是這也只是保證了a變量具有可見性,但是不能保證a++;這一步自增操作具有原子性。a++;是一個非原子操作,也就是說這個操作仍然可能是一個線程不安全的操作。
????而對于普通的沒有volatile,synchronized等修飾的共享變量,這個時候就更不能保證它的可見性了,因為普通共享變量被線程修改之后,并不知道什么時候會被寫回主內(nèi)存,也就是說如果在這個時候遇到別的線程需要訪問這個共享變量,訪問的極有可能是一個無效的值進而造成線程的不安全。
????同時在Java中通過synchronized,Lock兩個關(guān)鍵字也可保證可見性,但是這兩種方法保證可見性的原理與volatile并不相同,synchronized,Lock兩個關(guān)鍵字能保證在同一時刻只有一條線程能夠獲取共享變量的鎖,并且對該共享變量進行操作,并且在釋放鎖之前會對共享變量進行寫回操作,所以也就相當(dāng)于順序執(zhí)行對共享變量的操作,這樣實現(xiàn)的共享變量的可見性,與volatile是不一樣的
2. 原子性
????即一個操作或者多個操作要么全部執(zhí)行并且執(zhí)行的過程不會被任何因素打斷,要么就都不執(zhí)行。在Java中,對基本數(shù)據(jù)類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要么執(zhí)行,要么不執(zhí)行。
????比如a=0; (a是非long和非double類型) 這一步賦值操作,這就是一個不可分割的操作,所以我們稱這種賦值操作為原子操作,剛剛提到了a++;不具有原子性,是因為a++;這個操作實際上等同于a = a + 1;這一步操作是可分割的,也就是說執(zhí)行a++這個操作的時候可以分割為讀取a再對a進行+1操作,這個時候就需要我們使用synchronized關(guān)鍵字保證這個操作是原子操作。
????那么為什么又說a=0;這個操作的變量a除了long和double之外就是一個原子操作呢?分享一篇CSDN博客中的一句話
“深入java虛擬機”中提到,int等不大于32位的基本類型的操作都是原子操作,但是某些jvm對long和double類型的操作并不是原子操作,這樣就會造成錯誤數(shù)據(jù)的出現(xiàn)。
以及一篇以虛構(gòu)小k面試為故事的Java原子操作與并發(fā),介紹的內(nèi)容生動又不失技術(shù)內(nèi)容,最終告訴我們long型和double型的變量賦值可能存在并發(fā)執(zhí)行和賦值操作這兩個大坑。
這篇文章中最后總結(jié)到:Java 基礎(chǔ)類型中,long 和 double 是 64 位長的。32 位架構(gòu) CPU 的算術(shù)邏輯單元(ALU)寬度是 32 位的,在處理大于 32 位操作時需要處理兩次。當(dāng)然也取決于不同的操作系統(tǒng),這個問題因為我也沒有深入地了解過操作系統(tǒng)和JVM所以大家有感興趣的話自行下去搜索吧。
同時為了解決這種賦值的并發(fā)問題Java提供了一些并發(fā)處理包java.util.concurrent.atomic
其中就有AtomicBoolean、AtomicInteger、AtomicLong三大類。
我們進入AtomicLong的源碼看看
private volatile long value;
/**
* Creates a new AtomicLong with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicLong(long initialValue) {
value = initialValue;
}
????可以看到構(gòu)造AtomicLong對象的時候會將構(gòu)造器傳入的long型初始化值賦值給已經(jīng)聲明為volatile的成員變量,這樣也就保證了該long型的變量的原子性
3. 有序性
????有序性就是程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
????因為這里的東西涉及到的地方自己沒有了解過所以就引用原貼作者的話給大家分享:
????什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優(yōu)化,它不保證程序中各個語句的執(zhí)行先后順序同代碼中的順序一致,但是它會保證程序最終執(zhí)行結(jié)果和代碼順序執(zhí)行的結(jié)果是一致的。指令重排序不會影響單個線程的執(zhí)行,但是會影響到線程并發(fā)執(zhí)行的正確性。也就是說,要想并發(fā)程序正確地執(zhí)行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導(dǎo)致程序運行不正確。在Java內(nèi)存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執(zhí)行,卻會影響到多線程并發(fā)執(zhí)行的正確性。在Java里面,可以通過volatile關(guān)鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執(zhí)行同步代碼,相當(dāng)于是讓線程順序執(zhí)行同步代碼,自然就保證了有序性。
volatile原理
????Java提供了一種相對于synchronized,Lock相對較弱的同步機制,volatile修飾的變量可以保證該變量在并發(fā)時的可見性,因為一旦一個變量被聲明為了volatile,編譯器和運行時都會注意這個變量是共享變量,因此不會將該變量上的操作與其它內(nèi)存操作一起進行指令重排,volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在別的線程讀取上一個線程操作過后的共享變量時,總是能讀取到最新的共享變量,因為其保證了共享變量的可見性。
????相對于synchronized,Lock的較弱同步機制主要體現(xiàn)在,線程對其修飾的共享變量進行操作的時候并不會進行加鎖操作,這也就相當(dāng)于是一種弱同步機制。
????當(dāng)對非 volatile變量進行讀寫的時候,每個線程先從內(nèi)存拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味著每個線程可以拷貝到不同的 CPU cache 中。而聲明變量是 volatile 的,JVM 保證了每次讀變量都從內(nèi)存中讀,跳過 CPU cache 這一步。
1. volatile的可見性
如果存在一個共享變量被volatile修飾,那就有了兩層意思:
????????1. 保證不同線程對該共享變量操作時的可見性,即一個線程對共享變量修改之后會立即被寫回內(nèi)存以保證它的可見性。
????????2.禁止對共享變量進行操作的語句進行指令重排序。
????????舉一個原貼的例子說明
//線程1boolean stop = false;while(!stop){ doSomething();}//線程2stop = true;
????????這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會采用這種標(biāo)記辦法。但是事實上,這段代碼會完全運行正確么?即一定會將線程中斷么?不一定,也許在大多數(shù)時候,這個代碼能夠把線程中斷,但是也有可能會導(dǎo)致無法中斷線程(雖然這個可能性很小,但是只要一旦發(fā)生這種情況就會造成死循環(huán)了)。
????????下面解釋一下這段代碼為何有可能導(dǎo)致無法中斷線程。在前面已經(jīng)解釋過,每個線程在運行過程中都有自己的工作內(nèi)存,那么線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內(nèi)存當(dāng)中。
????????那么當(dāng)線程2更改了stop變量的值之后,但是還沒來得及寫入主存當(dāng)中,線程2轉(zhuǎn)去做其他事情了,那么線程1由于不知道線程2對stop變量的更改,因此還會一直循環(huán)下去。
????????但是用volatile修飾之后就變得不一樣了:
????????第一:使用volatile關(guān)鍵字會強制將修改的值立即寫入主存;
????????第二:使用volatile關(guān)鍵字的話,當(dāng)線程2進行修改時,會導(dǎo)致線程1的工作內(nèi)存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應(yīng)的緩存行無效);
????????第三:由于線程1的工作內(nèi)存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。 那么在線程2修改stop值時(當(dāng)然這里包括2個操作,修改線程2工作內(nèi)存中的值,然后將修改后的值寫入內(nèi)存),會使得線程1的工作內(nèi)存中緩存變量stop的緩存行無效,然后線程1讀取時,發(fā)現(xiàn)自己的緩存行無效,它會等待緩存行對應(yīng)的主存地址被更新之后,然后去對應(yīng)的主存讀取最新的值。那么線程1讀取到的就是最新的正確的值。
這也正是利用了volatile的可見性
2. 原子性
public class ThreadTest {
public volatile int number = 0;
public void increase() {
number++;
}
public static void main(String[] args) {
final ThreadTest test = new ThreadTest();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) test.increase();
}).start();
}
//此方法返回活動線程的當(dāng)前線程的線程組中的數(shù)量
//當(dāng)活躍線程數(shù)>2時
//main線程yield等待等待上面十個線程執(zhí)行完畢
while (Thread.activeCount() > 2) Thread.yield();
System.out.println(test.number);
}
}
????????再問大家一個問題,上面的代碼輸出結(jié)果是多少?
????????使用了volatile聲明了一個int類型的number變量初值為0,聲明一個方法對number自增,在mian方法中開啟十個線程對test對象執(zhí)行它的increase方法,Thread.activeCount()方法查看當(dāng)前線程的活躍線程數(shù)量,當(dāng)除了主線程還有線程處于活躍狀態(tài)時,說明上面的10個線程沒有執(zhí)行完它對test對象進行的increase方法,那么結(jié)果為10000嗎?
我們來測試一下
9401 Process finished with exit code 0
9851 Process finished with exit code 0
8901 Process finished with exit code 0
9727 Process finished with exit code 0
9181 Process finished with exit code 0
…………
????????跑了五遍,沒有一次是10000結(jié)果,有人就會問了,這是為什么,最終的結(jié)果都比10000要小?而且共享變量加了volatile修飾符,這又是為什么?
????????還記得上面說的自增操作是沒有原子性是可以拆分的嗎? 問題就是出在這里,increase方法中執(zhí)行的對共享變量的操作就是自增操作,沒有保證共享變量操作的原子性,所以會出現(xiàn)線程不安全的情況。我們來仔細(xì)分析一下這種情況:
????????這里面就有一個誤區(qū)了,volatile關(guān)鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。
????????假如:某個時刻number的值為10,線程1對number進行自增操作,首先讀取了number的值到工作內(nèi)存,然后線程1被阻塞,這時線程2開始讀取number的值,因為線程1此時被阻塞沒有執(zhí)行完自增操作,更沒有寫回主存,所以這時線程2讀取到的還是一開始的10,當(dāng)線程2執(zhí)行完了操作之后寫回主存11,這時線程1接著進行自增的+1操作,但是等到線程1執(zhí)行完所有的對number共享變量的操作之后立即寫回主存時寫回的值還是11,所以兩個線程對number進行自增操作相當(dāng)于只是進行了一次,volatile修飾符無法保證對變量的操作是原子性的!這時可能就更需要synchronized或Lock上鎖保證線程的安全,來保證操作的原子性,也可通過封裝好的AtomicInteger來實現(xiàn)。
3. volatile保證有序性
????????這里對有序性的介紹就引用原文了,也為了更好讓大家理解
????????在前面提到volatile關(guān)鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。
????????volatile關(guān)鍵字禁止指令重排序有兩層意思:
????????1)當(dāng)程序執(zhí)行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經(jīng)進行,且結(jié)果已經(jīng)對后面的操作可見;在其后面的操作肯定還沒有進行;
????????2)在進行指令優(yōu)化時,不能將在對volatile變量的讀操作或者寫操作的語句放在其后面執(zhí)行,也不能把volatile變量后面的語句放到其前面執(zhí)行。
volatile的實現(xiàn)原理
同樣引用原文內(nèi)容
????????處理器為了提高處理速度,不直接和內(nèi)存進行通訊,而是將系統(tǒng)內(nèi)部的數(shù)據(jù)讀到內(nèi)部緩存后在進行操作,但操作完之后不知道什么時候會寫入內(nèi)存。
????????如果對聲明了volatile變量進行寫操作時,JVM會向處理器發(fā)送一條Lock前綴的指令,將這個變量所在緩存行的數(shù)據(jù)寫會到系統(tǒng)內(nèi)存。 這一步確保了如果有其他線程對聲明了volatile變量進行修改,則立即更新主內(nèi)存中數(shù)據(jù)。
????????但這時候其他處理器的緩存還是舊的,所以在多處理器環(huán)境下,為了保證各個處理器緩存一致,每個處理會通過嗅探在總線上傳播的數(shù)據(jù)來檢查 自己的緩存是否過期,當(dāng)處理器發(fā)現(xiàn)自己緩存行對應(yīng)的內(nèi)存地址被修改了,就會將當(dāng)前處理器的緩存行設(shè)置成無效狀態(tài),當(dāng)處理器要對這個數(shù)據(jù)進行修改操作時,會強制重新從系統(tǒng)內(nèi)存把數(shù)據(jù)讀到處理器緩存里。 這一步確保了其他線程獲得的聲明了volatile變量都是從主內(nèi)存中獲取最新的。
????????Lock前綴指令實際上相當(dāng)于一個內(nèi)存屏障(也成內(nèi)存柵欄),它確保指令重排序時不會把其后面的指令排到內(nèi)存屏障之前的位置,也不會把前面的指令排到內(nèi)存屏障的后面;即在執(zhí)行到內(nèi)存屏障這句指令時,在它前面的操作已經(jīng)全部完成。
volatile的應(yīng)用場景
???????? synchronized關(guān)鍵字是防止多個線程同時執(zhí)行一段代碼,那么就會很影響程序執(zhí)行效率,而volatile關(guān)鍵字在某些情況下性能要優(yōu)于synchronized,但是要注意volatile關(guān)鍵字是無法替代synchronized關(guān)鍵字的,因為volatile關(guān)鍵字無法保證操作的原子性。
????????通常來說,使用volatile必須具備以下2個條件:
????????1)對變量的寫操作不依賴于當(dāng)前值
????????2)該變量沒有包含在具有其他變量的不變式中
????????今天也很榮幸有機會跟大家分享到這片文章以及自己的一些理解,因為我對操作系統(tǒng)學(xué)習(xí)的程度不夠,所以文中有很多地方還是直接引用了原貼作者的文段,也希望大家多多理解,希望在以后的學(xué)習(xí)道路上還能跟大家分享更多的知識!
?????????????????????????????????????????????????????????????????????????????西安郵電大學(xué)移動應(yīng)用開發(fā)實驗室