1. 程式人生 > >Java 內存模型 ,一篇就夠了!

Java 內存模型 ,一篇就夠了!

滿足 繼續 而且 undefine 按順序 發生 得到 一次 有變

Java 虛擬機

我們都知道 Java 語言的可以跨平臺的,這其中的核心是因為存在 Java 虛擬機這個玩意。虛擬機,顧名思義就是虛擬的機器,這不是真實存在的硬件,但是卻可以和不同的底層平臺進行交互。而且 Java 虛擬機模擬的還比較全面,它想象了自己擁有硬件,處理器,寄存器和堆棧等,還具有相應的指令系統,以此來對接不同的底層操作系統。

Java 內存模型(Java Memory Model)

上次已經說過了底層硬件中內存的相關結構和處理,那同樣的對於 Java 虛擬機這個“機器”來說,是不是也應該會有相應的結構呢?因為說到底虛擬機玩的再花,要什麽有什麽,最終還是要和底層 RAM 進行交互的嘛。

怎麽交互?這就是一個大問題,有一群專家就定義了一套規範,定義 Java 內存模型並不是一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓 Java 的並發操作不會產生歧義;但是,也必須得足夠寬松,使得虛擬機的實現能有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存等)來獲取更好的執行速度。

Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣底層細節。此處的變量與 Java 編程時所說的變量不一樣,只包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,後者是線程私有的,不會被共享。

Java 內存模型中規定了所有的變量都存儲在主內存中,每個線程還有自己的工作內存(類比緩存理解),線程的工作內存中保存了該線程使用到主內存中的變量拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞(通信)均需要在主內存來完成,線程、主內存和工作內存的交互關系如下圖所示

技術分享圖片

這個圖和 CPU 與緩存的圖非常類似,搞不好 JMM 的構建就是仿照硬件系統來的。同樣的道理我們要思考一下在多線程的環境中,JMM 又是如何保證主內存和工作內存中的變量一致性?回憶一下 CPU 是如何保證緩存一致性的,使用 MESI 協議。那在這裏呢,Java 內存模型就定義了 8 種操作和 8 個規則。

回頭想想,JMM 是一套規則呀,它只會給你定義規範,模型,具體的實現自己玩去!理解這一點很重要。我們來看看它給出了哪些操作和必須滿足的規則吧。

技術分享圖片

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 內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

1 不允許 read 和 load、store 和write 操作之一單獨出現

2 不允許一個線程丟棄它的最近 assign 的操作,即變量在工作內存中改變了之後必須同步到主內存中。

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

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

5 一個變量在同一時刻只允許一條線程對其進行 lock 操作,lock 和 unlock 必須成對出現

6 如果對一個變量執行 lock 操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行 load 或 assign 操作初始化變量的值

7 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作;也不允許去 unlock 一個被其他線程鎖定的變量

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

好了,到這裏看似我們可以完美的保證多線程情況下主內存和工作內存中數據的一致性了(也就是線程安全),But 醒醒好不好,JMM 只是一套規則呀,請問你實現了麽???並沒有……

對了,還是要說明一下,我從開始到現在都在介紹 JMM 看清楚這不是 JVM 啊,我的理解啊,JMM 是靈魂,是規範,是一個標準,那我們說的 JVM 其實是一個個的實現,其中比較著名的一個實現就是 HotSpot VM,好了,現在的問題就比較清楚了,接口中已經告訴你了,你只需要滿足 8 個操作和 8 條規則,你就是線程安全的。

問題是,我們的 HotSpot VM 怎麽實現???先別急,還有一個事請呢!

你難道忘了嗎,在 CPU 執行指令的時候會存在亂序執行優化,這裏也是一樣啊,也會為了提高執行效率而對我們寫的代碼進行優化,但是呢,這裏換一個叫法,改名指令重排。

而且這裏重排的範圍變的更大了,即可以指編譯器的優化,也可以指指令的優化。上次舉個【買飲料喝】的例子,但是我忘記說指令優化之後可能帶來的問題了。繼續說一下上次那個例子;一個人的時候:去店裏->點單->付錢->喝飲料 ,嗯,這沒毛病;優化之後: 【去店裏,路上隨便下單,付錢】->喝飲料。嗯,確實提高了效率。

兩個人的時候:小A此時在店裏,小B:去店裏->點單->付錢->【小A喝飲料】,此時小B的心情,WTF ? 好了,看出來了吧,雖然優化是好,但是容易出問題啊!

