在我工做的十幾年裏,寫了不少Java的程序。同時,我也面試過大量的Java工程師。對於一些表示本身深刻了解和擅長多線程的同窗,
我常常會問這樣一個面試題:「 volatile這個關鍵字有什麼做用?」若是你或者你的朋友寫過Java程序,不妨來一塊兒試着回答一下這個問題。java
就我面試過的工程師而言,即便是工做了多年的Java工程師,也不多有人能準確說出volatile這個關鍵字的含義。這裏面最多見的理解錯誤有兩個,
一個是把volatile當成一種鎖機制,認爲給變量加上了volatile,就好像是給函數加了sychronized關鍵字同樣,不一樣的線程對於特定變量的訪問會去加鎖;
另外一個是把volatile當成一種原子化的操做機制,認爲加了volatile以後,對於一個變量的自增的操做就會變成原子性的了。面試
// 一種錯誤的理解,是把 volatile 關鍵詞,當成是一個鎖,能夠把 long/double 這樣的數的操做自動加鎖 private volatile long synchronizedValue = 0; // 另外一種錯誤的理解,是把 volatile 關鍵詞,當成可讓整數自增的操做也變成原子性的 private volatile int atomicInt = 0; amoticInt++;
事實上,這兩種理解都是徹底錯誤的。不少工程師容易把volatile關鍵字,當成和鎖或者數據數據原子性相關的知識點。而實際上,
volatile關鍵字的最核心知識點,要關係到Java內存模型(JMM,Java MemoryModel)上。
雖然JMM只是Java虛擬機這個進程級虛擬機裏的一個內存模型,可是這個內存模型,和計算機組成裏的CPU、高速緩存和主內存組合在一塊兒的硬件體系很是類似。
理解了JMM,可讓你很容易理解計算機組成裏CPU、高速緩存和主內存之間的關係。
緩存
咱們先來一塊兒看一段Java程序。這是一段經典的volatile代碼,來自知名的Java開發者網站dzone.com,後續咱們會修改這段代碼來進行各類小實驗。多線程
public class VolatileTest { private static volatile int COUNTER = 0; public static void main(String[] args) { new ChangeListener().start(); new ChangeMaker().start(); } static class ChangeListener extends Thread { @Override public void run() { int threadValue = COUNTER; while ( threadValue < 5){ if( threadValue!= COUNTER){ System.out.println("Got Change for COUNTER : " + COUNTER + ""); threadValue= COUNTER; } } } } static class ChangeMaker extends Thread{ @Override public void run() { int threadValue = COUNTER; while (COUNTER <5){ System.out.println("Incrementing COUNTER to : " + (threadValue+1) + ""); COUNTER = ++threadValue; try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } } }
Incrementing COUNTER to : 1 Got Change for COUNTER : 1 Incrementing COUNTER to : 2 Got Change for COUNTER : 2 Incrementing COUNTER to : 3 Got Change for COUNTER : 3 Incrementing COUNTER to : 4 Got Change for COUNTER : 4 Incrementing COUNTER to : 5 Got Change for COUNTER : 5
由於全部數據的讀和寫都來自主內存。那麼天然地,咱們的ChangeMaker和ChangeListener之間,看到的COUNTER值就是同樣的。ide
private static int COUNTER = 0;
沒錯,你會發現,咱們的ChangeMaker仍是能正常工做的,每隔500ms仍然可以對COUNTER自增1。可是,奇怪的事情在ChangeListener上發生了,函數
咱們的ChangeListener再也不工做了。在ChangeListener眼裏,它彷佛一直以爲COUNTER的值仍是一開始的0。彷佛COUNTER的變化,對於咱們的ChangeListener完全「隱身」了。性能
Incrementing COUNTER to : 1 Incrementing COUNTER to : 2 Incrementing COUNTER to : 3 Incrementing COUNTER to : 4 Incrementing COUNTER to : 5
咱們去掉了volatile關鍵字。這個時候,ChangeListener又是一個忙等待的循環,它嘗試不停地獲取COUNTER的值,這樣就會從當前線程的「Cache」裏面獲取。
因而,這個線程就沒有時間從主內存裏面同步更新後的COUNTER值。這樣,它就一直卡死在COUNTER=0的死循環上了。網站
咱們能夠再對程序作一些小小的修改。咱們再也不讓ChangeListener進行徹底的忙等待,而是在while循環裏面,小小地等待上5毫秒,看看會發生什麼狀況。atom
static class ChangeListener extends Thread { @Override public void run() { int threadValue = COUNTER; while ( threadValue < 5){ if( threadValue!= COUNTER){ System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + ""); threadValue= COUNTER; } try { Thread.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } } }
好了,不知道你有沒有本身動手試一試呢?又一個使人驚奇的現象要發生了。雖然咱們的COUNTER變量,仍然沒有設置volatile這個關鍵字,可是咱們的ChangeListener彷佛「睡醒了」。
在經過Thread.sleep(5)在每一個循環裏「睡上「5毫秒以後,ChangeListener又可以正常取到COUNTER的值了。spa
Incrementing COUNTER to : 1 Sleep 5ms, Got Change for COUNTER : 1 Incrementing COUNTER to : 2 Sleep 5ms, Got Change for COUNTER : 2 Incrementing COUNTER to : 3 Sleep 5ms, Got Change for COUNTER : 3 Incrementing COUNTER to : 4 Sleep 5ms, Got Change for COUNTER : 4 Incrementing COUNTER to : 5 Sleep 5ms, Got Change for COUNTER : 5
雖然仍是沒有使用volatile關鍵字,可是短短5ms的Thead.Sleep給了這個線程喘息之機。既然這個線程沒有這麼忙了,
它也就有機會把最新的數據從主內存同步到本身的高速緩存裏面了。因而,ChangeListener在下一次查看COUNTER值的時候,就能看到ChangeMaker形成的變化了。
這些有意思的現象,其實來自於咱們的Java內存模型以及關鍵字volatile的含義。 那volatile關鍵字究竟表明什麼含義呢?它會確保咱們對於這個變量的讀取和寫入,
都必定會同步到主內存裏,而不是從Cache裏面讀取。該怎麼理解這個解釋呢?咱們經過剛纔的例子來進行分析。
雖然Java內存模型是一個隔離了硬件實現的虛擬機內的抽象模型,可是它給了咱們一個很好的「緩存同步」問題的示例。也就是說,若是咱們的數據,在不一樣的線程或者CPU核裏面去更新,
由於不一樣的線程或CPU核有着本身各自的緩存,頗有可能在A線程的更新,到B線程裏面是看不見的。
一、咱們如今用的Intel CPU,一般都是多核的的。每個CPU核裏面,都有獨立屬於本身的L一、L2的Cache,而後再有多個CPU核共用的L3的Cache、主內存。
二、由於CPU Cache的訪問速度要比主內存快不少,而在CPU Cache裏面,L1/L2的Cache也要比L3的Cache快。
三、因此,上一講咱們能夠看到,CPU始終都是儘量地從CPU Cache中去獲取數據,而不是每一次都要從主內存裏面去讀取數據。
這個層級結構,就好像咱們在Java內存模型裏面,每個線程都有屬於本身的線程棧。線程在讀取COUNTER的數據的時候,
實際上是從本地的線程棧的Cache副本里面讀取數據,而不是從主內存裏面讀取數據。
若是咱們對於數據僅僅只是讀,問題還不大。咱們在上一講裏,已經看到Cache Line的組成,以及如何從內存裏面把對應的數據加載到Cache裏。
最簡單的一種寫入策略,叫做寫直達(Write-Through)。在這個策略裏,每一次數據都要寫入到主內存裏面。在寫直達的策略裏面,
一、寫入前,咱們會先去判斷數據是否已經在Cache裏面了。若是數據已經在Cache裏面了,咱們先把數據寫入更新到Cache裏面,再寫入到主內存裏面;
二、若是數據不在Cache裏,咱們就只更新主內存。
寫直達的這個策略很直觀,可是問題也很明顯,那就是這個策略很慢。不管數據是否是在Cache裏面,咱們都須要把數據寫到主內存裏面。
這個方式就有點兒像咱們上面用volatile關鍵字,始終都要把數據同步到主內存裏面。
這個時候,咱們就想了,既然咱們去讀數據也是默認從Cache裏面加載,可否不用把全部的寫入都同步到主內存裏呢?只寫入CPU Cache裏面是否是能夠?
固然是能夠的。在CPU Cache的寫入策略裏,還有一種策略就叫做寫回(Write-Back)。這個策略裏,咱們再也不是每次都把數據寫入到主內存,而是隻寫到CPU Cache裏。
只有當CPU Cache裏面的數據要被「替換」的時候,咱們才把數據寫入到主內存裏面去。
寫回策略的過程是這樣的:
一、若是發現咱們要寫入的數據,就在CPU Cache裏面,那麼咱們就只是更新CPU Cache裏面的數據。同時,咱們會標記CPU Cache裏的這個Block是髒(Dirty)的。
所謂髒的,就是指這個時候,咱們的CPU Cache裏面的這個Block的數據,和主內存是不一致的。
二、若是咱們發現,咱們要寫入的數據所對應的Cache Block裏,放的是別的內存地址的數據,那麼咱們就要看一看,那個Cache Block裏面的數據有沒有被標記成髒的。
三、若是是髒的話,咱們要先把這個Cache Block裏面的數據,寫入到主內存裏面。
四、而後,再把當前要寫入的數據,寫入到Cache裏,同時把Cache Block標記成髒的。
五、若是Block裏面的數據沒有被標記成髒的,那麼咱們直接把數據寫入到Cache裏面,而後再把CacheBlock標記成髒的就行了。
在用了寫回這個策略以後,咱們在加載內存數據到Cache裏面的時候,也要多出一步同步髒Cache的動做。
六、若是加載內存裏面的數據到Cache的時候,發現Cache Block裏面有髒標記,咱們也要先把Cache Block裏的數據寫回到主內存,才能加載數據覆蓋掉Cache。
若是咱們大量的操做,都可以命中緩存。那麼大部分時間裏,咱們都不須要讀寫主內存,天然性能會比寫直達的效果好不少
要解決這個問題,咱們須要引入一個新的方法,叫做MESI協議。這是一個維護緩存一致性協議。這個協議不只能夠用在CPU Cache之間,也能夠普遍用於各類須要使用緩存,
同時緩存之間須要同步的場景下。今天的內容差很少了,咱們放在下一講,仔細講解緩存一致性問題。
最後,咱們一塊兒來回顧一下這一講的知識點。經過一個使用Java程序中使用volatile關鍵字程序,咱們能夠看到,在有緩存的狀況下會遇到一致性問題。
volatile這個關鍵字能夠保障咱們對於數據的讀寫都會到達主內存。
進一步地,咱們能夠看到,Java內存模型和CPU、CPU Cache以及主內存的組織結構很是類似。在CPUCache裏,對於數據的寫入,
咱們也有寫直達和寫回這兩種解決方案。寫直達把全部的數據都直接寫入到主內存裏面,簡單直觀,可是性能就會受限於內存的訪問速度。
而寫回則一般只更新緩存,只有在須要把緩存裏面的髒數據交換出去的時候,才把數據同步到主內存裏。在緩存常常會命中的狀況下,性能更好。
可是,除了採用讀寫都直接訪問主內存的辦法以外,如何解決緩存一致性的問題,咱們仍是沒有解答。這個問題的解決方案,咱們放到下一講來詳細解說。