深入理解JVM

Yanci丶發表於2021-06-01

本文是基於jdk8進行分析的

概述

  JVM是Java Virtual Machine(Java虛擬機器)的縮寫,JVM是一種用於計算裝置的規範,它是一個虛構出來的計算機,是通過在實際的計算機上模擬模擬各種計算機功能來實現的。
  Java虛擬機器本質上就是一個程式,當它在命令列上啟動的時候,就開始執行儲存在某位元組碼檔案中的指令。Java語言的可移植性正是建立在Java虛擬機器的基礎上。任何平臺只要裝有針對於該平臺的Java虛擬機器(JVM從軟體層面幫我們遮蔽不同作業系統在底層硬體與指令上的區別),位元組碼檔案(.class)就可以在該平臺上執行。這就是“一次編譯,多次執行”。

JVM體系結構

  Java虛擬機器包含類裝載器子系統、執行引擎、執行時資料區、本地方法介面和垃圾收集模組。其中垃圾收集模組在Java虛擬機器規範中並沒有要求Java虛擬機器垃圾收集,但是在沒有發明無限的記憶體之前,大多數JVM實現都是有垃圾收集的。

  • 類裝載器子系統:根據給定的全限定類名(如:java.lang.Object)來裝載class檔案到執行時資料區域的方法區中。
  • 執行引擎:執行位元組碼或執行本地方法。
  • 執行時資料區:我們常說的JVM的記憶體,堆,方法區,虛擬機器棧,本地方法棧,程式計數器。
  • 本地方法介面:與本地方法庫互動,作用就是為了融合不同程式語言為Java所用,它的初衷是融合C/C++程式。

  首先通過編譯器把Java程式碼轉換成位元組碼,類載入器再把位元組碼載入到記憶體中(執行時資料區的方法區內),而位元組碼檔案只是JVM的一套指令集規範,不能直接交給底層系統去執行,所以需要特定的命令解析器執行引擎將位元組碼翻譯成底層系統指令,再交給CPI去執行,而這個過程需要呼叫其他語言的本地庫介面來實現整個程式的功能。

類載入機制

  Java類載入機制就是虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,解析和初始化,最終形成可以被虛擬機器直接使用的java型別。

  類載入器

  類載入器分為啟動類載入器,擴充套件類載入器,應用程式類載入器,自定義類載入器。各種類載入器之間存在著邏輯上的父子關係,但不是真正意義上的父子關係,因為它們直接沒有從屬關係。除了啟動類載入器(Bootstrap ClassLoader)是由C++編寫的,其他都是由Java編寫的。由Java編寫的類載入器都繼承自類java.lang.ClassLoader。

  1. 啟動類載入器(BootstrapClassLoader):負責載入$JAVA_HOME/jre/lib目錄下的核心類庫,比如rt.jar,charsets.jar。
  2. 擴充套件類載入器(ExtClassLoader):負責載入支撐J$JAVA_HOME/jre/lib/ext目錄下的JAR類包。父載入器是啟動類載入器。
  3. 應用類載入器(AppClassLoader):負責載入ClassPath路徑下的類包,主要就是載入我們自己寫的那些類。父載入器是擴充套件類載入器。
  4. 自定義類載入器(CustomClassLoader):負責載入使用者自定義目錄下的類包。父載入器是應用類載入器。

  類載入過程

  java類載入分為5個過程,載入-->驗證-->準備-->解析-->初始化。這5個階段一般是順序發生的,但在動態繫結的情況下,解析階段發生在初始化階段之後。

  • 載入:將位元組碼從不同的資料來源(可能是 class 檔案,也可能是 jar 包,甚至網路)轉化為二進位制位元組流載入到記憶體中;將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;並在堆中生成一個代表該類的 java.lang.Class 物件,作為對方法區這個類的各種資料的訪問入口。
  • 驗證:驗證的目的是為了確保載入進來的class檔案符合JVM的規範,一般是進行檔案格式的驗證、後設資料的驗證、位元組碼驗證和符號引用驗證。
  1. 檔案格式的驗證:驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理,該驗證的主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,後面的三個驗證都是基於方法區的儲存結構進行的。
  2. 後設資料的驗證:對類的後設資料資訊進行語義校驗(其實就是對類中的各資料型別進行語法校驗),保證不存在不符合Java語法規範的後設資料資訊。
  3. 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在執行時不會做出危害虛擬機器安全的行為。
  4. 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段中發生該轉化),主要是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。
  • 準備:給類的靜態變數分配空間,並賦予預設值。
  • 解析:將符號引用替換為直接引用,該階段會把一些靜態方法(符號引用,比如main()方法)替換為指向資料所存記憶體的指標或控制程式碼等(直接引用),這是所謂的靜態連結過程(類載入期間完成),動態連結是在程式執行期間完成的將符號引用替換為直接引用。
  • 初始化:對類的靜態變數初始化為指定的值,執行靜態程式碼塊。

  雙親委派機制

  雙親委派機制就是當某個類載入器收到載入類的請求,如果這個類沒有被載入過,該類載入器不會直接載入,會先為委派給父載入器,如果父載入器沒有載入過,依次往上傳遞,直到頂層啟動類載入器。如果父載入器可以完成載入任務,則父載入器載入返回;如果父載入器不能完成載入任務,才會自己去進行載入。一句話概述雙親委派機制載入流程就是,從下往上檢查類是否已經被載入,從上往下嘗試去載入。

  雙親委派機制的優點:

  1. 沙箱安全機制:避免核心API被篡改。自己寫的java.lang.String.class類不會被載入。
  2. 避免重複載入:如果父載入器已經載入過該類,子類載入器就沒有必要再去載入。

  雙親委派機制載入類的核心程式碼 ClassLoader類的loadClass()方法:

 1     protected Class<?> loadClass(String name, boolean resolve)
 2         throws ClassNotFoundException
 3     {
 4         synchronized (getClassLoadingLock(name)) {
 5             // 首先會檢查該類是否已經被本類載入器載入,如果已經被載入則直接返回
 6             Class<?> c = findLoadedClass(name);
 7             if (c == null) {
 8                 // 如果沒有被載入,則委託父載入器去載入
 9                 long t0 = System.nanoTime();
10                 try {
11                     if (parent != null) {
12                         // 讓父載入器物件去呼叫loadClass方法
13                         c = parent.loadClass(name, false);
14                     } else {
15                         // parent==null,說明父載入器是啟動類載入器。啟動類載入器是C++編寫的,這裡去呼叫本地方法區嘗試載入該類。
16                         c = findBootstrapClassOrNull(name);
17                     }
18                 } catch (ClassNotFoundException e) {
19                 }
20                 if (c == null) {
21                     // If still not found, then invoke findClass in order
22                     // to find the class.
23                     long t1 = System.nanoTime();
24                     // 如果父載入器沒有載入到該類,則自己去載入。這裡會呼叫URLClassLoader類的findClass()方法
25                     c = findClass(name);
26                     sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
27                     sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
28                     sun.misc.PerfCounter.getFindClasses().increment();
29                 }
30             }
31             if (resolve) {
32                 resolveClass(c);
33             }
34             return c;
35         }
36     }

  全盤負責委託機制

  全盤負責委託機制就是當一個Classloader載入一個Class的時候,這個Class所依賴的和引用的其它Class通常也由這個Classloader負責載入。

  打破雙親委派機制

  打破雙親委派機制就是我們希望自定義類載入器去直接載入指定類,而不是先委託父載入器去載入或者是自定義類載入器載入不到才讓父載入器去進行載入。

  自定義類載入器實現

  瞭解雙親委派機制以及打破雙親委派機制之後,我們可以自己寫一個自定義類載入器。自定義類載入器實現思路:

    如果使用雙親委派機制就是重寫findClass()方法(類載入器具體去載入類的方法),程式碼傳送門

    如果要打破雙親委派機制,在重寫findClass()方法基礎上,還需要重新loadClass()方法,這裡我們可以改寫邏輯,先讓該類載入器去載入類,載入不到再讓父載入器去進行載入,程式碼傳送門

