JMM中的原子性、可见性、有序性和volatile关键字
相信如果對JMM底層有過了解或者接觸過java并發編程的讀者對以上的概念并不陌生,但是真正理解的可能并不多。這里我就對這些概念再做一次講解。相信讀者多讀幾遍應該就有自己的理解,實在不理解也沒關系,說明知識的儲備還不夠,不妨以后再來讀一遍,可能會瞬間突然明白。
參考內容:
JMM的關鍵技術點都是圍繞著多線程的原子性、可見性和有序性來建立的。要理解JMM首先就需要了解這三個特性。而volatile關鍵字很好的貫徹了可見性和有序性,提到volatile關鍵字也是為了加深對這三個特性的理解,同時volatile也是非常重要的內容,也是難點。
1、原子性
原子性其實非常好理解,原子性操作就是指這些操作是不可中斷的,要做一定做完,要么就沒有執行,也就是不可被中斷。
我們使用的int類型的數據如果只是是簡單的讀取和賦值的話就是原子操作。下面給出幾個例子:
i = 2; 賦值給i -----------------------------操作步驟:1 原子操作 j = i; 讀取i值 賦值給j -----------------------操作步驟:2 非原子操作 i++; 讀取i值 i值加1 賦值給i ------------------操作步驟:3 非原子操作 i = i+1; 讀取i值 i值加1 賦值給i --------------操作步驟:3 非原子操作但是如果我們不使用int型而使用long型的話,對于32位系統來說,long型數據的讀寫不是原子性的(因為long有64位)。虛擬機規范中允許對 64位數據類型( long和 double),分為 2次 32為的操作來處理,也就是說,如果兩個線程同時對long進行寫入的話(或者讀取),對線程之間的結果是有干擾的??赡芨呶皇且粋€線程寫的,低位又是另一個線程寫的,如果這時候讀的話,就會讀到錯誤的值。不是線程1寫的值,也不是線程2寫的值。但是最新 JDK實現還是實現了原子操作的。JMM只實現了基本的原子性,像上面 i++那樣的操作,必須借助于 synchronized和 Lock來保證整塊代碼的原子性了。
2、可見性
可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改。
顯然,對于串行程序來說,可見性問題是不存在的。因為你在任何一個操作步驟中修改了某個變量,那么在后續的步驟中,讀取這個變量的值,一定是修改后的新值。但是這個問題在并行程序中就不見得了。如果一個線程修改了某一個全局變量,那么其他線程未必可以馬上知道這個改動。這個問題可能由cache優化引起,比如下面這個例子:
如果在CPU1和CPU2上各運行了一個線程,它們共享變量t,由于編譯器優化或者硬件優化的緣故,在CPU1上的線程將變量t進行了優化,將其緩存在cache中或者寄存器里。這種情況下,如果在CPU2上的某個線程修改了變量t的實際值,那么CPU1上的線程可能并無法意識到這個改動,依然會讀取cache中或者寄存器里的數據。因此,就產生了可見性問題。外在表現為:變量t的值被修改,但是CPU1上的線程依然會讀到一個舊值??梢娦詥栴}也是并行程序開發中需要重點關注的問題之一。
可見性問題是一個綜合性問題。除了上述提到的緩存優化或者硬件優化(有些內存讀寫可能不會立即觸發,而會先進入一個硬件隊列等待)會導致可見性問題外,指令重排(這個問題將在下一節中更詳細討論)以及編輯器的優化,都有可能導致一個線程的修改不會立即被其他線程察覺。
3、有序性
JMM是允許編譯器和處理器對指令重排序的,但是規定了 as-if-serial語義,即不管怎么重排序,程序的執行結果不能改變。比如下面的程序段:
double pi = 3.14; // A double r = 1; // B doubles = pi *r *r; // C無論是 A->B->C 還是 B->A->C 都對結果沒有影響。但是這是發生在單線程之中的。在多線程之中可能就不是這樣了,多線程有序性引起的問題我們可以看一個典型的例子:
class OrderExample{int a = 0;boolean flag = false;public void writer(){a = 1;flag = true;}public void reader(){if(flag){int i = a + 1;}} }如果這個類的writer()和reader()方法是在不同的線程中運行的。那么writer()中的方法可能會被重排序為flag= true先執行。這個時候如果被中斷,換到執行reader()的線程執行,flag為true,進入if判斷就會自然認為a = 1;但是這個時候a還是0。這里大概就能理解重排序帶來的問題了。
JMM具備一些先天的有序性,即不需要通過任何手段就可以保證的有序性,也就是在下面這些情況中,是不能進行重排序的。通常稱為 happens-before原則
關于為什么需要重排序,這里再詳細說明一下:
比如執行:
在cpu中執行的過程可能是這樣:
左邊是匯編指令,右邊就是流水線的情況。注意,在ADD指令上,有一個大叉,表示一個中斷。也就是說ADD在這里停頓了一下。為什么ADD會在這里停頓呢?原因很簡單,R2中的數據還沒有準備好!所以,ADD操作必須進行一次等待。由于ADD的延遲,導致其后面所有的指令都要慢一個節拍。
既然停頓是因為數據還沒有準備好,那我們就在它等待數據準備好的時候做其他事情。也就是在ADD和前面的LW指令之間插入一個做其他事情的指令,SUB同理,具體來說我們可以這樣移動指令:
變成:
可以看到一共節約了兩步執行時間。
4、volatile關鍵字
被 volatile修飾的共享變量,具有以下兩點特性:
JMM規定對一個 volatile域的寫, happens-before于后續對這個 volatile域的讀(也就是一個線程寫了volatile域,其他線程如果執行讀操作就會知道它改變了),其實就是如果一個變量聲明成是 volatile的,那么當我讀變量時,總是能讀到它的最新值,這里最新值是指不管其它哪個線程對該變量做了寫操作,都會立刻被更新到主存里,我也能從主存里讀到這個剛寫入的值。
從內存語義上來看
當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存
當讀一個volatile變量時,JMM會把該線程對應的本地內存置為無效,線程接下來將從主內存中讀取共享變量。
關于禁止重排序也就是不會讓volatile寫之前執行的結果跑到后面去,再拿這個例子說明就是a=1;不會到flag = true之后執行。
class OrderExample{int a = 0;boolean flag = false;public void writer(){a = 1;flag = true;}public void reader(){if(flag){int i = a + 1;}} }同時保證volatile讀之后的操作不會到volatile讀之前操作;
但是volatile是無法保證原子性的,但是和int類型變量一樣,簡單的讀取賦值還是原子的,但是這和volatile關鍵字沒什么關系,只是作為普通變量的特性。舉個例子,比如線程A讀取了volatile變量,阻塞了。換到線程B讀取了volatile變量執行加1操作,寫回?,F在又切換到線程A,因為線程A已經執行了讀操作,無法觸發線程A感知volatile變量已經改變,只有在做讀取操作時,發現自己緩存行無效,才會去讀主存的值,所以該線程直接加1,寫回。所以雖然執行了2次加1,但實際只加了一次加1。理解只有volatile變量的讀操作才能觸發線程感知變量已經改變是非常重要的。
關于volatile的底層實現機制:
如果把加入 volatile關鍵字的代碼和未加入 volatile關鍵字的代碼都生成匯編代碼,會發現加入 volatile關鍵字的代碼會多出一個 lock前綴指令。
lock前綴指令實際相當于一個內存屏障,內存屏障提供了以下功能:
1 . 重排序時不能把后面的指令重排序到內存屏障之前的位置
2 . 使得本CPU的Cache寫入內存
3 . 寫入動作也會引起別的CPU或者別的內核無效化其Cache,相當于讓新寫入的值對別的線程可見。
關于volatile的使用場景
使用volatile來標示flag,就能解決上面說到的可見性問題,這種對變量的讀寫操作,標記為 volatile可以保證修改對線程立刻可見。比 synchronized, Lock有一定的效率提升。
這是一種懶漢的單例模式,使用時才創建對象,為了避免初始化操作的指令重排序,給 instance加上了 volatile。
總結
以上是生活随笔為你收集整理的JMM中的原子性、可见性、有序性和volatile关键字的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java 7 并发编程指南
- 下一篇: 使用Fork/Join框架优化归并排序