Java 內存模型都不會,就敢在簡歷寫熟悉併發編程嗎

從 PC 內存架構到 Java 內存模型

你知道 Java 內存模型 JMM 嗎?那你知道它的三大特性嗎?

Java 是如何解決指令重排問題的?

既然CPU有緩存一致性協議(MESI),爲什麼 JMM 還需要volatile關鍵字?

帶着問題,尤其是面試問題的學習纔是最高效的。加油,奧利給!

文章收錄在 GitHub JavaKeeper ,N線互聯網開發必備技能兵器譜

前兩天看到同學和我顯擺他們公司配的電腦多好多好,我默默打開了自己的電腦,酷睿 i7-4770,也不是不夠用嘛,4 核 8 線程的 CPU,也是槓槓的。

扯這玩意幹啥,Em~~~~

介紹 Java 內存模型之前,先溫習下計算機硬件內存模型

硬件內存架構

計算機在執行程序的時候,每條指令都是在 CPU 中執行的,而執行的時候,又免不了要和數據打交道。而計算機上面的數據,是存放在主存當中的,也就是計算機的物理內存。

計算機硬件架構簡易圖:

我們以多核 CPU 爲例,每個CPU 核都包含一組 「CPU 寄存器」,這些寄存器本質上是在 CPU 內存中。CPU 在這些寄存器上執行操作的速度要比在主內存(RAM)中執行的速度快得多。

因爲CPU速率高, 內存速率慢,爲了讓存儲體系可以跟上CPU的速度,所以中間又加上 Cache 層,就是我們說的 「CPU 高速緩存」

CPU多級緩存

由於CPU的運算速度遠遠超越了1級緩存的數據I\O能力,CPU廠商又引入了多級的緩存結構。通常L1、L2 是每個CPU 核有一個,L3 是多個核共用一個。

Cache Line

Cache又是由很多個**「緩存行」**(Cache line) 組成的。Cache line 是 Cache 和 RAM 交換數據的最小單位。

Cache 存儲數據是固定大小爲單位的,稱爲一個Cache entry,這個單位稱爲Cache lineCache block。給定Cache 容量大小和 Cache line size 的情況下,它能存儲的條目個數(number of cache entries)就是固定的。因爲Cache 是固定大小的,所以它從主內存獲取數據也是固定大小。對於X86來講,是 64Bytes。對於ARM來講,較舊的架構的Cache line是32Bytes,但一次內存訪存只訪問一半的數據也不太合適,所以它經常是一次填兩個 Cache line,叫做 double fill。

緩存的工作原理

這裏的緩存的工作原理和我們項目中用 memcached、redis 做常用數據的緩存層是一個道理。

當 CPU 要讀取一個數據時,首先從緩存中查找,如果找到就立即讀取並送給CPU處理;如果沒有找到,就去內存中讀取並送給 CPU 處理,同時把這個數據所在的數據塊(就是我們上邊說的 Cache block)調入緩存中,即把臨近的共 64 Byte 的數據也一同載入,因爲臨近的數據在將來被訪問的可能性更大,可以使得以後對整塊數據的讀取都從緩存中進行,不必再調用內存。

這就增加了CPU讀取緩存的命中率(Cache hit)了。

計算機層級存儲

計算機存儲系統是有層次結構的,類似一個金字塔,頂層的寄存器讀寫速度較高,但是空間較小。底層的讀寫速度較低,但是空間較大

緩存一致性

既然每個核中都有單獨的緩存,那我的 4 核 8 線程 CPU 處理主內存數據的時候,不就會出現數據不一致問題了嗎?

爲了解決這個問題,先後有過兩種方法:總線鎖機制緩存鎖機制

總線鎖就是使用 CPU 提供的一個LOCK#信號,當一個處理器在總線上輸出此信號,其他處理器的請求將被阻塞,那麼該處理器就可以獨佔共享鎖。這樣就保證了數據一致性。