JVM執行時資料區

  • 程式計數器

  程式計數器執行緒私有的,它的生命週期與執行緒相同,它是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果執行緒當前正在執行的方法是本地方法,這個計數器值則應為空。位元組碼直譯器的工作就是通過這個計數器的值,來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能,都需要依賴這個計數器來完成。這個區域是唯一不會丟擲OutOfMemoryError異常的區域。

  • 虛擬機器棧

  虛擬機器棧是執行緒私有的,它的生命週期與執行緒相同。每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

  區域性變數表存放了編譯期可知的各種Java虛擬機器基本資料型別(boolean、byte、char、short、int、float、long、double),物件引用和returnAddress型別。

  以下異常條件與Java虛擬機器棧相關:

    如果執行緒中請求的棧深度大於虛擬機器所允許的深度,Java虛擬機器將會丟擲StackOverflowError異常。

    如果Java虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體,Java虛擬機器將會丟擲OutOfMemoryError異常。

  • 本地方法棧

  本地方法棧是執行緒私有的,生命週期與當前執行緒一致。與虛擬機器棧的作用是一樣的,區別只是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧是為虛擬機器使用到的本地方法服務。

  以下異常條件與本地方法棧相關聯(與虛擬機器棧一樣):

    如果執行緒中請求的棧深度大於虛擬機器所允許的深度,Java虛擬機器將會丟擲StackOverflowError異常。

    如果Java虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體,Java虛擬機器將會丟擲OutOfMemoryError異常。

  堆是執行緒共享的,在虛擬機器啟動的時候建立,從中分配類例項(幾乎所有的物件都存放在堆中,但是不是所有的)和陣列的記憶體。對於大多數應用來說,堆是記憶體最大的一塊區域;同時堆是記憶體模型中最重要的一個區域,也是JVM調優重點關注的區域。物件的堆儲存由自動儲存管理系統(稱為垃圾收集器)回收;物件永遠不會顯式釋放。

  以下異常情況與堆相關聯:

    如果在Java堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,Java虛擬機器將會丟擲OutOfMemoryError異常。

  堆記憶體分為年輕代(Young Generation)和老年代(Old Generation)。

  1. 年輕代(YoungGen):年輕代又分為Eden和Survivor區。Survivor區由FromSpace和ToSpace組成。Eden區佔大容量,Survivor兩個區佔小容量,預設比例是8:1:1。
  2. 老年代(OldGen):
  • 方法區(元空間)

  方法區是執行緒共享的,在虛擬機器啟動的時候建立,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

  以下異常條件與方法區域相關聯:

    如果方法區無法滿足新的記憶體分配需求時,Java虛擬機器將會丟擲OutOfMemoryError異常。

  • 執行時常量池

  執行時常量池是方法區的一部分。它包含多種常量,範圍從編譯時已知的數字文字到必須在執行時解析的方法和欄位引用。執行時常量池的功能類似於常規程式語言的符號表,儘管它包含的資料範圍比典型的符號表還大。每個執行時常量池都是從Java虛擬機器的方法區分配的。當Java虛擬機器建立類或介面時,將為該類或介面構造執行時常量池。

  以下異常條件與類或介面的執行時常量池的構造相關聯:

    如果執行時常量池無法再申請到記憶體時,則Java虛擬機器將丟擲OutOfMemoryError異常

  • 直接記憶體

  直接記憶體並不是虛擬機器執行時資料區的一部分,也不是《Java虛擬機器規範》中定義的記憶體區域。但是這部分記憶體有時候會使用,而且也可能導致OutOfMemoryError異常出現,所以這裡簡單提一下。直接記憶體的分配不會受到Java 堆大小的限制,既然是記憶體,肯定還是會受到本機總記憶體的大小及處理器定址空間的限制。伺服器管理員配置虛擬機器引數時,一般會根據實際記憶體設定-Xmx等引數資訊,但經常會忽略掉直接記憶體,使得各個記憶體區域的總和大於實體記憶體限制從而導致動態擴充套件時出現OutOfMemoryError異常。

