一. 問題背景
遇到一條面試題“簡述Java內存模型”,今天瞭解一下java內存模型。
此筆記僅供自己參考,如有錯誤請指正。
參考:《深入理解Java虛擬機》第三版 周志明著
(有需要此書的pdf版可私信或評論回覆)
二. 儲備知識
Java內存模型涉及某些硬件知識以及併發知識(多線程知識),在此先給出一些需要提前掌握好的知識點,方便後面闡述JMM。
2.1 併發和並行
併發: 併發是同一時間段內,多個線程運行起來
並行: 並行是同一時刻,多個線程運行起來
如下:
2.2 硬件效率與一致性
2.2.1 引入Cache提高效率
因爲CPU的運算速度遠遠高於內存的速度,因此爲了彌補這一數量級的差異,我們在CPU和內存之間引入了一個=高速緩存(Cache)。這樣每個CPU從內存中讀取運算需要用到的數據到高速緩存中,運算結束後,再將結果同步回到內存中。如下圖:
2.2.2 迎來新的問題:緩存一致性Cache Coherence
雖然高速緩存解決了CPU與內存的運算速度差異,但是卻引入了一個新的問題:緩存一致性(Cache Coherence)。由於每個CPU都有自己的高速緩存,那麼假如多個CPU的運算任務都涉及內存裏面同一塊區域,各自讀取數據到各自的高速緩存中,那麼此時運算完,這幾個高速緩存的運算結果可能會不同,那麼將運算結果同步回內存時,到底以哪個高速緩存的結果爲標準呢?
2.2.3 解決方案:緩存一致性協議
爲了解決緩存一致性問題,設計者們在Cache和內存之間設計了一個緩存協議。在讀寫操作時,需要根據協議的具體內容去進行讀寫數據。
這些協議常見的有 MSI,MESI。MESI也是常用的協議。
如下圖:
總結: 緩存一致性協議,與Java內存模型裏面的某些解決方案很相似。後面可以類比學習
2.2.4 處理器內部優化:亂序執行
爲了最大利用CPU內的運算單元,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)。其內容是指:亂序執行時,輸入代碼的順序與執行的順序可能並不一致。
Java虛擬機的JIT即時編譯器也有相似的指令重排序(Instruction Reorder) 優化
三. Java內存模型
Java虛擬機規範試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的併發效果。
Java內存模型的主要目標是定系程序中各個變量的訪問規則,即在虛擬機中將變量存到內存中以及從內存中取出變量這樣的底層細節。(此處的變量是指實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量以及方法參數,因爲這是線程私有的,並不是共享的)。
總之,Java內存模型定義一套規則從內存取出共享數據或將共享數據存到內存中。
3.1 主內存與工作內存
-
JMM規定所有變量都存在主內存中(Main Memory),此主內存只是虛擬機內存的一部分而已。
-
每個線程都有自己的工作內存,工作內存中保存了該線程需要用到的變量的主內存副本拷貝。線程對變量的所有操作(讀取,賦值等)都在工作內存中進行,而不能直接讀寫主內存中的變量。
-
不同線程之間無法訪問對方的工作內存中的變量,唯有通過主內存充當中介角色來完成線程間的值傳遞
如下圖:
3.2 內存間交互操作
工作內存與主內存之間的交互協議(即從主內存拷貝變量到工作內存、從工作內存同步回主內存的實現細節)。
3.2.1 JMM的8個原子性操作
JMM定義8種以下操作來完成:
-
lock(鎖定): 作用於主內存的變量,把一個變量標識爲一條線程獨佔的狀態
-
read(讀取): 作用於主內存的變量,把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load操作使用
-
load(載入): 作用於工作內存的變量,把read操作從主內存中得到的值放入工作內存的變量副本中
-
use(使用): 作用於工作內存的變量,把工作變量的值傳遞給執行引擎。每當虛擬機遇到需要用到變量的值的字節碼指令時將會執行此操作
-
assign(賦值): 作用於工作內存的變量,把從執行引擎中收到的值賦值給工作內存中的變量。每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作
-
store(存儲): 作用於工作內存的變量,把工作內存中變量的值傳遞到主內存中,以便後面的write操作使用
-
write(寫入): 作用於主內存的變量,把從工作內存得到的變量的值存入主內存的變量中
-
unlock(解鎖): 作用於主內存的變量,把一個處於鎖狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定
流程如下圖所示:
注:如果要把一個變量從主內存賦值到工作內存,那就要按順序執行read、load操作;同理如果要把變量同步回到主內存中,那就要按順序執行store、write操作。只要求按順序執行,並不要求連續地執行,如readA->readB->loadB->loadA
,是允許的。
3.2.2 JMM的8條內存交互規則
JMM還規定執行上述操作時還應滿足下述8個規則:
-
不允許read和load、store和write單一出現。即不允許從主內存讀進來一個變量但工作內存卻不接受它,或者不允許從工作內存發起回寫但主內存卻不接受
-
不允許線程丟棄它最近的assign操作。即變量在工作內存中改變了之後必須把該變化回寫到主內存。
-
不允許一個線程無原因地(沒有發生任何assign操作)把數據從工作內存同步回主內存
-
一個變量只能在主內存誕生,不允許在工作內存中直接使用一個未被初始化(load和assign)的變量。即發生use和store操作之前必須執行load和assign操作。
-
一個變量在同一時刻只允許一條線程對其進行lock操作。但是lock操作能被同一線程執行多次。多次執行lock後,只有執行相同次數的unlock,變量纔會被解鎖
-
如果對一個變量進行lock操作,將會清空工作內存此變量的值。在執行引擎使用此變量之前,需要重新執行load或assign操作初始化變量的值。
-
如果一個變量事先沒有被lock鎖定,則不允許對他執行unlcok操作;也不允許去unlock被其他線程鎖住的對象。
-
對一個對象執行unlock之前,必須先把此變量同步回主內存(執行store和write操作)
3.3 對volatile型變量的特殊規則
關鍵字volatile可以說是最輕量級的同步機制。JVM對volatile定義了一些特殊的訪問規則。
一個變量被volatile定義之後,會有2種特性: 對所有線程的可見性;禁止指令重排序優化
3.3.1 規則一:volatile變量對所有線程的可見性
保證此變量對所有線程的可見性。可見性:意思是一個線程對此變量做了修改,新值對其他線程來說是可以立即得知的。很多人錯誤地認爲”基於volatile變量的運算在併發下是安全的“,正確答案是不安全的,因爲Java裏面的運算並非是原子操作。
如下的例子證明非原子操作:
開啓20個線程,每個線程運行10000次加1操作,理想的答案是2010000,但實際得到的結果是小於2010000
發生小於20*10000的原因:
總結:由於volatile只保證可見性,不保證原子性,所以不符合以下2條規則的運算場景中,我們仍需要通過加鎖(synchronized或使用java.util.concurrent包下的原子類)來保證原子性。
規則1: 運算結果不依賴變量的當前值,或能確保只有單一線程修改當前的值。
規則2: 變量不需要與其他狀態變量共同參與不變約束
3.3.2 規則二:volatile變量禁止指令重排序
普通變量僅僅保證程序運行過程中依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的順序一致。
以下是例子:
3.3.3 volatile變量的特殊規則的總結
-
use和load必須是連續的。此規則要求工作內存中,每次使用volatile變量前必須從主內存刷新最新的值,用於保證能看見其他線程對volatile變量修改後的值
-
assign和store必須是連續的。此規則要求工作內存中,每次修改volatile變量後都必須同步回主內存。用於保證其他線程可以看到自己對volatile變量的修改
-
volatile變量不會被指令重排序優化。保證代碼的執行順序與程序的順序相同。
3.4 對long和double型變量的規則
JVM要求 lock、read、load、use、assign、store、write、unlock是原子性操作 。而對於是64位的數據類型(long、double),jvm允許沒有被volatile修飾的64位數據的讀寫操作(即read、load、store、write)劃分爲2次32位的操作來進行,即jvm不保證64位的read、load、store、write操作的原子性。
但在實際開發中,商用虛擬機幾乎把64位數據的讀寫操作作爲原子性操作來對待,所以一般不需要將long、double變量專門聲明爲volatile。
3.5 原子性、可見性與有序性
JMM圍繞着併發過程中如何處理原子性、可見性和有序性這3個特徵來建立的。我們來看哪些操作實現了這3個特性。
-
原子性(Atomicity)。由JMM直接保證的原子性變量操作包括read、load、use、assign、store、write,我們大致可以認爲基本數據的 訪問讀寫是具備原子性 的。如果應用場景需要更大範圍的原子性保證,JMM還提供了lock和unlock,雖然這2個操作不直接開放給用戶使用,但是jvm卻提供了更高層次的字節碼monitorenter和monitorexit來隱式使用這2個操作。這2個字節碼反映到代碼中就是同步塊——synchronized關鍵字,因此synchronized塊之間的操作也具備原子性。
-
可見性(Visibility)。JMM通過2條內存交互規則(不允許丟棄最近的assign;一個變量只能從主內存誕生)依賴主內存作爲傳遞媒介來實現可見性,無論是普通變量還是volatile變量都是如此。普通變量和volatile變量的區別是:volatile的特殊規則保證了新值能立即同步回主內存,以及每次使用前立即從主內存刷新。因此volatile保證了多線程操作時變量的可見性。而普通變量不能保證這一點。 除了volatile,java還有synchronized關鍵字和final關鍵字實現可見性。同步塊(synchronized)是由“對一個變量unlock之前必須把此變量同步回主內存中(執行store、write)”這條規則獲得的。final的可見性:被final修飾的字段一旦被構造器初始化完並且構造器沒有把this引用傳遞出去(即其他線程不可能通過這個引用訪問到初始化一半的對象),那麼其他線程可以看見final字段的值。
-
有序性(Ordering)。volatile和synchronized保證線程之間操作的有序性。volatile本身禁止指令重排序。synchronized則由“一個變量同一時刻只允許一條線程對其進行lock操作”這個規則獲得的,此規則決定了持有同一個鎖的2個同步塊只能串行地進入。
3.6 先行發生原則
如果JMM中所有的有序性都只靠volatile和synchronized,那麼有一些操作會變得很囉嗦。其實我們在編程過程並沒察覺到囉嗦,是因爲Java有一套 “先行發生”(happen-before)原則。這個原則是判斷數據是否存在競爭,線程是否安全的主要依據。通過這個原則,我們可以解決併發環境下兩個操作之間是否存在衝突的問題。
以下8個天然的先行發生關係,如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對他們進行重排序。
-
程序次序規則(Program Order Rule)。在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。(準確地說應該是控制流順序而不是程序代碼順序,因爲要考慮分支、循環等結構)
-
管程鎖定規則(Monitor Lock Rule)。一個unlock操作先行發生於後面對同一個鎖的lock操作。這裏必須強調的是同一個鎖,“後面”是指時間上的先後順序。
-
volatile變量規則(Volatile Variable Rule)。對一個變量的寫操作先行發生於後面對這個變量的讀操作。“後面”是指時間上的先後順序。
-
線程啓動規則(Thread Start Rule)。Thread對象的start()方法先行發生於此線程的每一個動作。
-
線程終止規則(Thread Termination Rule)。線程中所有的操作先行發生於對此線程的終止檢測。我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
-
線程中斷規則(Thread Interruption Rule)。對線程interrupt()方法先行發生於被中斷線程的代碼檢測到中斷事件的發生。可通過Thread.intterrupted()方法檢測到是否有中斷髮生。
-
對象終結規則(Finalizer Rule)。一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
-
傳遞性(Transitivity)。如果A操作先行發生於B操作,B操作先行發生於C操作,那麼A操作先行發生於C操作。
例子:
總結:時間上的先後順序與先行發生原則之間基本沒有太大的關係。所以我們衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則爲準。