但是總線鎖開銷太大,我們需要控制鎖的粒度,所以又有了緩存鎖,核心就是“緩存一致性協議”,不同的 CPU 硬件廠商實現方式稍有不同,有MSI、MESI、MOSI等。

代碼亂序執行優化

爲了使得處理器內部的運算單元儘量被充分利用,提高運算效率,處理器可能會對輸入的代碼進行「亂序執行」**(Out-Of-Order Execution),處理器會在計算之後將亂序執行的結果重組,亂序優化可以保證在單線程下該執行結果與順序執行的結果是一致的,但不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致。

亂序執行技術是處理器爲提高運算速度而做出違背代碼原有順序的優化。在單核時代,處理器保證做出的優化不會導致執行結果遠離預期目標,但在多核環境下卻並非如此。

多核環境下, 如果存在一個核的計算任務依賴另一個核的計算任務的中間結果,而且對相關數據讀寫沒做任何防護措施,那麼其順序性並不能靠代碼的先後順序來保證,處理器最終得出的結果和我們邏輯得到的結果可能會大不相同。

編譯器指令重排

除了上述由處理器和緩存引起的亂序之外,現代編譯器同樣提供了亂序優化。之所以出現編譯器亂序優化其根本原因在於處理器每次只能分析一小塊指令,但編譯器卻能在很大範圍內進行代碼分析,從而做出更優的策略,充分利用處理器的亂序執行功能。

內存屏障

儘管我們看到亂序執行初始目的是爲了提高效率,但是它看來其好像在這多核時代不盡人意,其中的某些”自作聰明”的優化導致多線程程序產生各種各樣的意外。因此有必要存在一種機制來消除亂序執行帶來的壞影響,也就是說應該允許程序員顯式的告訴處理器對某些地方禁止亂序執行。這種機制就是所謂內存屏障。不同架構的處理器在其指令集中提供了不同的指令來發起內存屏障,對應在編程語言當中就是提供特殊的關鍵字來調用處理器相關的指令,JMM裏我們再探討。


Java內存模型

Java 內存模型即 Java Memory Model,簡稱 JMM

這裏的內存模型可不是 JVM 裏的運行時數據區。

「內存模型」可以理解爲在特定操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象

不同架構的物理計算機可以有不一樣的內存模型,Java虛擬機也有自己的內存模型。

Java虛擬機規範中試圖定義一種「 Java 內存模型」來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果,不必因爲不同平臺上的物理機的內存模型的差異,對各平臺定製化開發程序。

Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。這裏的變量與我們寫 Java 代碼中的變量不同,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量和方法參數,因爲他們是線程私有的,不會被共享。

JMM 組成

  • 主內存:Java 內存模型規定了所有變量都存儲在主內存(Main Memory)中(此處的主內存與物理硬件的主內存RAM 名字一樣,兩者可以互相類比,但此處僅是虛擬機內存的一部分)。

  • 工作內存:每條線程都有自己的工作內存(Working Memory,又稱本地內存,可與CPU高速緩存類比),線程的工作內存中保存了該線程使用到的主內存中的共享變量的副本拷貝。線程對變量的所有操作都必須在工作內存進行,而不能直接讀寫主內存中的變量工作內存是 JMM 的一個抽象概念,並不真實存在

JMM 與 JVM 內存結構

JMM 與 Java 內存區域中的堆、棧、方法區等並不是同一個層次的內存劃分,兩者基本沒有關係。如果一定要勉強對應,那從變量、主內存、工作內存的定義看,主內存主要對應 Java 堆中的對象實例數據部分,工作內存則對應虛擬機棧的部分區域(與上圖對應着看哈)。

JMM 與計算機內存結構

Java 內存模型和硬件內存體系結構也沒有什麼關係。硬件內存體系結構不區分棧和堆。在硬件上,線程棧和堆都位於主內存中。線程棧和堆的一部分有時可能出現在高速緩存和CPU寄存器中。如下圖所示:

