Java內存 模型理解


概述

  在正式講Java內存模型之前,我們先了解一些物理計算機並發問題,然后一點點的引出Java內存模型的由來。

  多任務處理在現在計算機操作系統中幾乎是一項必備的功能。這不單是因為計算機計算能力強大,更重要的原因是計算機的計算速度遠高於它的的存儲和通信子系統速度。所以我們就通過讓計算機同時處理多個任務來講處理器的運算能力得到充分運用。

  除了充分運用計算機的處理能力外,一個服務端同時對多個客戶端提供服務則是另一個更具體的並發應用的場景。衡量一個服務性能的高低好壞,每秒事務處理數(TPS)是一個重要指標,它代表着一秒內服務端平均能響應的請求總數,而TPS值和程序的並發能力又有非常密切的關系。對於計算量相同的任務,程勛線程的並發協調性越有條不紊,效率自然就會越高;反之,線程之間頻繁的阻塞甚至死鎖,將會大大降低程序的並發能力。

  在了解Java並發問題之前我們先了解一下,物理計算機的並發問題,物理機遇到的並發問題和虛擬機中的情況有很多相似的地方,物理機對並發的處理方案對於虛擬機有很大的參考意義。

  前面說過,為了更充分的利用處理器的性能,我們讓計算機並發執行多個運算任務,這種因果關系看起來順理成章。但是他們的其實並沒有這么簡單,因為絕大多數的運算都不可能只靠處理器,處理器至少要和內存進行交互,如讀取運算數據,存儲運算結果等,這個I/O操作時很難消除的(無法緊靠寄存器來完成所有的運算任務)。所以現在計算機都會加入一層讀寫速度盡可能接近處理器運算速度的“高速緩存”,來作為處理器和內存之間的緩沖:將運算需要的數據復制到緩存中,讓運算能快速的進行,當運算結束后從緩存同步回內存之中,這樣處理器就不用等待緩慢的內存讀寫了。

  高速緩存很好的解決了處理器和內存的速度矛盾,但是這也為計算機系統帶來了更高的復雜度,因為它引起了一個新的問題:緩存一致性。在多處理器系統中,每個處理器都有自己的高速緩存,二他們有共享一個主內存。當多個處理器的運算任務逗哦設計到同一塊主內存區域時,將可能導致各自的緩存數據不一致。如果真的發生了緩存不一致的問題,那同步回到主內存時以誰的緩存數據為准呢?為了解決緩存一致性問題,需要各個處理器在訪問緩存時都遵守一些協議,在讀寫時根據這些協議來進行操作。而在本文中要討論的內存模型可以理解為在特定的操作協議下對特定的內存和高速緩存進行的讀寫訪問的過程抽象。不同架構的物理機可以擁有不一樣的內存模型,java虛擬機也有自己的內存模型。

  除了增加高速緩存,為了使處理器內部的運算單元能盡量的被充分利用,處理器可能會對輸入代碼進行亂序執行優化,處理器會在計算之后將亂序執行的結果重組,保證該結果和順序執行的結果是一致的。因此,如果哦存在一個計算任務以來另一個計算任務的中間結果,那么氣順序性並不能卡哦哦代碼的先后順序來保證。其實java虛擬機中指令重拍優化也是類似的優化。

