1. 程式人生 > >基於JVM原理、JMM模型和CPU快取模型深入理解Java併發程式設計

基於JVM原理、JMM模型和CPU快取模型深入理解Java併發程式設計

許多以Java多執行緒開發為主題的技術書籍,都會把對Java虛擬機器和Java記憶體模型的講解,作為講授Java併發程式設計開發的主要內容,有的還深入到計算機系統的記憶體、CPU、快取等予以說明。實際上,在實際的Java開發工作中,僅僅瞭解併發程式設計的建立、啟動、管理和通訊等基本知識還是不夠的。一方面,如果要開發出高效、安全的併發程式,就必須深入Java記憶體模型和Java虛擬機器的工作原理,從底層瞭解併發程式設計的實質;更進一步地,在現今大資料的時代,要開發出高併發、高可用、考可靠的分散式應用及各種中介軟體,更需要深入到計算機工作原理的底層去進行程式碼開發。

本文嘗試以一個較為全面的角度,以Java虛擬機器工作原理和Java記憶體模型為切入,配合一些計算機CPU快取的知識,深入理解Java多執行緒開發中的難點,包括執行緒安全和執行緒通訊等內容。

如果需要先行了解Java併發程式設計的基礎知識,可參考以下隨筆:

Java併發程式設計之執行緒建立和啟動(Thread、Runnable、Callable和Future)

Java併發程式設計之執行緒生命週期、守護執行緒、優先順序、關閉和join、sleep、yield、interrupt

Java併發程式設計之執行緒安全、執行緒通訊

CPU快取模型

一般來說,邏輯上是先有了現行的計算機體系,再有基於計算機系統的高階程式語言及其編譯器、虛擬機器等構件。因此第一部分先介紹一下困擾許多初學者的Java多執行緒開發的源頭——CPU快取模型。

計算機中,所有的計算都是在CPU暫存器中完成,而指令完成所需要的資料讀取和寫入,都需要從RAM主存獲取。受硬體工藝的影響,現在的CPU處理速度已經遠遠超過主存的訪問速度,差額基本是成千上萬的差距。

因此,CPU快取設計應運而生。如下為CPU快取架構圖和CPU快取與主存的速度對比:

使用CPU快取來處理資料的步驟大致為:

1. 把需要的資料從主存複製一份到CPU快取中;

2. CPU從快取中讀取資料並計算;

3. 計算完成的資料重新整理到主存中。

“快取一致性問題”

如上的工作機制,會在多執行緒環境下導致快取不一致的問題。為此,使用“匯流排加鎖”(已淘汰)和“快取一致性協議”來解決,它大致的思想是:

當CPU操作快取中的資料時,如果發現該變數是一個共享變數,意味著其它快取中也會有這個變數的副本,然後——

1. 如果是讀操作,不做任何處理,只是從快取中讀取資料到暫存器

2. 如果是寫操作,發出訊號通知其它CPU將該變數的cache line置為無效狀態,其它CPU在執行該變數讀取的時候需要從主存更新資料。

Java虛擬機器

受許多資料和書籍講述不嚴謹所致,很多初學者往往簡單地把Java虛擬機器理解為類似編譯器甚至直譯器的存在,把Java虛擬機器當做黑盒,認為輸入了Java原始碼,就可以輸出計算機直接跑的程式了;因為JVM在不同作業系統上都有實現,所以可以做到“一份程式碼,多種機器執行的效果”。這樣理解對小白或者外行人來說可能OK,但對於有想法深入學習Java的小夥伴,是遠遠不夠的。

事實上,Java虛擬機器有自己完善的硬體架構,如處理器、堆疊、暫存器等,還具有相應的指令系統。包括編譯器以及JRE在內的整套體系,構成了完整的JVM。JVM原生支援包括Java、Scala、Kotlin在內的語言編譯後執行。而其中,JRE又是JVM的核心部分。JRE的體系結構圖如下:

JVM的類載入過程

當Java原始檔經過javac編譯完成,生成類檔案之後,首先會被類載入器即ClassLoader載入。ClassLoader的主要職責是載入編譯好的類檔案,在對應的記憶體區域中生成該類的各個資料結構。類的載入分為載入、連線初始化三個階段,如圖:

1. 載入:載入類的class檔案

2. 連線

2.1 驗證:確保class檔案的正確性,如版本、魔術因子等

2.2 準備為類的靜態變數分配記憶體,並且初始化預設值

2.3 解析:把類中的符號引用轉為直接引用

3. 初始化為類的靜態變數賦程式碼編寫階段鎖賦的值