img

當對象和變量可以存儲在計算機中不同的內存區域時,這就可能會出現某些問題。兩個主要問題是:

  • 線程更新(寫)到共享變量的可見性
  • 讀取、檢查和寫入共享變量時的競爭條件

可見性問題(Visibility of Shared Objects)

如果兩個或多個線程共享一個對象,則一個線程對共享對象的更新可能對其他線程不可見(當然可以用 Java 提供的關鍵字 volatile)。
假設共享對象最初存儲在主內存中。在 CPU 1上運行的線程將共享對象讀入它的CPU緩存後修改,但是還沒來得及即刷新回主內存,這時其他 CPU 上運行的線程就不會看到共享對象的更改。這樣,每個線程都可能以自己的線程結束,就出現了可見性問題,如下

競爭條件(Race Conditions)

這個其實就是我們常說的原子問題。

如果兩個或多個線程共享一個對象,並且多個線程更新該共享對象中的變量,則可能出現競爭條件。

想象一下,如果線程A將一個共享對象的變量讀入到它的CPU緩存中。此時,線程B執行相同的操作,但是進入不同的CPU緩存。現在線程A執行 +1 操作,線程B也這樣做。現在該變量增加了兩次,在每個CPU緩存中一次。

如果這些增量是按順序執行的,則變量結果會是3,並將原始值+ 2寫回主內存。但是,這兩個增量是同時執行的,沒有適當的同步。不管將哪個線程的結構寫回主內存,更新後的值只比原始值高1,顯然是有問題的。如下(當然可以用 Java 提供的關鍵字 Synchronized)

JMM 特性

JMM 就是用來解決如上問題的。 JMM是圍繞着併發過程中如何處理可見性、原子性和有序性這 3 個 特徵建立起來的

  • 可見性:可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。Java 中的 volatile、synchronzied、final 都可以實現可見性

  • 原子性:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。即使在多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程所幹擾。

  • 有序性

    計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排,一般分爲以下 3 種

    單線程環境裏確保程序最終執行結果和代碼順序執行的結果一致;

    處理器在進行重排序時必須要考慮指令之間的數據依賴性

    多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測

內存之間的交互操作

關於主內存和工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節,Java 內存模型中定義了 8 種 操作來完成,虛擬機實現必須保證每一種操作都是原子的、不可再拆分的(double和long類型例外)

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

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

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

  • 不允許 read 和 load、store 和 write 操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現。
  • 不允許一個線程丟棄它的最近的 assign 操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  • 不允許一個線程無原因地(沒有發生過任何 assign 操作)把數據從線程的工作內存同步回主內存。
  • 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load 或 assign)的變量,換句話說,就是對一個變量實施 use、store 操作之前,必須先執行過了 assign 和 load 操作。
  • 一個變量在同一時刻只允許一條線程對其進行 lock 操作,但 lock 操作可以被同一條線程重複執行多次,多次執行 lock 後,只有執行相同次數的 unlock 操作,變量纔會被解鎖。
  • 如果對一個變量執行 lock 操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行 load 或 assign 操作初始化變量的值。
  • 如果一個變量事先沒有被 lock 操作鎖定,那就不允許對它執行 unlock 操作,也不允許去 unlock 一個被其他線程鎖定住的變量。
  • 對一個變量執行 unlock 操作之前,必須先把此變量同步回主內存中(執行 store、write 操作)。

long 和 double 型變量的特殊規則

Java 內存模型要求 lock,unlock,read,load,assign,use,store,write 這 8 個操作都具有原子性,但對於64 位的數據類型( long 或 double),在模型中定義了一條相對寬鬆的規定,允許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操作劃分爲兩次 32 位的操作來進行,即允許虛擬機實現選擇可以不保證 64 位數據類型的load,store,read,write 這 4 個操作的原子性,即 long 和 double 的非原子性協定

