明明加了 volatile,為什么數據還是錯的?
“知道”?
如果每次都強制刷新主內存,性能代價是否太高?
有沒有一種機制,只在需要時才確保可見性,而不犧牲并發性能?
這就是 volatile 登場的理由。
它是怎么做到“可見”的?
我們知道,volatile 的核心承諾之一是 可見性。那它是怎么做到的?
當一個變量被聲明為 volatile 后,編譯器和 CPU 都會受到一系列“約束”:
- 寫 volatile 變量時:JVM 會在生成的字節碼中插入一個 store barrier(寫屏障),強制將當前線程的工作內存中對應的變量刷新到主內存。
- 讀 volatile 變量時:插入一個 load barrier(讀屏障),強制從主內存讀取變量,禁止從緩存中獲取舊值。
而在更底層的匯編層面,HotSpot 會將這些讀寫操作轉換為對應平臺的內存屏障指令(如 x86 上的 LOCK 前綴),這會觸發 緩存一致性協議(MESI) 來確保其他 CPU 核心能感知這一變更。
CPU緩存、多級緩存與主內存之間的一致性交互
CPU緩存、多級緩存與主內存之間的一致性交互
所以,當你寫了一個 volatile 變量,本質上你是在告訴 JVM 和 CPU:
“這個變量很重要,我要確保它對其他線程立即可見,別偷偷緩存。”
這機制本身非常高效,因為它避免了顯式加鎖,卻仍能在某些關鍵場景下確保同步。但問題來了:
那 volatile 能解決“并發寫”的問題嗎?
我們再看另一個經典的例子:
volatile int count = 0;
// 多線程執行:
count++;
你也許以為,volatile 保證了可見性,線程A加1后,線程B就能“看到”變化。但實際運行中,count 的結果常常是錯的,甚至比預期小很多。這是為什么?
我們來拆解一下count++的底層執行:
- 讀取 count
- 自增(加1)
- 寫回 count
這個操作看似一個語句,但其實是 三個獨立的步驟。在多線程環境中,多個線程可能在同一時間讀取到相同的舊值,然后各自加1,最終寫回,就發生了“覆蓋”。
于是我們意識到:
volatile 確保了“你讀到的是最新的值”,但不會阻止其他線程在你讀完和寫入之間“插一腳”。
這就是 volatile 的第二個重要特性:不保證原子性。
所以,如果你要保證 count++ 是線程安全的,volatile 是不夠的。你需要加鎖(synchronized)或使用原子類(如 AtomicInteger),這些機制提供了“操作不可分割”的原子語義。
那么它如何禁止“指令重排”?
還有一個非常重要但容易被忽視的 volatile 特性是:禁止指令重排序(只針對特定場景)。
你可能會問:什么是重排序?它又會帶來什么風險?
現代 CPU 和 JIT 編譯器為了優化性能,會調整指令執行順序,只要最終結果不變,它們就有理由這么做。但在并發環境中,這種“聰明”可能帶來災難。
比如,在 雙重檢查鎖中:
if (instance == null) {
synchronized(...) {
if (instance == null) {
instance = new Singleton(); // 可能會被重排序
}
}
}
如果 instance 沒有被聲明為 volatile,那么這段代碼可能會出現 對象引用先被賦值,再初始化成員變量 的情況,導致另一個線程拿到的是“半初始化”的對象。
這是因為 instance = new Singleton() 在字節碼層面大致分為三步:
- 分配內存
- 調用構造方法初始化
- 將引用賦值給 instance
在沒有 volatile 的保護下,步驟2和3可能被重排序,最終讓另一個線程看到一個“不是 null 但沒初始化”的引用。
指令重排序示意圖:構造順序 vs 實際執行順序
指令重排序示意圖:構造順序 vs 實際執行順序
而 volatile 則通過內存屏障來禁止這些特定的重排,從而讓雙檢鎖的懶加載寫法變得安全。
volatile 是不是一種“輕量級鎖”?
這個說法常常被提起,但它并不準確。我們可以這么理解:
- 鎖(如 synchronized) 提供了:可見性 + 原子性 + 互斥執行
- volatile 僅提供:可見性 + 有序性(部分)
也就是說,volatile 是一個比鎖“輕”的同步工具,但它并不能替代鎖。它適合那些:
- 只有一個寫線程,多讀線程(典型場景如配置更新)
- 狀態標志控制(如停止線程、開關變量)
- 雙檢鎖中的對象引用
但一旦涉及多個線程同時修改變量(如計數器、列表增刪),就必須用到真正的互斥機制。
總結:volatile 能做什么,不能做什么?
我們回頭看 volatile,其實它解決了并發編程中最“微妙”的部分之一 —— 內存可見性和有序性。它的設計精妙之處在于:
- 用極低的開銷,換來了主內存與線程緩存之間的數據同步
- 在特定場景下,用內存屏障保障了代碼執行順序的可預期性
但它的能力也有明確邊界:
- 不提供原子性
- 無法互斥訪問臨界區
- 不能替代鎖
如果你記住這一點,你就不會再對“加了 volatile 為什么還錯”感到困惑了。
思考一個延伸問題:
如果 AtomicInteger 內部用了 volatile,又怎么實現了原子性?它到底是如何做到“又輕量又安全”的?
下次,我們不妨走進 CAS(Compare-And-Swap)的世界,看看它和 volatile 是如何攜手,讓并發編程“快且對”的。