走進高併發(三)深入理解Java內存模型

在這裏插入圖片描述

多線程程序要比單線程程序複雜的多,單線程程序中,線程從內存中讀取一個變量,如果這個變量的值本身就是1,那麼線程讀取到的值必然是1。但是在多線程程序中,如果多線程對變量的讀寫沒有進行合理的控制,那麼後續線程讀取到的變量的值很可能是2,甚至是3等。所以有必要定義一種或多種規則,保證多線程下內存數據的一致性和準確性,Java內存模型(Java Memory Model,JMM)由此誕生。

在討論Java內存模型之前,這裏先一起聊聊CPU、高速緩存以及主內存,在瞭解這些知識後,對理解Java內存模型會有很大的幫助。

一、CPU、高速緩存以及主內存

中央處理器(Central Processing Unit,CPU)作爲計算機系統的運算和控制核心,是信息處理、程序運行的最終執行單元,具有非常高的處理速度。CPU的執行速度相較於其他的操作如網絡IO、內存數據讀寫等等,往往高出幾個數量級,因此有必要解決CPU與其他物理硬件的速度差異性,否則在短時間內CPU將有可能進入到等待階段,嚴重浪費了CPU資源。CPU高速緩存的出現就是爲了解決CPU和內存之間的巨大速度差異性問題。CPU高速緩存與CPU以及主內存之間的關係圖如下所示:
在這裏插入圖片描述

圖1-1 CPU、高速緩存與主內存的關係

CPU主要是處理熱點數據,如果熱點數據總是需要從主存中去讀取,這必將存在嚴重的性能損耗,CPU高速緩存的存在大大改善了這種不必要的損耗,高速緩存存儲了熱點數據,這種數據可以從兩個方面來理解:

  • 時間局部性:CPU在某個時刻訪問了主存中的某個數據,那麼這個數據在往後的某個時刻很可能被再次訪問。
  • 空間局部性:CPU在某個時刻訪問了主存中的某個數據,那麼這個數據的周邊數據很可能在未來很短的時間內被訪問。

CPU高速緩存是如何緩存熱點數據的呢?這裏需要簡單介紹一下。CPU從硬件方面來說由密集晶體管組成,從功能考慮主要是由寄存器,控制器,運算器和時鐘四部分構成,四個部分通過電流信號進行通信。這裏主要來講解一下寄存器,寄存器用來存儲CPU核心需要處理的指令和數據等,可以理解爲CPU中的內存區域。CPU核心對不直接對高速緩存和主存中的數據進行處理,而是處理寄存器中的數據,寄存器中的數據的直接來源是來自高速緩存,間接來源於主存。CPU需要處理某個數據的時候,首先將數據從主存中讀取到高速緩存,再從高速緩存中讀取到寄存器中,最後操作寄存器中的數據,操作完成之後,寫回高速緩存再寫回主存中。

在單核CPU中,這種設計是沒有問題,只要保證高速緩存能被及時寫回主存中,那麼就可以保證寄存器、高速緩存、主存中的數據一致性問題,但是如果在多核CPU中,每個CPU核心都存在各自的高速緩存,這就可能存在緩存不一致問題。

在下面的CPU緩存結構圖中,該CPU有四個核心,每個CPU核心具有一級緩存和二級緩存,這兩種緩存屬於核心的私有緩存,多個核心之間不能互相訪問其私有緩存。三級緩存則是所有核心共享的緩存,是各個CPU核心二級緩存的直接來源。
在這裏插入圖片描述

圖1-2 CPU緩存結構圖

CPU在處理數據的時候,直接處理的是寄存器中的數據,寄存器中的數據來源於一級緩存,一級緩存的數據來源於二級緩存,二級緩存的數據來源於主存,各級緩存的速度由高到低排序依次爲:寄存器、一級緩存、二級緩存、三級緩存、主存。CPU就是通過這種多級緩存的方式解決了速度差異性問題,但是在多核CPU中尚未解決多核CPU對共享數據的處理而存在的數據不一致性問題,爲了解決這個問題,伊利諾斯州立大學提出了著名的MESI(Modified Exclusive Shared or Invalid)協議,是一種廣泛應用於支持寫回策略的緩存一致性協議。

