淺分析Java volatile關鍵字
大家好,前不久看了掘金一篇文章
原貼請點連結,那麼今天就來給大家分享一下從這篇文章中學到的volatile以及線程安全相關的知識點。
Java記憶體模型
在介紹volatile關鍵字之前,還是先給大家講講Java的記憶體模型

Java的記憶體模型規定所有的變量都存儲在主記憶體中,每條線程中還有屬于自己的工作記憶體,現成的工作記憶體中儲存了被該線程所使用到的變量(這些變量是從主記憶體中拷貝過來的),線程對變量的所有操作都必須在工作記憶體中進行,不同線程之間也無法通路到對方的工作記憶體中的變量,線程間變量值的傳遞都需要通過主記憶體來完成(從主記憶體讀取共享變量到工作記憶體->在工作記憶體進行修改->寫回主記憶體供其他線程通路)
并發程式設計的三大概念
1. 可見性
可見性是一種較為複雜的屬性,通常我們的直覺在這一部分很大程度來說都是錯的,并且通常我們沒有辦法保證執行将共享變量讀取到工作記憶體的線程讀取的一定是最新的共享變量值,也就是說我們不能保證一個線程在執行讀操作的時候能适時看到其他線程剛剛寫入的值或者說已經寫入的值,有的時候我們的确無法控制,為了確定多個線程之間對共享變量的可見性,我們必須使用一些同步的政策。
可見性是指線程之間的可見性,具體的就是一個線程修改的結果對其他線程是可見的,如何實作這個可見性?上面說到了線程之間變量的傳遞需要通過主記憶體來完成,那麼可見性可以了解為一個線程讀取共享變量到自己的工作線程後,執行完自己對該共享變量的操作後,立即将其寫回主記憶體,這樣也就保證了對其他線程的可見性。
比如,使用volatile關鍵字,就能保證線程之間具有可見性,volatile關鍵字修飾的變量不允許線程内部緩存和重排序,即直接修改記憶體。 但是volatile隻能讓它修飾的内容具有可見性,但是不能保證它的原子性。比如
volatile int a = 0;
//線上程中對a變量執行自增操作
……
new Thread(() -> {
a++;
}).start();
……
雖然volatile修飾了a變量,但是這也隻是保證了a變量具有可見性,但是不能保證a++;這一步自增操作具有原子性。a++;是一個非原子操作,也就是說這個操作仍然可能是一個線程不安全的操作。
而對于普通的沒有volatile,synchronized等修飾的共享變量,這個時候就更不能保證它的可見性了,因為普通共享變量被線程修改之後,并不知道什麼時候會被寫回主記憶體,也就是說如果在這個時候遇到别的線程需要通路這個共享變量,通路的極有可能是一個無效的值進而造成線程的不安全。
同時在Java中通過synchronized,Lock兩個關鍵字也可保證可見性,但是這兩種方法保證可見性的原理與volatile并不相同,synchronized,Lock兩個關鍵字能保證在同一時刻隻有一條線程能夠擷取共享變量的鎖,并且對該共享變量進行操作,并且在釋放鎖之前會對共享變量進行寫回操作,是以也就相當于順序執行對共享變量的操作,這樣實作的共享變量的可見性,與volatile是不一樣的
2. 原子性
即一個操作或者多個操作要麼全部執行并且執行的過程不會被任何因素打斷,要麼就都不執行。在Java中,對基本資料類型的變量的讀取和指派操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。
比如a=0; (a是非long和非double類型) 這一步指派操作,這就是一個不可分割的操作,是以我們稱這種指派操作為原子操作,剛剛提到了a++;不具有原子性,是因為a++;這個操作實際上等同于a = a + 1;這一步操作是可分割的,也就是說執行a++這個操作的時候可以分割為讀取a再對a進行+1操作,這個時候就需要我們使用synchronized關鍵字保證這個操作是原子操作。
那麼為什麼又說a=0;這個操作的變量a除了long和double之外就是一個原子操作呢?分享一篇CSDN部落格中的一句話
“深入java虛拟機”中提到,int等不大于32位的基本類型的操作都是原子操作,但是某些jvm對long和double類型的操作并不是原子操作,這樣就會造成錯誤資料的出現。
以及一篇以虛構小k面試為故事的
Java原子操作與并發,介紹的内容生動又不失技術内容,最終告訴我們long型和double型的變量指派可能存在并發執行和指派操作這兩個大坑。
這篇文章中最後總結到:Java 基礎類型中,long 和 double 是 64 位長的。32 位架構 CPU 的算術邏輯單元(ALU)寬度是 32 位的,在處理大于 32 位操作時需要處理兩次。當然也取決于不同的作業系統,這個問題因為我也沒有深入地了解過作業系統和JVM是以大家有感興趣的話自行下去搜尋吧。
同時為了解決這種指派的并發問題Java提供了一些并發處理包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;
}
可以看到構造AtomicLong對象的時候會将構造器傳入的long型初始化值指派給已經聲明為volatile的成員變量,這樣也就保證了該long型的變量的原子性
3. 有序性
有序性就是程式執行的順序按照代碼的先後順序執行。
因為這裡的東西涉及到的地方自己沒有了解過是以就引用原貼作者的話給大家分享:
什麼是指令重排序,一般來說,處理器為了提高程式運作效率,可能會對輸入代碼進行優化,它不保證程式中各個語句的執行先後順序同代碼中的順序一緻,但是它會保證程式最終執行結果和代碼順序執行的結果是一緻的。指令重排序不會影響單個線程的執行,但是會影響到線程并發執行的正确性。也就是說,要想并發程式正确地執行,必須要保證原子性、可見性以及有序性。隻要有一個沒有被保證,就有可能會導緻程式運作不正确。在Java記憶體模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程式的執行,卻會影響到多線程并發執行的正确性。在Java裡面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證了有序性。
volatile原理
Java提供了一種相對于synchronized,Lock相對較弱的同步機制,volatile修飾的變量可以保證該變量在并發時的可見性,因為一旦一個變量被聲明為了volatile,編譯器和運作時都會注意這個變量是共享變量,是以不會将該變量上的操作與其它記憶體操作一起進行指令重排,volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,是以在别的線程讀取上一個線程操作過後的共享變量時,總是能讀取到最新的共享變量,因為其保證了共享變量的可見性。
相對于synchronized,Lock的較弱同步機制主要展現在,線程對其修飾的共享變量進行操作的時候并不會進行加鎖操作,這也就相當于是一種弱同步機制。
當對非 volatile變量進行讀寫的時候,每個線程先從記憶體拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個線程可以拷貝到不同的 CPU cache 中。而聲明變量是 volatile 的,JVM 保證了每次讀變量都從記憶體中讀,跳過 CPU cache 這一步。
1. volatile的可見性
如果存在一個共享變量被volatile修飾,那就有了兩層意思:
1. 保證不同線程對該共享變量操作時的可見性,即一個線程對共享變量修改之後會立即被寫回記憶體以保證它的可見性。
2.禁止對共享變量進行操作的語句進行指令重排序。
舉一個原貼的例子說明
//線程1boolean stop = false;while(!stop){ doSomething();}//線程2stop = true;
這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會采用這種标記辦法。但是事實上,這段代碼會完全運作正确麼?即一定會将線程中斷麼?不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導緻無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。
下面解釋一下這段代碼為何有可能導緻無法中斷線程。在前面已經解釋過,每個線程在運作過程中都有自己的工作記憶體,那麼線程1在運作的時候,會将stop變量的值拷貝一份放在自己的工作記憶體當中。
那麼當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由于不知道線程2對stop變量的更改,是以還會一直循環下去。
但是用volatile修飾之後就變得不一樣了:
第一:使用volatile關鍵字會強制将修改的值立即寫入主存;
第二:使用volatile關鍵字的話,當線程2進行修改時,會導緻線程1的工作記憶體中緩存變量stop的緩存行無效(反映到硬體層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);
第三:由于線程1的工作記憶體中緩存變量stop的緩存行無效,是以線程1再次讀取變量stop的值時會去主存讀取。 那麼線上程2修改stop值時(當然這裡包括2個操作,修改線程2工作記憶體中的值,然後将修改後的值寫入記憶體),會使得線程1的工作記憶體中緩存變量stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存位址被更新之後,然後去對應的主存讀取最新的值。那麼線程1讀取到的就是最新的正确的值。
這也正是利用了volatile的可見性
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();
}
//此方法傳回活動線程的目前線程的線程組中的數量
//當活躍線程數>2時
//main線程yield等待等待上面十個線程執行完畢
while (Thread.activeCount() > 2) Thread.yield();
System.out.println(test.number);
}
}
再問大家一個問題,上面的代碼輸出結果是多少?
使用了volatile聲明了一個int類型的number變量初值為0,聲明一個方法對number自增,在mian方法中開啟十個線程對test對象執行它的increase方法,Thread.activeCount()方法檢視目前線程的活躍線程數量,當除了主線程還有線程處于活躍狀态時,說明上面的10個線程沒有執行完它對test對象進行的increase方法,那麼結果為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結果,有人就會問了,這是為什麼,最終的結果都比10000要小?而且共享變量加了volatile修飾符,這又是為什麼?
還記得上面說的自增操作是沒有原子性是可以拆分的嗎? 問題就是出在這裡,increase方法中執行的對共享變量的操作就是自增操作,沒有保證共享變量操作的原子性,是以會出現線程不安全的情況。我們來仔細分析一下這種情況:
這裡面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程式錯在沒能保證原子性。可見性隻能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。
假如:某個時刻number的值為10,線程1對number進行自增操作,首先讀取了number的值到工作記憶體,然後線程1被阻塞,這時線程2開始讀取number的值,因為線程1此時被阻塞沒有執行完自增操作,更沒有寫回主存,是以這時線程2讀取到的還是一開始的10,當線程2執行完了操作之後寫回主存11,這時線程1接着進行自增的+1操作,但是等到線程1執行完所有的對number共享變量的操作之後立即寫回主存時寫回的值還是11,是以兩個線程對number進行自增操作相當于隻是進行了一次,volatile修飾符無法保證對變量的操作是原子性的!這時可能就更需要synchronized或Lock上鎖保證線程的安全,來保證操作的原子性,也可通過封裝好的AtomicInteger來實作。
3. volatile保證有序性
這裡對有序性的介紹就引用原文了,也為了更好讓大家了解
在前面提到volatile關鍵字能禁止指令重排序,是以volatile能在一定程度上保證有序性。
volatile關鍵字禁止指令重排序有兩層意思:
1)當程式執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
2)在進行指令優化時,不能将在對volatile變量的讀操作或者寫操作的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。
volatile的實作原理
同樣引用原文内容
處理器為了提高處理速度,不直接和記憶體進行通訊,而是将系統内部的資料讀到内部緩存後在進行操作,但操作完之後不知道什麼時候會寫入記憶體。
如果對聲明了volatile變量進行寫操作時,JVM會向處理器發送一條Lock字首的指令,将這個變量所在緩存行的資料寫會到系統記憶體。 這一步確定了如果有其他線程對聲明了volatile變量進行修改,則立即更新主記憶體中資料。
但這時候其他處理器的緩存還是舊的,是以在多處理器環境下,為了保證各個處理器緩存一緻,每個處理會通過嗅探在總線上傳播的資料來檢查 自己的緩存是否過期,當處理器發現自己緩存行對應的記憶體位址被修改了,就會将目前處理器的緩存行設定成無效狀态,當處理器要對這個資料進行修改操作時,會強制重新從系統記憶體把資料讀到處理器緩存裡。 這一步確定了其他線程獲得的聲明了volatile變量都是從主記憶體中擷取最新的。
Lock字首指令實際上相當于一個記憶體屏障(也成記憶體栅欄),它確定指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成。
volatile的應用場景
synchronized關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程式執行效率,而volatile關鍵字在某些情況下性能要優于synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。
通常來說,使用volatile必須具備以下2個條件:
1)對變量的寫操作不依賴于目前值
2)該變量沒有包含在具有其他變量的不變式中
今天也很榮幸有機會跟大家分享到這片文章以及自己的一些了解,因為我對作業系統學習的程度不夠,是以文中有很多地方還是直接引用了原貼作者的文段,也希望大家多多了解,希望在以後的學習道路上還能跟大家分享更多的知識!
西安郵電大學移動應用開發實驗室