38 | 高速緩存(下):你确定你的資料更新了麼?
在我工作的十幾年裡,寫了很多 Java 的程式。同時,我也面試過大量的 Java 工程師。對于一些表示自己深入了解和擅長多線程的同學,我經常會問這樣一個面試題:“volatile 這個關鍵字有什麼作用?” 如果你或者你的朋友寫過 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 Memory Model)上。
雖然 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(); }
}
}
}
}
我們先來看看這個程式做了什麼。在這個程式裡,我們先定義了一個 volatile 的 int 類型的變量,COUNTER。
然後,我們分别啟動了兩個單獨的線程,一個線程我們叫 ChangeListener。另一個線程,我們叫 ChangeMaker。
ChangeListener 這個線程運作的任務很簡單。它先取到 COUNTER 目前的值,然後一直監聽着這個 COUNTER 的值。一旦 COUNTER 的值發生了變化,就把新的值通過 println 列印出來。直到 COUNTER 的值達到 5 為止。這個監聽的過程,通過一個永不停歇的 while 循環的忙等待來實作。
ChangeMaker 這個線程運作的任務同樣很簡單。它同樣是取到 COUNTER 的值,在 COUNTER 小于 5 的時候,每隔 500 毫秒,就讓 COUNTER 自增 1。在自增之前,通過 println 方法把自增後的值列印出來。
最後,在 main 函數裡,我們分别啟動這兩個線程,來看一看這個程式的執行情況。程式的輸出結果并不讓人意外。ChangeMaker 函數會一次一次将 COUNTER 從 0 增加到 5。因為這個自增是每 500 毫秒一次,而 ChangeListener 去監聽 COUNTER 是忙等待的,是以每一次自增都會被 ChangeListener 監聽到,然後對應的結果就會被列印出來。
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
這個時候,我們就可以來做一個很有意思的實驗。如果我們把上面的程式小小地修改一行代碼,把我們定義 COUNTER 這個變量的時候,設定的 volatile 關鍵字給去掉,會發生什麼事情呢?你可以自己先試一試,看結果是否會讓你大吃一驚。
沒錯,你會發現,我們的 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
這個有意思的小程式還沒有結束,我們可以再對程式做一些小小的修改。我們不再讓 ChangeListener 進行完全的忙等待,而是在 while 循環裡面,小小地等待上 5 毫秒,看看會發生什麼情況。
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 的值了。
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
這些有意思的現象,其實來自于我們的 Java 記憶體模型以及關鍵字 volatile 的含義。**那 volatile 關鍵字究竟代表什麼含義呢?它會確定我們對于這個變量的讀取和寫入,都一定會同步到主記憶體裡,而不是從 Cache 裡面讀取。**該怎麼了解這個解釋呢?我們通過剛才的例子來進行分析。
剛剛第一個使用了 volatile 關鍵字的例子裡,因為所有資料的讀和寫都來自主記憶體。那麼自然地,我們的 ChangeMaker 和 ChangeListener 之間,看到的 COUNTER 值就是一樣的。
到了第二段進行小小修改的時候,我們去掉了 volatile 關鍵字。這個時候,ChangeListener 又是一個忙等待的循環,它嘗試不停地擷取 COUNTER 的值,這樣就會從目前線程的 “Cache” 裡面擷取。于是,這個線程就沒有時間從主記憶體裡面同步更新後的 COUNTER 值。這樣,它就一直卡死在 COUNTER=0 的死循環上了。
而到了我們再次修改的第三段代碼裡面,雖然還是沒有使用 volatile 關鍵字,但是短短 5ms 的 Thead.Sleep 給了這個線程喘息之機。既然這個線程沒有這麼忙了,它也就有機會把最新的資料從主記憶體同步到自己的高速緩存裡面了。于是,ChangeListener 在下一次檢視 COUNTER 值的時候,就能看到 ChangeMaker 造成的變化了。
雖然 Java 記憶體模型是一個隔離了硬體實作的虛拟機内的抽象模型,但是它給了我們一個很好的 “緩存同步” 問題的示例。也就是說,如果我們的資料,在不同的線程或者 CPU 核裡面去更新,因為不同的線程或 CPU 核有着自己各自的緩存,很有可能在 A 線程的更新,到 B 線程裡面是看不見的。
CPU 高速緩存的寫入
事實上,我們可以把 Java 記憶體模型和計算機組成裡的 CPU 結構對照起來看。
我們現在用的 Intel CPU,通常都是多核的的。每一個 CPU 核裡面,都有獨立屬于自己的 L1、L2 的 Cache,然後再有多個 CPU 核共用的 L3 的 Cache、主記憶體。
因為 CPU Cache 的通路速度要比主記憶體快很多,而在 CPU Cache 裡面,L1/L2 的 Cache 也要比 L3 的 Cache 快。是以,上一講我們可以看到,CPU 始終都是盡可能地從 CPU Cache 中去擷取資料,而不是每一次都要從主記憶體裡面去讀取資料。

