微信公眾號【黃小斜】大廠程序員,互聯網行業新知,終身學習踐行者。關注后回復「Java」、「Python」、「C++」、「大數據」、「機器學習」、「算法」、「AI」、「Android」、「前端」、「iOS」、「考研」、「BAT」、「校招」、「筆試」、「面試」、「面經」、「計算機基礎」、「LeetCode」 等關鍵字可以獲取對應的免費學習資料。
一、不得不提的volatile
volatile是個很老的關鍵字,幾乎伴隨着JDK的誕生而誕生,我們都知道這個關鍵字,但又不太清楚什么時候會使用它;我們在JDK及開源框架中隨處可見這個關鍵字,但並發專家又往往建議我們遠離它。比如Thread這個很基礎的類,其中很重要的線程狀態字段,就是用volatile來修飾,見代碼
/* Java thread status for tools,
* initialized to indicate thread 'not yet started'
*/
private volatile int threadStatus = 0;
如上面所說,並發專家建議我們遠離它,尤其是在JDK6的synchronized關鍵字的性能被大幅優化之后,更是幾乎沒有使用它的場景,但這仍然是個值得研究的關鍵字,研究它的意義不在於去使用它,而在於理解它對理解Java的整個多線程的機制是很有幫助的。
1. 例子
先來體會一下volatile的作用,從下面代碼開始
1: public class VolatileExample extends Thread{
2: //設置類靜態變量,各線程訪問這同一共享變量
3: private static boolean flag = false;
4:
5: //無限循環,等待flag變為true時才跳出循環
6: public void run() {while (!flag){};}
7:
8: public static void main(String[] args) throws Exception {
9: new VolatileExample().start();
10: //sleep的目的是等待線程啟動完畢,也就是說進入run的無限循環體了
11: Thread.sleep(100);
12: flag = true;
13: }
14: }
這個例子很好理解,main函數里啟動一個線程,其run方法是一個以flag為標志位的無限循環。如果flag為true則跳出循環。當main執行到12行的時候,flag被置為true,按邏輯分析此時線程該結束,即整個程序執行完畢。
執行一下看看是什么結果?結果是令人驚訝的,程序始終也不會結束。main是肯定結束了的,其原因就是線程的run方法未結束,即run方法中的flag仍然為false。
把第3行加上volatile修飾符,即
private static volatile boolean flag = false;
再執行一遍看看?結果是程序正常退出,volatile生效了。
我們再修改一下。去掉volatile關鍵字,恢復到起始的例子,然后把while(!flag){}改為while(!flag){System.out.println(1);},再執行一下看看。按分析,沒有volatile關鍵字的時候,程序不會執行結束,雖然加上了打印語句,但沒有做任何的關鍵字/邏輯的修改,應該程序也不會結束才對,但執行結果卻是:程序正常結束。
有了這些感性認識,我們再來分析volatile的語義以及它的作用。
2.volatile語義
volatile的第一條語義是保證線程間變量的可見性,簡單地說就是當線程A對變量X進行了修改后,在線程A后面執行的其他線程能看到變量X的變動,更詳細地說是要符合以下兩個規則:
- 線程對變量進行修改之后,要立刻回寫到主內存。
- 線程對變量讀取的時候,要從主內存中讀,而不是緩存。
要詳細地解釋這個問題,就不得不提一下Java的內存模型(Java Memory Model,簡稱JMM)。Java的內存模型是一個比較復雜的話題,屬於Java語言規范的范疇,個人水平有限,不能在有限篇幅里完整地講述清楚這個事,如果要清晰地認識,請學習《深入理解Java虛擬機-JVM高級特性與最佳實踐》和《The Java Language Specification, Java SE 7 Edition》,這里簡單地引用一些資料略加解釋。
Java為了保證其平台性,使Java應用程序與操作系統內存模型隔離開,需要定義自己的內存模型。在Java內存模型中,內存分為主內存和工作內存兩個部分,其中主內存是所有線程所共享的,而工作內存則是每個線程分配一份,各線程的工作內存間彼此獨立、互不可見,在線程啟動的時候,虛擬機為每個內存分配一塊工作內存,不僅包含了線程內部定義的局部變量,也包含了線程所需要使用的共享變量(非線程內構造的對象)的副本,即為了提高執行效率,讀取副本比直接讀取主內存更快(這里可以簡單地將主內存理解為虛擬機中的堆,而工作內存理解為棧(或稱為虛擬機棧),棧是連續的小空間、順序入棧出棧,而堆是不連續的大空間,所以在棧中尋址的速度比堆要快很多)。工作內存與主內存之間的數據交換通過主內存來進行,如下圖:
同時,Java內存模型還定義了一系列工作內存和主內存之間交互的操作及操作之間的順序的規則(這規則比較多也比較復雜,參見《深入理解Java虛擬機-JVM高級特性與最佳實踐》第12章12.3.2部分),這里只談和volatile有關的部分。對於共享普通變量來說,約定了變量在工作內存中發生變化了之后,必須要回寫到工作內存(遲早要回寫但並非馬上回寫),但對於volatile變量則要求工作內存中發生變化之后,必須馬上回寫到工作內存,而線程讀取volatile變量的時候,必須馬上到工作內存中去取最新值而不是讀取本地工作內存的副本,此規則保證了前面所說的“當線程A對變量X進行了修改后,在線程A后面執行的其他線程能看到變量X的變動”。
大部分網上的文章對於volatile的解釋都是到此為止,但我覺得還是有遺漏的,提出來探討。工作內存可以說是主內存的一份緩存,為了避免緩存的不一致性,所以volatile需要廢棄此緩存。但除了內存緩存之外,在CPU硬件級別也是有緩存的,即寄存器。假如線程A將變量X由0修改為1的時候,CPU是在其緩存內操作,沒有及時回寫到內存,那么JVM是無法X=1是能及時被之后執行的線程B看到的,所以我覺得JVM在處理volatile變量的時候,也同樣用了硬件級別的緩存一致性原則(CPU的緩存一致性原則參見《Java的多線程機制系列:(二)緩存一致性和CAS》。
volatile的第二條語義:禁止指令重排序。關於指令重排序請參見后面的“指令重排序”章節。這是volatile目前主要的一個使用場景。
3. volatile不能保證原子性
介紹volatile不能保證原子性的文章比較多,這里就不舉詳細例子了,大家可以去網上查閱相關資料。在多線程並發執行i++的操作結果來說,i加與不加volatile都是一樣的,只要線程數足夠,一定會出現不一致。這里就其為什么不能保證原子性的原理說一下。
上面提到volatile的兩條語義保證了線程間共享變量的及時可見性,但整個過程並沒有保證同步(參見《Java的多線程機制系列:(一)總述及基礎概念》中對“鎖”的兩種特性的描述),這是與volatile的使命有關的,創造它的背景就是在某些情況下可以代替synchronized實現可見性的目的,規避synchronized帶來的線程掛起、調度的開銷。如果volatile也能保證同步,那么它就是個鎖,可以完全取代synchronized了。從這點看,volatile不可能保證同步,也正基於上面的原因,隨着synchronized性能逐漸提高,volatile逐漸退出歷史舞台。
為什么volatile不能保證原子性?以i++為例,其包括讀取、操作、賦值三個操作,下面是兩個線程的操作順序
假如說線程A在做了i+1,但未賦值的時候,線程B就開始讀取i,那么當線程A賦值i=1,並回寫到主內存,而此時線程B已經不再需要i的值了,而是直接交給處理器去做+1的操作,於是當線程B執行完並回寫到主內存,i的值仍然是1,而不是預期的2。也就是說,volatile縮短了普通變量在不同線程之間執行的時間差,但仍然存有漏洞,依然不能保證原子性。
這里必須要提的是,在本章開頭所說的“各線程的工作內存間彼此獨立、互不可見,在線程啟動的時候,虛擬機為每個內存分配一塊工作內存,不僅包含了線程內部定義的局部變量,也包含了線程所需要使用的共享變量(非線程內構造的對象)的副本,即為了提高執行效率”並不准確。如今的volatile的例子已經是很難重現,如本文開頭時只有在while死循環時才體現出volatile的作用,哪怕只是加了System.out.println(1)這么一小段,普通變量也能達到volatile的效果,這是什么原因呢?原來只有在對變量讀取頻率很高的情況下,虛擬機才不會及時回寫主內存,而當頻率沒有達到虛擬機認為的高頻率時,普通變量和volatile是同樣的處理邏輯。如在每個循環中執行System.out.println(1)加大了讀取變量的時間間隔,使虛擬機認為讀取頻率並不那么高,所以實現了和volatile的效果(本文開頭的例子只在HotSpot24上測試過,沒有在JRockit之類其余版本JDK上測過)。volatile的效果在jdk1.2及之前很容易重現,但隨着虛擬機的不斷優化,如今的普通變量的可見性已經不是那么嚴重的問題了,這也是volatile如今確實不太有使用場景的原因吧。
4. volatile的適用場景
並發專家建議我們遠離volatile是有道理的,這里再總結一下:
- volatile是在synchronized性能低下的時候提出的。如今synchronized的效率已經大幅提升,所以volatile存在的意義不大。
- 如今非volatile的共享變量,在訪問不是超級頻繁的情況下,已經和volatile修飾的變量有同樣的效果了。
- volatile不能保證原子性,這點是大家沒太搞清楚的,所以很容易出錯。
- volatile可以禁止重排序。
所以如果我們確定能正確使用volatile,那么在禁止重排序時是一個較好的使用場景,否則我們不需要再使用它。這里只列舉出一種volatile的使用場景,即作為標識位的時候(比如本文例子中boolean類型的flag)。用專業點更廣泛的說法就是“對變量的寫操作不依賴於當前值且該變量沒有包含在其他具體變量的不變式中”,具體參見《Java 理論與實踐: 正確使用 Volatile 變量》。
二、指令重排序(happen-before)
指令重排序是個比較復雜、覺得有些不可思議的問題,同樣是先以例子開頭(建議大家跑下例子,這是實實在在可以重現的,重排序的概率還是挺高的),有個感性的認識
/**
* 一個簡單的展示Happen-Before的例子.
* 這里有兩個共享變量:a和flag,初始值分別為0和false.在ThreadA中先給a=1,然后flag=true.
* 如果按照有序的話,那么在ThreadB中如果if(flag)成功的話,則應該a=1,而a=a*1之后a仍然為1,下方的if(a==0)應該永遠不會為真,永遠不會打印.
* 但實際情況是:在試驗100次的情況下會出現0次或幾次的打印結果,而試驗1000次結果更明顯,有十幾次打印.
*/
public class SimpleHappenBefore {
/** 這是一個驗證結果的變量 */
private static int a=0;
/** 這是一個標志位 */
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
//由於多線程情況下未必會試出重排序的結論,所以多試一些次
for(int i=0;i<1000;i++){
ThreadA threadA=new ThreadA();
ThreadB threadB=new ThreadB();
threadA.start();
threadB.start();
//這里等待線程結束后,重置共享變量,以使驗證結果的工作變得簡單些.
threadA.join();
threadB.join();
a=0;
flag=false;
}
}
static class ThreadA extends Thread{
public void run(){
a=1;
flag=true;
}
}
static class ThreadB extends Thread{
public void run(){
if(flag){
a=a*1;
}
if(a==0){
System.out.println("ha,a==0");
}
}
}
}
例子比較簡單,也添加了注釋,不再詳細敘述。
什么是指令重排序?有兩個層面:
- 在虛擬機層面,為了盡可能減少內存操作速度遠慢於CPU運行速度所帶來的CPU空置的影響,虛擬機會按照自己的一些規則(這規則后面再敘述)將程序編寫順序打亂——即寫在后面的代碼在時間順序上可能會先執行,而寫在前面的代碼會后執行——以盡可能充分地利用CPU。拿上面的例子來說:假如不是a=1的操作,而是a=new byte[1024*1024](分配1M空間),那么它會運行地很慢,此時CPU是等待其執行結束呢,還是先執行下面那句flag=true呢?顯然,先執行flag=true可以提前使用CPU,加快整體效率,當然這樣的前提是不會產生錯誤(什么樣的錯誤后面再說)。雖然這里有兩種情況:后面的代碼先於前面的代碼開始執行;前面的代碼先開始執行,但當效率較慢的時候,后面的代碼開始執行並先於前面的代碼執行結束。不管誰先開始,總之后面的代碼在一些情況下存在先結束的可能。
- 在硬件層面,CPU會將接收到的一批指令按照其規則重排序,同樣是基於CPU速度比緩存速度快的原因,和上一點的目的類似,只是硬件處理的話,每次只能在接收到的有限指令范圍內重排序,而虛擬機可以在更大層面、更多指令范圍內重排序。硬件的重排序機制參見《從JVM並發看CPU內存指令重排序(Memory Reordering)》
重排序很不好理解,上面只是簡單地提了下其場景,要想較好地理解這個概念,需要構造一些例子和圖表,在這里介紹兩篇介紹比較詳細、生動的文章《happens-before俗解》和《深入理解Java內存模型(二)——重排序》。其中的“as-if-serial”是應該掌握的,即:不管怎么重排序,單線程程序的執行結果不能被改變。編譯器、運行時和處理器都必須遵守“as-if-serial”語義。拿個簡單例子來說,
public void execute(){
int a=0;
int b=1;
int c=a+b;
}
這里a=0,b=1兩句可以隨便排序,不影響程序邏輯結果,但c=a+b這句必須在前兩句的后面執行。
從前面那個例子可以看到,重排序在多線程環境下出現的概率還是挺高的,在關鍵字上有volatile和synchronized可以禁用重排序,除此之外還有一些規則,也正是這些規則,使得我們在平時的編程工作中沒有感受到重排序的壞處。
- 程序次序規則(Program Order Rule):在一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作。准確地說應該是控制流順序而不是代碼順序,因為要考慮分支、循環等結構。
- 監視器鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面對同一個對象鎖的lock操作。這里強調的是同一個鎖,而“后面”指的是時間上的先后順序,如發生在其他線程中的lock操作。
- volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作發生於后面對這個變量的讀操作,這里的“后面”也指的是時間上的先后順序。
- 線程啟動規則(Thread Start Rule):Thread獨享的start()方法先行於此線程的每一個動作。
- 線程終止規則(Thread Termination Rule):線程中的每個操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值檢測到線程已經終止執行。
- 線程中斷規則(Thread Interruption Rule):對線程interrupte()方法的調用優先於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否已中斷。
- 對象終結原則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
- 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
正是以上這些規則保障了happen-before的順序,如果不符合以上規則,那么在多線程環境下就不能保證執行順序等同於代碼順序,也就是“如果在本線程中觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,則不符合以上規則的都是無序的”,因此,如果我們的多線程程序依賴於代碼書寫順序,那么就要考慮是否符合以上規則,如果不符合就要通過一些機制使其符合,最常用的就是synchronized、Lock以及volatile修飾符。