為什么要定義Java內存模型

  java虛擬機規范中試圖定義一種java內存模型(JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓java程序在各種平台下都可能達到一致的內存訪問效果。在此之前,C語言/C++直接使用物理硬件和操作系統的內存模型,所以就會出現在一套平台上並發訪問正常,但是在另一套平台上卻有問題,平台兼容性相對較差。

Java內存模型的目的及實現方式

   JMM的主要目標是定義程序中的各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存取出變量這樣的底層細節。此處的變量與java變成中變量有所區別,它包括了實例字段,靜態字段和構成數據對象的元素,但是不包括局部變量與方法參數,因為后者是線程私有的,不會被共享,自然就不會存在競爭問題。為了更好地性能,java內存模型並沒有限制執行引擎使用處理器的特定寄存器和緩存來和主內存進行交互,也沒有限制即時編譯器進行調整代碼執行順序這類優化。

  JMM規定所有的變量都存貯在主內存(虛擬機內存的一部分)中,每條線程還有自己的工作內存,線程的工作內存中保存了該線程使用到的主內存中的變量的副本(注意:一個對像如果10M,是不是會把這個10M的內存復制一份到工作內存呢?顯然是不會的,但是這個對像的引用,對像中的某個在線程中訪問到的字段是有可能會復制到工作能存中的,但是不會把整個對象復制一份),線程對變量的所有操作(讀取,賦值等)都需要在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間也是無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。

  另外要注意,這里所說的主內存、工作內存和java內存區域中的java堆,棧,方法區等並不是一個層次的內存划分,這兩者沒有任何關系,如果非要勉強對應的話,主內存主要對應於java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。從更低層次上說,主內存就是直接對應於物理硬件的內存,而為了獲取更好的運行速度,虛擬機可能會讓工作內存有限存儲於寄存器和高速緩存中,因為程序運行時主要訪問讀寫的是工作內存。

主內存和工作內存之間的交互

  Java內存模型定義了8種操作來完成關於主內存和工作內存之間具體的交互,這些操作都是原子的,不可分割(long double類型除外)。這8種操作如下所示:

  • 1) lock(鎖定) 作用於主內存的變量,它把一個變量標志為一條線程獨占的狀態
  • 2) unlock(解鎖) 作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其它線程鎖定
  • 3) read(讀取) 作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用
  • 4) load(載入) 作用於工作內存的變量,它把read操作從主內存得到的變量值放入工作內存的變量副本中
  • 5) use(使用) 作用於工作內存的變量,它把變量副本的值傳遞給執行引擎,每當虛擬機遇到一個需要使用的變量的值的字節碼指令時,將會執行這個操作。
  • 6) assign(賦值) 作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作副本變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作
  • 7) store(存儲) 作用於工作內存的變量,將工作副本變量的值傳輸給主內存,以便隨后的write操作使用
  • 8) write(寫入) 作用於主內存的變量, 它把store操作從工作內存得到的變量的值放入主內存的變量

    如果要把一個變量從主內存復制到工作內存,那就要按順序地執行read和load操作,如果要把變量從工作內存同步回主內存,那就要順序地執行store和write操作。注意,Java內存模型只要求上述兩個操作必須按順序地執行,而沒有保證必須是連續執行,也就是說read和load之間,store和write之間是可以插入其它指令的,如對內存中的變量a,b進行訪問時,一種可能出現的順序是read a, read b, load b, load a。

  除此之外,java內存模型還規定了在執行上述8中基本操作時必須滿足以下規則

  1.不允許read和load,store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但是工作內存不接受,或者從工作內存中發起了回寫了但是主內存不接受的情況出現。

  2.不允許一個縣城丟棄它的最近的assign操作,即變量在工作內存中改變后必須把該變化同步回主內存。

  3.不允許一個線程無原因的(沒有發生過任何assign操作)吧數據從線程的工作內存同步回主內存中。

  4.一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。換就話說,就是對一個變量實施use,store操作之前,必須先執行過了assign和load操作。

  5.一個變量統一時刻只允許一個線程對其進行lock操作,但是lock操作可以被同一個線程重復執行多次,多次執行lock后,只有執行相同次數的unlock操作,變量才會被解鎖

  6.如果對一個變量執行lock操作,那將會情況鞏固走內存中次變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。

  7.如果一個變量事前沒有被lock操作鎖定,那就不允許對她執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。

  8.對一個變量執行unlock之前,必須先把變量同步回主內存中(執行store,write操作)。

  通過這8中內存訪問操作及其相關的規定,再加上volatile的一些特殊規定,就完全可以確定哪些內存訪問操作在並發下是安全的。由於這種定義相當嚴謹但又十分的繁瑣,實踐起來很是麻煩,所以java虛擬機提供了一個等效判斷原則--先行發現原則

