1. 程式人生 > >基於JVM原理JMM模型和CPU緩存模型深入理解Java並發編程

基於JVM原理JMM模型和CPU緩存模型深入理解Java並發編程

可靠的 解決 start 關鍵字 juc .com 失效 接下來 直接

許多以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的主要職責是加載編譯好的類文件,在對應的內存區域中生成該類的各個數據結構。類的加載分為加載、連接和初始化三個階段,如圖:

技術分享圖片

  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關鍵字,來確保多線程下的有序性。

基於JVM原理JMM模型和CPU緩存模型深入理解Java並發編程