一、知識體系導圖

二、 平台無關性
JVM是Java Virtual Machine(Java虛拟機)的縮寫,JVM是一種用于計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上仿真模拟各種計算機功能來實作的。Java虛拟機包括一套位元組碼指令集、一組寄存器、一個棧、一個垃圾回收堆和一個存儲方法域。 JVM屏蔽了與具體作業系統平台相關的資訊,使Java程式隻需生成在Java虛拟機上運作的目标代碼(位元組碼),就可以在多種平台上不加修改地運作。JVM在執行位元組碼時,實際上最終還是把位元組碼解釋成具體平台上的機器指令執行。
Java語言的一個非常重要的特點就是與平台的無關性。而使用Java虛拟機是實作這一特點的關鍵。一般的進階語言如果要在不同的平台上運作,至少需要編譯成不同的目标代碼。而引入Java語言虛拟機後,Java語言在不同平台上運作時不需要重新編譯。Java語言使用Java虛拟機屏蔽了與具體平台相關的資訊,使得Java語言編譯程式隻需生成在Java虛拟機上運作的目标代碼(位元組碼),就可以在多種平台上不加修改地運作。Java虛拟機在執行位元組碼時,把位元組碼解釋成具體平台上的機器指令執行。這就是Java的能夠“一次編譯,到處運作”的原因。
JAVA開發的平台有:
- JAVA SE:主要用在用戶端開發
- JAVA EE:主要用在web應用程式開發
- JAVA ME:主要用在嵌入式應用程式開發
三、JDK、JRE、JVM
JDK:Java Development Kit(Java開發工具包),是開發人員所需要安裝的環境。
JRE:Java Runtime Environment(Java運作時環境),java程式運作所需要安裝的環境。
JVM:Java Virtual Machine(Java虛拟機),是一個虛構出來的計算機 ,用于執行Java程式。有兩種不同運作風格:
client
和
server。
三者之間的關系:JDK包含JRE;JRE包含JVM。如圖所示:
JDK附帶的一些重要元件:
- apt 注解處理工具
- javadoc 文檔生成器,可以自動從源代碼生成說明文檔
- jar 歸檔器,将相關的類庫打包到一個JAR檔案中。還可以幫助管理JAR檔案
- jConsole Java監控和管理平台
- jvisualvm:JDK 自帶的全能分析工具,可以分析:記憶體快照、線程快照、程式死鎖、監控記憶體的變化、gc 變化等。
- jhat Java堆分析工具
- jstack 列印Java線程的堆棧資訊
- keytool 政策建立和管理工具
- jarsigner Java簽名和驗證工具
四、Java程式執行過程
1、運作流程概述
在深入Java内部之前,先用一張圖直覺的展示一下Java源檔案是如何運作的:
再用文字描述一下整個運作流程:
- 開發:使用內建開發環境Ecilpse編寫Java源檔案(.java);
- 編譯:Java編譯器 javac 将源檔案(.java)編譯成位元組碼檔案(.class);
- 加載:類加載器ClassLoader加載位元組碼(.class)檔案到Java虛拟機Jvm中。它主要有負責三個步驟:
- Loading(加載):指将類的位元組碼檔案(.class)中的二進制資料讀入記憶體,将其放在運作時資料區的方法區内,然後在堆上建立java.lang.Class對象,封裝類在方法區内的資料結構。類加載的最終産品是位于堆中的類對象,類對象封裝了類在方法區内的資料結構,并且向JAVA程式提供了通路方法區内資料結構的接口。
- Linking(連結):驗證位元組碼,為靜态域配置設定存儲空間(隻是配置設定,并不初始化該存儲空間);包括3個步驟:
- 驗證(Verification),驗證是保證二進制位元組碼在結構上的正确性,具體來說,工作包括檢測類型正确性,接入屬性正确性(public、private),檢查final class 沒有被繼承,檢查靜态變量的正确性等。
- 準備(Preparation),準備階段主要是建立靜态域,配置設定空間,給這些域設預設值,需要注意的是兩點:一個是在準備階段不會執行任何代碼,僅僅是設定預設值,二個是這些預設值是這樣配置設定的,原生類型全部設為0,如:float:0f,int 0, long 0L, boolean:0(布爾類型也是0),其它引用類型為null。
- 解析(Resolution),解析的過程就是對類中的接口、類、方法、變量的符号引用進行解析并定位,解析成直接引用(符号引用就是編碼是用字元串表示某個變量、接口的位置,直接引用就是根據符号引用翻譯出來的位址),并保證這些類被正确的找到。解析的過程可能導緻其它的類被加載。需要注意的是,根據不同的解析政策,這一步不一定是必須的,有些解析政策在解析時遞歸的把所有引用解析,這是early resolution,要求所有引用都必須存在;還有一種政策是late resolution,這也是Oracle 的JDK所采取的政策,即在類隻是被引用了,還沒有被真正用到時,并不進行解析,隻有當真正用到了,才去加載和解析這個類。
- Initialization(初始化):這是類加載的最後一個階段,所有的靜态變量都被賦以原始初值,并執行靜态代碼塊。
- 執行:JVM執行引擎将使用特定的指令解析器執行引擎(Execution Engine),将位元組碼翻譯成底層系統指令(機器可以執行的機器碼(0,1二進制)),再交由 CPU 去執行,而這個過程中需要調用其他語言的本地庫接口(Native Interface)來實作整個程式的功能。
- 資料:在整個程式執行過程中,JVM會用運作時資料區Runtime Data Area來存儲程式執行期間需要用到的資料和相關資訊,也就是我們常說的JVM記憶體。
2、ClassLoader
- 啟動類加載器(BootstrapClassLoader):在JVM運作時被建立,負責加載存放在JDK安裝目錄下的jre\lib的類檔案,或者被-Xbootclasspath參數指定的路徑中,并且能被虛拟機識别的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader加載)。啟動類無法被JAVA程式直接引用。
- 擴充類加載器(Extension ClassLoader):該類加載器負責加載JDK安裝目錄下的\jre\lib\ext的類,或者由java.ext.dirs系統變量指定路徑中的所有類庫,開發者也可以直接使用擴充類加載器。
- 應用程式類加載器(AppClassLoader):負責加載使用者類路徑(Classpath)所指定的類,開發者可以直接使用該類加載器,如果應用程式中沒有定義過自己的類加載器,該類加載器為預設的類加載器。
- 使用者自定義類加載器(User ClassLoader):JVM自帶的類加載器是從本地檔案系統加載标準的java class檔案,而自定義的類加載器可以做到在執行非置信代碼之前,自動驗證數字簽名,動态地建立符合使用者特定需要的定制化建構類,從特定的場所(資料庫、網絡中)取得java class。
雙親委派模型:如果一個類加載器收到了類加載的請求,它首先不會自己去加載這個類,而是把這個請求委派給父類加載器去完成。每一層的類加載器都是如此,這樣所有的加載請求都會被傳送到頂層的啟動類加載器中,隻有當父加載無法完成加載請求(它的搜尋範圍中沒找到所需的類)時,子加載器才會嘗試去加載類。(當一個類收到了類加載請求時,不會自己先去加載這個類,而是将其委派給父類,由父類去加載,如果此時父類不能加載,回報給子類,由子類去完成類的加載。)
3、Runtime Data Area
根據《Java虛拟機規範》的規定,運作時資料區通常包括這幾個部分:程式計數器(Program Counter Register)、Java棧(VM Stack)、本地方法棧(Native Method Stack)、方法區(Method Area)、堆(Heap)。
如上圖所示,JVM中的運作時資料區應該包括這些部分。在JVM規範中雖然規定了程式在執行期間運作時資料區應該包括這幾部分,但是至于具體如何實作并沒有做出規定,不同的虛拟機廠商可以有不同的實作方式。下面我們來了解一下運作時資料區的每部分具體用來存儲程式執行過程中的哪些資料。
程式計數器(Program Counter Register):也有稱作為PC寄存器。它儲存的是程式目前執行的指令的位址(也可以說儲存下一條指令的所在存儲單元的位址),當CPU根據得到的位址擷取到指令之後,程式計數器便自動加1或者根據轉移指針得到下一條指令的位址,如此循環,直至執行完所有的指令。由于多線程是輪流切換CPU來獲得執行時間,是以在任一時刻,核心隻會儲存一個線程的指令。為了每個線程能在切換後,能恢複之前執行的位置,它們都需要有自己獨立的程式計數器。是以,程式計數器是每個線程所私有的。在JVM規範中規定,執行的是非native方法,則程式計數器中儲存的是目前需要執行的指令的位址;如果是native方法,則程式計數器中的值是undefined。
Java虛拟機棧(Java Vitual Machine Stack):Java棧是Java方法執行的記憶體模型。Java棧中存放的是一個個棧幀,每個棧幀對應一個被調用的方法。在棧幀中包括局部變量表(Local Variables)、操作數棧(Operand Stack)、指向目前方法所屬類的運作時常量池的引用、方法傳回位址(Return Address)和一些額外的附加資訊。當線程執行一個方法時,就會随之建立一個對應的棧幀,并将建立的棧幀壓棧。當方法執行完畢之後,便會将棧幀出棧。對于所有的程式設計語言來說,棧這部分空間對程式員來說是不透明的。由于每個線程正在執行的方法可能不同,是以每個線程都會有一個自己的Java棧,互不幹擾。
- 局部變量表:用來存儲方法中的局部變量(包括在方法中聲明的非靜态變量以及函數形參)。對于基本資料存儲它的值;對于引用類型存儲它的引用。局部變量表的大小在編譯器就可以确定其大小了,是以在程式執行期間局部變量表的大小是不會改變的。
- 操作數棧:棧最典型的一個應用就是用來對表達式求值。想想一個線程執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。是以可以這麼說,程式中的所有計算過程都是在借助于操作數棧來完成的。
- 指向運作時常量池的引用:因為在方法執行的過程中有可能需要用到類中的常量,是以必須要有一個引用指向運作時常量。
- 方法傳回位址:當一個方法執行完畢之後,要傳回之前調用它的地方,是以在棧幀中必須儲存一個方法傳回位址。
本地方法棧(Native Method Stacks):與Java棧的作用和原理非常相似。差別隻不過是Java棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規範中,并沒有對本地方發展的具體實作方法以及資料結構作強制規定,虛拟機可以自由實作它。在HotSopt虛拟機中直接就把本地方法棧和Java棧合二為一。每個線程都會有一個自己的本地方法棧。
堆(Java Heap):用來存儲對象本身的以及數組(當然,數組引用是存放在Java棧中的)。Java的垃圾回收機制會自動進行處理。是以這部分空間也是Java垃圾收集器管理的主要區域。另外,堆是JVM中所有線程共享的,是以在其上進行對象記憶體的配置設定均需要進行加鎖,這也導緻了new對象的開銷是比較大的。Sun Hotspot JVM為了提升對象記憶體配置設定的效率,對于所建立的線程都會配置設定一塊獨立的空間TLAB(Thread Local Allocation Buffer),其大小由JVM根據運作的情況計算而得,在TLAB上配置設定對象時不需要加鎖,是以JVM在給線程的對象配置設定記憶體時會盡量的在TLAB上配置設定,TLAB僅作用于新生代的Eden Space,在這種情況下JVM中配置設定對象記憶體的性能和C基本是一樣高效的,但如果對象過大的話則仍然是直接使用堆空間配置設定。根據Java虛拟機規範的規定,Java堆可以處在實體上不連續的記憶體空間中,隻要邏輯上是連續的即可。
方法區(Method Area):在方法區中,存儲了每個類的資訊(包括類的名稱、方法資訊、字段資訊)、靜态變量、常量以及編譯器編譯後的代碼等。與堆一樣,是被線程共享的區域。在Class檔案中除了類的字段、方法、接口等描述資訊外,還有一項資訊是常量池,用來存儲編譯期間生成的字面量和符号引用。
- 在方法區中有一個非常重要的部分就是運作時常量池,它是每一個類或接口常量池的運作時表示形式,在類和接口被加載到JVM後,對應的運作時常量池就被建立出來。
- 當然并非Class檔案常量池中的内容才能進入運作時常量池,在運作期間也可将新的常量放入運作時常量池中,比如String的intern方法。
- 在JVM規範中,沒有強制要求方法區必須實作垃圾回收。很多人習慣将方法區稱為“永久代”,是因為HotSpot虛拟機以永久代來實作方法區,進而JVM的垃圾收集器可以像管理堆區一樣管理這部分區域,進而不需要專門為這部分設計垃圾回收機制。不過自從JDK7之後,Hotspot虛拟機便将運作時常量池從永久代移除了。
直接記憶體(Direct Memory):不是虛拟機運作時資料區的一部分,也不是Java虛拟機規範中定義的記憶體區域,它直接從作業系統中配置設定,是以不受Java堆大小的限制,但是會受到本機總記憶體的大小及處理器尋址空間的限制,是以它也可能導緻OutOfMemoryError異常出現。在JDK1.4中新引入了NIO機制,它是一種基于通道與緩沖區的新I/O方式,可以直接從作業系統中配置設定直接記憶體,即在堆外配置設定記憶體,這樣能在一些場景中提高性能,因為避免了在Java堆和Native堆中來回複制資料。
4、記憶體洩露、記憶體溢出
記憶體洩露:是指不再被使用的對象或者變量一直被占據在記憶體中。理論上來說,Java是有GC垃圾回收機制的,也就是說,不再被使用的對象,會被GC自動回收掉,自動從記憶體中清除。但是,即使這樣,Java也還是存在着記憶體洩漏的情況,java導緻記憶體洩露的原因很明确:長生命周期的對象持有短生命周期對象的引用就很可能發生記憶體洩露,盡管短生命周期對象已經不再需要,但是因為長生命周期對象持有它的引用而導緻不能被回收,這就是java中記憶體洩露的發生場景。
記憶體溢出:是指程式所需要的記憶體超出了系統所能配置設定的記憶體(包括動态擴充)的上限。
五、對象執行個體化解析
對記憶體配置設定情況分析最常見的示例便是對象執行個體化:Object obj = new Object();
- Object obj表示一個本地引用,存儲在JVM棧的本地變量表中,表示一個reference類型資料;
- new Object()作為執行個體對象資料存儲在堆中;
- 堆中還記錄了Object類的類型資訊(接口、方法、field、對象類型等)的位址,這些位址所執行的資料存儲在方法區中;
這段代碼會涉及Java棧、Java堆、方法區三個最重要的區域。虛拟機遇到一條new指令時,先檢查常量池是否已經加載相應的類,如果沒有,必須先執行相應的類加載。類加載通過後,接下來配置設定記憶體。若Java堆中記憶體是絕對規整的,使用“指針碰撞“方式配置設定記憶體;如果不是規整的,就從空閑清單中配置設定,叫做”空閑清單“方式。劃分記憶體時還需要考慮一個問題-并發,也有兩種方式: CAS同步處理,或者本地線程配置設定緩沖(Thread Local Allocation Buffer, TLAB)。然後記憶體空間初始化操作,接着是做一些必要的對象設定(元資訊、哈希碼…),最後執行<init>方法。
1、為對象配置設定記憶體
類加載完成後,接着會在Java堆中劃分一塊記憶體配置設定給對象。記憶體配置設定根據Java堆是否規整,有兩種方式:
- 指針碰撞:如果Java堆的記憶體是規整,即所有用過的記憶體放在一邊,而空閑的的放在另一邊。配置設定記憶體時将位于中間的指針訓示器向空閑的記憶體移動一段與對象大小相等的距離,這樣便完成配置設定記憶體工作。
- 空閑清單:如果Java堆的記憶體不是規整的,則需要由虛拟機維護一個清單來記錄那些記憶體是可用的,這樣在配置設定的時候可以從清單中查詢到足夠大的記憶體配置設定給對象,并在配置設定後更新清單記錄。
選擇哪種配置設定方式是由 Java 堆是否規整來決定的,而 Java 堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。
2、處理并發安全問題
對象的建立在虛拟機中是一個非常頻繁的行為,哪怕隻是修改一個指針所指向的位置,在并發情況下也是不安全的,可能出現正在給對象 A 配置設定記憶體,指針還沒來得及修改,對象 B 又同時使用了原來的指針來配置設定記憶體的情況。解決這個問題有兩種方案:
- 對配置設定記憶體空間的動作進行同步處理(采用 CAS + 失敗重試來保障更新操作的原子性);
- 把記憶體配置設定的動作按照線程劃分在不同的空間之中進行,即每個線程在 Java 堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖(Thread Local Allocation Buffer, TLAB)。哪個線程要配置設定記憶體,就在哪個線程的 TLAB 上配置設定。隻有 TLAB 用完并配置設定新的 TLAB 時,才需要同步鎖。通過-XX:+/-UserTLAB參數來設定虛拟機是否使用TLAB。
3、對象的通路定位
由于reference類型在Java虛拟機規範裡面隻規定了一個指向對象的引用,并沒有定義這個引用應該通過哪種方式去定位,以及通路到Java堆中的對象的具體位置,是以不同虛拟機實作的對象通路方式會有所不同,主流的通路方式有兩種:使用句柄池和直接使用指針。
通過句柄池通路:
通過直接指針通路的方式:
這兩種對象的通路方式各有優勢,使用句柄通路方式的最大好處就是reference中存放的是穩定的句柄位址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時隻會改變句柄中的執行個體資料指針,而reference本身不需要修改。使用直接指針通路方式的最大好處是速度快,它節省了一次指針定位的時間開銷。目前Java預設使用的Hot Spot虛拟機采用的便是是第二種方式進行對象通路的。
六、Java記憶體模型
Java虛拟機規範中定義了Java記憶體模型(Java Memory Model,JMM),JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主記憶體之間的抽象關系:線程之間的共享變量存儲在主記憶體(main memory)中,每個線程都有一個私有的本地記憶體(local memory),本地記憶體中存儲了該線程以讀/寫共享變量的副本。本地記憶體是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬體和編譯器優化。
1、JMM記憶體模型
Java記憶體模型中規定了所有的變量都存儲在主記憶體中,每條線程還有自己的工作記憶體(可以與前面将的處理器的高速緩存類比),線程的工作記憶體中儲存了該線程使用到的變量到主記憶體副本拷貝,線程對變量的所有操作(讀取、指派)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變量。不同線程之間無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞均需要在主記憶體來完成,線程、主記憶體和工作記憶體的互動關系如下圖所示:
2、變量的加載與寫入
操作 | 作用對象 | 解釋 |
---|---|---|
lock | 主記憶體 | 把一個變量辨別為一條線程獨占的狀态 |
unlock | 主記憶體 | 把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定 |
read | 主記憶體 | 把一個變量值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用 |
load | 工作記憶體 | 把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中 |
use | 工作記憶體 | 把工作記憶體中的一個變量值傳遞給執行引擎,每當虛拟機遇到一個需要使用變量的值的位元組碼指令時将會執行這個操作 |
assign | 工作記憶體 | 把一個從執行引擎接收到的值指派給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作 |
store | 工作記憶體 | 把工作記憶體中的一個變量的值傳送到主記憶體中,以便随後的write的操作 |
write | 主記憶體 | 把store操作從工作記憶體中一個變量的值傳送到主記憶體的變量中 |
3、變量的加載與寫入
執行程式時為了提高性能,編譯器和處理器經常會對指令進行重排序。重排序分成三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程式語義放入前提下,可以重新安排語句的執行順序。
- 指令級并行的重排序。現代處理器采用了指令級并行技術來将多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。由于處理器使用緩存和讀寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從Java源代碼到最終實際執行的指令序列,會經過下面三種重排序:
為了保證記憶體的可見性,Java編譯器在生成指令序列的适當位置會插入記憶體屏障指令來禁止特定類型的處理器重排序。Java記憶體模型把記憶體屏障分為LoadLoad、LoadStore、StoreLoad和StoreStore四種:
下面我們通過具體的示例代碼來說明:
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一個 volatile 讀
int j = v2; // 第二個 volatile 讀
a = i + j; // 普通寫
v1 = i + 1; // 第一個 volatile 寫
v2 = j * 2; // 第二個 volatile 寫
}
… // 其他方法
}
4、CPU和緩存一緻性
當程式在運作過程中,會将運算需要的資料從主存複制一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取資料和向其中寫入資料,當運算結束之後,再将高速緩存中的資料重新整理到主存當中。當CPU要讀取一個資料時,首先從一級緩存中查找,如果沒有找到再從二級緩存中查找,如果還是沒有就從三級緩存或記憶體中查找。在CPU和主存之間增加緩存,在多線程場景下就可能存在緩存一緻性問題,也就是說,在多核CPU中,每個核的自己的緩存中,關于同一個資料的緩存内容可能不一緻。
除了這種情況,還有一種硬體問題也比較重要。那就是為了使處理器内部的運算單元能夠盡量的被充分利用,處理器可能會對輸入代碼進行亂序執行處理。這就是處理器優化。除了現在很多流行的處理器會對代碼進行優化亂序處理,很多程式設計語言的編譯器也會有類似的優化,比如Java虛拟機的即時編譯器(JIT)也會做指令重排。可想而知,如果任由處理器優化和編譯器對指令重排的話,就可能導緻各種各樣的問題。
記憶體模型到底是怎麼保證緩存一緻性的呢?
- 通過在總線加
鎖的方式:在早期的CPU當中,是通過在總線上加LOCK#
鎖的形式來解決緩存不一緻的問題。因為CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#
鎖的話,也就是說阻塞了其他CPU對其他部件通路(如記憶體),進而使得隻能有一個CPU能使用這個變量的記憶體。在總線上發出了LOCK#
鎖的信号,那麼隻有等待這段代碼完全執行完畢之後,其他CPU才能從其記憶體讀取變量,然後進行相應的操作。這樣就解決了緩存不一緻的問題。但是由于在鎖住總線期間,其他CPU無法通路記憶體,會導緻效率低下。是以出現了第二種解決方案,通過緩存一緻性協定來解決緩存一緻性問題。LCOK#
- 通過緩存一緻性協定(Cache Coherence Protocol):最出名的就是Intel 的MESI協定,MESI協定保證了每個緩存中使用的共享變量的副本是一緻的。MESI的核心的思想是:當CPU寫資料時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信号通知其他CPU将該變量的緩存行置為無效狀态,是以當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從記憶體重新讀取。MESI協定,可以保證緩存的一緻性,但是無法保證明時性。
深入Java虛拟機之一:Java記憶體區域與記憶體溢出
虛拟機工作原理_best.lei
Java系列筆記(1) - Java 類加載與初始化
Java虛拟機(JVM)面試題(2020最新版)