也許是東半球最叼的Java內存模型

面試官:你好,你先自我介紹一下。

安琪拉:面試官你好,我叫安琪拉,草叢三婊,最強中單,草地摩托車車手,第21套廣播體操推廣者,火球擁有者、不焚者,安琪拉,這是我的簡歷,請過目。

面試官:看你簡歷上寫熟悉多線程編程,跟我講講Java內存模型。

安琪拉:講Java內存模型前我希望給您講一個故事,從CPU的發展史說起。

面試官:我喜歡聽故事,你說吧。

安琪拉: 先說現代CPU 架構的形成,一切要從馮洛伊曼計算機體系開始說起!

面試官: 扯的是不是有點遠,你能不能快點進入主題!

安琪拉: 你對一個從上海開3個小時車來杭州,真心誠意求職的人就這麼沒有耐心的。

面試官: 孽緣,真是孽緣。你講吧。

安琪拉: 下圖就是經典的 馮洛伊曼體系結構,基本把計算機的組成模塊都定義好了,現在的計算機都是以這個體系弄的,其中最核心的就是由運算器和控制器組成的中央處理器,就是我們常說的CPU。

馮洛伊曼體系結構

面試官: 這個跟Java內存模型有什麼關係?

安琪拉: 不要着急嘛!

安琪拉: 剛纔說到馮洛伊曼體系中的CPU,你應該聽過摩爾定律吧!就是英特爾創始人戈登·摩爾講的:

集成電路上可容納的晶體管數目,約每隔18個月便會增加一倍,性能也將提升一倍。

面試官: 聽過的,然後呢?

安琪拉:所以你看到我們電腦CPU 的性能越來越強勁,英特爾CPU 從Intel Core 一直到 Intel Core i7,前些年單核CPU 的晶體管數量確實符合摩爾定律,看下面這張圖。

橫軸爲新CPU發明的年份,縱軸爲可容納晶體管的對數。所有的點近似成一條直線,這意味着晶體管數目隨年份呈指數變化,大概每兩年翻一番。

面試官: 後來呢?

安琪拉:彆着急啊!後來摩爾定律越來越撐不住了,但是更新換代的程序對電腦性能的期望和要求還在不斷上漲,就出現了下面的劇情。

他爲其Pentium 4新一代芯片取消上市而道歉, 近幾年來,英特爾不斷地在增加其處理器的運行速度。當前最快的一款,其速度已達3.4GHz,雖然強化處理器的運行速度,也增強了芯片運作效能,但速度提升卻使得芯片的能源消耗量增加,並衍生出冷卻芯片的問題。

因此,英特爾摒棄將心力集中在提升運行速度的做法,在未來幾年,將其芯片轉爲以多模核心(multi-core)的方式設計等其他方式,來提升芯片的表現。多模核心的設計法是將多模核心置入單一芯片中。如此一來,這些核心芯片即能以較緩慢的速度運轉,除了可減少運轉消耗的能量,也能減少運轉生成的熱量。此外,集衆核心芯片之力,可提供較單一核心芯片更大的處理能力。—《經濟學人》

安琪拉:當然上面貝瑞特當然只是在開玩笑,眼看摩爾定律撐不住了,後來怎麼處理的呢?一顆CPU 不行,我們多來幾顆嘛!這就是現在我們常見的多核CPU,四核8G 聽着熟悉不熟悉?當然完全依據馮洛伊曼體系設計的計算機也是有缺陷的!

面試官: 什麼缺陷?說說看。

安琪拉:CPU 運算器的運算速度遠比內存讀寫速度快,所以CPU 大部分時間都在等數據從內存讀取,運算完數據寫回內存。

面試官: 那怎麼解決?

安琪拉:因爲CPU 運行速度實在太快,主存(就是內存)的數據讀取速度和CPU 運算速度差了有幾個數量級,因此現代計算機系統通過在CPU 和主存之前加了一層讀寫速度儘可能接近CPU 運行速度的高速緩存來做數據緩衝,這樣緩存提前從主存獲取數據,CPU 不再從主存取數據,而是從緩存取數據。這樣就緩解由於主存速度太慢導致的CPU 飢餓的問題。同時CPU 內還有寄存器,一些計算的中間結果臨時放在寄存器內。

面試官: 既然你提到緩存,那我問你一個問題,CPU 從緩存讀取數據和從內存讀取數據除了讀取速度的差異?有什麼本質的區別嗎?不都是讀數據寫數據,而且加緩存會讓整個體系結構變得更加複雜。