在介紹MESI協議之前,先來探討一下緩存寫回。緩存寫回是指CPU處理後的數據通過緩存寫回到主存中,寫回的方式通常包括兩種:Write Through和Write Back。

  • Write Through是指CPU將更新後的數據從寄存器寫回到緩存後立馬同步更新到主存中,這種方式在在多核CPU環境中,依賴總線事務來保證強一致性,因此在效率上會有一定的折扣。
  • Write Back是指CPU將更新後的數據從寄存器寫回到緩存後,並不會立即更新到主存中,而是在等到某個合適的時機後才寫回到主存中。這種方式效率高,但是缺點也很明顯,一旦出現系統掉電,緩存中的數據將會丟失。

兩種緩存寫回方式,無論是哪一種,都需要處理多線程環境下緩存不一致問題。爲了保證緩存的一致性,處理器提供寫失效(write invalidate)和寫更新(write update)兩種策略來保證緩存一致。

  • 寫失效是指CPU核心處理其私有緩存的某個數據之後,會通知所有其它CPU核心的緩存中的這一數據在它們中的副本失效。這樣就可以避免其它“過時”的副本被使用而造成數據異常。
  • 寫更新是指某個CPU核心更新其私有緩存中的某個數據時,它把所更新的數據發送給所有的其它CPU核心的緩存,此舉用來更新這一數據在其它CPU核心緩存中的副本。

比較兩種策略,一般來說,使用寫更新策略,需要傳輸更新後的數據,而寫失效只需傳輸寫失效信息,因此寫更新傳輸的數據量比寫失效要大,而且,被更新的數據的某些副本以後也不一定再被使用,綜合考慮來說,基於寫失效MESI協議是保持緩存一致性比較好的途徑。

CPU核心操作緩存都是基於緩存行的,一個緩存行可以存儲多個變量(存滿當前緩存行的字節數,目前主流CPU緩存的緩存行大小都是64字節),而CPU對緩存的修改又是以緩存行爲最小單位的,緩存行的基本結構圖如下所示:

MESI協議描述了CPU緩存中緩存行的四種狀態,且這四種狀態之間可以進行相互轉換。對四種狀態的詳細描述如下:

  • Modified:被修改的
    表示被修改過的緩存行,CPU緩存行中的數據已經被修改過,即與主內存中的數據存在差異,該修改過的緩存行中的內存數據將在未來的某個合適的時間點寫回到主內存中,在寫回之前,主內存中的數據是允許被其他CPU直接讀取或者緩存到對應的緩存行中。一旦寫回到主內存,那麼該CPU對應的緩存行的狀態就變更爲獨享(Exclusive)狀態。

  • Exclusive:獨享的
    該緩存行只被緩存在該CPU的緩存中,它是未被修改過的,與主存中數據一致。該狀態可以在任何時刻當有其它CPU讀取該內存時變成共享(Shared)狀態。同樣地,當CPU修改該緩存行中內容時,該狀態可以變成Modified狀態。

  • Shared:共享的
    該狀態意味着該緩存行可能被多個CPU緩存,並且各個緩存中的數據與主存數據一致,當有一個CPU修改該緩存行中數據,其它CPU中該緩存行可以被作廢,變成無效狀態,也就是變成Invalid狀態。

  • Invalid:無效的
    CPU緩存的緩存行是無效的,可能是因爲其他CPU修改了共享緩存行的數據。

緩存行中的數據狀態有以上四種,引起緩存行中數據的變化的操作也有四種,分別是:

操作 說明
Local Read Local Read表示讀取本地緩存中的數據,也就是CPU讀取高速緩存中的數據。
Local Write Local Write表示寫回本地緩存中的數據,也就是CPU寫數據到高速緩存中。
Remote Read Remote Read表示CPU從主內存中讀取數據,將數據讀取到高速緩存中。
Remote Write Remote Write表示CPU將高速緩存中的數據寫回到主內存中。

在一個典型系統中,可能會有幾個緩存(在多核系統中,每個核心都會有自己的緩存)共享主存總線,每個相應的CPU會發出讀寫請求,而緩存的目的是爲了減少CPU讀寫共享主存的次數。