垃圾回收機制

  在java中,程式設計師是不需要顯示的去釋放一個物件的記憶體的,而是由虛擬機器自行執行。在JVM中,有一個垃圾回收執行緒,它是低優先順序的,在正常情況下是不會執行的,只有在虛擬機器空閒或者當前堆記憶體不足時,才會觸發執行,掃描那些沒有被任何引用的物件,並將它們新增到要回收的集合中,進行回收。

  GC物件判定方法

  1. 引用計數法:為每個物件建立一個引用計數器,有物件引用時計數器+1,引用被釋放時計數器-1,當計數器為0時就可以被回收。它有一個缺點就是不能解決迴圈引用的問題。

  2. 可達性演算法(引用鏈法):從GC Roots 開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到GC Roots 沒有任何引用鏈相連時,則證明此物件是可以被回收的。

  垃圾收集演算法

  • 分代收集理論

  分代收集演算法,顧名思義就是根據物件的存活週期將記憶體劃分為幾塊。一般包括年輕代和老年代。

  • 標記—清除演算法

  標記無用物件,然後進行清除回收。缺點:效率不高,無法清除垃圾碎片。

 

  • 標記—複製演算法

  按照容量劃分為2個大小相等的記憶體區域,當一塊用完的時候將活著的物件複製到另一塊上,然後再把已使用區域的記憶體空間一次清理掉。缺點:記憶體使用率不高,只有原來的一半。

 

  • 標記—整理演算法

  標記無用物件,讓所有存活物件都向一端移動,然後直接清除掉端邊界以外的記憶體。

  

  垃圾收集器

  • Serial 收集器(標記—複製演算法):新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效。

  • ParNew 收集器(標記—複製演算法):新生代並行收集器,實際上是Serial收集器的多執行緒版本,在多核CPI環境下有著比Serial更好的表現。

  • Parallel Scavenge 收集器(標記—複製演算法):新生代並行收集器,追求高吞吐量,高效利用CPU。吞吐量=使用者執行緒時間/(使用者執行緒時間+GC執行緒時間),高吞吐量可以高效的利用CPU時間,儘快完成程式的運算任務,適合後臺應用等對互動相應要求不高的場景。

  • Serial Old 收集器(標記—整理演算法):老年代單執行緒收集器,Serial收集器的老年代版本。

  • Parallel Old 收集器(標記—整理演算法):老年代並行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本。

  • CMS 收集器(標記—清除演算法):老年代並行收集器,以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。

  • Garbage First 收集器(標記—整理演算法):Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記—整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或者老年代。

  JVM調優引數

  • -Xms4g:初始化堆大小為4g
  • -Xmx4g:堆最大記憶體為4g
  • -XX:NewRatio=4:設定年輕代和老年代的記憶體比例為1:4
  • -XX:SurvivorRatio=8:設定新生代Eden和Survivor比例為8:2(8:1:1)
  • -XX:+UseParNewGC:指定使用ParNew + Serial Old 垃圾回收器組合
  • -XX:+UseParallelOldGC:指定使用ParNew + ParNew Old 垃圾回收器組合
  • -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器組合
  • -XX:+PrintGC:開啟列印gc資訊
  • -XX:+PrintGCDetails:列印gc詳細資訊

相關文章