關于Java內存模型(JMM)的那些事
一、為什么要有內存模型
- 在現代多核處理器中,每個處理器都有自己的緩存,需要定期的與主內存進行協調。
- 想要確保每個處理器在任意時刻知道其他處理器正在進行的工作,將需要很大的開銷,且通常是沒必要的。
1.1 硬件的效率與一致性
1、 由于計算機的存儲設備與處理器的運算能力之間有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中沒這樣處理器就無需等待緩慢的內存讀寫了。
2、多個處理器運算任務都涉及同一塊主存,需要一種協議可以保障數據的一致性,這類協議有MSI、MESI、MOSI及Dragon Protocol等。Java虛擬機內存模型中定義的內存訪問操作與硬件的緩存訪問操作是具有可比性的。
3、基于高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是引入了一個新的問題:
緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共享同一主存,下面會介紹這個問題
二、CPU和緩存一致性
2.1 為什么需要CPU cache
因為CPU的頻率太快了,快到主存跟不上,這樣在處理器時鐘周期內,CPU常常需要等待主存,浪費資源。CPU往往需要重復處理相同的數據、重復執行相同的指令,如果這部分數據、指令CPU能在CPU緩存中找到,CPU就不需要從內存或硬盤中再讀取數據、指令,從而減少了整機的響應時間,所以cache的出現,是為了緩解CPU和內存之間速度的不匹配問題(結構:cpu -> cache -> memory)
在程序執行的過程中就變成了:
當程序在運行過程中,會將運算需要的數據從主存復制一份到CPU的高速緩存當中,那么CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之后,再將高速緩存中的數據刷新到主存當中。
在Intel官網上產品-處理器界面內對緩存的定義為:CPU高速緩存是處理器上的一個快速記憶區域。英特爾智能高速緩存(SmartCache)是指可讓所有內核動態共享最后一級高速緩存的架構。這里就提及到了最后一級高速緩存的概念,即為CPU緩存中的L3(三級緩存),那么我們繼續來解釋一下什么叫三級緩存,分別又是指哪三級緩存。
2.2 三級緩存(L1、L2、L3)
1) 三級緩存(L1一級緩存、L2二級緩存、L3三級緩存)都是集成在CPU內的緩存 2) 它們的作用都是作為CPU與主內存之間的高速數據緩沖區 3) L1最靠近CPU核心,L2其次,L3再次 運行速度方面:L1最快、L2次快、L3最慢
容量大小方面:L1最小、L2較大、L3最大
4) CPU會先在最快的L1中尋找需要的數據,找不到再去找次快的L2,還找不到再去找L3,L3都沒有那就只能去內存找了。
5) 單核CPU只含有一套L1,L2,L3緩存;如果CPU含有多個核心,即多核CPU,則每個核心都含有一套L1(甚至和L2)緩存,而共享L3(或者和L2)緩存。
單CPU雙核的緩存結構:
在單線程環境下,cpu核心的緩存只被一個線程訪問。緩存獨占,不會出現訪問沖突等問題在多線程場景下,在CPU和主存之間增加緩存,就可能存在緩存一致性問題,也就是說,在多核CPU中,每個核的自己的緩存中,關于同一個數據的緩存內容可能不一致,這也就是我們上面提到的緩存一致性的問題
2.3 亂序執行優化
從java源碼到最終實際執行的指令序列,會經歷下面3種重排序:
重排序的現象:
- a=10,b=a 這一組 b依賴a,不會重排序
- a=10,b=50 這一組 a和b 沒有關系,那么就有可能被重排序執行 b=50,a=10
- cpu和編譯器為了提高程序的執行效率會按照一定的規則允許指令優化,不影響單線程程序執行結果,但是多線程就會影響程序結果
三、java內存模型
Java內存模型即Java Memory Model,簡稱JMM。JMM定義了Java 虛擬機(JVM)在計算機內存(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬于JVM的。
Java內存模型(Java Memory Model ,JMM)就是一種符合內存模型規范的,屏蔽了各種硬件和操作系統的訪問差異的,保證了Java程序在各種平臺下對內存的訪問都能保證效果一致的機制及規范。可以避免像c++等直接使用物理硬件和操作系統的內存模型在不同操作系統和硬件平臺下表現不同,比如有些c/c++程序可能在windows平臺運行正常,而在linux平臺卻運行有問題。
注意JMM與JVM內存區域劃分的區別: JMM描述的是一組規則,圍繞原子性、有序性和可見性展開; 相似點:存在共享區域和私有區域
Java線程之間的通信采用的是過共享內存模型,這里提到的共享內存模型指的就是Java內存模型(簡稱JMM),JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
- 線程A把本地內存A中更新過的共享變量刷新到主內存中去。
- 線程B到主內存中去讀取線程A之前已更新過的共享變量。具體示意圖:
如上圖所示,本地內存A和B有主內存中共享變量z的副本。假設初始時,這三個內存中的z值都為0。線程A在執行時,把更新后的z值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改后的z值刷新到主內存中,此時主內存中的z值變為了1。隨后,線程B到主內存中去讀取線程A更新后的z值,此時線程B的本地內存的z值也變為了1。
從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為java程序員提供內存可見性保證。
3.1 JVM對Java內存模型的實現
在JVM內部,Java 內存模型把 Java 虛擬機內部劃分為:線程棧和堆
線程棧:
每一個運行在 Java 虛擬機里的線程都擁有自己的線程棧。這個線程棧包含了這個線程調用的方法當前執行點相關的信息。一個線程僅能訪問自己的線程棧。一個線程創建的本地變量對其它線程不可見,僅自己可見。即使兩個線程執行同樣的代碼,這兩個線程任然在在自己的線程棧中的代碼來創建本地變量。因此,每個線程擁有每個本地變量的獨有版本。
線程堆:
堆上包含在 Java 程序中創建的所有對象,無論是哪一個對象創建的。這包括原始類型的對象版本。如果一個對象被創建然后賦值給一個局部變量,或者用來作為另一個對象的成員變量,這個對象任然是存放在堆上。
- 一個本地變量如果是原始類型,那么它會被完全存儲到棧區
- 一個本地變量也有可能是一個對象的引用,這種情況下,這個本地引用會被存儲到棧中,但是對象本身仍然存儲在堆區
- 對于一個對象的成員方法,這些方法中包含本地變量,仍需要存儲在棧區,即使它們所屬的對象在堆區
- 對于一個對象的成員變量,不管它是原始類型還是包裝類型,都會被存儲到堆區
- Static類型的變量以及類本身相關信息都會隨著類本身存儲在堆區
- 堆中的對象可以被多線程共享。如果一個線程獲得一個對象的應用,它便可訪問這個對象的成員變量。如果兩個線程同時調用了同一個對象的同一個方法,那么這兩個線程便可同時訪問這個對象的成員變量,但是對于本地變量,每個線程都會拷貝一份到自己的線程棧中
3.2 Java內存模型和硬件架構之間的橋接
Java內存模型和硬件內存架構并不一致。硬件內存架構中并沒有區分棧和堆,從硬件上看,不管是棧還是堆,大部分數據都會存到主存中,當然一部分棧和堆的數據也有可能會存到CPU寄存器中,如下圖所示,Java內存模型和計算機硬件內存架構是一個交叉關系:
3.3 Java內存模型 - 同步八種操作
1) lock(鎖定):作用于主內存的變量,把一個變量標識為一條線程獨占狀態
2) unock(解鎖):作用于主內存的變量,把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定
3) read(讀取):作用于主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用
4) load(載入):作用于工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中
5) use(使用):作用于工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎
6) assign(賦值):作用于工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量
7) store(存儲):作用于工作內存的變量,把工作內存中的一個變量的值傳送到內存中,以便隨后的write的操作
8) write(寫入):作用于工作內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中
3.4 Java內存模型 - 同步規則
- 如果要把一個變量從主內存中復制到工作內存,就需要按順序地執行read和load操作,如果把變量從工作內存中同步回主內存中,就要按順序地執行store和write操作。但Java內存模型只要求上訴操作必須按順序執行,而沒有保證必須是連續執行
- 不允許read和load、store和write操作之一單獨出現
- 不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之后必須同步到主內存中
- 不允許一個線程無原因的(沒有發生過任何assign操作)把數據從工作內存同步回主內存中
- 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作
- 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重復執行多次,多次執行lock后,只有執行相同次數的unlock操作,變量才會解鎖。lock和unlock必須成對出現
- 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值
- 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量
- 對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)
原子性、可見性、有序性:可以查看我上一篇文章:線程安全性詳解(原子性、可見性、有序性)
四、并發的優勢與風險
優勢:1) 速度:使用處理多個請求,響應更快,復雜的操作可以分成多個進程同時執行 2) 設計:程序設計在某些情況下更簡單,也可以有更多的選擇 3) 資源利用:CPU能夠在等待IO的時候做一些其他的事情
風險:1) 安全性:多個線程共享數據時可能會產生于期望不相符的結果 2) 活躍性:某個操作無法繼續進行下去時,就會發生活躍性問題。比如死鎖、饑餓等問題 3) 性能:線程過多時會使得:CPU頻繁切換,調度時間增多;同步機制;消耗過多內存
五、總結
CPU多級緩存:緩存一致性、亂序執行優化 Java內存模型:JMM規定、抽象結構、同步八種操作及規則 Java并發的優勢與風險