文章首發於51CTO技術棧公衆號
做者 陳彩華
文章轉載交流請聯繫 caison@aliyun.com
複製代碼
最近從新學習一遍《深刻學習Java虛擬機》,把以前Java內存模型中模糊的知識從新梳理一遍,這篇文章主要介紹模型產生的問題背景,解決的問題,處理思路,相關實現規則,環環相扣,但願讀者看完這篇文章後能對Java內存模型體系產生一個相對清晰的理解,知其然而知其因此然。html
在介紹Java內存模型以前,咱們先了解一下物理計算機中的併發問題,理解這些問題能夠搞清楚內存模型產生的背景。物理機遇到的併發問題與虛擬機中的狀況有很多類似之處,物理機的解決方案對虛擬機的實現有至關的參考意義。java
計算機處理器處理絕大多數運行任務都不可能只靠處理器「計算」就能完成,處理器至少須要與內存交互,如讀取運算數據、存儲運算結果,這個I/O操做很難消除(沒法僅靠寄存器完成全部運算任務)。編程
因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,爲了不處理器等待緩慢的內存讀寫操做完成,現代計算機系統經過加入一層讀寫速度儘量接近處理器運算速度的高速緩存。緩存做爲內存和處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速運行,當運算結束後再從緩存同步回內存之中。 緩存
基於高速緩存的存儲系統交互很好地解決了處理器與內存速度的矛盾,可是也爲計算機系統帶來更高的複雜度,由於引入了一個新問題:緩存一致性。安全
在多處理器的系統中(或者單處理器多核的系統),每一個處理器(每一個核)都有本身的高速緩存,而它們有共享同一主內存(Main Memory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致。 爲此,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議進行操做,來維護緩存的一致性。bash
爲了使得處理器內部的運算單元儘可能被充分利用,提升運算效率,處理器可能會對輸入的代碼進行亂序執行,處理器會在計算以後將亂序執行的結果重組,亂序優化能夠保證在單線程下該執行結果與順序執行的結果是一致的,但不保證程序中各個語句計算的前後順序與輸入代碼中的順序一致。服務器
亂序執行技術是處理器爲提升運算速度而作出違背代碼原有順序的優化。在單核時代,處理器保證作出的優化不會致使執行結果遠離預期目標,但在多核環境下卻並不是如此。多線程
多核環境下, 若是存在一個核的計算任務依賴另外一個核 計的算任務的中間結果,並且對相關數據讀寫沒作任何防禦措施,那麼其順序性並不能靠代碼的前後順序來保證,處理器最終得出的結果和咱們邏輯獲得的結果可能會大不相同。架構
以上圖爲例進行說明:CPU的core2中的邏輯B依賴core1中的邏輯A先執行併發
爲了更好解決上面提到系列問題,內存模型被總結提出,咱們能夠把內存模型理解爲在特定操做協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。
不一樣架構的物理計算機能夠有不同的內存模型,Java虛擬機也有本身的內存模型。Java虛擬機規範中試圖定義一種Java內存模型(Java Memory Model,簡稱JMM)來屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果,沒必要由於不一樣平臺上的物理機的內存模型的差別,對各平臺定製化開發程序。
更具體一點說,Java內存模型提出目標在於,定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量(Variables)與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數值對象的元素,但不包括局部變量與方法參數,由於後者是線程私有的。(若是局部變量是一個reference類型,它引用的對象在Java堆中可被各個線程共享,可是reference自己在Java棧的局部變量表中,它是線程私有的)。
主內存 Java內存模型規定了全部變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件的主內存名字同樣,二者能夠互相類比,但此處僅是虛擬機內存的一部分)。
工做內存 每條線程都有本身的工做內存(Working Memory,又稱本地內存,可與前面介紹的處理器高速緩存類比),線程的工做內存中保存了該線程使用到的變量的主內存中的共享變量的副本拷貝。工做內存是 JMM 的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩衝區,寄存器以及其餘的硬件和編譯器優化。
Java內存模型抽象示意圖以下:
結合前面介紹的物理機的處理器處理內存的問題,能夠類比總結出JVM內存操做的問題,下面介紹的Java內存模型的執行處理將圍繞解決這2個問題展開:
1 工做內存數據一致性 各個線程操做數據時會保存使用到的主內存中的共享變量副本,當多個線程的運算任務都涉及同一個共享變量時,將致使各自的的共享變量副本不一致,若是真的發生這種狀況,數據同步回主內存以誰的副本數據爲準? Java內存模型主要經過一系列的數據同步協議、規則來保證數據的一致性,後面再詳細介紹。
2 指令重排序優化 Java中重排序一般是編譯器或運行時環境爲了優化程序性能而採起的對指令進行從新排序執行的一種手段。重排序分爲兩類:編譯期重排序和運行期重排序,分別對應編譯時和運行時環境。 一樣的,指令重排序不是隨意重排序,它須要知足如下兩個條件:
多線程環境下,若是線程處理邏輯之間存在依賴關係,有可能由於指令重排序致使運行結果與預期不一樣,後面再展開Java內存模型如何解決這種狀況。
在理解Java內存模型的系列協議、特殊規則以前,咱們先理解Java中內存間的交互操做。
爲了更好理解內存的交互操做,以線程通訊爲例,咱們看看具體如何進行線程間值的同步:
線程1和線程2都有主內存中共享變量x的副本,初始時,這3個內存中x的值都爲0。線程1中更新x的值爲1以後同步到線程2主要涉及2個步驟:
從總體上看,這2個步驟是線程1在向線程2發消息,這個通訊過程必須通過主內存。線程對變量的全部操做(讀取,賦值)都必須在工做內存中進行。不一樣線程之間也沒法直接訪問對方工做內存中的變量,線程間變量值的傳遞均須要經過主內存來完成,實現各個線程提供共享變量的可見性。
關於主內存與工做內存之間的具體交互協議,即一個變量如何從主內存拷貝到工做內存、如何從工做內存同步回主內存之類的實現細節,Java內存模型中定義了下面介紹8種操做來完成。
虛擬機實現時必須保證下面介紹的每種操做都是原子的,不可再分的(對於double和long型的變量來講,load、store、read、和write操做在某些平臺上容許有例外,後面會介紹)。
在介紹內存的交互的具體的8種基本操做以前,有必要先介紹一下操做的3個特性,Java內存模型是圍繞着在併發過程當中如何處理這3個特性來創建的,這裏先給出定義和基本實現的簡單介紹,後面會逐步展開分析。
原子性(Atomicity) 即一個操做或者多個操做 要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。即便在多個線程一塊兒執行的時候,一個操做一旦開始,就不會被其餘線程所幹擾。
可見性(Visibility) 是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。 正如上面「交互操做流程」中所說明的同樣,JMM是經過在線程1變量工做內存修改後將新值同步回主內存,線程2在變量讀取前從主內存刷新變量值,這種依賴主內存做爲傳遞媒介的方式來實現可見性。
有序性(Ordering) 有序性規則表如今如下兩種場景: 線程內和線程間
Java內存模型的一系列運行規則看起來有點繁瑣,但總結起來,是圍繞原子性、可見性、有序性特徵創建。歸根究底,是爲實現共享變量的在多個線程的工做內存的數據一致性,多線程併發,指令重排序優化的環境中程序能如預期運行。
介紹系列規則以前,首先了解一下happens-before關係:用於描述下2個操做的內存可見性:若是操做A happens-before 操做B,那麼A的結果對B可見。happens-before關係的分析須要分爲單線程和多線程的狀況:
單線程下的 happens-before 字節碼的前後順序自然包含happens-before關係:由於單線程內共享一份工做內存,不存在數據一致性的問題。 在程序控制流路徑中靠前的字節碼 happens-before 靠後的字節碼,即靠前的字節碼執行完以後操做結果對靠後的字節碼可見。然而,這並不意味着前者必定在後者以前執行。實際上,若是後者不依賴前者的運行結果,那麼它們可能會被重排序。
多線程下的 happens-before 多線程因爲每一個線程有共享變量的副本,若是沒有對共享變量作同步處理,線程1更新執行操做A共享變量的值以後,線程2開始執行操做B,此時操做A產生的結果對操做B不必定可見。
爲了方便程序開發,Java內存模型實現了下述支持happens-before關係的操做:
Java中如何保證底層操做的有序性和可見性?能夠經過內存屏障。
內存屏障是被插入兩個CPU指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障同樣),從而保障有序性的。另外,爲了達到屏障的效果,它也會使處理器寫入、讀取值以前,將主內存的值寫入高速緩存,清空無效隊列,從而保障可見性。
舉個例子:
Store1;
Store2;
Load1;
StoreLoad; //內存屏障
Store3;
Load2;
Load3;
複製代碼
對於上面的一組CPU指令(Store表示寫入指令,Load表示讀取指令),StoreLoad屏障以前的Store指令沒法與StoreLoad屏障以後的Load指令進行交換位置,即重排序。可是StoreLoad屏障以前和以後的指令是能夠互換位置的,即Store1能夠和Store2互換,Load2能夠和Load3互換。
常見有4種屏障
Java中對內存屏障的使用在通常的代碼中不太容易見到,常見的有volatile和synchronized關鍵字修飾的代碼塊(後面再展開介紹),還能夠經過Unsafe這個類來使用內存屏障。
JMM在執行前面介紹8種基本操做時,爲了保證內存間數據一致性,JMM中規定須要知足如下規則:
看起來這些規則有些繁瑣,其實也不難理解:
volatile的中文意思是不穩定的,易變的,用volatile修飾變量是爲了保證變量的可見性。
volatile主要有下面2種語義
保證了不一樣線程對該變量操做的內存可見性。
這裏保證可見性是不等同於volatile變量併發操做的安全性,保證可見性具體一點解釋:
線程寫volatile變量的過程:
線程讀volatile變量的過程:
可是若是多個線程同時把更新後的變量值同時刷新回主內存,可能致使獲得的值不是預期結果:
舉個例子: 定義volatile int count = 0,2個線程同時執行count++操做,每一個線程都執行500次,最終結果小於1000,緣由是每一個線程執行count++須要如下3個步驟:
具體一點解釋,禁止重排序的規則以下:
普通的變量僅僅會保證該方法的執行過程當中全部依賴賦值結果的地方都能獲取到正確的結果,而不能保證賦值操做的順序與程序代碼中的執行順序一致。
舉個例子:
volatile boolean initialized = false;
// 下面代碼線程A中執行
// 讀取配置信息,當讀取完成後將initialized設置爲true以通知其餘線程配置可用
doSomethingReadConfg();
initialized = true;
// 下面代碼線程B中執行
// 等待initialized 爲true,表明線程A已經把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用線程A初始化好的配置信息
doSomethingWithConfig();
複製代碼
上面代碼中若是定義initialized變量時沒有使用volatile修飾,就有可能會因爲指令重排序的優化,致使線程A中最後一句代碼 "initialized = true" 在 「doSomethingReadConfg()」 以前被執行,這樣會致使線程B中使用配置信息的代碼就可能出現錯誤,而volatile關鍵字就禁止重排序的語義能夠避免此類狀況發生。
具體實現方式是在編譯期生成字節碼時,會在指令序列中增長內存屏障來保證,下面是基於保守策略的JMM內存屏障插入策略:
在每一個volatile寫操做的前面插入一個StoreStore屏障。 該屏障除了保證了屏障以前的寫操做和該屏障以後的寫操做不能重排序,還會保證了volatile寫操做以前,任何的讀寫操做都會先於volatile被提交。
在每一個volatile寫操做的後面插入一個StoreLoad屏障。 該屏障除了使volatile寫操做不會與以後的讀操做重排序外,還會刷新處理器緩存,使volatile變量的寫更新對其餘線程可見。
在每一個volatile讀操做的後面插入一個LoadLoad屏障。 該屏障除了使volatile讀操做不會與以前的寫操做發生重排序外,還會刷新處理器緩存,使volatile變量讀取的爲最新值。
在每一個volatile讀操做的後面插入一個LoadStore屏障。 該屏障除了禁止了volatile讀操做與其以後的任何寫操做進行重排序,還會刷新處理器緩存,使其餘線程volatile變量的寫更新對volatile讀操做的線程可見。
總結起來,就是「一次寫入,處處讀取」,某一線程負責更新變量,其餘線程只讀取變量(不更新變量),並根據變量的新值執行相應邏輯。例如狀態標誌位更新,觀察者模型變量值發佈。
咱們知道,final成員變量必須在聲明的時候初始化或者在構造器中初始化,不然就會報編譯錯誤。 final關鍵字的可見性是指:被final修飾的字段在聲明時或者構造器中,一旦初始化完成,那麼在其餘線程無須同步就能正確看見final字段的值。這是由於一旦初始化完成,final變量的值馬上回寫到主內存。
經過 synchronized關鍵字包住的代碼區域,對數據的讀寫進行控制:
Java內存模型要求lock、unlock、read、load、assign、use、store、write這8種操做都具備原子性,可是對於64位的數據類型(long和double),在模型中特別定義相對寬鬆的規定:容許虛擬機將沒有被volatile修飾的64位數據的讀寫操做分爲2次32位的操做來進行。也就是說虛擬機可選擇不保證64位數據類型的load、store、read和write這4個操做的原子性。因爲這種非原子性,有可能致使其餘線程讀到同步未完成的「32位的半個變量」的值。
不過實際開發中,Java內存模型強烈建議虛擬機把64位數據的讀寫實現爲具備原子性,目前各類平臺下的商用虛擬機都選擇把64位數據的讀寫操做做爲原子操做來對待,所以咱們在編寫代碼時通常不須要把用到的long和double變量專門聲明爲volatile。
因爲Java內存模型涉及系列規則,網上的文章大部分就是對這些規則進行解析,可是不少沒有解釋爲何須要這些規則,這些規則的做用,其實這是不利於初學者學習的,容易繞進去這些繁瑣規則不知因此然,下面談談個人一點學習知識的我的體會:
學習知識的過程不是等同於只是理解知識和記憶知識,而是要對知識解決的問題的輸入和輸出創建鏈接,知識的本質是解決問題,因此在學習以前要理解問題,理解這個問題要的輸出和輸出,而知識就是輸入到輸出的一個關係映射。知識的學習要結合大量的例子來理解這個映射關係,而後壓縮知識,華羅庚說過:「把一本書讀厚,而後再讀薄」,解釋的就是這個道理,先結合大量的例子理解知識,而後再壓縮知識。
以學習Java內存模型爲例:
但願對你們有幫助。
更多精彩,歡迎關注做者公衆號【分佈式系統架構】
《深刻學習Java虛擬機》
Synchronization and the Java Memory Model ——Doug Lea
阿里雲最近開始發放代金券了,新老用戶都可免費獲取, 新註冊用戶能夠得到1000元代金券,老用戶能夠得到270元代金券,建議你們都領取一份,反正是免費領的,說不定之後須要呢? 阿里雲代金券 領取 promotion.aliyun.com/ntms/yunpar…
熱門活動 高性能雲服務器特惠 助力企業上雲 性能級主機2-5折 promotion.aliyun.com/ntms/act/en…