安琪拉:緩存和主存不僅僅是讀取寫入數據速度上的差異,還有另外更大的區別:研究人員發現了程序80%的時間在運行20% 的代碼,所以緩存本質上只要把20%的常用數據和指令放進來就可以了(是不是和Redis 存放熱點數據很像),另外CPU 訪問主存數據時存在二個局部性現象:

  1. 時間局部性現象如果一個主存數據正在被訪問,那麼在近期它被再次訪問的概率非常大。想想你程序大部分時間是不是在運行主流程20%的代碼。
  2. 空間局部性現象CPU使用到某塊內存區域數據,這塊內存區域後面臨近的數據很大概率立即會被使用到。這個很好解釋,我們程序經常用的數組、集合(本質也是數組)經常會順序訪問(內存地址連續或鄰近)。

因爲這二個局部性現象的存在使得緩存的存在可以很大程度上緩解CPU 飢餓的問題。

面試官: 講的是那麼回事,那能給我畫一下現在CPU、緩存、主存的關係圖嗎?

安琪拉:可以。我們來看下現在主流的多核CPU的硬件架構,如下圖所示。

多核心CPU架構

安琪拉:現代操作系統一般會有多級緩存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的電腦緩存信息,一共4核,三級緩存,L1 緩存(在CPU核心內)這裏沒有顯示出來,這裏L2 緩存後面括號標識了是每個核都有L2 緩存,而L3 緩存沒有標識,是因爲L3 緩存是4個核共享的緩存:

安琪拉的電腦多級緩存

面試官: 那你能跟我簡單講講程序運行時,數據是怎麼在主存、緩存、CPU寄存器之間流轉的嗎?

安琪拉:可以。比如以 i = i + 2; 爲例, 當線程執行到這條語句時,會先從主存中讀取i 的值,然後複製一份到緩存中,CPU 讀取緩存數據(取數指令),進行 i + 2 操作(中間數據放寄存器),然後把結果寫入緩存,最後將緩存中i最新的值刷新到主存當中(寫回主存時間不確定)。

面試官: 這個數據操作邏輯在單線程環境和多線程環境下有什麼區別?

安琪拉:比如i 如果是共享變量(例如類的成員變量),單線程運行沒有任何問題,但是多線程中運行就有可能出問題。

例如:有A、B二個線程,在二個不同的CPU 上運行,因爲每個線程運行的CPU 都有自己的緩存,i是共享變量,初始值是0,A 線程從內存讀取i 的值存入緩存,B 線程此時也讀取i 的值存入自己CPU的緩存,A 線程對i 進行+1操作,i變成了1,B線程緩存中的變量 i 還是0,B線程也對i 進行+1操作,最後A、B線程先後將緩存數據寫回內存共享區,預期的結果應該是2,因爲發生了二次+1操作,但是實際是1。

執行過程如下圖:

緩存不一致

這個就是非常著名的緩存一致性問題,注意這裏還只是多CPU的緩存一致性問題,和我們常說的多線程共享變量安全問題還不相同。

說明:單核CPU 的多線程也會出現上面的線程不安全的問題,只是產生原因不是多核CPU緩存不一致的問題導致,而是CPU調度線程切換,多線程局部變量不同步引起的。

面試官: 那CPU 怎麼解決緩存一致性問題呢?