需要注意的是:類的載入實施的是懶載入,即用的時候才載入,並且在同一個執行時包下,一個類只會被初始化一次。

類的完整的生命週期,除了類載入,還包括使用解除安裝

關於使用,JVM定義了6種主動使用類的場景,會導致類的載入和初始化

new物件;訪問類的靜態變數(靜態常量不會!);訪問類的靜態方法;使用反射;初始化子類會初始化父類;啟動類

注意初始化一個類為元素的陣列不會載入類。

類載入的最終產物,是堆記憶體中的Class物件。而對於同一個ClassLoader,不管類被載入多少次,指向的都是同一個Class物件

類被載入後在棧記憶體中的分佈情況如圖

Java記憶體模型

通過CPU快取和JVM工作模式的介紹,是為了引入Java記憶體模型的概念。Java記憶體模型(Java Memory Mode, JMM)定義了JVM如何與計算機的主存進行工作,理解JMM對正確理解Java多執行緒開發是十分重要的。JMM模型如下圖所示:

Java記憶體模型的工作邏輯,與上面介紹到的CPU快取一致性工作邏輯十分相似,其關於多執行緒的工作要點如下:

1. 共享變數儲存於主記憶體中,每個執行緒都可以訪問。

2. 每個執行緒都有私有的工作記憶體,或稱本地記憶體。這只是個邏輯概念,其實質是涵蓋了暫存器、快取、編譯器優化和硬體等。

3. 共享變數只以副本的形式,儲存在本地記憶體中。

4. 執行緒不能直接操作主記憶體,只有操作了本地記憶體中的副本,才能重新整理到主記憶體中。

5. 每個執行緒也不能操作其它執行緒的私有的本地記憶體

Java執行緒安全的實現

Java併發程式設計安全需要具備的三大特性:原子性、可見性和有序性。下面將介紹,基於JMM模型和Java執行緒安全的實現方式,是如何確保三大特性的。

原子性

  • 在Java併發程式設計中,簡單的讀取和賦值操作是原子性的,但是多個原子操作並在一起就不是了,比如將一個變數賦值給另外一個變數的操作。
  • JMM只保證了簡單讀取和賦值的原子性。因此,併發程式設計中需要用到synchronized實現同步,或者使用Lock介面的實現類加鎖;對於基本資料型別如int的自增操作,也可以使用JUC包下的java.util.concurrent.atomic.*包下的原子型別。而volatile修飾的變數,不具備原子性。

可見性

基於JMM模型,對於執行緒讀取共享變數:首次只要從主記憶體讀取到工作記憶體,以後都在工作記憶體中讀取即可;對於修改共享變數,新值先更新在工作記憶體中,再重新整理到主存中。但什麼時候重新整理是不確定的。因此,Java併發程式設計中,要確保共享變數在多執行緒中同步更新,可以採取如下方式:

  • 通過synchronized關鍵字同步,可以確保在鎖釋放之前,對變數的修改重新整理到主記憶體中;
  • 通過Lock介面實現類實現同步,同樣可以在鎖unlock之前,把修改重新整理到主記憶體中;
  • 使用volatile關鍵字,當某執行緒修改了工作記憶體中的共享變數副本,會直接重新整理主存中的值,並且其它執行緒會立刻收到本地記憶體中共享變數副本失效的資訊,從而及時從主記憶體中更新值。

有序性

在JMM模型中,為了充分利用硬體效能,編譯器和指令器有可能會對程式指令進行重排序。單執行緒下,這不會有什麼問題,但多執行緒下則可能帶來意想不到的狀況。

關於併發程式設計的有序性,JMM基於一套原生Happens-before原則,來確保了多執行緒下一定程度的有序性。具體說來:

  • 程式次序規則:即便發生了重排序,在一個執行緒內最終的執行結果會與程式編寫順序的結果一致。
  • 鎖定規則:先unlock再lock。即一個鎖是鎖定狀態,需要先解鎖才能再加鎖。
  • volatile規則:如果一個執行緒對volatile變數讀,另一個執行緒對該變數寫,那麼寫操作一定發生在讀操作之前。
  • 傳遞規則:如果操作A先於B,B先於C,那麼A肯定先於C。
  • 執行緒啟動規則:執行緒的start方法先於其它操作。
  • 執行緒中斷規則:必須是先有interrupt()方法呼叫,才有中斷訊號的捕獲。
  • 執行緒終結規則:執行緒的所有操作都必須先於執行緒死亡。
  • 物件終結規則:一個物件的初始化先於物件GC之前。

此外,在併發程式設計中,比較常用的是使用synchronized關鍵字和Lock介面同步,或者volatile關鍵字,來確保多執行緒下的有序性。