基於JVM原理JMM模型和CPU快取模型深入理解Java併發程式設計
許多以Java多執行緒開發為主題的技術書籍,都會把對Java虛擬機器和Java記憶體模型的講解,作為講授Java併發程式設計開發的主要內容,有的還深入到計算機系統的記憶體、CPU、快取等予以說明。實際上,在實際的Java開發工作中,僅僅瞭解併發程式設計的建立、啟動、管理和通訊等基本知識還是不夠的。一方面,如果要開發出高效、安全的併發程式,就必須深入Java記憶體模型和Java虛擬機器的工作原理,從底層瞭解併發程式設計的實質;更進一步地,在現今大資料的時代,要開發出高併發、高可用、考可靠的分散式應用及各種中介軟體,更需要深入到計算機工作原理的底層去進行程式碼開發。
本文嘗試以一個較為全面的角度,以Java虛擬機器工作原理和Java記憶體模型為切入,配合一些計算機CPU快取的知識,深入理解Java多執行緒開發中的難點,包括執行緒安全和執行緒通訊等內容。CPU快取模型邏輯上來說,大部分計算機系統的高階程式語言及其編譯器、虛擬機器等構件,都是來源於計算機硬體系統的原理和要求,而不是相反。Java虛擬機器和併發程式設計原理也不例外,因此第一部分先介紹一下困擾許多初學者的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的體系結構圖如下:
程式計數器:執行緒私有,每個執行緒都有獨立的程式計數器,用於存放當前執行緒接下來將要執行的位元組碼指令、分支、迴圈、跳轉、異常處理等資訊。Java虛擬機器棧:執行緒私有,生命週期與執行緒相同。執行緒執行中,執行方法時都會建立“棧幀”,用於存放區域性變量表、操作棧、動態連結、方法出口等資訊。虛擬機器棧的大小可以通過-xss來配置,需要特別注意的是:方法的呼叫是棧幀被壓入和彈出的過程。在一定的容量之下,如果區域性變量表等佔用的記憶體越小,則可被壓入的棧幀就越多,反之亦然。棧幀的記憶體大小稱為寬度,棧幀的數量則稱為深度,兩者成反比。本地方法棧:執行緒私有,JVM為本地方法(Java Native Interface, C/C++實現的程式)所劃分的記憶體區域,用於被執行緒呼叫諸如網路通訊、檔案操作等方法。堆:所有執行緒共享,Java執行期間幾乎所有物件都儲存於此。堆記憶體也會被細分為新生代、老生代等子堆。方法區:多個執行緒共享,儲存那些在類的載入階段(詳見下文)已經被JVM載入的類資訊、常量、靜態變數、即時編譯器JIT編譯後的程式碼等資料。Java8中,改區的持久代記憶體改為元空間。特別地,Java程式中執行緒的數量,受Java虛擬機器棧和堆影響較大,可以粗略地認為:一個Java程序的記憶體大小=堆記憶體 + 執行緒數量 * 執行緒私有棧記憶體。結合作業系統特性,可以明確一個計算執行緒數量的公式:執行緒數=(最大地址空間MaxProcessMemory - JVM堆記憶體 - 系統保留記憶體ReservedOsMemory)/ThreadStackSize(XSS)JVM的類載入過程當Java原始檔經過javac編譯完成,生成類檔案之後,首先會被類載入器即ClassLoader載入。ClassLoader的主要職責是載入編譯好的類檔案,在對應的記憶體區域中生成該類的各個資料結構。類的載入分為載入、連線和初始化三個階段,如圖:
- 載入:載入類的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關鍵字,來確保多執行緒下的有序性。