我這例子舉的很粗糙,主要是為了讓大家可以快速理解在多線程中指令重排雖能提高效率,但是容易出現 bug 啊,有興趣的同學可以自己去看看關於指令重排具體的說明。

那 JMM 又是怎麽來處理多線程下的指令重排呢?想想 CPU 是怎麽處理的,內存屏障,JMM 直接搬過來,嗯,我也使用一些特殊的指令來達到某些指令的順序執行。JMM 提供了一個關鍵字 volatile 來解決指令重排問題。

關於 volatile 關鍵字,JMM 專門定義了一套特殊的訪問規則,主要為達到兩個目的,一是保證此變量對所有變量的可見性。二是禁止指令重排優化。解釋一下第一個目的,我們知道在主內存和工作內存中變量交互的時候,假如線程將變量 a + 1,還沒有寫入主內存的時候,其它線程是不知道 a 的值被修改了。那現在就是希望我在工作內存改了變量之後,其它的線程能看到變量被改了。

具體怎麽實現的呢?還記得 CPU 是怎麽解決緩存一致性的不,使用 MESI 啊,所以這裏的邏輯就是在解釋執行到 volatile 關鍵字之後,會被解釋成相關的指令,而這個指令就可以觸發 CPU 將其它線程的 cache line 變為 invalid。

我們經常會誤用 volatile 關鍵字,雖然被 volatile 修飾的變量 i 是可見的,我們也保證 i 的值是可以實時從主存中獲取,但是這並不代表 i ++ 就是線程安全的,因為 i ++ 不是原子性操作,可以被拆成 geti addi puti 3步。

好了,到這裏 JMM 就有了一套解決多線程安全問題的方案,這套方案又有哪些特性呢,或者說,線程安全的特性有哪些呢?

首先是原子性,我們要求一個線程在操作數據的時候,不能被打斷,不能出現付錢之後飲料被人家拿走的情況。Java 內存模型定義的 8 種操作,就要求虛擬機的實現每一步都必須是原子性的,即不可分割的。

對於基本數據類型的讀或寫可以看成是原子性操作,但是有例外,對於32位的機器來說,一次只能處理32位,但是 double 和 long 類型的數據長度為 64,理論上會可能會被拆分,但實際運行中沒有這種情況,所以可以認為對 double 和 long 來說,讀或寫也是線程安全的。

其次是可見性,因為在多線程環境下主內存和工作內存中數據不一致可能會導致問題,可見性要求一個線程修改了主內存中的值之後,其它的線程能立即得知這個修改。

最後是有序性,主要體現在在單線程時邏輯上的有序,在定義 8 種操作規則的時候的有序,還有最後的指令重排中的有序。

總結一下,以上我們說了 Java 內存模型的結構,以及在多線程情況下由於主內存和工作內存的不同功能和指令重排而出現的問題,以及基本的解決方案。最後,我們總結出 JMM 保證線程安全是圍繞原子性、可見性、有序性這 3 個特性來建立的。

體貼的 JMM 在滿足這 3 個特性的時候給了很多關鍵字或預定規則,比方說,在原子性中的 8 種操作,這樣我們在操作基本數據類型的讀和寫就可以看成是線程安全的。其中的 lock 和 unlock 指令在 Java 語言中的體現就是 synchronized 關鍵字,所以 synchronized 塊之間的操作也是原子性的。

可見性的體現,volatile 關鍵字,上面已經說過了,還有兩個關鍵字 synchronized 和 final。因為被 synchronized 包圍的代碼被線程執行的前提是要 lock,對應的有條規則說到“對一個變量執行 unlock 之前,要先把其寫回主存”。而 final 定義的變量,一旦初始化完成,其它線程都能看到(當然這裏假設不出現對象逃逸,就是不會在對象初始化沒有完成的時候被其它線程拿到 this 對象進行操作。)

最後一個有序性,提供了關鍵字 volatile 和 synchronized,volatile 禁止重排序來達到有序,而 synchronized 則是基於 “一個變量在同一時刻只允許一條線程對其進行 lock 操作,lock 和 unlock 必須成對出現” 這個規則。

另外,JMM 為了保證有序,還內置了一套先行發生規則(happens-before)兩個操作間具有 happens-before 關系,並不意味著前一個操作必須要在後一個操作之前執行。happens-before 僅僅要求前一個操作對後一個操作可見,和一般意義上時間的先後是不一樣的,達到邏輯上的順序執行即可。