一個緩存除在Invalid狀態外都可以滿足CPU的讀請求,一個Invalid的緩存行必須從主存中讀取(變成S或者E狀態)來滿足該CPU的讀請求。

一個寫請求只有在該緩存行是M或者E狀態時才能被執行,如果緩存行處於S狀態,必須先將其它緩存中該緩存行變成Invalid狀態(也就是不允許不同CPU同時修改同一緩存行,即使修改該緩存行中不同位置的數據也不允許)。該操作經常作用廣播的方式來完成,例如:RequestFor Ownership(RFO)。

緩存可以隨時將一個非M狀態的緩存行作廢,或者變成Invalid狀態,而一個M狀態的緩存行必須先被寫回主存。

一個處於M狀態的緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S狀態之前被延遲執行。

一個處於S狀態的緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。

一個處於E狀態的緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S狀態。

對於M和E狀態而言總是精確的,他們在和該緩存行的真正狀態是一致的。而S狀態可能是非一致的,如果一個緩存將處於S狀態的緩存行作廢了,而另一個緩存實際上可能已經獨享了該緩存行,但是該緩存卻不會將該緩存行升遷爲E狀態,這是因爲其它緩存不會廣播他們作廢掉該緩存行的通知,同樣由於緩存並沒有保存該緩存行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該緩存行。

從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的緩存行,總線事務需要將所有該緩存行的copy變成Invalid狀態,而修改E狀態的緩存不需要使用總線事務。

遵循MESI協議的CPU高級緩存與CPU以及主內存之間的關係圖如下所示:
在這裏插入圖片描述

圖1-3 CPU、高速緩存(MESI)與主內存的關係

二、Java內存模型

2.1 定義

Java內存模型(Java Memory Model,JMM)是爲Java虛擬機定義的一套規範,旨在屏蔽在不同平臺中對物理內存訪問的差異性,使得Java程序可以在不同平臺上都能達到內存訪問的一致性,這也是Java程序跨平臺的重要特點。

Java內存模型主要定義的是程序中對共享變量的訪問規則,即Java虛擬機對主存上的共享變量的訪問規則。需要注意的是這裏的變量跟我們寫Java程序中的變量不是完全等同的。這裏的變量是指實例字段,靜態字段,構成數組對象的元素,但是不包括局部變量和方法參數(因爲這是線程私有的)。這裏可以簡單的認爲主內存是Java虛擬機內存區域中的堆,局部變量和方法參數是在虛擬機棧中定義的。但是在堆中的變量如果在多線程中都使用,就涉及到了堆和不同虛擬機棧中變量的值的一致性問題了。
在這裏插入圖片描述

圖2-1 Java內存分配棧、堆模型

上圖中,Java內存分配僅僅展示了線程棧和堆內存。堆內存是運行時動態分配對象的存儲空間,用於存放對象的成員變量等,對象的成員方法存放在方法區中(這裏對方法區不進行討論),由於堆內存是在運行時進行動態內存劃分,所以堆內存的訪問效率沒有棧高。而線程棧中存儲的是基本數據類型以及複雜數據類型的句柄(引用),由於線程棧在整個運行時的生命週期是完全確定的,所以它是缺乏一定的靈活性,但是其擁有較高的訪問效率,僅次於計算機的寄存器。通常通過Java代碼new語句創建出來的對象都是存儲在堆內存中的,當線程棧通過對象的句柄來訪問對象的成員變量的時候,都會對對象的成員變量進行私有拷貝,然後對私有拷貝數據進行讀寫。 由於堆內存在多線程環境是共享的,如果多個線程同時訪問同一個對象的成員變量且沒有對對象的變量進行任何的控制,那麼就很有可能出現數據不一致的現象。

2.2 Java內存模型的抽象

Java內存模型其實就是模仿了真實物理CPU與主內存交互模型,是對物理CPU與主內存的交互進行軟件層面的模擬與抽象,Java內存模型的抽象結構圖如下所示:
在這裏插入圖片描述

圖2-2 Java內存模型抽象圖

這裏對Java內存模型的抽象圖進行說明:

