詳述Java記憶體屏障,透徹理解volatile

2023-10-29 12:00:36

一般來說記憶體屏障分為兩層:編譯器屏障和CPU屏障,前者只在編譯期生效,目的是防止編譯器生成亂序的記憶體存取指令;後者通過插入或修改特定的CPU指令,在執行時防止記憶體存取指令亂序執行。

下面簡單說一下這兩種屏障。

1、編譯器屏障

編譯器屏障如下:

asm volatile("": : :"memory")

內聯組合時只是插入了一個空指令"",關鍵在在內聯組合中的修改暫存器列表中指定了"memory",它告訴編譯器:這條指令(其實是空的)可能會讀取任何記憶體地址,也可能會改寫任何記憶體地址。那麼編譯器會變得保守起來,它會防止這條fence命令上方的記憶體存取操作移到下方,同時防止下方的操作移到上面,也就是防止了亂序,是我們想要的結果。這條命令還有另外一個副作用:它會讓編譯器把所有快取在暫存器中的記憶體變數重新整理到記憶體中,然後重新從記憶體中讀取這些值。 

總結一下就是,如上命令有兩個作用,防止指令重排序以及保證可見性。

如果使用純位元組碼直譯器來執行Java,那麼HotSpot VM中orderAccess_linux_zero.inline.hpp檔案中有如下實現:

static inline void compiler_barrier() {
  __asm__ volatile ("" : : : "memory");
}

inline void OrderAccess::loadload()   {
  compiler_barrier(); }
inline void OrderAccess::storestore() {
  compiler_barrier(); }
inline void OrderAccess::loadstore()  {
  compiler_barrier(); }

這種方式依賴於編譯器達到目的時,如果編譯器支援,就不用在不同的平臺和CPU上再專門編寫對應的實現,簡化了跨平臺操作。

2、x86 CPU屏障 

x86屬於一個強記憶體模型,這意味著在大多數情況下CPU會保證記憶體存取指令有序執行。為了防止這種CPU亂序,我們需要新增CPU記憶體屏障。X86專門的記憶體屏障指令是"mfence",另外還可以使用lock指令字首起到相同的效果,後者開銷更小。也就是說,記憶體屏障可以分為兩類:

  • 本身是記憶體屏障,比如「lfence」,「sfence」和「mfence」組合指令
  • 本身不是記憶體屏障,但是被lock指令字首修飾,其組合成為一個記憶體屏障。在X86指令體系中,其中一類記憶體屏障常使用「lock指令字首加上一個空操作」方式實現,比如lock addl $0x0,(%esp)

下面介紹一下lock指令字首。lock指令字首功能如下:

  • 被修飾的組合指令成為「原子的」
  • 與被修飾的組合指令一起提供記憶體屏障效果

在X86指令體系中,具有lock指令字首,其內允許使用lock指令字首修飾的組合指令有:

ADD,ADC,AND,BTC,BTR,BTS,CMPXCHG,CMPXCH8B,DEC,INC,NEG,NOT,OR,SBB,SUB,XOR,XADD,XCHG等

需要注意的是,「XCHG」和「XADD」組合指令本身是原子指令,但也允許使用lock指令字首進行修飾。

lock字首的2個作用要記住。第一個是記憶體屏障,任何顯式或隱式帶有lock字首的指令以及CPUID等指令都有記憶體屏障的作用。如xchg [mem], reg具有隱式的lock字首。第二個是原子性,單指令並不是一個不可分割的操作,比如mov,本身只有其運算元滿足某些條件的時候才是原子的,但是如果允許有lock字首,那就是原子的。

3、HotSpot VM中的記憶體屏障

JMM為了更好讓Java開發者獨立於CPU的方式理解這些概念,對記憶體讀(Load)和寫(Store)操作進行兩兩組合:LoadLoad、LoadStore、StoreLoad以及StoreStore,只有StoreLoad組合可能亂序,而且Store和Load的記憶體地址必須是不一樣的。

現在只討論x86架構下的CPU屏障,參考的是Intel手冊。4個屏障只是Java為了跨平臺而設計出來的,實際上根據CPU的不同,對應 CPU 平臺上的 JVM 可能可以優化掉一些 屏障,例如LoadLoad、LoadStore和StoreStore是x86上預設就有的行為,在這個平臺上寫程式碼時會簡化一些開發過程。X86-64下僅支援一種指令重排:StoreLoad ,即讀操作可能會重排到寫操作前面,同時不同執行緒的寫操作並沒有保證全域性可見,例子見《Intel® 64 and IA-32 Architectures Software Developer’s Manual》手冊8.6.1、8.2.3.7節。這個問題用lock或mfence解決,不能靠組合sfence和lfence解決。

JDK 1.8版本中的HotSpot VM在x86上實現的loadload()、storestore()以及loadstore()函數如下:

inline void OrderAccess::loadload(){
	acquire();
}
inline void OrderAccess::storestore(){
	release();
}
inline void OrderAccess::loadstore(){
	acquire();
}
inline void OrderAccess::storeload(){
	fence();
}

inline void OrderAccess::acquire() {
  volatile intptr_t local_dummy;
#ifdef AMD64
  __asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");
#else
  __asm__ volatile ("movl 0(%%esp),%0" : "=r" (local_dummy) : : "memory");
#endif // AMD64
}

inline void OrderAccess::release() {
  // Avoid hitting the same cache-line from different threads.
  volatile jint local_dummy = 0;
}

acquire語意防止它後面的讀寫操作重排序到acquire前面,所以LoadLoad和LoadStore組合後可滿足要求;release防止它前面的讀寫操作重排序到release後面,所以可由StoreStore和LoadStore組合後滿足要求。這樣acquire和release就可以實現一個"柵欄",禁止內部讀寫操作跑到外邊,但是外邊的讀寫操作仍然可以跑到「柵欄」內。

在x86上,acquire和release沒有涉及到StoreLoad,所以本來預設支援,在函數實現時,完全可以不做任何操作。具體在實現時,acquire()函數讀取了一個C++的volatile變數,而release()函數寫入了一個C++的volatile變數。這可能是支援微軟從Visual Studio 2005開始就對C++ volatile關鍵字新增了同步語意,也就是對volatile變數的讀操作具有acquire語意,對volatile變數的寫操作具有release語意。

另外還可以順便說一下,藉助acquire與release語意可以實現互斥鎖(mutex),實際上,mutex正是acquire與release這兩個原語的由來,acquire的本意是acquire a lock,release的本意是release a lock,因此,互斥鎖能保證被鎖住的區域內得到的資料不會是過期的資料,而且所有寫入操作在release之前一定會寫入記憶體。所以後續我們在實現鎖的過程中會有如下程式碼出現:

pthread_mutex_lock(&mutex);
// 操作
pthread_mutex_unlock(&mutex);

OrderAccess::storeload()函數呼叫的fence()的實現如下:

inline void OrderAccess::fence() {
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
}

可以看到是使用lock字首來解決記憶體屏障問題。

下面看一下Java的volatile變數的實現。   

位元組碼層面會在access_flags中會標記某個屬性為volatitle,到HotSpot VM後,對volatitle記憶體區進行讀寫時,都加屏障,如讀取volatile變數時加如下屏障:

volatile變數讀操作
LoadLoad 
LoadStore

在寫volatilie變數時加如下屏障:

LoadStore
StoreStore 
volatile變數寫操作
StoreLoad 

如上的volatile變數讀之後的操作不允許重排序到前面,而寫之前的操作也不允許重排序到寫後面,所以volatile有acquire和release的語意。

對x86-64位元來說,只需要對StoreLoad進行處理,所以從解釋執行的putfield或putstatic指令來看(可參考文章:第26篇-虛擬機器器物件操作指令之putstatic),會在最後寫入volatilie變數後加如下指令:

lock addl $0x0,(%rsp)

在Java的synchronized過程中,也要保證可見性及亂序行為。 寫單執行緒程式碼的程式設計師不需要關心記憶體亂序的問題。在多執行緒程式設計中,由於使用互斥量,號誌和事件都在設計的時候都阻止了它們呼叫點中的記憶體亂序(已經隱式包含各種memery barrier),記憶體亂序的問題同樣不需要考慮了。只有當使用無鎖(lock-free)技術時–記憶體線上程間共用而沒有任何的互斥量,記憶體亂序的效果才會顯露無疑,這樣我們才需要考慮在合適的地方加入合適的memery barrier。

B站上已經更新出了一系列的課程,關於一個手寫Hotspot VM的課程,超級硬核,從0開始寫HotSpot VM,將HotSpot VM所有核心的實現全部走一遍,有興趣可關注B站Up主。