volatile關鍵字是與Java的內存模型有關的,因此需要先了解一下與內存模型相關的概念和知識,再去分析volatile關鍵字的實現原理和應用場景。
發音:英[ˈvɒlətaɪl] |
美[ˈvɑ:lətl] |
|
|
|
|
1 內存模型
1.1 內存模型
計算機執行每條指令都是在CPU中執行的,肯定涉及到了數據的讀取和寫入。
這時就存在一個問題:由於CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,兩者協調工作會大大降低指令執行的速度,因此引入了高速緩存的概念。
也就是,當程序在運行過程中,會將運算需要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之後,再將高速緩存中的數據刷新到主存當中。
兩種實現方式:
1)在總線家Lock#鎖的方式:阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存
2)緩存一致性協議:Intel 的MESI協議:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存重新讀取。
1.2 併發編程的三個概念
1.原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
2.可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值並去獲取最新的值。否則,應用到的數據就是錯誤的數據。
3.有序性:即程序執行的順序按照代碼的先後順序執行。
處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,比如重排序,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。但是,多線程中的重排序不能保證和執行的結果一致。
即,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性。
總結:要想併發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
1.3 Java內存模型
在Java虛擬機規範中試圖定義一種Java內存模型(JavaMemory Model,JMM)來屏蔽各個硬件平臺和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。
那麼Java內存模型規定了哪些東西呢,它定義了程序中變量的訪問規則,往大一點說是定義了程序執行的次序。注意,爲了獲得較好的執行性能,Java內存模型並沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java內存模型中,也會存在緩存一致性問題和指令重排序的問題。
Java內存模型規定所有的變量都是存在主存當中(類似於物理內存),每個線程都有自己的工作內存(類似於高速緩存)。線程對變量的所有操作都必須在工作內存中進行,而不能直接對主存進行操作。並且每個線程不能訪問其他線程的工作內存。
1.原子性
在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。
x = 10; //語句1:原子 寫值 y = x; //語句2:非原子 先讀取,後寫值 x++; //語句3:非原子 先讀取,後累加,然後寫值 x = x + 1; //語句4:非原子 先讀取,後累加,然後寫值
Java內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。
2.可見性
Java提供了volatile關鍵字來保證可見性。 當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。
而普通的共享變量不能保證可見性,因爲普通共享變量被修改之後,什麼時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。
另外,通過synchronized和Lock也能夠保證可見性,保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。
3.有序性
Java內存模型允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。
在Java裏面,可以通過volatile關鍵字來保證一定的“有序性”。
另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。
另外,Java內存模型也可以利用happens-before 原則保證先天的“有序性”。
happens-before原則(先行發生原則):
1). 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作
2). 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作
3). volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作
4). 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
5). 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作
6). 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
7). 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
8). 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始
如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。
2 volatile關鍵字
1.volatile關鍵字保證了操作時變量的可見性
一個被volatile修飾的共享變量(類的成員變量、類的靜態成員變量)具備兩層語義:
1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
2)禁止進行指令重排序。
先看一段代碼,假如線程1先執行,線程2後執行:
//線程1 boolean stop = false; while(!stop){ doSomething(); } //線程2 stop = true;
上述典型代碼:大多數時候,線程可以發生中斷,但是也有可能無法中斷(小概率事件,但是會造成嚴重的死鎖後果)。
無法中斷的可能原因:線程1在運行的時候,會將stop變量的值拷貝放入自己的工作內存;當線程2更改了stop值後,還沒及時寫入主存中就轉去做其他事情了,那麼線程1仍然會按照之前的stop值來判斷是否繼續循環下去。
But,用volatile修飾:
第一:使用volatile關鍵字會強制將修改的值立即寫入主存;
第二:使用volatile關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);
第三:由於線程1的工作內存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。
那麼在線程2修改stop值時(當然這裏包括2個操作,修改線程2工作內存中的值,然後將修改後的值寫入內存),會使得線程1的工作內存中緩存變量stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。線程1讀取到的就是最新的正確的值。
2.volatile無法保證操作的原子性
public class Test { public volatile int inc = 0; public void increase() { inc++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保證前面的線程都執行完 Thread.yield(); System.out.println(test.inc); } }
這段程序事實上運行後會發現每次運行結果都不一致,都是一個小於10000的數字。
前面知道,volatile保證了可見性,如果變量值發生了改變,會立即讀取最新結果,那麼爲什麼還保證不了他的正確性呢?
比如:有沒有這樣一種可能?A線程讀取了inc,並進行了自增操作得到結果11,因爲某種原因暫時處於阻塞狀態然後B線程緊接着讀取了inc進行自增操作,並將結果11寫進去,然後線程A脫離阻塞狀態,將結果寫入內存。這裏就發生了錯誤,明明又兩次自增 操作,但是內存中卻是隻有一次自增操作的結果。
原因是:volatile關鍵字能在這裏保證了可見性沒有錯,但是沒有保證原子性。前面說到,自增操作不是原子操作,volatile關鍵字沒有讓自增操作的讀取/自增/寫值三個步驟一氣呵成,導致結果發生意外。
可以通過如下三種方式保證操作的原子性:
1)採用synchronized:
public synchronized void increase() { inc++; }
2)採用Lock:
Lock lock = new ReentrantLock(); public void increase() { lock.lock(); try { inc++; } finally{ lock.unlock(); } }
3)採用AtomicInteger:
public void increase() { inc.getAndIncrement(); }
java 1.5的java.util.concurrent.atomic包下提供了一些原子操作類,即對基本數據類型的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。atomic是利用CAS來實現原子性操作的(Compare And Swap),CAS實際上是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。
3.volatile能保證有序性嗎?
volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。
兩層意思:
1)當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見,在其後面的操作肯定還沒有進行;
2)在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。
4.volatile的原理和實現機制
volatile到底如何保證可見性和禁止指令重排序的?
“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”--- 《深入理解Java虛擬機》
lock前綴指令相當於一個內存屏障(也成內存柵欄),會提供3個功能:
1)確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
2)它會強制將對緩存的修改操作立即寫入主存;
3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。
3 volatile關鍵字使用場景
synchronized關鍵字可以是防止多個線程同時執行一段代碼,但是很影響程序執行效率;而volatile關鍵字在某些情況下性能要優於synchronized,但無法替代synchronized,因爲volatile關鍵字無法保證操作的原子性。
使用volatile必須具備2個條件:
1)對變量的寫操作不依賴於當前值
2)該變量沒有包含在具有其他變量的不變式中
實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。
幾個volatile的幾個場景:
1. 狀態標記量
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag= true; } volatile boolean inited = false; //線程1: context =loadContext(); inited = true; //線程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
2. double check
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; } }