來一波騷操作,Java記憶體模型
文章整理自 博學谷狂野架構師
什麼是JMM
併發程式設計領域的關鍵問題
執行緒之間的通訊
執行緒的通訊是指執行緒之間以何種機制來交換資訊。在程式設計中,執行緒之間的通訊機制有兩種,共享記憶體和訊息傳遞。 在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊,典型的共享記憶體通訊方式就是通過共享物件進行通訊。 在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊,在java中典型的訊息傳遞方式就是wait()和notify()。
執行緒間的同步
同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。
在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。 在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。
現代計算機的記憶體模型
物理計算機中的併發問題,物理機遇到的併發問題與虛擬機器中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機器的實現也有相當大的參考意義。
其中一個重要的複雜性來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個I/O操作是很難消除的(無法僅靠暫存器來完成所有運算任務)。
早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,cpu的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。
基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。
在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(MainMemory)。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,舉例說明變數在多個CPU之間的共享。
如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
該記憶體模型帶來的問題
現代的處理器使用寫緩衝區臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。
同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。
雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對記憶體操作的執行順序產生重要的影響:處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致! 處理器A和處理器B按程式的順序並行執行記憶體訪問,最終可能得到x=y=0的結果。 處理器A和處理器B可以同時把共享變數寫入自己的寫緩衝區(A1,B1),然後從記憶體中讀取另一個共享變數(A2,B2),最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(A3,B3)。
當以這種時序執行時,程式就可以得到x=y=0的結果。 從記憶體操作實際發生的順序來看,直到處理器A執行A3來重新整理自己的寫快取區,寫操作A1才算真正執行了。雖然處理器A執行記憶體操作的順序為:A1→A2,但記憶體操作實際發生的順序卻是A2→A1。
Processor A | Processor B | |
---|---|---|
程式碼 | a=1; //A1 x=1; //A2 | b=2; //B1 y=a; //B2 |
執行結果 | 初始狀態 a=b=0 處理器允許得到結果 x=y=0 |
Java記憶體模型定義
JMM定義了Java 虛擬機器(JVM)在計算機記憶體(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬於JVM的。
從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。
本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。
Java記憶體區域
Java虛擬機器在執行程式時會把其自動管理的記憶體劃分為以上幾個區域,每個區域都有的用途以及建立銷燬的時機,其中藍色部分代表的是所有執行緒共享的資料區域,而紫色部分代表的是每個執行緒的私有資料區域。
方法區
方法區屬於執行緒共享的記憶體區域,又稱Non-Heap(非堆),主要用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料,根據Java 虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError 異常。
值得注意的是在方法區中存在一個叫執行時常量池(Runtime Constant Pool)的區域,它主要用於存放編譯器生成的各種字面量和符號引用,這些內容將在類載入後存放到執行時常量池中,以便後續使用。
JVM堆
Java 堆也是屬於執行緒共享的記憶體區域,它在虛擬機器啟動時建立,是Java 虛擬機器所管理的記憶體中最大的一塊,主要用於存放物件例項,幾乎所有的物件例項都在這裡分配記憶體,注意Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做GC 堆,如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError 異常。
程式計數器
屬於執行緒私有的資料區域,是一小塊記憶體空間,主要代表當前執行緒所執行的位元組碼行號指示器。位元組碼直譯器工作時,通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
虛擬機器棧
屬於執行緒私有的資料區域,與執行緒同時建立,總數與執行緒關聯,代表Java方法執行的記憶體模型。每個方法執行時都會建立一個棧楨來儲存方法的的變量表、運算元棧、動態連結方法、返回值、返回地址等資訊。每個方法從呼叫直結束就對於一個棧楨在虛擬機器棧中的入棧和出棧過程,如下(圖有誤,應該為棧楨):
本地方法棧
本地方法棧屬於執行緒私有的資料區域,這部分主要與虛擬機器用到的 Native 方法相關,一般情況下,我們無需關心此區域。
小結
這裡之所以簡要說明這部分內容,注意是為了區別Java記憶體模型與Java記憶體區域的劃分,畢竟這兩種劃分是屬於不同層次的概念。
Java記憶體模型概述
Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。
由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的資料,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,
所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝的自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,不能直接操作主記憶體中的變數,工作記憶體中儲存著主記憶體中的變數副本拷貝,
前面說過,工作記憶體是每個執行緒的私有資料區域,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程如下圖
需要注意的是,JMM與Java記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變數在共享資料區域和私有資料區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的(稍後會分析)。
JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬於共享資料區域,從某個程度上講應該包括了堆和方法區,而工作記憶體資料執行緒私有資料區域,從某個程度上講則應該包括程式計數器、虛擬機器棧以及本地方法棧。
或許在某些地方,我們可能會看見主記憶體被描述為堆記憶體,工作記憶體被稱為執行緒棧,實際上他們表達的都是同一個含義。關於JMM中的主記憶體和工作記憶體說明如下
主記憶體
主要儲存的是Java例項物件,所有執行緒建立的例項物件都存放在主記憶體中,不管該例項物件是成員變數還是方法中的本地變數(也稱區域性變數),當然也包括了共享的類資訊、常量、靜態變數。
由於是共享資料區域,多條執行緒對同一個變數進行訪問可能會發現執行緒安全問題。
工作記憶體
主要儲存當前方法的所有本地變數資訊(工作記憶體中儲存著主記憶體中的變數副本拷貝),每個執行緒只能訪問自己的工作記憶體,即執行緒中的本地變數對其它執行緒是不可見的,就算是兩個執行緒執行的是同一段程式碼,它們也會各自在自己的工作記憶體中建立屬於當前執行緒的本地變數,當然也包括了位元組碼行號指示器、相關Native方法的資訊。
注意由於工作記憶體是每個執行緒的私有資料,執行緒間無法相互訪問工作記憶體,因此儲存在工作記憶體的資料不存線上程安全問題。
資料同步
弄清楚主記憶體和工作記憶體後,接瞭解一下主記憶體與工作記憶體的資料儲存型別以及操作方式,根據虛擬機器規範,對於一個例項物件中的成員方法而言,如果方法中包含本地變數是基本資料型別(boolean,byte,short,char,int,long,float,double),將直接儲存在工作記憶體的幀棧結構中,但倘若本地變數是引用型別,那麼該變數的引用會儲存在功能記憶體的幀棧中,而物件例項將儲存在主記憶體(共享資料區域,堆)中。
但對於例項物件的成員變數,不管它是基本資料型別或者包裝型別(Integer、Double等)還是引用型別,都會被儲存到堆區。
至於static變數以及類本身相關資訊將會儲存在主記憶體中。需要注意的是,在主記憶體中的例項物件可以被多執行緒共享,倘若兩個執行緒同時呼叫了同一個物件的同一個方法,那麼兩條執行緒會將要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體,簡單示意圖如下所示:
硬體記憶體架構與Java記憶體模型
硬體記憶體架構
正如上圖所示,經過簡化CPU與記憶體操作的簡易圖,實際上沒有這麼簡單,這裡為了理解方便,我們省去了南北橋並將三級快取統一為CPU快取(有些CPU只有二級快取,有些CPU有三級快取)。
就目前計算機而言,一般擁有多個CPU並且每個CPU可能存在多個核心,多核是指在一枚處理器(CPU)中整合兩個或多個完整的計算引擎(核心),這樣就可以支援多工並行執行,從多執行緒的排程來說,每個執行緒都會對映到各個CPU核心中並行執行。
在CPU內部有一組CPU暫存器,暫存器是cpu直接訪問和處理的資料,是一個臨時放資料的空間。一般CPU都會從記憶體取資料到暫存器,然後進行處理,但由於記憶體的處理速度遠遠低於CPU,導致CPU在處理指令時往往花費很多時間在等待記憶體做準備工作
於是在暫存器和主記憶體間添加了CPU快取,CPU快取比較小,但訪問速度比主記憶體快得多,如果CPU總是操作主記憶體中的同一址地的資料,很容易影響CPU執行速度,此時CPU快取就可以把從記憶體提取的資料暫時儲存起來,如果暫存器要取記憶體中同一位置的資料,直接從快取中提取,無需直接從主記憶體取。
需要注意的是,暫存器並不每次資料都可以從快取中取得資料,萬一不是同一個記憶體地址中的資料,那暫存器還必須直接繞過快取從記憶體中取資料。
所以並不每次都得到快取中取資料,這種現象有個專業的名稱叫做快取的命中率,從快取中取就命中,不從快取中取從記憶體中取,就沒命中,可見快取命中率的高低也會影響CPU執行效能,這就是CPU、快取以及主記憶體間的簡要互動過程,
總而言之當一個CPU需要訪問主存時,會先讀取一部分主存資料到CPU快取(當然如果CPU快取中存在需要的資料就會直接從快取獲取),進而在讀取CPU快取到暫存器,當CPU需要寫資料到主存時,同樣會先重新整理暫存器中的資料到CPU快取,然後再把資料重新整理到主記憶體中。
Java執行緒與硬體處理器
瞭解完硬體的記憶體架構後,接著瞭解JVM中執行緒的實現原理,理解執行緒的實現原理,有助於我們瞭解Java記憶體模型與硬體記憶體架構的關係,在Window系統和Linux系統上,Java執行緒的實現是基於一對一的執行緒模型,所謂的一對一模型,實際上就是通過語言級別層面程式去間接呼叫系統核心的執行緒模型,即我們在使用Java執行緒時,Java虛擬機器內部是轉而呼叫當前作業系統的核心執行緒來完成當前任務。
這裡需要了解一個術語,核心執行緒(Kernel-Level Thread,KLT),它是由作業系統核心(Kernel)支援的執行緒,這種執行緒是由作業系統核心來完成執行緒切換,核心通過操作排程器進而對執行緒執行排程,並將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這也就是作業系統可以同時處理多工的原因。
由於我們編寫的多執行緒程式屬於語言層面的,程式一般不會直接去呼叫核心執行緒,取而代之的是一種輕量級的程序(Light Weight Process),也是通常意義上的執行緒,由於每個輕量級程序都會對映到一個核心執行緒,因此我們可以通過輕量級程序呼叫核心執行緒,進而由作業系統核心將任務對映到各個處理器,這種輕量級程序與核心執行緒間1對1的關係就稱為一對一的執行緒模型。如下圖
如圖所示,每個執行緒最終都會對映到CPU中進行處理,如果CPU存在多核,那麼一個CPU將可以並行執行多個執行緒任務。
Java記憶體模型與硬體記憶體架構的關係
通過對前面的硬體記憶體架構、Java記憶體模型以及Java多執行緒的實現原理的瞭解,我們應該已經意識到,多執行緒的執行最終都會對映到硬體處理器上進行執行,但Java記憶體模型和硬體記憶體架構並不完全一致。
對於硬體記憶體來說只有暫存器、快取記憶體、主記憶體的概念,並沒有工作記憶體(執行緒私有資料區域)和主記憶體(堆記憶體)之分,也就是說Java記憶體模型對記憶體的劃分對硬體記憶體並沒有任何影響,因為JMM只是一種抽象的概念,是一組規則,並不實際存在,
不管是工作記憶體的資料還是主記憶體的資料,對於計算機硬體來說都會儲存在計算機主記憶體中,當然也有可能儲存到CPU快取或者暫存器中,因此總體上來說,Java記憶體模型和計算機硬體記憶體架構是一個相互交叉的關係,是一種抽象概念劃分與真實物理硬體的交叉。(注意對於Java記憶體區域劃分也是同樣的道理)
當物件和變數可以儲存在計算機的各種不同儲存區域中時,可能會出現某些問題。兩個主要問題是:
- 執行緒更新(寫入)共享變數的可見性。
- 讀取,檢查和寫入共享變數時的競爭條件。
共享物件的可見性
如果兩個或多個執行緒共享一個物件,而沒有正確使用 volatile
宣告或同步,則一個執行緒對共享物件的更改對於在其他CPU上執行的執行緒是不可見的。
這樣,每個執行緒最終都可能擁有自己的共享物件副本,每個副本都位於不同的CPU快取中,並且其中的內容不相同。
下圖簡單說明了情況。在左邊CPU上執行的一個執行緒將共享物件複製到其CPU快取中,並將其 count
變數更改為2。此更改對於在CPU上執行的其他執行緒不可見,因為count
的更新尚未重新整理回主記憶體。
要解決此問題,您可以使用Java的volatile關鍵字。volatile
關鍵字可以確保變數從主記憶體中直接讀取而不是從快取中,並且更新的時候總是立即寫回主記憶體。
競爭條件
如果兩個或多個執行緒共享一個物件,並且多個執行緒更新該共享物件中的成員變數,則可能會出現競爭條件。
想象一下,如果執行緒A將共享物件的變數count
讀入其CPU快取中。再想象一下,執行緒B也做了同樣的事情,但是進入到了不同的CPU快取。現線上程A新增一個值到count
,執行緒B執行相同的操作。現在var1
已經增加了兩次,每次CPU快取一次。
如果這些增加操作按順序執行,則變數count
將增加兩次並將”原始值+ 2”後產生的新值寫回主儲存器。
但是,兩個增加操作同時執行卻沒有進行適當的同步。無論執行緒A和B中的哪一個將其更新版本count
寫回主到儲存器,更新的值將僅比原始值多1,儘管有兩個增加操作。
該圖說明了如上所述的競爭條件問題的發生:
要解決此問題,您可以使用Java synchronized塊。同步塊保證在任何給定時間只有一個執行緒可以進入程式碼的臨界區。同步塊還保證在同步塊內訪問的所有變數都將從主儲存器中讀入,當執行緒退出同步塊時,所有更新的變數將再次重新整理回主儲存器,無論變數是否宣告為volatile。
JMM存在的必要性
在明白了Java記憶體區域劃分、硬體記憶體架構、Java多執行緒的實現原理與Java記憶體模型的具體關係後,接著來談談Java記憶體模型存在的必要性。
由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用於儲存執行緒私有的資料,執行緒與主記憶體中的變數操作必須通過工作記憶體間接完成,主要過程是將變數從主記憶體拷貝的每個執行緒各自的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體,如果存在兩個執行緒同時對一個主記憶體中的例項物件的變數進行操作就有可能誘發執行緒安全問題。
如下圖,主記憶體中存在一個共享變數x,現在有A和B兩條執行緒分別對該變數x=1進行操作,A/B執行緒各自的工作記憶體中存在共享變數副本x。
假設現在A執行緒想要修改x的值為2,而B執行緒卻想要讀取x的值,那麼B執行緒讀取到的值是A執行緒更新後的值2還是更新前的值1呢?答案是,不確定,即B執行緒有可能讀取到A執行緒更新前的值1,也有可能讀取到A執行緒更新後的值2,這是因為工作記憶體是每個執行緒私有的資料區域,而執行緒A變數x時,
首先是將變數從主記憶體拷貝到A執行緒的工作記憶體中,然後對變數進行操作,操作完成後再將變數x寫回主內,而對於B執行緒的也是類似的,這樣就有可能造成主記憶體與工作記憶體間資料存在一致性問題,假如A執行緒修改完後正在將資料寫回主記憶體,而B執行緒此時正在讀取主記憶體,即將x=1拷貝到自己的工作記憶體中,
這樣B執行緒讀取到的值就是x=1,但如果A執行緒已將x=2寫回主記憶體後,B執行緒才開始讀取的話,那麼此時B執行緒讀取到的就是x=2,但到底是哪種情況先發生呢?這是不確定的,這也就是所謂的執行緒安全問題。
為了解決類似上述的問題,JVM定義了一組規則,通過這組規則來決定一個執行緒對共享變數的寫入何時對另一個執行緒可見,這組規則也稱為Java記憶體模型(即JMM),JMM是圍繞著程式執行的原子性、有序性、可見性展開的,下面我們看看這三個特性。
本文由
傳智教育博學谷狂野架構師
教研團隊釋出。如果本文對您有幫助,歡迎
關注
和點贊
;如果您有任何建議也可留言評論
或私信
,您的支援是我堅持創作的動力。轉載請註明出處!
- ElasticSearch還能效能調優,漲見識、漲見識了!!!
- 【必須收藏】別再亂找TiDB 叢集部署教程了,這篇保姆級教程來幫你!!| 博學谷狂野架構師
- 【建議收藏】7000 字的TIDB保姆級簡介,你見過嗎
- Tomcat架構設計剖析 | 博學谷狂野架構師
- 你可能不那麼知道的Tomcat生命週期管理 | 博學谷狂野架構師
- 大哥,這是併發不是並行,Are You Ok?
- 為啥要重學Tomcat?| 博學谷狂野架構師
- 這是一篇純講SQL語句優化的文章!!!| 博學谷狂野架構師
- 捲起來!!!看了這篇文章我才知道MySQL事務&MVCC到底是啥?
- 為什麼99%的程式設計師都做不好SQL優化?
- 如何搞定MySQL鎖(全域性鎖、表級鎖、行級鎖)?這篇文章告訴你答案!太TMD詳細了!!!
- 【建議收藏】超詳細的Canal入門,看這篇就夠了!!!
- 從菜鳥程式設計師到高階架構師,竟然是因為這個字final
- 為什麼95%的Java程式設計師,都是用不好Synchronized?
- 99%的Java程式設計師者,都敗給這一個字!
- 8000 字,就說一個字Volatile
- 98%的程式設計師,都沒有研究過JVM重排序和順序一致性
- 來一波騷操作,Java記憶體模型
- 時隔多年,這次我終於把動態代理的原始碼翻了個地兒朝天
- 再有人問你分散式事務,把這篇文章砸過去給他