安琪拉:早期的一些CPU 設計中,是通過鎖總線(總線訪問加Lock# 鎖)的方式解決的。看下CPU 體系結構圖,如下:

CPU內體系結構

因爲CPU 都是通過總線來讀取主存中的數據,因此對總線加Lock# 鎖的話,其他CPU 訪問主存就被阻塞了,這樣防止了對共享變量的競爭。但是鎖總線對CPU的性能損耗非常大,把多核CPU 並行的優勢直接給乾沒了!(還記得併發第一集的並行知識吧)

後面研究人員就搞出了一套協議:緩存一致性協議。協議的類型很多(MSI、MESI、MOSI、Synapse、Firefly),最常見的就是Intel (英特爾)的MESI 協議。緩存一致性協議主要規範了CPU 讀寫主存、管理緩存數據的一系列規範,如下圖所示。

緩存一致性協議

面試官: 那講講緩存一致性協議(MESI協議)唄!

安琪拉: 緩存一致性協議(MESI協議)的核心思想:

  • 定義了緩存中的數據狀態只有四種,MESI 是四種狀態的首字母。
  • 當CPU寫數據時,如果寫的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態;
  • 當CPU讀取共享變量時,發現自己緩存的該變量的緩存行是無效的,那麼它就會從內存中重新讀取。

緩存中數據都是以緩存行(Cache Line)爲單位存儲;MESI 各個狀態描述如下表所示:

面試官: MESI 協議解決了什麼問題?

安琪拉: 解決了**多核CPU **緩存不一致的問題。

面試官: 那我有個疑問了,既然有MESI 的存在,解決多核CPU的緩存一致性,爲什麼還需要Java用volatile 這種關鍵字?

因爲我們知道volatile 也是保證共享變量的可見性。

安琪拉: volatile是Java語言層面來定義的,Java語言實現volatile 的內存可見性需要藉助MESI,但是有的CPU只有單核、或者不支持MESI、那怎麼實現內存可見呢?可以是通過鎖總線的方式,volatile屏蔽了硬件的差異,說直接點:使用volatile 修飾的變量是有內存可見性的,這是Java 語法定的,Java 不關心你底層操作系統、硬件CPU 是如何實現內存可見的,我的語法規定就是volatile 修飾的變量必須是具有可見性的。

虛擬機實現volatile的方式是寫入了一條lock 前綴的彙編指令,lock 前綴的彙編指令會強制變量寫入主存,也可避免前後指令的CPU重排序,並及時讓其他核中的相應緩存行失效,volatile是利用MESI達到符合預期的效果。

面試官: 你故事講完了嗎?可以說說爲什麼需要Java內存模型了吧?

安琪拉: CPU 有X86(複雜指令集)、ARM(精簡指令集)等體系架構,版本類型也有很多種,CPU 可能通過鎖總線、MESI 協議實現多核心緩存的一致性。因爲有硬件的差異以及編譯器和處理器的指令重排優化的存在,所以Java 需要一種協議來規避硬件平臺的差異,保障同一段代碼在所有平臺運行效果一致,這個協議叫做Java 內存模型(Java Memory Model)。

面試官: 詳細說說。

安琪拉:Java內存模型( Java Memory Model),簡稱JMM, 是 Java 中非常重要的一個概念,是Java 併發編程的核心。JMM 是Java 定義的一套協議,用來屏蔽各種硬件和操作系統的內存訪問差異,讓Java 程序在各種平臺都能有一致的運行效果。

面試官:你說Java 定義的一套協議,那既然是協議,肯定是約定了一些內容,這套協議規定了什麼內容?

安琪拉:是的,協議這個詞很熟悉,HTTP 協議、TCP 協議等。Java內存模型(JMM) 協議定了一套規範:

所有的變量都存儲在主內存中,每個線程還有自己的工作內存,線程的工作內存中保存了該線程使用到的變量(主內存的拷貝),線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成,如下圖所示,線程的所有操作都是把主內存的數據放在自己的工作內存進行。

面試官:你剛纔說了一大堆概念,能詳細講講嗎?比如你剛纔講的所有變量都在主內存中,每個線程有自己的工作內存,能好好講講什麼是主內存和工作內存嗎?

安琪拉:很多人在這裏會有一個誤區,認爲主內存、工作內存是物理的內存條中的內存,實際上工作內存、主內存都是Java內存模型中的概念模型。

面試官:那我們上一節說的JVM內存區域劃分,有堆和棧,堆是所有線程共享的,棧是線程私有的,這個和真實的物理存儲有什麼關係呢?

安琪拉:這個問題非常棒!JMM 中定義的每個線程私有的工作內存是抽象的規範,實際上工作內存和真實的CPU 內存架構如下所示,Java 內存模型和真實硬件內存架構是不同的:

JMM與真實內存架構

JMM 是內存模型,是抽象的協議。首先真實的內存架構是沒有區分堆和棧的,這個Java 的JVM 來做的劃分,另外線程私有的本地內存線程棧可能包括CPU 寄存器、緩存和主存。堆亦是如此!

面試官: 能具體講講JMM 內存模型規範嗎?

安琪拉: 可以。前面已經講了線程本地內存和物理真實內存之間的關係,說的詳細些:

  • 初始變量首先存儲在主內存中;
  • 線程操作變量需要從主內存拷貝到線程本地內存中;
  • 線程的本地工作內存是一個抽象概念,包括了緩存、寄存器、store buffer(CPU內的緩存區域)等。

一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作(單一操作都是原子的)來完成:

  • lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
  • unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量解除鎖定,解除鎖定後的變量纔可以被其他線程鎖定。
  • read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(有的指令是save/存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

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操作)。

面試官: 併發編程的三個特徵,你知道嗎?

安琪拉: 多線程併發編程中主要圍繞着三個特性實現。

  • 可見性可見性是指當多個線程訪問同一個共享變量時,一個線程修改了這個變量的值,其他線程能夠立即看到修改後的值。
  • 原子性原子性指的一個操作或一組操作要麼全部執行,要麼全部不執行。
  • 有序性有序性是指程序執行的順序按照代碼的先後順序執行。

主要JMM的內容介紹完了,後面再介紹volatile的時候詳細說lock指令,併發編程的原子性、可見性、有序性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章