記憶體屏障,又稱記憶體柵欄,是一組處理器指令,用於強制限制記憶體操作的順序。本文探討記憶體屏障對多執行緒程式的影響,分析其與 JVM 並發原語(volatile、synchronized、原子條件式)的關係。假設讀者已掌握 Java 記憶體模型(JMM)基礎,不討論互斥、平行機制與原子性,專注於屏障如何實現「可見性」。
主記憶體存取需數百個時鐘週期。處理器透過快取大幅降低延遲,但為了效能會重新排序待定記憶體操作——程式的讀寫順序不一定等於處理器執行順序。對於不可變資料或執行緒區域資料無害,但結合對稱多處理(SMP)與共享可變狀態時,重排序會導致程式行為非確定性:一個執行緒寫入的資料可能因寫入順序不一致而被其他執行緒以錯誤順序看見。適當放置記憶體屏障,強制處理器依序執行待定操作,即可避免此問題。
屏障不直接由 JVM 暴露,而是由 JVM 插入指令序列以維持語言層並發原語語義。我們透過幾個簡單 Java 程式的原始碼與組合語言指令來研究。
// 執行緒 1 // 執行緒 2
intentFirst = true; intentSecond = true;
while (intentSecond) while (intentFirst) // volatile 讀
if (turn != 0) { if (turn != 1) { // volatile 讀
intentFirst = false; intentSecond = false;
while (turn != 0) {} while (turn != 1) {}
intentFirst = true; intentSecond = true;
}
criticalSection(); criticalSection();
turn = 1; turn = 0; // volatile 寫
intentFirst = false; intentSecond = false; // volatile 寫
關鍵點:第 3-4 行兩次連續 volatile 讀,第 12-13 行兩次連續 volatile 寫。硬體優化若無屏障會打亂順序。volatile 在寫操作間建立先後關係,編譯器不會重排序,必要時插入屏障禁止處理器重排序。
連續讀操作(第 3 行):
1 adds r37=597,r36;;
2 ld1.acq r38=[r37];; // 載入獲取:單向屏障,確保此讀在後續讀前完成
3 nop.m 0x0
4 sxt1 r38=r38;;
5 cmp4.eq p0,p6=0,r38
6 nop.i 0x0
7 br.cond.dpnt.many 0x2000000001de8220;;
ld.acq(load acquire)確保第一次讀在第二次讀前完成,解決讀端排序問題。
連續寫操作(第 12-13 行):
1 adds r37=592,r36;;
2 st4.rel [r37]=r39 // 儲存釋放:單向屏障
3 adds r36=596,r36;;
4 st1.rel [r36]=r0
5 mf // 記憶體柵欄:雙向完整屏障
6 nop.i 0x0;;
7 mov r12=r33
...
st.rel 確保第一次寫在第二次寫前完成。第 5 行 mf 是完整雙向屏障,作者認為對 Dekker 演算法屬多餘。
不同硬體架構的屏障指令迥異。JVM 需彌補 JMM 與硬體記憶體模型的差距。
...
4 movb $0x1,-0x505a72f0(%edi)
5 mfence // 顯式雙向屏障
6 mov $0x148,%ebp
...
11 movl $0x1,-0x505a72f0(%ebp)
12 movb $0x0,-0x505a72f0(%edi)
13 mfence // 顯式雙向屏障
x86 在兩次 volatile 寫之後插入 mfence,而非寫操作之間。
5 st %l0, [ %l1 + 0x150 ]
6 clrb [ %l1 + 0x154 ]
7 membar #StoreLoad // 顯式雙向屏障
同樣在寫操作之後插入 membar。
JVM 透過屏障縮減 JMM 與硬體模型的差距。
顯式屏障指令非序列化記憶體操作的唯一方式。
class Counter {
static int counter = 0;
static synchronized void inc() { counter += 1; }
}
組合語言中未見顯式屏障,而是透過 lock cmpxchg 指令(第 10、25 行)一石二鳥:原子性執行寫入並刷新待定讀寫操作,確保寫入在所有後續記憶體操作前完成。
static AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();
同樣出現 lock cmpxchg(第 14 行),確保新值在後續操作前可見。
JVM 擅長消除不必要的屏障。當硬體模型一致性保證強於或等於 JMM 時,插入 no op 而非真實屏障。
3 movsbl -0x505a72f0(%edx),%ebx // 第一次讀
...
14 mov -0x505a72f0(%ebp),%ebx // 第二次讀
// 兩者間無任何屏障指令
x86/SPARC 上 volatile 讀效能損失極小,指令與普通讀無異。
JVM 在單向屏障足夠時避免使用雙向屏障。Itanium 上連續讀插入單向 ld.acq,若改用雙向屏障雖正確但延遲較高。
動態編譯器可在執行時決定優化策略。單處理器模式下,JVM 為所有屏障插入 no op,因記憶體操作天然序列化。
10 movl $0x1,-0x507572f0(%ebp)
11 movb $0x0,-0x507572f0(%edi)
12 add $0x8,%esp
13 pop %ebp
// 無 mfence
每次寫入後雖有屏障位置,但實為 no op。
14 cmpxchg %esi,(%edi) // 無 lock 前綴
缺少 SMP 時,JVM 移除 lock 前綴避免成本。
記憶體屏障是多執行緒程式設計的必要裝備,形式多樣:顯式/隱式、單向/雙向。JVM 靈活運用這些形式在所有平台有效支援 JMM。理解底層運作有助資深開發者寫出更高效、正確的並發程式碼。