volatile的含義和用法

  • volatile的語義

  第一:保證了此變量對所有的線程是可見的,這里的可見性是指當一個線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。普通的變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成。

  第二:禁止指令的重排序,普通的變量僅僅會保證在該方法的執行過程中所有依賴復制結果的地方都能獲取到正確的結果,而不能保證變量復制操作的順序與程序代碼中的執行順序一致。

  由此我們可以看到volatile變量在各個線程的工作內存中不存在一致性的問題(在各個線程的工作內存中也可以存在不一致的情況,但是由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題,至於如何解決不一致的情況請看下面的“如何解決緩存一致性問題?”),但是java里面的運算並非原子的操作,導致volatile變量的運算在並發情況下一樣是不安全的。

  要使用volatile關鍵字的應用場景,必須滿足以下規則,否則仍然會出現並發問題:

  1.運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。

  2.變量不需要與其他的狀態變量共同參與不變約束

  volatile變量從主內存到工作內存,又到主內存的過程:線程A在操作一個被volatile變量修飾的變量x時,會將其值復制到線程A的工作內存副本中,然后修改它的值,在修改完之后會將值強制寫回主內存,這是如果查看匯編指令會發現比沒有volatile指令修飾的變量多了一個lock#語句,正是因為這個lock執行保證了volatile變量的內存可見性。早期的處理器lock鎖的是總線,會阻塞其他cpu的讀寫操作,導致性能比較低;所以在最近的處理器中,如果訪問的內存有高速緩存,那么就是用“高速緩存鎖”,確認對高速緩存中的數據進行原子操作,並不會對總線和總線上的相關內存加鎖,但是如果訪問的內存在高速緩存中不存在,那么就會鎖總線。而在某個cpu要把修改的緩存行數據前需要向總線申請獨占式訪問權,同時通知其他cpu他們相同的緩存行置為無效,只有申請到了獨占式訪問權,才可以修改緩存行中的數據,在修改完緩存行數據后,其他cpu要想訪問想要讀取這個緩存行的數據,這個緩存行的數據必須為“共享”狀態,而已被修改的數據會立馬回寫到內存中,這是由於其他的cpu一直在嗅探總線,所以會立馬感知到這個數據變化。這里需要說明一下,CPU緩存不僅僅在做內存傳輸的時候才與總線打交道,每個cpu也會不停的嗅探總線上的數據變化以及其他緩存在干什么,一直在不停的嗅探總線的其他的cpu就會立馬知道有cpu對自己緩存中的變量的值進行了修改,前提是如果有這個變量的話。當這些cpu需要對這個變量進行操作時就需要重新去內存中讀取。

  • volatile使用場景

  場景一 使用  volatile 變量作為狀態標志。在該場景中,應用程序的某個狀態由一個線程設置,其他線程會讀取該狀態並以該狀態作為其計算的依據( 或者僅僅讀取並輸出這個狀態值。此時使用 volatile變量作為同步機制的好處是一個線程能夠 “通知” 另外一個線程某種事件( 例如,網絡連接斷連之后重新連上)的發生,而這些線程又無須因此而使用鎖,從而避免了鎖的開銷以及相關問題。

  場景二  使用 volatile  保障可見性。在該場景中,多個線程共享一個可變狀態變量 ,其中一個線程更新了該變量之后。其他線程在元須加鎖的情況下也能夠看到該更新。

  場景三 使用 volatile變量替代鎖。volatile 關鍵字並非鎖的替代品,但是在一定的條件下它比鎖更合適 ( 性能開銷小 、代碼簡單 )。多個線程共享一組可變狀態變量的時候,通常我們需要使用鎖來保障對這些變量的更新操作的原子性,以避免產生數據不一致問題。利用 volatile 變量寫操作具有的原子性 ,我們可以把這一組可變狀態變量封裝成一個對象,那么對這些狀態變量的更新操作就可以通過創建一個新的對象並將該對象引用賦值給相應的引用型變量來實現。在這個過程中, volatile 保障了原子性和可見性。從而避免了鎖的使用。

  切記volatile並不能保證排他性操作,當一個變量參與計算時仍然需要使用鎖,來實現更廣范圍的原子性操作,所以votile一般適用於直接賦值,而不適用於計算例如:i++。

  volatile實現原理

  volatile詳解

如何解決緩存一致性問題

  解決緩存一致性問題,有兩種方式:

  1.通過在總線加LOCK#鎖的方式

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。因為CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。比如上面例子中 如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在總線上發出了LCOK#鎖的信號,那么只有等待這段代碼完全執行完畢之后,其他CPU才能從變量i所在的內存讀取變量,然后進行相應的操作。這樣就解決了緩存不一致的問題。

  2.通過緩存一致性協議

  上面說了,LOCK#會鎖總線,實際上這不現實,因為鎖總線效率太低了。因此最好能做到:使用多組緩存,但是它們的行為看起來只有一組緩存那樣。緩存一致性協議就是為了做到這一點而設計的,就像名稱所暗示的那樣,這類協議就是要使多組緩存的內容保持一致緩存一致性協議有多種,但是日常處理的大多數計算機設備都屬於"嗅探(snooping)"協議,基本思想是:

  所有內存的傳輸都發生在一條共享的總線上,而所有的處理器都能看到這條總線:緩存本身是獨立的,但是內存是共享資源,所有的內存訪問都要經過仲裁(同一個指令周期中,只有一個CPU緩存可以讀寫內存)。CPU緩存不僅僅在做內存傳輸的時候才與總線打交道,而是不停在嗅探總線上發生的數據交換,跟蹤其他緩存在做什么。所以當一個緩存代表它所屬的處理器去讀寫內存時,其它處理器都會得到通知,它們以此來使自己的緩存保持同步。只要某個處理器一寫內存,其它處理器馬上知道這塊內存在它們的緩存段中已失效。

並發安全過程中三原則

  • 原子性

  原子性:即一個操作或者多個操作 要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行。

   一個很經典的例子就是銀行賬戶轉賬問題:比如從賬戶A向賬戶B轉1000元,那么必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。

  試想一下,如果這2個操作不具備原子性,會造成什么樣的后果。假如從賬戶A減去1000元之后,操作突然中止。然后又從B取出了500元,取出500元之后,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。

   同樣地反映到並發編程中會出現什么結果呢?

  舉個最簡單的例子,大家想一下假如為一個32位的變量賦值過程不具備原子性的話,會發生什么后果?

1
i = 9;

   假若一個線程執行到這個語句時,我暫且假設為一個32位的變量賦值包括兩個過程:為低16位賦值,為高16位賦值。

  那么就可能發生一種情況:當將低16位數值寫入之后,突然被中斷,而此時又有一個線程去讀取i的值,那么讀取到的就是錯誤的數據。

  • 可見性

  可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

  舉個簡單的例子,看下面這段代碼:

//線程1執行的代碼
int i = 0;
i = 10;
 
//線程2執行的代碼
j = i;

   假若執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值加載到CPU1的高速緩存中,然后賦值為10,那么在CPU1的高速緩存當中i的值變為10了,卻沒有立即寫入到主存當中。

  此時線程2執行 j = i,它會先去主存讀取i的值並加載到CPU2的緩存當中,注意此時內存當中i的值還是0,那么就會使得j的值為0,而不是10.

  這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。

  • 有序性

  有序性:即程序執行的順序按照代碼的先后順序執行。舉個簡單的例子,看下面這段代碼:

int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;      //語句2

   上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什么呢?這里可能會發生指令重排序(Instruction Reorder)。

  下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

  比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那么就有可能在執行過程中,語句2先執行而語句1后執行。

  但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?再看下面一個例子:

int a = 10;    //語句1
int r = 2;    //語句2
a = a + 3;    //語句3
r = a*a;     //語句4

   這段代碼有4個語句,那么可能的一個執行順序是:

  

  那么可不可能是這個執行順序呢: 語句2   語句1    語句4   語句3

  不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那么處理器會保證Instruction 1會在Instruction 2之前執行。

  雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//線程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

   上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那么就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。

   從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程並發執行的正確性。

  也就是說,要想並發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM