最近重新學(xué)習(xí)了一遍《深入學(xué)習(xí) Java 虛擬機》,把之前 Java 內(nèi)存模型中模糊的知識重新梳理了一遍。
【51CTO.com原創(chuàng)稿件】這篇文章主要介紹模型產(chǎn)生的問題背景,解決的問題,處理思路,相關(guān)實現(xiàn)規(guī)則,環(huán)環(huán)相扣,希望讀者看完這篇文章后能對 Java 內(nèi)存模型體系產(chǎn)生一個相對清晰的理解,知其然知其所以然。
內(nèi)存模型產(chǎn)生背景
在介紹 Java 內(nèi)存模型之前,我們先了解一下物理計算機中的并發(fā)問題,理解這些問題可以搞清楚內(nèi)存模型產(chǎn)生的背景。
物理機遇到的并發(fā)問題與虛擬機中的情況有不少相似之處,物理機的解決方案對虛擬機的實現(xiàn)有相當(dāng)?shù)膮⒖家饬x。
物理機的并發(fā)問題
硬件的效率問題
計算機處理器處理絕大多數(shù)運行任務(wù)都不可能只靠處理器“計算”就能完成,處理器至少需要與內(nèi)存交互,如讀取運算數(shù)據(jù)、存儲運算結(jié)果,這個 I/O 操作很難消除(無法僅靠寄存器完成所有運算任務(wù))。
由于計算機的存儲設(shè)備與處理器的運算速度有幾個數(shù)量級的差距,為了避免處理器等待緩慢的內(nèi)存完成讀寫操作,現(xiàn)代計算機系統(tǒng)通過加入一層讀寫速度盡可能接近處理器運算速度的高速緩存。
緩存作為內(nèi)存和處理器之間的緩沖:將運算需要使用到的數(shù)據(jù)復(fù)制到緩存中,讓運算能快速運行,當(dāng)運算結(jié)束后再從緩存同步回內(nèi)存之中。
緩存一致性問題
基于高速緩存的存儲系統(tǒng)交互很好的解決了處理器與內(nèi)存速度的矛盾,但是也為計算機系統(tǒng)帶來更高的復(fù)雜度,因為引入了一個新問題:緩存一致性。
在多處理器的系統(tǒng)中(或者單處理器多核的系統(tǒng)),每個處理器(每個核)都有自己的高速緩存,而它們有共享同一主內(nèi)存(Main Memory)。
當(dāng)多個處理器的運算任務(wù)都涉及同一塊主內(nèi)存區(qū)域時,將可能導(dǎo)致各自的緩存數(shù)據(jù)不一致。
為此,需要各個處理器訪問緩存時都遵循一些協(xié)議,在讀寫時要根據(jù)協(xié)議進行操作,來維護緩存的一致性。
代碼亂序執(zhí)行優(yōu)化問題
為了使得處理器內(nèi)部的運算單元盡量被充分利用,提高運算效率,處理器可能會對輸入的代碼進行亂序執(zhí)行。
處理器會在計算之后將亂序執(zhí)行的結(jié)果重組,亂序優(yōu)化可以保證在單線程下該執(zhí)行結(jié)果與順序執(zhí)行的結(jié)果是一致的,但不保證程序中各個語句計算的先后順序與輸入代碼中的順序一致。
亂序執(zhí)行技術(shù)是處理器為提高運算速度而做出違背代碼原有順序的優(yōu)化。在單核時代,處理器保證做出的優(yōu)化不會導(dǎo)致執(zhí)行結(jié)果遠離預(yù)期目標,但在多核環(huán)境下卻并非如此。
在多核環(huán)境下, 如果存在一個核的計算任務(wù)依賴另一個核計算任務(wù)的中間結(jié)果。
而且對相關(guān)數(shù)據(jù)讀寫沒做任何防護措施,那么其順序性并不能靠代碼的先后順序來保證,處理器最終得出的結(jié)果和我們邏輯得到的結(jié)果可能會大不相同。
以上圖為例進行說明,CPU 的 core2 中的邏輯 B 依賴 core1 中的邏輯 A 先執(zhí)行:
Java 內(nèi)存模型的組成分析
內(nèi)存模型概念
為了更好解決上面提到的系列問題,內(nèi)存模型被總結(jié)提出,我們可以把內(nèi)存模型理解為在特定操作協(xié)議下,對特定的內(nèi)存或高速緩存進行讀寫訪問的過程抽象。
不同架構(gòu)的物理計算機可以有不一樣的內(nèi)存模型,Java 虛擬機也有自己的內(nèi)存模型。
Java 虛擬機規(guī)范中試圖定義一種 Java 內(nèi)存模型(Java Memory Model,簡稱 JMM)來屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,以實現(xiàn)讓 Java 程序在各種平臺下都能達到一致的內(nèi)存訪問效果,不必因為不同平臺上的物理機的內(nèi)存模型的差異,對各平臺定制化開發(fā)程序。
更具體一點說,Java 內(nèi)存模型提出目標在于,定義程序中各個變量的訪問規(guī)則,即在虛擬機中將變量存儲到內(nèi)存和從內(nèi)存中取出變量這樣的底層細節(jié)。
此處的變量(Variables)與 Java 編程中所說的變量有所區(qū)別,它包括了實例字段、靜態(tài)字段和構(gòu)成數(shù)值對象的元素,但不包括局部變量與方法參數(shù),因為后者是線程私有的。
注:如果局部變量是一個 reference 類型,它引用的對象在 Java 堆中可被各個線程共享,但是 reference 本身在 Java 棧的局部變量表中,它是線程私有的。
Java 內(nèi)存模型的組成
主內(nèi)存
Java 內(nèi)存模型規(guī)定了所有變量都存儲在主內(nèi)存(Main Memory)中(此處的主內(nèi)存與介紹物理硬件的主內(nèi)存名字一樣,兩者可以互相類比,但此處僅是虛擬機內(nèi)存的一部分)。
工作內(nèi)存
每條線程都有自己的工作內(nèi)存(Working Memory,又稱本地內(nèi)存,可與前面介紹的處理器高速緩存類比),線程的工作內(nèi)存中保存了該線程使用到的變量的主內(nèi)存中的共享變量的副本拷貝。
工作內(nèi)存是 JMM 的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區(qū),寄存器以及其他的硬件和編譯器優(yōu)化。
Java 內(nèi)存模型抽象示意圖如下:
JVM 內(nèi)存操作的并發(fā)問題
結(jié)合前面介紹的物理機的處理器處理內(nèi)存的問題,可以類比總結(jié)出 JVM 內(nèi)存操作的問題,下面介紹的 Java 內(nèi)存模型的執(zhí)行處理將圍繞解決這兩個問題展開。
工作內(nèi)存數(shù)據(jù)一致性
各個線程操作數(shù)據(jù)時會保存使用到的主內(nèi)存中的共享變量副本,當(dāng)多個線程的運算任務(wù)都涉及同一個共享變量時,將導(dǎo)致各自的共享變量副本不一致,如果真的發(fā)生這種情況,數(shù)據(jù)同步回主內(nèi)存以誰的副本數(shù)據(jù)為準?
Java 內(nèi)存模型主要通過一系列的數(shù)據(jù)同步協(xié)議、規(guī)則來保證數(shù)據(jù)的一致性,后面再詳細介紹。
指令重排序優(yōu)化
Java 中重排序通常是編譯器或運行時環(huán)境為了優(yōu)化程序性能而采取的對指令進行重新排序執(zhí)行的一種手段。
重排序分為兩類:編譯期重排序和運行期重排序,分別對應(yīng)編譯時和運行時環(huán)境。
同樣的,指令重排序不是隨意重排序,它需要滿足以下兩個條件:
通俗地說,就是在單線程情況下,要給程序一個順序執(zhí)行的假象。即經(jīng)過重排序的執(zhí)行結(jié)果要與順序執(zhí)行的結(jié)果保持一致。
多線程環(huán)境下,如果線程處理邏輯之間存在依賴關(guān)系,有可能因為指令重排序?qū)е逻\行結(jié)果與預(yù)期不同,后面再展開 Java 內(nèi)存模型如何解決這種情況。
Java 內(nèi)存間的交互操作
在理解 Java 內(nèi)存模型的系列協(xié)議、特殊規(guī)則之前,我們先理解 Java 中內(nèi)存間的交互操作。
交互操作流程
為了更好理解內(nèi)存的交互操作,以線程通信為例,我們看看具體如何進行線程間值的同步:
線程 1 和線程 2 都有主內(nèi)存中共享變量 x 的副本,初始時,這 3 個內(nèi)存中 x 的值都為 0。
線程 1 中更新 x 的值為 1 之后同步到線程 2 主要涉及兩個步驟:
從整體上看,這兩個步驟是線程 1 在向線程 2 發(fā)消息,這個通信過程必須經(jīng)過主內(nèi)存。
JMM 通過控制主內(nèi)存與每個線程本地內(nèi)存之間的交互,來為各個線程提供共享變量的可見性。
內(nèi)存交互的基本操作
關(guān)于主內(nèi)存與工作內(nèi)存之間的具體交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步回主內(nèi)存之類的實現(xiàn)細節(jié),Java 內(nèi)存模型中定義了下面 8 種操作來完成。
虛擬機實現(xiàn)時必須保證下面介紹的每種操作都是原子的,不可再分的(對于 double 和 long 型的變量來說,load、store、read、和 write 操作在某些平臺上允許有例外)。
8 種基本操作,如下圖:
Java 內(nèi)存模型運行規(guī)則
內(nèi)存交互基本操作的 3 個特性
在介紹內(nèi)存交互的具體的 8 種基本操作之前,有必要先介紹一下操作的 3 個特性。
Java 內(nèi)存模型是圍繞著在并發(fā)過程中如何處理這 3 個特性來建立的,這里先給出定義和基本實現(xiàn)的簡單介紹,后面會逐步展開分析。
原子性(Atomicity)
原子性,即一個操作或者多個操作要么全部執(zhí)行并且執(zhí)行的過程不會被任何因素打斷,要么就都不執(zhí)行。
即使在多個線程一起執(zhí)行的時候,一個操作一旦開始,就不會被其他線程所干擾。
可見性(Visibility)
可見性是指當(dāng)多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
正如上面“交互操作流程”中所說明的一樣,JMM 是通過在線程 1 變量工作內(nèi)存修改后將新值同步回主內(nèi)存,線程 2 在變量讀取前從主內(nèi)存刷新變量值,這種依賴主內(nèi)存作為傳遞媒介的方式來實現(xiàn)可見性。
有序性(Ordering)
有序性規(guī)則表現(xiàn)在以下兩種場景:
唯一起作用的約束是:對于同步方法,同步塊(synchronized 關(guān)鍵字修飾)以及 volatile 字段的操作仍維持相對有序。
Java 內(nèi)存模型的一系列運行規(guī)則看起來有點繁瑣,但總結(jié)起來,是圍繞原子性、可見性、有序性特征建立。
歸根究底,是為實現(xiàn)共享變量的在多個線程的工作內(nèi)存的數(shù)據(jù)一致性,多線程并發(fā),指令重排序優(yōu)化的環(huán)境中程序能如預(yù)期運行。
happens-before 關(guān)系
介紹系列規(guī)則之前,首先了解一下 happens-before 關(guān)系:用于描述下 2 個操作的內(nèi)存可見性。如果操作 A happens-before 操作 B,那么 A 的結(jié)果對 B 可見。
happens-before 關(guān)系的分析需要分為單線程和多線程的情況:
在程序控制流路徑中靠前的字節(jié)碼 happens-before 靠后的字節(jié)碼,即靠前的字節(jié)碼執(zhí)行完之后操作結(jié)果對靠后的字節(jié)碼可見。
多線程下的 happens-before,多線程由于每個線程有共享變量的副本,如果沒有對共享變量做同步處理,線程 1 更新執(zhí)行操作 A 共享變量的值之后,線程 2 開始執(zhí)行操作 B,此時操作 A 產(chǎn)生的結(jié)果對操作 B 不一定可見。
為了方便程序開發(fā),Java 內(nèi)存模型實現(xiàn)了下述支持 happens-before 關(guān)系的操作:
內(nèi)存屏障
Java 中如何保證底層操作的有序性和可見性?可以通過內(nèi)存屏障。
內(nèi)存屏障是被插入兩個 CPU 指令之間的一種指令,用來禁止處理器指令發(fā)生重排序(像屏障一樣),從而保障有序性的。
另外,為了達到屏障的效果,它也會使處理器寫入、讀取值之前,將主內(nèi)存的值寫入高速緩存,清空無效隊列,從而保障可見性。
舉個例子說明:
Store1;
Store2;
Load1;
StoreLoad; //內(nèi)存屏障
Store3;
Load2;
Load3;
對于上面的一組 CPU 指令(Store 表示寫入指令,Load 表示讀取指令,StoreLoad 代表寫讀內(nèi)存屏障),StoreLoad 屏障之前的 Store 指令無法與 StoreLoad 屏障之后的 Load 指令進行交換位置,即重排序。
但是 StoreLoad 屏障之前和之后的指令是可以互換位置的,即 Store1 可以和 Store2 互換,Load2 可以和 Load3 互換。
常見有 4 種屏障:
在大多數(shù)處理器的實現(xiàn)中,這個屏障是個萬能屏障,兼具其他三種內(nèi)存屏障的功能。
Java 中對內(nèi)存屏障的使用在一般的代碼中不太容易見到,常見的有 volatile 和 synchronized 關(guān)鍵字修飾的代碼塊(后面再展開介紹),還可以通過 Unsafe 這個類來使用內(nèi)存屏障。
8 種操作同步的規(guī)則
JMM 在執(zhí)行前面介紹 8 種基本操作時,為了保證內(nèi)存間數(shù)據(jù)一致性,JMM 中規(guī)定需要滿足以下規(guī)則:
看起來這些規(guī)則有些繁瑣,其實也不難理解:
工作內(nèi)存中的變量的值同步回主內(nèi)存需要 store 和 write 一起使用,這 2 組操作各自都是一個固定的有序搭配,不允許單獨出現(xiàn)。
volatile 型變量的特殊規(guī)則
volatile 的中文意思是不穩(wěn)定的,易變的,用 volatile 修飾變量是為了保證變量的可見性。
volatile 的語義
volatile 主要有下面 2 種語義:
保證可見性,保證了不同線程對該變量操作的內(nèi)存可見性。這里保證可見性不等同于 volatile 變量并發(fā)操作的安全性,保證可見性具體一點解釋:
但是如果多個線程同時把更新后的變量值同時刷新回主內(nèi)存,可能導(dǎo)致得到的值不是預(yù)期結(jié)果。
舉個例子:定義 volatile int count = 0,2 個線程同時執(zhí)行 count++ 操作,每個線程都執(zhí)行 500 次,最終結(jié)果小于 1000。
原因是每個線程執(zhí)行 count++ 需要以下 3 個步驟:
有可能某一時刻 2 個線程在步驟 1 讀取到的值都是 100,執(zhí)行完步驟 2 得到的值都是 101,最后刷新了 2 次 101 保存到主內(nèi)存。
禁止進行指令重排序,具體一點解釋,禁止重排序的規(guī)則如下:
普通的變量僅僅會保證該方法的執(zhí)行過程中所有依賴賦值結(jié)果的地方都能獲取到正確的結(jié)果,而不能保證賦值操作的順序與程序代碼中的執(zhí)行順序一致。
舉個例子:
volatile boolean initialized = false;
// 下面代碼線程A中執(zhí)行
// 讀取配置信息,當(dāng)讀取完成后將initialized設(shè)置為true以通知其他線程配置可用
doSomethingReadConfg();
initialized = true;
// 下面代碼線程B中執(zhí)行
// 等待initialized 為true,代表線程A已經(jīng)把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用線程A初始化好的配置信息
doSomethingWithConfig();
上面代碼中如果定義 initialized 變量時沒有使用 volatile 修飾,就有可能會由于指令重排序的優(yōu)化,導(dǎo)致線程 A 中最后一句代碼 "initialized = true" 在 “doSomethingReadConfg()” 之前被執(zhí)行。
這樣會導(dǎo)致線程 B 中使用配置信息的代碼可能出現(xiàn)錯誤,而 volatile 關(guān)鍵字就禁止重排序的語義可以避免此類情況發(fā)生。
volatile 型變量實現(xiàn)原理
具體實現(xiàn)方式是在編譯期生成字節(jié)碼時,會在指令序列中增加內(nèi)存屏障來保證,下面是基于保守策略的 JMM 內(nèi)存屏障插入策略:
在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。該屏障除了保證了屏障之前的寫操作和該屏障之后的寫操作不能重排序,還會保證了 volatile 寫操作之前,任何的讀寫操作都會先于 volatile 被提交。
在每個 volatile 寫操作的后面插入一個 StoreLoad 屏障。該屏障除了使 volatile 寫操作不會與之后的讀操作重排序外,還會刷新處理器緩存,使 volatile 變量的寫更新對其他線程可見。
在每個 volatile 讀操作的后面插入一個 LoadLoad 屏障。該屏障除了使 volatile 讀操作不會與之前的寫操作發(fā)生重排序外,還會刷新處理器緩存,使 volatile 變量讀取的為最新值。
在每個 volatile 讀操作的后面插入一個 LoadStore 屏障。該屏障除了禁止了 volatile 讀操作與其之后的任何寫操作進行重排序,還會刷新處理器緩存,使其他線程 volatile 變量的寫更新對 volatile 讀操作的線程可見。
volatile 型變量使用場景
總結(jié)起來,就是“一次寫入,到處讀取”,某一線程負責(zé)更新變量,其他線程只讀取變量(不更新變量),并根據(jù)變量的新值執(zhí)行相應(yīng)邏輯。例如狀態(tài)標志位更新,觀察者模型變量值發(fā)布。
final 型變量的特殊規(guī)則
我們知道,final 成員變量必須在聲明的時候初始化或者在構(gòu)造器中初始化,否則就會報編譯錯誤。
final 關(guān)鍵字的可見性是指:被 final 修飾的字段在聲明時或者構(gòu)造器中,一旦初始化完成,那么在其他線程無須同步就能正確看見 final 字段的值。這是因為一旦初始化完成,final 變量的值立刻回寫到主內(nèi)存。
synchronized 的特殊規(guī)則
通過 synchronized 關(guān)鍵字包住的代碼區(qū)域,對數(shù)據(jù)的讀寫進行控制:
long 和 double 型變量的特殊規(guī)則
Java 內(nèi)存模型要求 lock、unlock、read、load、assign、use、store、write 這 8 種操作都具有原子性。
但是對于 64 位的數(shù)據(jù)類型(long 和 double),在模型中特別定義相對寬松的規(guī)定:允許虛擬機將沒有被 volatile 修飾的 64 位數(shù)據(jù)的讀寫操作分為 2 次 32 位的操作來進行。
也就是說虛擬機可選擇不保證 64 位數(shù)據(jù)類型的 load、store、read 和 write 這 4 個操作的原子性。
由于這種非原子性,有可能導(dǎo)致其他線程讀到同步未完成的“32 位的半個變量”的值。
不過實際開發(fā)中,Java 內(nèi)存模型強烈建議虛擬機把 64 位數(shù)據(jù)的讀寫實現(xiàn)為具有原子性。
目前各種平臺下的商用虛擬機都選擇把 64 位數(shù)據(jù)的讀寫操作作為原子操作來對待,因此我們在編寫代碼時一般不需要把用到的 long 和 double 變量專門聲明為 volatile。
總結(jié)
由于 Java 內(nèi)存模型涉及系列規(guī)則,網(wǎng)上的文章大部分就是對這些規(guī)則進行解析,但是很多沒有解釋為什么需要這些規(guī)則,這些規(guī)則的作用。
其實這是不利于初學(xué)者學(xué)習(xí)的,容易繞進這些繁瑣規(guī)則不知所以然,下面談?wù)勎业囊稽c學(xué)習(xí)知識的個人體會:
學(xué)習(xí)知識的過程不是等同于只是理解知識和記憶知識,而是要對知識解決的問題的輸入和輸出建立連接。
知識的本質(zhì)是解決問題,所以在學(xué)習(xí)之前要理解問題,理解這個問題要的輸入和輸出,而知識就是輸入到輸出的一個關(guān)系映射。
知識的學(xué)習(xí)要結(jié)合大量的例子來理解這個映射關(guān)系,然后壓縮知識,華羅庚說過:“把一本書讀厚,然后再讀薄”,解釋的就是這個道理,先結(jié)合大量的例子理解知識,然后再壓縮知識。
以學(xué)習(xí) Java 內(nèi)存模型為例:
參考文章:
陳彩華(caison),從事服務(wù)端開發(fā),善于系統(tǒng)設(shè)計、優(yōu)化重構(gòu)、線上問題排查工作,主要開發(fā)語言是 Java,微信號:hua1881375。
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】