線程間的共享變量存儲在主內存中,每個線程還擁有一個本地內存(工作內存),這個本地內存是一個抽象概念,並不是真實存在的內存,它是對CPU高速緩存,寄存器,以及其他的物理硬件的一個優化,它存儲了線程讀取主內存中的共享變量的副本,線程對共享變量的操作是基於這個副本的。這裏提一個重要的概念,線程間的通信不是通過本地內存,而是通過主內存的,例如,線程1將處理完的數據從本地內存中寫回到主內存中,線程2在讀取共享變量的時候是將線程1寫回到主內存的共享變量讀取到自己的本地內存中,這就完成了線程間的通信。在併發場景中,假如有一個計數的需求,那麼如果主內存中的共享變量是1,線程1和線程2同時對共享變量進行讀取操作,那麼兩個線程中本地內存中的共享變量副本的值均爲1,由於線程間的通信不是通過本地內存的,所以線程間的共享變量的副本是不可見的,在經過各自的加1操作後,線程1和線程2都將本地內存中的數據寫回到主內存,那麼就出現了計數不準確的問題。在多線程環境中,如果不對共享變量的訪問進行合理的控制,那麼有很大可能性會引發數據的異常。

要深入理解JMM,那麼首先必須掌握幾個與內存模型相關的概念,JMM的主要技術點也是圍繞了這幾個概念來展開的,這些基本概念分別是:多線程的原子性、可見性和有序性。理解了這三個基本概念,那麼就可以通過八種同步操作來保證多線程環境下共享變量訪問的一致性。

2.3 原子性、可見性和有序性

從上面的內容就可以瞭解到,JMM就是爲了控制多線程環境下共享變量訪問一致性問題而提出的一種規範。JMM存在的目的就是爲了解決由於多線程通過共享內存操作共享數據時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的一系列併發問題,而它最終目的是保證併發場景中各種操作的原子性、可見性和有序性。

原子性是指一個操作是不可中斷的,即使多個線程同時執行時,一個操作一旦開始,就不會受到其他線程的干擾,直到操作完成。舉個例子,有一個全局變量i的初始值是0,線程A對其賦值爲1,線程B對其賦值爲-1,那麼第三個線程C在賦值結束後讀取i的值要麼是1要麼是-1。線程A和B之間是沒有任何干擾的,沒有因爲賦值不同而產生異常,且不可中斷,這是原子性的一個特點。原子性是通用的,但是也有例外,在32位系統中,long類型的數據的多線程讀寫不是原子性的,這是因爲long型數據有64位,多線程之間的讀寫是有干擾的。對於這個特殊的案例,資料很多,有興趣的讀者可以自行了解。

在Java中,是通過synchronized關鍵字來保證原子性的,它的基本實現原理是在字節碼層面提供了兩個字節碼指令monitorenter和monitorexit,在JVM中解釋執行字節碼的時候,遇到這兩個關鍵字,只允許一個線程去操作兩者之間的字節碼,其他的線程要進行等待,也就是說需要等待當前線程釋放對象鎖後其他線程纔可以繼續執行。因此,爲了保證原子性,可以使用Java中的synchronized關鍵字,這是一個比較方便的方式來保證方法和代碼塊內的操作是原子性的。

可見性是指某個線程對共享變量的任何操作,其他線程是可見的,也就是說其他線程讀取到的數據一直都是最新的。主要的實現原理是通過在變量修改後將新值同步寫回主內存,其他線程在操作變量前去主存讀取刷新變量值的方式來實現的。Java中提供了volatile關鍵字來實現這一功能,被其修飾的變量在被修改後可以立即同步到主內存,被其修飾的變量在每次是用之前都從主內存刷新。因此,可以使用volatile來保證多線程操作時變量的可見性。當然除了volatile,Java中的synchronized和final兩個關鍵字也可以實現可見性。

有序性是指程序執行的順序按照代碼的先後順序執行,在Java中,可以使用synchronized和volatile來保證多線程之間操作的有序性。實現方式有所區別:volatile關鍵字會禁止指令重排。synchronized關鍵字保證同一時刻只允許一條線程操作。

瞭解更多幹貨,歡迎關注我的微信公衆號:爪哇論劍(微信號:itlemon)
微信公衆號-爪哇論劍-itlemon

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