如果多線程的情況下double 或 long 類型並未聲明爲 volatile,可能會出現“半個變量”的數值,也就是既非原值,也非修改後的值。

雖然 Java 規範允許上面的實現,但商用虛擬機中基本都採用了原子性的操作,因此在日常使用中幾乎不會出現讀取到“半個變量”的情況,so,這個瞭解下就行。

先行發生原則

先行發生(happens-before)是 Java 內存模型中定義的兩項操作之間的偏序關係,如果操作A 先行發生於操作B,那麼A的結果對B可見。happens-before關係的分析需要分爲單線程和多線程的情況:

  • 單線程下的 happens-before 字節碼的先後順序天然包含happens-before關係:因爲單線程內共享一份工作內存,不存在數據一致性的問題。 在程序控制流路徑中靠前的字節碼 happens-before 靠後的字節碼,即靠前的字節碼執行完之後操作結果對靠後的字節碼可見。然而,這並不意味着前者一定在後者之前執行。實際上,如果後者不依賴前者的運行結果,那麼它們可能會被重排序。
  • 多線程下的 happens-before 多線程由於每個線程有共享變量的副本,如果沒有對共享變量做同步處理,線程1更新執行操作A共享變量的值之後,線程2開始執行操作B,此時操作A產生的結果對操作B不一定可見。

爲了方便程序開發,Java 內存模型實現了下述的先行發生關係:

  • 程序次序規則: 一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。
  • 管程鎖定規則: 一個unLock操作先行發生於後面對同一個鎖的lock操作。
  • volatile變量規則: 對一個變量的寫操作 happens-before 後面對這個變量的讀操作。
  • 傳遞規則: 如果操作A 先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A 先行發生於操作C。
  • 線程啓動規則: Thread對象的start()方法先行發生於此線程的每個一個動作。
  • 線程中斷規則: 對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
  • 線程終結規則: 線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行。
  • 對象終結規則: 一個對象的初始化完成先行發生於它的 finalize()方法的開始

內存屏障

上邊的一系列操作保證了數據一致性,Java中如何保證底層操作的有序性和可見性?可以通過內存屏障。

內存屏障是被插入兩個 CPU 指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障一樣),從而保障有序性的。另外,爲了達到屏障的效果,它也會使處理器寫入、讀取值之前,將主內存的值寫入高速緩存,清空無效隊列,從而保障可見性

eg:

Store1; 
Store2;   
Load1;   
StoreLoad;  //內存屏障
Store3;   
Load2;   
Load3;

對於上面的一組 CPU 指令(Store表示寫入指令,Load表示讀取指令),StoreLoad 屏障之前的 Store 指令無法與StoreLoad 屏障之後的 Load 指令進行交換位置,即重排序。但是 StoreLoad 屏障之前和之後的指令是可以互換位置的,即 Store1 可以和 Store2 互換,Load2 可以和 Load3 互換。

常見的 4 種屏障

  • LoadLoad 屏障: 對於這樣的語句 Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
  • StoreStore 屏障: 對於這樣的語句 Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore 屏障: 對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被執行前,保證Load1要讀取的數據被讀取完畢。
  • StoreLoad 屏障: 對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的(沖刷寫緩衝器,清空無效化隊列)。在大多數處理器的實現中,這個屏障也被稱爲全能屏障,兼具其它三種內存屏障的功能。

Java 中對內存屏障的使用在一般的代碼中不太容易見到,常見的有 volatile 和 synchronized 關鍵字修飾的代碼塊,還可以通過 Unsafe 這個類來使用內存屏障。(下一章扯扯這些)

Java 內存模型就是通過定義的這些來解決可見性、原子性和有序性的。

參考

《深入理解 Java 虛擬機》第二版

http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

https://juejin.im/post/5bf2977751882505d840321d#heading-5

http://rsim.cs.uiuc.edu/Pubs/popl05.pdf

http://ifeve.com/wp-content/uploads/2014/03/JSR133中文版.pdf

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章