轉載於: https://mp.weixin.qq.com/s/EhIJpxRUb26KCJqpFbBCrA
1 JMM
1.1 問題引入
為什么要有內存模型?
要想回答這個問題,我們需要先弄懂傳統計算機硬件內存架構。好了,要開始畫圖了
硬件內存架構圖
含有一二三級架構的內存架構圖
1.2 CPU模型
去過機房的同學都知道,一般在大型服務器上會配置多個CPU,每個CPU還會有多個核,這就意味着多個CPU或者多個核可以同時(並發)工作。如果使用Java
起了一個多線程的任務,很有可能每個 CPU
都會跑一個線程,那么你的任務在某一刻就是真正並發執行了。
1.2.1 CPU Register
CPU Register
也就是 CPU 寄存器
。CPU寄存器
是 CPU
內部集成的,在寄存器上執行操作的效率要比在主存上高出幾個數量級
在CPU
中至少要有六類寄存器:指令寄存器(IR)、程序計數器(PC)、地址寄存器(AR)、數據寄存器(DR)、累加寄存器(AC)、程序狀態字寄存器(PSW)。這些寄存器用來暫存一個計算機字,其數目可以根據需要進行擴充
按與CPU遠近來分
,離得最近的是寄存器
,然后緩存
,最后內存
。所以,寄存器是最貼近CPU
的,而且CPU只與寄存器中進行存取。寄存器從內存中讀取數據,但由於寄存器和內存讀取速度相差太大,所以有了緩存
。即讀取數據的方式為:
CPU〈------〉寄存器〈---->緩存<----->內存
當寄存器沒有從緩存中讀取到數據時,也就是沒有命中,那么就從內存中讀取數據
1.2.2 CPU Cache Memory
CPU Cache Memory
也就是CPU
高速緩存。相對於硬盤讀取速度來說內存讀取的效率非常高,但是與 CPU
還是相差數量級,所以在 CPU 和主存間引入了多級緩存,目的是為了做一下緩沖。
CPU
內部集成的緩存稱為一級緩存(L1 Cache
),外部的稱為二級緩存(L2 Cache
)。
一級緩存中又分為數據緩存(D-Cache
)和指令緩存(I-Cache
)。二者可以同時被CPU進行訪問,減少了爭用Cache
所造成的沖突,提高了CPU的效能。
CPU的一級緩存通常都是靜態RAM
(Static RAM/SRAM),速度非常快,但是貴
二級緩存
是CPU
性能表現的關鍵之一,在CPU核心不變化的情況下,增加二級緩存容量能使性能大幅度提高。而同一核心的CPU高低端之分往往也是在二級緩存上存在差異
三級緩存
是為讀取二級緩存后未命中的數據設計的一種緩存,在擁有三級緩存的CPU中,只有約5%的數據需要從內存中調用,這進一步提高了CPU的效率,從某種意義上說,預取效率的提高,大大降低了生產成本卻提供了非常接近理想狀態的性能
1.2.3 Main Memory
Main Memory
就是主存,主存比 L1、L2
緩存要大很多
注意:部分高端機器還有 L3
三級緩存。
內存中相關概念:
- ROM(Read Only Memory)
只讀儲存器 ,對於電腦來講就是硬盤,在系統停止供電的時候仍然可以保持數據 - PROM
PROM
是可編程的ROM
,PROM
和EPROM
(可擦除可編程ROM)兩者區別是,PROM
是一次性的,也就是軟件灌入后,就無法修改了,現在已經不可能使用了,而EPROM
是通過紫外光的照射擦除原先的程序,是一種通用的存儲器。另外一種EEPROM
是通過電子擦除,價格很高,寫入時間很長,寫入很慢。 - RAM(Random Access Memory)
隨機儲存器 ,就是電腦內存條
。用於存放動態數據。(也叫運行內存)系統運行的時候,需要把操作系統從ROM
中讀取出來,放在RAM
中運行,而RAM
通常都是在掉電之后就丟失數據,典型的RAM
就是計算機的內存 - 靜態RAM(Static RAM/SRAM)
當數據被存入其中后不會消失。SRAM
速度非常快,是目前讀寫最快的存儲設備。當這個SRAM
單元被賦予0 或者1 的狀態之后,它會保持這個狀態直到下次被賦予新的狀態或者斷電之后才會更改或者消失。需要4-6 只晶體管實現, 價格昂貴。
一級,二級,三級緩存都是使用SRAM
- 動態RAM(Dynamic RAM/DRAM)
DRAM
必須在一定的時間內不停的刷新才能保持其中存儲的數據。DRAM
只要1 只晶體管就可以實現。
DRAM
保留數據的時間很短,速度也比SRAM
慢,不過它還是比任何的ROM都要快,但從價格上來說DRAM
相比SRAM
要便宜很 多,
計算機內存就是DRAM
的
1.2.4 主存存取原理
目前計算機使用的主存基本都是隨機讀寫存儲器(RAM),現代RAM的結構和存取原理比較復雜,這里本文拋卻具體差別,抽象出一個十分簡單的存取模型來說明RAM的工作原理。
從抽象角度看,主存是一系列的存儲單元組成的矩陣,每個存儲單元存儲固定大小的數據。每個存儲單元有唯一的地址,現代主存的編址規則比較復雜,這里將其簡化成一個二維地址:通過一個行地址和一個列地址可以唯一定位到一個存儲單元。上圖展示了一個4 x 4的主存模型。
主存的存取過程如下:
- 當系統需要讀取主存時,則將地址信號放到地址總線上傳給主存,主存讀到地址信號后,解析信號並定位到指定存儲單元,然后將此存儲單元數據放到數據總線上,供其它部件讀取。
- 寫主存的過程類似,系統將要寫入單元地址和數據分別放在地址總線和數據總線上,主存讀取兩個總線的內容,做相應的寫操作。
這里可以看出,主存存取的時間僅與存取次數呈線性關系,因為不存在機械操作,兩次存取的數據的“距離”不會對時間有任何影響,例如,先取A0再取A1和先取A0再取D3的時間消耗是一樣的。
1.2.5 磁盤存取原理
與主存不同,磁盤I/O存在機械運動耗費,因此磁盤I/O的時間消耗是巨大的。
下圖是磁盤的整體結構示意圖。
一個磁盤由大小相同且同軸的圓形盤片組成,磁盤可以轉動(各個磁盤必須同步轉動)。在磁盤的一側有磁頭支架,磁頭支架固定了一組磁頭,每個磁頭負責存取一個磁盤的內容。磁頭不能轉動,但是可以沿磁盤半徑方向運動(實際是斜切向運動),每個磁頭同一時刻也必須是同軸的,即從正上方向下看,所有磁頭任何時候都是重疊的(不過目前已經有多磁頭獨立技術,可不受此限制)。
下圖是磁盤結構的示意圖。
盤片被划分成一系列同心環,圓心是盤片中心,每個同心環叫做一個磁道,所有半徑相同的磁道組成一個柱面。磁道被沿半徑線划分成一個個小的段,每個段叫做一個扇區,每個扇區是磁盤的最小存儲單元。為了簡單起見,我們下面假設磁盤只有一個盤片和一個磁頭。
當需要從磁盤讀取數據時,系統會將數據邏輯地址傳給磁盤,磁盤的控制電路按照尋址邏輯將邏輯地址翻譯成物理地址,即確定要讀的數據在哪個磁道,哪個扇區。為了讀取這個扇區的數據,需要將磁頭放到這個扇區上方,為了實現這一點,磁頭需要移動對准相應磁道,這個過程叫做尋道
,所耗費時間叫做尋道時間
,然后磁盤旋轉將目標扇區旋轉到磁頭下,這個過程耗費的時間叫做旋轉時間
。
1.2.6 局部性原理與磁盤預讀
由於存儲介質的特性,磁盤本身存取就比主存慢很多,再加上機械運動耗費,磁盤的存取速度往往是主存的幾百分分之一,因此為了提高效率,要盡量減少磁盤I/O。為了達到這個目的,磁盤往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個字節,磁盤也會從這個位置開始,順序向后讀取一定長度的數據放入內存。
這樣做的理論依據是計算機科學中著名的局部性原理:
當一個數據被用到時,其附近的數據也通常會馬上被使用。程序運行期間所需要的數據通常比較集中。由於磁盤順序讀取的效率很高(不需要尋道時間,只需很少的旋轉時間),因此對於具有局部性的程序來說,預讀可以提高I/O效率。
預讀的長度一般為頁(page)
的整倍數。頁是計算機管理存儲器的邏輯塊,硬件及操作系統往往將主存和磁盤存儲區分割為連續的大小相等的塊,每個存儲塊稱為一頁(在許多操作系統中,頁得大小通常為4k),主存和磁盤以頁為單位交換數據。當程序要讀取的數據不在主存中時,會觸發一個缺頁異常,此時系統會向磁盤發出讀盤信號,磁盤會找到數據的起始位置並向后連續讀取一頁或幾頁載入內存中,然后異常返回,程序繼續運行。
1.3 緩存一致性問題
由於主存與 CPU
處理器的運算能力之間有數量級的差距,所以在傳統計算機內存架構中會引入高速緩存來作為主存和處理器之間的緩沖,CPU
將常用的數據放在高速緩存中,運算結束后 CPU
再將運算結果同步到主存中
使用高速緩存解決了 CPU
和主存速率不匹配的問題,但同時又引入另外一個新問題:緩存一致性問題
在多CPU
的系統中(或者單CPU
多核的系統),每個CPU
內核都有自己的高速緩存,它們共享同一主內存(Main Memory
)。當多個CPU
的運算任務都涉及同一塊主內存區域時,CPU
會將數據讀取到緩存中進行運算,這可能會導致各自的緩存數據不一致。
因此需要每個CPU
訪問緩存時遵循一定的協議,在讀寫數據時根據協議進行操作,共同來維護緩存的一致性。這類協議有 MSI、MESI、MOSI、和 Dragon Protocol
等
緩存一致性和原子操作
1.4 處理器優化和指令重排序
為了提升性能在 CPU
和主內存之間增加了高速緩存,但在多線程並發場景可能會遇到緩存一致性問題。那還有沒有辦法進一步提升 CPU 的執行效率呢?答案是:處理器優化
為了使處理器內部的運算單元能夠最大化被充分利用,處理器會對輸入代碼進行亂序執行處理,這就是處理器優化
除了處理器會對代碼進行優化處理,很多現代編程語言的編譯器也會做類似的優化
處理器優化其實也是重排序的一種類型,這里總結一下,重排序可以分為三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器采用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序
- 內存系統的重排序。由於處理器使用緩存和讀寫緩沖區,這使得
加載
和存儲
操作看上去可能是在亂序執行
1.5 並發編程的問題
上面講了一堆硬件相關的東西,有些同學可能會有點懵,繞了這么大圈,這些東西跟 Java
內存模型有啥關系嗎?不要急咱們慢慢往下看
熟悉 Java
並發的同學肯定對這三個問題很熟悉:可見性問題
、原子性問題
、有序性問題
。如果從更深層次看這三個問題,其實就是上面講的緩存一致性
、處理器優化
、指令重排序
造成的
緩存一致性問題其實就是可見性問題,處理器優化可能會造成原子性問題,指令重排序會造成有序性問題,你看是不是都聯系上了
出了問題總是要解決的,那有什么辦法呢?首先想到簡單粗暴的辦法,干掉緩存讓 CPU
直接與主內存交互就解決了可見性問題,禁止處理器優化和指令重排序就解決了原子性和有序性問題,但這樣一夜回到解放前了,顯然不可取。
所以技術前輩們想到了在物理機器上定義出一套內存模型, 規范內存的讀寫操作。內存模型解決並發問題主要采用兩種方式:限制處理器優化
和使用內存屏障
1.5.1 可見性
可見性:當一個線程修改了共享變量的值,其他線程會馬上知道這個修改。當其他線程要讀取這個變量的時候,最終會去內存中讀取,而不是從緩存中讀取
當對非volatile
變量進行讀寫時,每個線程從內存拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個線程可以拷貝到不同的CPU緩存中。而聲明變量是volatile
的,JVM保證了每次讀變量都從內存中讀,跳過了CPU cache這一步
1.5.2 原子性
原子性:即一個操作或者多個操作,要么全部執行並且不被打斷,要么就都不執行
對變量的寫操作不依賴於當前值才是原子級別的,在多線程環境中才可以不用考慮多並發問題。比如:n=n+1、n++ 就不行。n=m+1才是原子級別的,實在沒把握就使用synchronized關鍵字來代替volatile關鍵字
1.5.3 有序性
有序性:虛擬機在進行代碼編譯時,對於那些改變順序之后不會對最終結果造成影響的代碼,虛擬機不一定會按照我們寫的代碼的順序來執行,有可能將他們重排序。實際上,對於有些代碼進行重排序之后,雖然對變量的值沒有造成影響,但有可能會出現線程安全問題。
volatile
本身就包含了禁止指令重排序的語義,而synchronized
關鍵字是由一個變量在同一時刻只允許一條線程對其進行lock操作
這條規則明確的
synchronized的特點,一個線程執行互斥代碼過程如下:
- 獲得同步鎖;
- 清空工作內存;
- 從主內存拷貝對象副本到工作內存;
- 執行代碼(計算或者輸出等);
- 刷新主內存數據;
- 釋放同步鎖
1.6 介紹JMM
1.6.1 JMM定義
Java內存模型
可以理解為在特定的操作協議下,對特定的內存或者高速緩存進行讀寫訪問的過程抽象描述,不同架構下的物理機擁有不一樣的內存模型,Java虛擬機
是一個實現了跨平台的虛擬系統,因此它也有自己的內存模型,即Java
內存模型(Java Memory Model, JMM
)
Java
內存模型是一種規范,定義了很多東西:
- 所有的變量都存儲在主內存(
Main Memory
)中 - 每個線程都有一個私有的本地內存(
Local Memory
),本地內存中存儲了該線程以讀/寫共享變量的拷貝副本 - 線程對變量的所有操作都必須在本地內存中進行,而不能直接讀寫主內存。
- 不同的線程之間無法直接訪問對方本地內存中的變量
1.6.2 線程間通信
如果兩個線程都對一個共享變量進行操作,共享變量初始值為 1,每個線程都變量進行加 1,預期共享變量的值為 3。在 JMM 規范下會有一系列的操作
為了更好的控制主內存和本地內存的交互,Java
內存模型定義了八種操作來實現:
lock
:鎖定,作用於主內存的變量,把一個變量標識為一條線程獨占狀態unlock
:解鎖,作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定read
:讀取,作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨后的load
動作使用load
:載入,作用於工作內存的變量,它把read
操作從主內存中得到的變量值放入工作內存的變量副本中use
:使用,作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作assign
:賦值,作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作store
:存儲,作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的write
的操作write
:寫入,作用於主內存的變量,它把store
操作從工作內存中一個變量的值傳送到主內存的變量中
注意:
工作內存也就是本地內存的意思
對被volatile
修飾的變量進行操作時,需要滿足以下規則:
- 規則1:線程對變量執行的前一個動作是
load
時才能執行use
,反之只有后一個動作是use
時才能執行load
。線程對變量的read
,load
,use
動作關聯,必須連續一起出現。-----這保證了線程每次使用變量時都需要從主存拿到最新的值,保證了其他線程修改的變量本線程能看到。 - 規則2:線程對變量執行的前一個動作是
assign
時才能執行store
,反之只有后一個動作是store
時才能執行assign
。線程對變量的assign
,store
,write
動作關聯,必須連續一起出現。-----這保證了線程每次修改變量后都會立即同步回主內存,保證了本線程修改的變量其他線程能看到。 - 規則3:有線程T,變量V、變量W。假設動作A是T對V的
use或assign
動作,P是根據規則2、3與A關聯的read或write
動作;動作B是T對W的use或assign動作,Q是根據規則2、3與B關聯的read或write動作。如果A先與B,那么P先與Q。------這保證了volatile修飾的變量不會被指令重排序優化,代碼的執行順序與程序的順序相同
1.6.3 Java運行時內存區域與硬件內存的關系
了解過 JVM
的同學都知道,JVM
運行時內存區域是分片的,分為棧、堆等,其實這些都是 JVM
定義的邏輯概念。在傳統的硬件內存架構中是沒有棧和堆這種概念
從圖中可以看出棧和堆既存在於高速緩存中又存在於主內存中,所以兩者並沒有很直接的關系
2 JMM總結
由於CPU
和主內存間存在數量級的速率差,想到了引入了多級高速緩存的傳統硬件內存架構來解決,多級高速緩存作為 CPU
和主內間的緩沖提升了整體性能。解決了速率差的問題,卻又帶來了緩存一致性問題。
數據同時存在於高速緩存和主內存中,如果不加以規范勢必造成災難,因此在傳統機器上又抽象出了內存模型
Java
語言在遵循內存模型的基礎上推出了 JMM
規范,目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題