你知道 Java 內(nèi)存模型 JMM 嗎,?那你知道它的三大特性嗎? Java 是如何解決指令重排問題的,? 既然CPU有緩存一致性協(xié)議(MESI),,為什么 JMM 還需要 volatile 關(guān)鍵字?
帶著問題,,尤其是面試問題的學(xué)習(xí)才是最高效的。加油,,奧利給,! 文章收錄在 GitHub JavaKeeper
(opens new window) ,N線互聯(lián)網(wǎng)開發(fā)必備技能兵器譜
前兩天看到同學(xué)和我顯擺他們公司配的電腦多好多好,,我默默打開了自己的電腦,,酷睿 i7-4770,也不是不夠用嘛,,4 核 8 線程的 CPU,,也是杠杠的。 扯這玩意干啥,,Em~~~~ 介紹 Java 內(nèi)存模型之前,,先溫習(xí)下計(jì)算機(jī)硬件內(nèi)存模型 # 一、硬件內(nèi)存架構(gòu)計(jì)算機(jī)在執(zhí)行程序的時(shí)候,,每條指令都是在 CPU 中執(zhí)行的,,而執(zhí)行的時(shí)候,又免不了要和數(shù)據(jù)打交道,。而計(jì)算機(jī)上面的數(shù)據(jù),,是存放在主存當(dāng)中的,也就是計(jì)算機(jī)的物理內(nèi)存,。 計(jì)算機(jī)硬件架構(gòu)簡(jiǎn)易圖: 這些年,,我們的 CPU、內(nèi)存,、I/O 設(shè)備都在不斷迭代,,不斷朝著更快的方向努力。但是,,在這個(gè)快速發(fā)展的過程中,,有一個(gè)核心矛盾一直存在,就是這三者的速度差異,。CPU 和內(nèi)存的速度差異可以形象地描述為:CPU 是天上一天,,內(nèi)存是地上一年(假設(shè) CPU 執(zhí)行一條普通指令需要一天,那么 CPU 讀寫內(nèi)存得等待一年的時(shí)間),。內(nèi)存和 I/O 設(shè)備的速度差異就更大了,,內(nèi)存是天上一天,,I/O 設(shè)備是地上十年。
我們以多核 CPU 為例,,每個(gè) CPU 核都包含一組 「CPU 寄存器」,,這些寄存器本質(zhì)上是在 CPU 內(nèi)存中。CPU 在這些寄存器上執(zhí)行操作的速度要比在主內(nèi)存(RAM)中執(zhí)行的速度快得多,。 因?yàn)?strong>CPU速率高,, 內(nèi)存速率慢,為了讓存儲(chǔ)體系可以跟上 CPU 的速度,,所以中間又加上 Cache 層,,就是我們說(shuō)的 『CPU 高速緩存』。 為了合理利用 CPU 的高性能,,平衡 CPU ,、內(nèi)存、I/O 設(shè)備的速度差異,,計(jì)算機(jī)體系結(jié)構(gòu),、操作系統(tǒng)、編譯程序都做出了貢獻(xiàn),,主要體現(xiàn)為: CPU 增加了緩存,,以均衡與內(nèi)存的速度差異; 操作系統(tǒng)增加了進(jìn)程,、線程,,以分時(shí)復(fù)用 CPU,進(jìn)而均衡 CPU 與 I/O 設(shè)備的速度差異,; 編譯程序優(yōu)化指令執(zhí)行次序,,使得緩存能夠得到更加合理地利用。
# CPU多級(jí)緩存由于 CPU 的運(yùn)算速度遠(yuǎn)遠(yuǎn)超越了 1 級(jí)緩存的數(shù)據(jù) I\O 能力,,CPU 廠商又引入了多級(jí)的緩存結(jié)構(gòu),。通常 L1、L2 是每個(gè) CPU 核有一個(gè),,L3 是多個(gè)核共用一個(gè),。 # Cache LineCache 又是由很多個(gè)「緩存行」(Cache line) 組成的。Cache line 是 Cache 和 RAM 交換數(shù)據(jù)的最小單位,。 Cache 存儲(chǔ)數(shù)據(jù)是固定大小為單位的,,稱為一個(gè)Cache entry,這個(gè)單位稱為 Cache line 或 Cache block,。給定 Cache 容量大小和 Cache line size 的情況下,,它能存儲(chǔ)的條目個(gè)數(shù)(number of cache entries)就是固定的。因?yàn)镃ache 是固定大小的,,所以它從主內(nèi)存獲取數(shù)據(jù)也是固定大小,。對(duì)于 X86 來(lái)講,,是 64Bytes。對(duì)于 ARM 來(lái)講,,較舊的架構(gòu)的 Cache line 是 32Bytes,,但一次內(nèi)存訪存只訪問一半的數(shù)據(jù)也不太合適,所以它經(jīng)常是一次填兩個(gè) Cache line,,叫做 double fill,。 # 緩存的工作原理這里的緩存的工作原理和我們項(xiàng)目中用 memcached、redis 做常用數(shù)據(jù)的緩存層是一個(gè)道理,。 當(dāng) CPU 要讀取一個(gè)數(shù)據(jù)時(shí),,首先從緩存中查找,如果找到就立即讀取并送給CPU處理,;如果沒有找到,就去內(nèi)存中讀取并送給 CPU 處理,,同時(shí)把這個(gè)數(shù)據(jù)所在的數(shù)據(jù)塊(就是我們上邊說(shuō)的 Cache block)調(diào)入緩存中,,即把臨近的共 64 Byte 的數(shù)據(jù)也一同載入,因?yàn)榕R近的數(shù)據(jù)在將來(lái)被訪問的可能性更大,,可以使得以后對(duì)整塊數(shù)據(jù)的讀取都從緩存中進(jìn)行,,不必再調(diào)用內(nèi)存。 這就增加了CPU讀取緩存的命中率(Cache hit)了,。 # 計(jì)算機(jī)層級(jí)存儲(chǔ)計(jì)算機(jī)存儲(chǔ)系統(tǒng)是有層次結(jié)構(gòu)的,,類似一個(gè)金字塔,頂層的寄存器讀寫速度較高,,但是空間較小,。底層的讀寫速度較低,但是空間較大 # 緩存一致性既然每個(gè)核中都有單獨(dú)的緩存,,那我的 4 核 8 線程 CPU 處理主內(nèi)存數(shù)據(jù)的時(shí)候,,不就會(huì)出現(xiàn)數(shù)據(jù)不一致問題了嗎? 為了解決這個(gè)問題,,先后有過兩種方法:總線鎖機(jī)制和緩存鎖機(jī)制,。 總線鎖就是使用 CPU 提供的一個(gè) LOCK# 信號(hào),當(dāng)一個(gè)處理器在總線上輸出此信號(hào),,其他處理器的請(qǐng)求將被阻塞,,那么該處理器就可以獨(dú)占共享鎖。這樣就保證了數(shù)據(jù)一致性,。 但是總線鎖開銷太大,,我們需要控制鎖的粒度,所以又有了緩存鎖,,核心就是“緩存一致性協(xié)議”,,不同的 CPU 硬件廠商實(shí)現(xiàn)方式稍有不同,,有 MSI、MESI,、MOSI 等,。 # 代碼亂序執(zhí)行優(yōu)化為了使得處理器內(nèi)部的運(yùn)算單元盡量被充分利用,提高運(yùn)算效率,,處理器可能會(huì)對(duì)輸入的代碼進(jìn)行「亂序執(zhí)行」(Out-Of-Order Execution),,處理器會(huì)在計(jì)算之后將亂序執(zhí)行的結(jié)果重組,亂序優(yōu)化可以保證在單線程下該執(zhí)行結(jié)果與順序執(zhí)行的結(jié)果是一致的,,但不保證程序中各個(gè)語(yǔ)句計(jì)算的先后順序與輸入代碼中的順序一致,。 亂序執(zhí)行技術(shù)是處理器為提高運(yùn)算速度而做出違背代碼原有順序的優(yōu)化。在單核時(shí)代,,處理器保證做出的優(yōu)化不會(huì)導(dǎo)致執(zhí)行結(jié)果遠(yuǎn)離預(yù)期目標(biāo),,但在多核環(huán)境下卻并非如此。 多核環(huán)境下,, 如果存在一個(gè)核的計(jì)算任務(wù)依賴另一個(gè)核的計(jì)算任務(wù)的中間結(jié)果,,而且對(duì)相關(guān)數(shù)據(jù)讀寫沒做任何防護(hù)措施,那么其順序性并不能靠代碼的先后順序來(lái)保證,,處理器最終得出的結(jié)果和我們邏輯得到的結(jié)果可能會(huì)大不相同,。 # 編譯器指令重排除了上述由處理器和緩存引起的亂序之外,現(xiàn)代編譯器同樣提供了亂序優(yōu)化,。之所以出現(xiàn)編譯器亂序優(yōu)化其根本原因在于處理器每次只能分析一小塊指令,,但編譯器卻能在很大范圍內(nèi)進(jìn)行代碼分析,從而做出更優(yōu)的策略,,充分利用處理器的亂序執(zhí)行功能,。 # 內(nèi)存屏障又稱為內(nèi)存柵欄,是一個(gè) CPU 指令,。盡管我們看到亂序執(zhí)行初始目的是為了提高效率,,但是在這多核時(shí)代效果好像不盡人意,其中的某些”自作聰明”的優(yōu)化導(dǎo)致多線程程序產(chǎn)生各種各樣的意外,。因此有必要存在一種機(jī)制來(lái)消除亂序執(zhí)行帶來(lái)的壞影響,,也就是說(shuō)應(yīng)該允許程序員顯式的告訴處理器對(duì)某些地方禁止亂序執(zhí)行。這種機(jī)制就是所謂內(nèi)存屏障,。不同架構(gòu)的處理器在其指令集中提供了不同的指令來(lái)發(fā)起內(nèi)存屏障,,對(duì)應(yīng)在編程語(yǔ)言當(dāng)中就是提供特殊的關(guān)鍵字來(lái)調(diào)用處理器相關(guān)的指令,JMM 里我們?cè)偬接憽?/p>
# 二,、Java 內(nèi)存模型Java 內(nèi)存模型即 Java Memory Model,,簡(jiǎn)稱 JMM。 這里的內(nèi)存模型可不是 JVM 里的運(yùn)行時(shí)數(shù)據(jù)區(qū),。 「內(nèi)存模型」可以理解為在特定操作協(xié)議下,,對(duì)特定的內(nèi)存或高速緩存進(jìn)行讀寫訪問的過程抽象,。 不同架構(gòu)的物理計(jì)算機(jī)可以有不一樣的內(nèi)存模型,Java 虛擬機(jī)也有自己的內(nèi)存模型,。 Java 虛擬機(jī)規(guī)范中試圖定義一種「 Java 內(nèi)存模型」來(lái)屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,,以實(shí)現(xiàn)讓 Java 程序在各種平臺(tái)下都能達(dá)到一致的內(nèi)存訪問效果,不必因?yàn)椴煌脚_(tái)上的物理機(jī)的內(nèi)存模型的差異,,對(duì)各平臺(tái)定制化開發(fā)程序,。 Java 內(nèi)存模型的主要目標(biāo)是定義程序中各個(gè)變量的訪問規(guī)則,即在虛擬機(jī)中將變量存儲(chǔ)到內(nèi)存和從內(nèi)存中取出變量這樣的底層細(xì)節(jié),。這里的變量與我們寫 Java 代碼中的變量不同,,它包括了實(shí)例字段、靜態(tài)字段和構(gòu)成數(shù)組對(duì)象的元素,,但不包括局部變量和方法參數(shù),,因?yàn)樗麄兪蔷€程私有的,不會(huì)被共享,。 Java 內(nèi)存模型是個(gè)很復(fù)雜的規(guī)范,,可以從不同的視角來(lái)解讀,站在程序員的視角,,本質(zhì)上可以理解為,Java 內(nèi)存模型規(guī)范了 JVM 如何提供按需禁用緩存(解決可見性問題)和編譯優(yōu)化(解決有序性問題)的方法,。具體來(lái)說(shuō),,這些方法包括 volatile、synchronized 和 final 三個(gè)關(guān)鍵字,,以及六項(xiàng) Happens-Before 規(guī)則,,這也正是本期的重點(diǎn)內(nèi)容。
# JMM 組成- 主內(nèi)存:Java 內(nèi)存模型規(guī)定了所有變量都存儲(chǔ)在主內(nèi)存(Main Memory)中(此處的主內(nèi)存與物理硬件的主內(nèi)存 RAM 名字一樣,,兩者可以互相類比,,但此處僅是虛擬機(jī)內(nèi)存的一部分)。
- 工作內(nèi)存:每條線程都有自己的工作內(nèi)存(Working Memory,,又稱本地內(nèi)存,,可與 CPU 高速緩存類比),線程的工作內(nèi)存中保存了該線程使用到的主內(nèi)存中的共享變量的副本拷貝,。線程對(duì)變量的所有操作都必須在工作內(nèi)存進(jìn)行,,而不能直接讀寫主內(nèi)存中的變量。工作內(nèi)存是 JMM 的一個(gè)抽象概念,,并不真實(shí)存在,。
# JMM 與 JVM 內(nèi)存結(jié)構(gòu)JMM 與 Java 內(nèi)存區(qū)域中的堆、棧,、方法區(qū)等并不是同一個(gè)層次的內(nèi)存劃分,,兩者基本沒有關(guān)系,。如果一定要勉強(qiáng)對(duì)應(yīng),那從變量,、主內(nèi)存,、工作內(nèi)存的定義看,主內(nèi)存主要對(duì)應(yīng) Java 堆中的對(duì)象實(shí)例數(shù)據(jù)部分,,工作內(nèi)存則對(duì)應(yīng)虛擬機(jī)棧的部分區(qū)域(與上圖對(duì)應(yīng)著看哈),。 # JMM 與計(jì)算機(jī)內(nèi)存結(jié)構(gòu)Java 內(nèi)存模型和硬件內(nèi)存體系結(jié)構(gòu)也沒有什么關(guān)系。硬件內(nèi)存體系結(jié)構(gòu)不區(qū)分棧和堆,。在硬件上,,線程棧和堆都位于主內(nèi)存中。線程棧和堆的一部分有時(shí)可能出現(xiàn)在高速緩存和 CPU 寄存器中,。如下圖所示: 當(dāng)對(duì)象和變量可以存儲(chǔ)在計(jì)算機(jī)中不同的內(nèi)存區(qū)域時(shí),,這就可能會(huì)出現(xiàn)某些問題。兩個(gè)主要問題是: - 線程更新(寫)到共享變量的可見性
- 讀取,、檢查和寫入共享變量時(shí)的競(jìng)爭(zhēng)條件
# 可見性問題(Visibility of Shared Objects)如果兩個(gè)或多個(gè)線程共享一個(gè)對(duì)象,,則一個(gè)線程對(duì)共享對(duì)象的更新可能對(duì)其他線程不可見(當(dāng)然可以用 Java 提供的關(guān)鍵字 volatile)。 假設(shè)共享對(duì)象最初存儲(chǔ)在主內(nèi)存中,。在 CPU 1上運(yùn)行的線程將共享對(duì)象讀入它的 CPU 緩存后修改,,但是還沒來(lái)得及刷新回主內(nèi)存,這時(shí)其他 CPU 上運(yùn)行的線程就不會(huì)看到共享對(duì)象的更改,。這樣,,每個(gè)線程都可能以自己的線程結(jié)束,就出現(xiàn)了可見性問題,,如下 # 競(jìng)爭(zhēng)條件(Race Conditions)這個(gè)其實(shí)就是我們常說(shuō)的「原子性問題」,。 如果兩個(gè)或多個(gè)線程共享一個(gè)對(duì)象,并且多個(gè)線程更新該共享對(duì)象中的變量,,則可能出現(xiàn)競(jìng)爭(zhēng)條件,。 由于 IO 太慢,早期的操作系統(tǒng)就發(fā)明了多進(jìn)程,,即便在單核的 CPU 上我們也可以一邊聽著歌,,一邊寫 Bug,這個(gè)就是多進(jìn)程的功勞,。 操作系統(tǒng)允許某個(gè)進(jìn)程執(zhí)行一小段時(shí)間,,例如 50 毫秒,過了 50 毫秒操作系統(tǒng)就會(huì)重新選擇一個(gè)進(jìn)程來(lái)執(zhí)行(我們稱為“任務(wù)切換”),,這個(gè) 50 毫秒稱為“時(shí)間片”,。 這里的進(jìn)程在等待 IO 時(shí)之所以會(huì)釋放 CPU 使用權(quán),是為了讓 CPU 在這段等待時(shí)間里可以做別的事情,這樣一來(lái) CPU 的使用率就上來(lái)了,;此外,,如果這時(shí)有另外一個(gè)進(jìn)程也讀文件,讀文件的操作就會(huì)排隊(duì),,磁盤驅(qū)動(dòng)在完成一個(gè)進(jìn)程的讀操作后,,發(fā)現(xiàn)有排隊(duì)的任務(wù),就會(huì)立即啟動(dòng)下一個(gè)讀操作,,這樣 IO 的使用率也上來(lái)了,。 是不是很簡(jiǎn)單的邏輯?但是,,雖然看似簡(jiǎn)單,,支持多進(jìn)程分時(shí)復(fù)用在操作系統(tǒng)的發(fā)展史上卻具有里程碑意義,Unix 就是因?yàn)榻鉀Q了這個(gè)問題而名噪天下的,。 早期的操作系統(tǒng)基于進(jìn)程來(lái)調(diào)度 CPU,,不同進(jìn)程間是不共享內(nèi)存空間的,所以進(jìn)程要做任務(wù)切換就要切換內(nèi)存映射地址,,而一個(gè)進(jìn)程創(chuàng)建的所有線程,,都是共享一個(gè)內(nèi)存空間的,所以線程做任務(wù)切換成本就很低了?,F(xiàn)代的操作系統(tǒng)都基于更輕量的線程來(lái)調(diào)度,,現(xiàn)在我們提到的“任務(wù)切換”都是指“線程切換”。 Java 并發(fā)程序都是基于多線程的,,自然也會(huì)涉及到任務(wù)切換,,也許你想不到,任務(wù)切換竟然也是并發(fā)編程里詭異 Bug 的源頭之一,。任務(wù)切換的時(shí)機(jī)大多數(shù)是在時(shí)間片結(jié)束的時(shí)候,我們現(xiàn)在基本都使用高級(jí)語(yǔ)言編程,,高級(jí)語(yǔ)言里一條語(yǔ)句往往需要多條 CPU 指令完成,,例如 count += 1,至少需要三條 CPU 指令,。 指令 1:首先,,需要把變量 count 從內(nèi)存加載到 CPU 的寄存器; 指令 2:之后,,在寄存器中執(zhí)行 +1 操作,; 指令 3:最后,將結(jié)果寫入內(nèi)存(緩存機(jī)制導(dǎo)致可能寫入的是 CPU 緩存而不是內(nèi)存),。 操作系統(tǒng)做任務(wù)切換,,可以發(fā)生在任何一條 CPU 指令執(zhí)行完,是的,是 CPU 指令,,而不是高級(jí)語(yǔ)言里的一條語(yǔ)句,。這樣不同步的操作,就會(huì)出現(xiàn) bug,。
想象一下,,如果線程 A 將一個(gè)共享對(duì)象的變量讀入到它的 CPU 緩存中。此時(shí),,線程 B 執(zhí)行相同的操作,,但是進(jìn)入不同的 CPU 緩存。現(xiàn)在線程 A 執(zhí)行 +1 操作,,線程 B 也這樣做?,F(xiàn)在該變量增加了兩次,在每個(gè) CPU 緩存中一次,。 如果這些增量是按順序執(zhí)行的,,則變量結(jié)果會(huì)是 3,并將原始值 +2 寫回主內(nèi)存,。但是,,這兩個(gè)增量是同時(shí)執(zhí)行的,沒有適當(dāng)?shù)耐?。不管將哪個(gè)線程的結(jié)果寫回主內(nèi)存,,更新后的值只比原始值高 1,顯然是有問題的,。如下(當(dāng)然可以用 Java 提供的關(guān)鍵字 Synchronized) # 有序性問題顧名思義,,有序性指的是程序按照代碼的先后順序執(zhí)行。編譯器為了優(yōu)化性能,,有時(shí)候會(huì)改變程序中語(yǔ)句的先后順序,,例如程序中:“a=6;b=7,;”編譯器優(yōu)化后可能變成“b=7,;a=6;”,,在這個(gè)例子中,,編譯器調(diào)整了語(yǔ)句的順序,但是不影響程序的最終結(jié)果,。 這個(gè)就是我們上文說(shuō)到的代碼亂序執(zhí)行優(yōu)化,。 不過有時(shí)候編譯器及解釋器的優(yōu)化可能導(dǎo)致意想不到的 Bug。 在 Java 領(lǐng)域一個(gè)經(jīng)典的案例就是利用雙重檢查創(chuàng)建單例對(duì)象,,例如下面的代碼:在獲取實(shí)例 getInstance() 的方法中,,我們首先判斷 instance 是否為空,,如果為空,則鎖定 Singleton.class 并再次檢查 instance 是否為空,,如果還為空則創(chuàng)建 Singleton 的一個(gè)實(shí)例,。 public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } } 假設(shè)有兩個(gè)線程 A、B 同時(shí)調(diào)用 getInstance() 方法,,他們會(huì)同時(shí)發(fā)現(xiàn) instance == null ,,于是同時(shí)對(duì) Singleton.class 加鎖,此時(shí) JVM 保證只有一個(gè)線程能夠加鎖成功(假設(shè)是線程 A),,另外一個(gè)線程則會(huì)處于等待狀態(tài)(假設(shè)是線程 B),;線程 A 會(huì)創(chuàng)建一個(gè) Singleton 實(shí)例,之后釋放鎖,,鎖釋放后,,線程 B 被喚醒,線程 B 再次嘗試加鎖,,此時(shí)是可以加鎖成功的,,加鎖成功后,線程 B 檢查 instance == null 時(shí)會(huì)發(fā)現(xiàn),,已經(jīng)創(chuàng)建過 Singleton 實(shí)例了,,所以線程 B 不會(huì)再創(chuàng)建一個(gè) Singleton 實(shí)例。 這看上去一切都很完美,,無(wú)懈可擊,,但實(shí)際上這個(gè) getInstance() 方法并不完美。問題出在哪里呢,? 出在 new 操作上,,我們以為的 new 操作應(yīng)該是: 分配一塊內(nèi)存 M; 在內(nèi)存 M 上初始化 Singleton 對(duì)象,; 然后 M 的地址賦值給 instance 變量,。 但是實(shí)際上優(yōu)化后的執(zhí)行路徑可能是這樣的: 分配一塊內(nèi)存 M; 將 M 的地址賦值給 instance 變量,; 最后在內(nèi)存 M 上初始化 Singleton 對(duì)象,。 優(yōu)化后會(huì)導(dǎo)致什么問題呢?我們假設(shè)線程 A 先執(zhí)行 getInstance() 方法,,當(dāng)執(zhí)行完指令 2 時(shí)恰好發(fā)生了線程切換,切換到了線程 B 上,;如果此時(shí)線程 B 也執(zhí)行 getInstance() 方法,,那么線程 B 在執(zhí)行第一個(gè)判斷時(shí)會(huì)發(fā)現(xiàn) instance != null ,所以直接返回 instance,,而此時(shí)的 instance 是沒有初始化過的,,如果我們這個(gè)時(shí)候訪問 instance 的成員變量就可能觸發(fā)空指針異常。
# JMM 特性JMM 就是用來(lái)解決如上問題的。 JMM是圍繞著并發(fā)過程中如何處理可見性,、原子性和有序性這 3 個(gè) 特征建立起來(lái)的 - 可見性:可見性是指當(dāng)一個(gè)線程修改了共享變量的值,,其他線程能夠立即得知這個(gè)修改。Java 中的 volatile,、synchronzied,、final 都可以實(shí)現(xiàn)可見性
- 原子性:即一個(gè)操作或者多個(gè)操作,要么全部執(zhí)行并且執(zhí)行的過程不會(huì)被任何因素打斷,,要么就都不執(zhí)行,。即使在多個(gè)線程一起執(zhí)行的時(shí)候,一個(gè)操作一旦開始,,就不會(huì)被其他線程所干擾,。
- 有序性:
- 計(jì)算機(jī)在執(zhí)行程序時(shí),為了提高性能,,編譯器和處理器常常會(huì)對(duì)指令做重排,,一般分為以下 3 種
- 單線程環(huán)境里確保程序最終執(zhí)行結(jié)果和代碼順序執(zhí)行的結(jié)果一致;
- 處理器在進(jìn)行重排序時(shí)必須要考慮指令之間的數(shù)據(jù)依賴性,;
- 多線程環(huán)境中線程交替執(zhí)行,,由于編譯器優(yōu)化重排的存在,兩個(gè)線程中使用的變量能否保證一致性是無(wú)法確定的,,結(jié)果無(wú)法預(yù)測(cè)
# 內(nèi)存之間的交互操作關(guān)于主內(nèi)存和工作內(nèi)存之間具體的交互協(xié)議,,即一個(gè)變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步回主內(nèi)存之類的實(shí)現(xiàn)細(xì)節(jié),,Java 內(nèi)存模型中定義了 8 種 操作來(lái)完成,,虛擬機(jī)實(shí)現(xiàn)必須保證每一種操作都是原子的、不可再拆分的(double 和 long 類型例外) - lock(鎖定):作用于主內(nèi)存的變量,,它把一個(gè)變量標(biāo)識(shí)為一條線程獨(dú)占的狀態(tài),。
- unlock(解鎖):作用于主內(nèi)存的變量,它把一個(gè)處于鎖定狀態(tài)的變量釋放出來(lái),,釋放后的變量才可以被其他線程鎖定,。
- read(讀取):作用于主內(nèi)存的變量,,它把一個(gè)變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,,以便隨后的load 動(dòng)作使用。
- load(載入):作用于工作內(nèi)存的變量,,它把 read 操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中,。
- use(使用):作用于工作內(nèi)存的變量,它把工作內(nèi)存中一個(gè)變量的值傳遞給執(zhí)行引擎,,每當(dāng)虛擬機(jī)遇到一個(gè)需要使用到變量的值的字節(jié)碼指令時(shí)將會(huì)執(zhí)行這個(gè)操作,。
- assign(賦值):作用于工作內(nèi)存的變量,,它把一個(gè)從執(zhí)行引擎接收到的值賦給工作內(nèi)存的變量,每當(dāng)虛擬機(jī)遇到一個(gè)給變量賦值的字節(jié)碼指令時(shí)執(zhí)行這個(gè)操作,。
- store(存儲(chǔ)):作用于工作內(nèi)存的變量,,它把工作內(nèi)存中一個(gè)變量的值傳送到主內(nèi)存中,以便隨后的 write操作使用,。
- write(寫入):作用于主內(nèi)存的變量,,它把 store 操作從工作內(nèi)存中得到的變量的值放入主內(nèi)存的變量中。
如果需要把一個(gè)變量從主內(nèi)存復(fù)制到工作內(nèi)存,,那就要順序地執(zhí)行 read 和 load 操作,,如果要把變量從工作內(nèi)存同步回主內(nèi)存,就要順序地執(zhí)行 store 和 write 操作,。注意,,Java 內(nèi)存模型只要求上述兩個(gè)操作必須按順序執(zhí)行,而沒有保證是連續(xù)執(zhí)行,。也就是說(shuō) read 與 load 之間,、store 與write 之間是可插入其他指令的,如對(duì)主內(nèi)存中的變量 a,、b 進(jìn)行訪問時(shí),,一種可能出現(xiàn)順序是 read a、read b,、load b,、load a。 除此之外,,Java 內(nèi)存模型還規(guī)定了在執(zhí)行上述 8 種基本操作時(shí)必須滿足如下規(guī)則 - 不允許 read 和 load,、store 和 write 操作之一單獨(dú)出現(xiàn),即不允許一個(gè)變量從主內(nèi)存讀取了但工作內(nèi)存不接受,,或者從工作內(nèi)存發(fā)起回寫了但主內(nèi)存不接受的情況出現(xiàn),。
- 不允許一個(gè)線程丟棄它的最近的 assign 操作,即變量在工作內(nèi)存中改變了之后必須把該變化同步回主內(nèi)存,。
- 不允許一個(gè)線程無(wú)原因地(沒有發(fā)生過任何 assign 操作)把數(shù)據(jù)從線程的工作內(nèi)存同步回主內(nèi)存,。
- 一個(gè)新的變量只能在主內(nèi)存中“誕生”,不允許在工作內(nèi)存中直接使用一個(gè)未被初始化(load 或 assign)的變量,,換句話說(shuō),,就是對(duì)一個(gè)變量實(shí)施 use、store 操作之前,,必須先執(zhí)行過了 assign 和 load 操作,。
- 一個(gè)變量在同一時(shí)刻只允許一條線程對(duì)其進(jìn)行 lock 操作,但 lock 操作可以被同一條線程重復(fù)執(zhí)行多次,,多次執(zhí)行 lock 后,,只有執(zhí)行相同次數(shù)的 unlock 操作,變量才會(huì)被解鎖,。
- 如果對(duì)一個(gè)變量執(zhí)行 lock 操作,,那將會(huì)清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個(gè)變量前,,需要重新執(zhí)行 load 或 assign 操作初始化變量的值,。
- 如果一個(gè)變量事先沒有被 lock 操作鎖定,那就不允許對(duì)它執(zhí)行 unlock 操作,,也不允許去 unlock 一個(gè)被其他線程鎖定住的變量,。
- 對(duì)一個(gè)變量執(zhí)行 unlock 操作之前,必須先把此變量同步回主內(nèi)存中(執(zhí)行 store,、write 操作),。
# long 和 double 型變量的特殊規(guī)則Java 內(nèi)存模型要求 lock,unlock,,read,,load,assign,,use,,store,write 這 8 個(gè)操作都具有原子性,,但對(duì)于64 位的數(shù)據(jù)類型( long 或 double),,在模型中定義了一條相對(duì)寬松的規(guī)定,允許虛擬機(jī)將沒有被 volatile 修飾的 64 位數(shù)據(jù)的讀寫操作劃分為兩次 32 位的操作來(lái)進(jìn)行,,即允許虛擬機(jī)實(shí)現(xiàn)選擇可以不保證 64 位數(shù)據(jù)類型的load,,store,read,,write 這 4 個(gè)操作的原子性,,即 long 和 double 的非原子性協(xié)定。 以 32 位 CPU 上執(zhí)行 long 型變量的寫操作為例來(lái)說(shuō)明這個(gè)問題,,long 型變量是 64 位,,在 32 位 CPU 上執(zhí)行寫操作會(huì)被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示),。
如果多線程的情況下 double 或 long 類型并未聲明為 volatile,,可能會(huì)出現(xiàn)“半個(gè)變量”的數(shù)值,也就是既非原值,,也非修改后的值,。 雖然 Java 規(guī)范允許上面的實(shí)現(xiàn),但商用虛擬機(jī)中基本都采用了原子性的操作,,因此在日常使用中幾乎不會(huì)出現(xiàn)讀取到“半個(gè)變量”的情況,,so,,這個(gè)了解下就行。 # 先行發(fā)生原則先行發(fā)生(happens-before)是 Java 內(nèi)存模型中定義的兩項(xiàng)操作之間的偏序關(guān)系,,如果操作 A 先行發(fā)生于操作 B,,那么 A 的結(jié)果對(duì) B 可見。 Happens-Before 約束了編譯器的優(yōu)化行為,,雖允許編譯器優(yōu)化,,但是要求編譯器優(yōu)化后一定遵守 Happens-Before 規(guī)則。
happens-before 關(guān)系的分析需要分為單線程和多線程的情況: - 單線程下的 happens-before 字節(jié)碼的先后順序天然包含 happens-before 關(guān)系:因?yàn)閱尉€程內(nèi)共享一份工作內(nèi)存,,不存在數(shù)據(jù)一致性的問題,。 在程序控制流路徑中靠前的字節(jié)碼 happens-before 靠后的字節(jié)碼,即靠前的字節(jié)碼執(zhí)行完之后操作結(jié)果對(duì)靠后的字節(jié)碼可見,。然而,,這并不意味著前者一定在后者之前執(zhí)行。實(shí)際上,,如果后者不依賴前者的運(yùn)行結(jié)果,,那么它們可能會(huì)被重排序。
- 多線程下的 happens-before 多線程由于每個(gè)線程有共享變量的副本,,如果沒有對(duì)共享變量做同步處理,,線程 1 更新執(zhí)行操作 A 共享變量的值之后,線程 2 開始執(zhí)行操作 B,,此時(shí)操作 A 產(chǎn)生的結(jié)果對(duì)操作 B 不一定可見,。
為了方便程序開發(fā),Java 內(nèi)存模型實(shí)現(xiàn)了下述的先行發(fā)生關(guān)系(“天然的”先行發(fā)生關(guān)系,,無(wú)需任何同步器協(xié)助就存在): - 程序次序規(guī)則: 一個(gè)線程內(nèi),,按照代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作,。
- 管程鎖定規(guī)則: 一個(gè) unLock 操作先行發(fā)生于后面對(duì)同一個(gè)鎖的 lock 操作,。
- volatile變量規(guī)則: 對(duì)一個(gè)變量的寫操作先行發(fā)生于后面對(duì)這個(gè)變量的讀操作。
- 傳遞規(guī)則: 如果操作 A 先行發(fā)生于操作 B,,而操作 B 又先行發(fā)生于操作 C,,則可以得出操作 A 先行發(fā)生于操作 C。
- 線程啟動(dòng)規(guī)則: Thread對(duì)象的 start() 方法先行發(fā)生于此線程的每一個(gè)動(dòng)作,。
- 線程中斷規(guī)則: 對(duì)線程 interrupt() 方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測(cè)到中斷事件的發(fā)生,。
- 線程終結(jié)規(guī)則: 線程中所有的操作都先行發(fā)生于線程的終止檢測(cè),我們可以通過Thread.join()方法結(jié)束,、Thread.isAlive()的返回值手段檢測(cè)到線程已經(jīng)終止執(zhí)行,。
- 對(duì)象終結(jié)規(guī)則: 一個(gè)對(duì)象的初始化完成先行發(fā)生于它的 finalize()方法的開始
# 內(nèi)存屏障上邊的一系列操作保證了數(shù)據(jù)一致性,Java 中如何保證底層操作的有序性和可見性?可以通過內(nèi)存屏障,。 內(nèi)存屏障是被插入兩個(gè) CPU 指令之間的一種指令,,用來(lái)禁止處理器指令發(fā)生重排序(像屏障一樣),從而保障有序性的,。另外,,為了達(dá)到屏障的效果,它也會(huì)使處理器寫入,、讀取值之前,將主內(nèi)存的值寫入高速緩存,,清空無(wú)效隊(duì)列,,從而保障可見性。 eg: Store1; Store2; Load1; StoreLoad; //內(nèi)存屏障Store3; Load2; Load3;
對(duì)于上面的一組 CPU 指令(Store表示寫入指令,,Load表示讀取指令),,StoreLoad 屏障之前的 Store 指令無(wú)法與 StoreLoad 屏障之后的 Load 指令進(jìn)行交換位置,即重排序,。但是 StoreLoad 屏障之前和之后的指令是可以互換位置的,,即 Store1 可以和 Store2 互換,Load2 可以和 Load3 互換,。 常見的 4 種屏障 - LoadLoad 屏障: 對(duì)于這樣的語(yǔ)句 Load1; LoadLoad; Load2,,在 Load2 及后續(xù)讀取操作要讀取的數(shù)據(jù)被訪問前,保證 Load1 要讀取的數(shù)據(jù)被讀取完畢,。
- StoreStore 屏障: 對(duì)于這樣的語(yǔ)句 Store1; StoreStore; Store2,,在 Store2 及后續(xù)寫入操作執(zhí)行前,保證Store1 的寫入操作對(duì)其它處理器可見,。
- LoadStore 屏障: 對(duì)于這樣的語(yǔ)句 Load1; LoadStore; Store2,,在 Store2 及后續(xù)寫入操作被執(zhí)行前,保證Load1 要讀取的數(shù)據(jù)被讀取完畢,。
- StoreLoad 屏障: 對(duì)于這樣的語(yǔ)句 Store1; StoreLoad; Load2,,在 Load2 及后續(xù)所有讀取操作執(zhí)行前,保證Store1 的寫入對(duì)所有處理器可見,。它的開銷是四種屏障中最大的(沖刷寫緩沖器,,清空無(wú)效化隊(duì)列)。在大多數(shù)處理器的實(shí)現(xiàn)中,,這個(gè)屏障也被稱為全能屏障,,兼具其它三種內(nèi)存屏障的功能。
Java 中對(duì)內(nèi)存屏障的使用在一般的代碼中不太容易見到,,常見的有 volatile 和 synchronized 關(guān)鍵字修飾的代碼塊,,還可以通過 Unsafe 這個(gè)類來(lái)使用內(nèi)存屏障。(下一章扯扯這些) 噢啦,,Java 內(nèi)存模型就是通過以上定義的這些來(lái)解決可見性,、原子性和有序性問題的,。 # 參考《深入理解 Java 虛擬機(jī)》第二版 《Java 并發(fā)編程實(shí)戰(zhàn)》 http://tutorials./java-concurrency/java-memory-model.html https:///post/5bf2977751882505d840321d#heading-5 http://rsim.cs./Pubs/popl05.pdf http:///wp-content/uploads/2014/03/JSR133中文版.pdf
|