如果 A 線程的寫操作 a 與 B 線程的讀操作 b 之間存在 happens-before 關系,盡管 a 操作和 b 操作在不同的線程中執行,但 JMM 向程序員保證 a 操作將對 b 操作可見。我們實際只想知道某線程的操作對另一個線程是否可見,於是就規定了 happens-before 這個可見性原則,程序員可以基於這個原則進行可見性的判斷。

具體的規則如下:

1 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生與書寫在後面的操作。【保證單線程的有序】

2 鎖定規則:一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作。

3 volatile 變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作。【先寫後讀】

4 傳遞規則:A 先於 B 且 B 先於 C 則 A 先於 C

5 線程啟動規則:Thread 對象的 start 方法先行發生於此線程的每一個動作。

6 線程中斷規則:對線程 interrupt 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。【先中斷,後檢測】

7 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過 Thread.join() 方法結束,Thread.isAlive() 的返回值手段檢測線程已經終止執行。

8 對象終結規則:一個對象的初始化完成先行發生於它的 finalize 方法的開始。

如果兩個操作的執行順序不能通過 happens-before 原則推導出來,就不能保證他們的執行次序,虛擬機就可以隨意的對他們進行重排序。

小結一下,以上說了在 Java 內存模型,以及為了保證多線程情況下的安全 JMM 做了哪些設定。

但是我們經常聽的都是一些堆,棧,方法區,本地方法棧之類的描述。這說的是【Java 內存區域】,而上面說的是【Java 內存模型】,這兩者有什麽區別呢?大家都說是不同層級的分類,我自己的理解是工作內存和主內存的分類側重點主要體現在與底層的數據交互方式上,但是我們在編程中具體涉及到的數據具體又是怎麽分的呢?這也就是 Java 內存區域的價值了。

Java 內存區域主要分為 Java堆,虛擬機棧,方法區,本地方法棧,程序計數器,這些區域存放的是什麽待會再說,先說明白了,這些都是虛擬的!不存在的,因為 Java 虛擬機本身就是虛擬的一個機器,但是真正在運行的時候虛擬機為了追求更高的速度,會把這些區域盡可能的分配在硬件的寄存器或緩存上,因為這樣運行速度更快。

但是一般分布都不是確定的,都有可能,所以要能理解這張圖的意思。

技術分享圖片

接下來說說 Java 內存中的各個區域都是幹什麽的。

程序計數器

程序計數器是一塊較小的內存空間,它的作用可以看做是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型裏,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。因此,為了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。

如果線程正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 natvie 方法,這個計數器值則為空(Undefined)。此內存區域是唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。

Java 虛擬機棧

與程序計數器一樣,Java 虛擬機棧也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是 Java 方法執行的內存模型:每個方法被執行的時候都會同時創建一個棧幀用於存儲局部變量表、操作棧、動態鏈接、方法出口等信息。每一個方法被調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。

我們常說的棧其實指的是“局部變量表”局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(它可能是一個指向對象起始地址的引用指針,也可能指向一個代表對象的句柄)和 returnAddress 類型(指向了一條字節碼指令的地址)。其中64 位長度的 long 和 double 類型的數據會占用 2 個局部變量空間(Slot),其余的數據類型只占用 1 個。

局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。

在 Java 虛擬機規範中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常;當無法申請到足夠的內存時會拋出 OutOfMemoryError 異常。

本地方法棧

本地方法棧與虛擬機棧所發揮的作用是非常相似的,其區別不過是虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的 native 方法服務。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。

Java 堆

對於大多數應用來說,Java 堆是 Java 虛擬機所管理的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都要在堆上分配內存。根據 Java 虛擬機規範的規定,Java 堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可,如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出 OutOfMemoryError 異常。

方法區

方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。根據 Java 虛擬機規範的規定,當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常。

運行時常量池

運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述等信息外,還有一項信息是常量池,用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中。既然運行時常量池是方法區的一部分,自然會受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

到這裏為止,已經介紹了 Java 內存模型和 Java 內存區域並介紹了它們之間的區別。更重要的是搞清楚了在多線程情況下如何才能保證線程安全,那就是要保證 3 個特性,原子性、可見性、有序性。

能看到這裏的小夥伴真的不容易哈,給你一個大大的贊,小夥伴們可能會納悶,哎,這些知識都是在哪裏的啊,為什麽我都不知道呢?那我就偷偷的告訴你,有一本秘籍,我都已經為你們準備好了。需要你們關註我的公號呦,後臺回復關鍵字【虛擬機】就行了~

Java 內存模型 ,一篇就夠了!