這個層級結構,就好像我們在 Java 記憶體模型裡面,每一個線程都有屬于自己的線程棧。線程在讀取 COUNTER 的資料的時候,其實是從本地的線程棧的 Cache 副本裡面讀取資料,而不是從主記憶體裡面讀取資料。如果我們對于資料僅僅隻是讀,問題還不大。我們在上一講裡,已經看到 Cache Line 的組成,以及如何從記憶體裡面把對應的資料加載到 Cache 裡。
但是,對于資料,我們不光要讀,還要去寫入修改。這個時候,有兩個問題來了。
**第一個問題是,寫入 Cache 的性能也比寫入主記憶體要快,那我們寫入的資料,到底應該寫到 Cache 裡還是主記憶體呢?如果我們直接寫入到主記憶體裡,Cache 裡的資料是否會失效呢?**為了解決這些疑問,下面我要給你介紹兩種寫入政策。
寫直達(Write-Through)
最簡單的一種寫入政策,叫作寫直達(Write-Through)。在這個政策裡,每一次資料都要寫入到主記憶體裡面。在寫直達的政策裡面,寫入前,我們會先去判斷資料是否已經在 Cache 裡面了。如果資料已經在 Cache 裡面了,我們先把資料寫入更新到 Cache 裡面,再寫入到主記憶體裡面;如果資料不在 Cache 裡,我們就隻更新主記憶體。
寫直達的這個政策很直覺,但是問題也很明顯,那就是這個政策很慢。無論資料是不是在 Cache 裡面,我們都需要把資料寫到主記憶體裡面。這個方式就有點兒像我們上面用 volatile 關鍵字,始終都要把資料同步到主記憶體裡面。
寫回(Write-Back)
這個時候,我們就想了,既然我們去讀資料也是預設從 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 裡面,然後再把 Cache Block 标記成髒的就好了。
在用了寫回這個政策之後,我們在加載記憶體資料到 Cache 裡面的時候,也要多出一步同步髒 Cache 的動作。如果加載記憶體裡面的資料到 Cache 的時候,發現 Cache Block 裡面有髒标記,我們也要先把 Cache Block 裡的資料寫回到主記憶體,才能加載資料覆寫掉 Cache。
可以看到,在寫回這個政策裡,如果我們大量的操作,都能夠命中緩存。那麼大部分時間裡,我們都不需要讀寫主記憶體,自然性能會比寫直達的效果好很多。
然而,無論是寫回還是寫直達,其實都還沒有解決我們在上面 volatile 程式示例中遇到的問題,也就是多個線程,或者是多個 CPU 核的緩存一緻性的問題。這也就是我們在寫入修改緩存後,需要解決的第二個問題。
要解決這個問題,我們需要引入一個新的方法,叫作 MESI 協定。這是一個維護緩存一緻性協定。這個協定不僅可以用在 CPU Cache 之間,也可以廣泛用于各種需要使用緩存,同時緩存之間需要同步的場景下。今天的内容差不多了,我們放在下一講,仔細講解緩存一緻性問題。
總結延伸
最後,我們一起來回顧一下這一講的知識點。通過一個使用 Java 程式中使用 volatile 關鍵字程式,我們可以看到,在有緩存的情況下會遇到一緻性問題。volatile 這個關鍵字可以保障我們對于資料的讀寫都會到達主記憶體。
進一步地,我們可以看到,Java 記憶體模型和 CPU、CPU Cache 以及主記憶體的組織結構非常相似。在 CPU Cache 裡,對于資料的寫入,我們也有寫直達和寫回這兩種解決方案。寫直達把所有的資料都直接寫入到主記憶體裡面,簡單直覺,但是性能就會受限于記憶體的通路速度。而寫回則通常隻更新緩存,隻有在需要把緩存裡面的髒資料交換出去的時候,才把資料同步到主記憶體裡。在緩存經常會命中的情況下,性能更好。
但是,除了采用讀寫都直接通路主記憶體的辦法之外,如何解決緩存一緻性的問題,我們還是沒有解答。這個問題的解決方案,我們放到下一講來詳細解說。
推薦閱讀
如果你是一個 Java 程式員,我推薦你去讀一讀 Fixing Java Memory Model 這篇文章。讀完這些内容,相信你會對 Java 裡的記憶體模型和多線程原理有更深入的了解,并且也能更好地和我們計算機底層的硬體架構聯系起來。
對于計算機組成的 CPU 高速緩存的寫操作處理,你也可以讀一讀《計算機組成與設計:硬體 / 軟體接口》的 5.3.3 小節。
課後思考
最後,給你留一道思考題。既然 volatile 關鍵字,會讓所有的資料寫入都要到主記憶體。你可以試着寫一個小的程式,看看使用 volatile 關鍵字和不使用 volatile 關鍵字,在資料寫入的性能上會不會有差異,以及這個差異到底會有多大。