作者:林振華
來源:編程原理

1.問題

  • 1、如何理解類文件結構佈局?
  • 2、如何應用類加載器的工作原理進行將應用輾轉騰挪?
  • 3、熱部署與熱替換有何區別,如何隔離類衝突?
  • 4、JVM如何管理內存,有何內存淘汰機制?
  • 5、JVM執行引擎的工作機制是什麼?
  • 6、JVM調優應該遵循什麼原則,使用什麼工具?
  • 7、JPDA架構是什麼,如何應用代碼熱替換?
  • 8、JVM字節碼增強技術有哪些?

2.關鍵詞

類結構,類加載器,加載,鏈接,初始化,雙親委派,熱部署,隔離,堆,棧,方法區,計數器,內存回收,執行引擎,調優工具,JVMTI,JDWP,JDI,熱替換,字節碼,ASM,CGLIB,DCEVM

3.全文概要

作爲三大工業級別語言之一的JAVA如此受企業青睞有加,離不開她背後JVM的默默復出。只是由於JAVA過於成功以至於我們常常忘了JVM平臺上還運行着像Clojure/Groovy/Kotlin/Scala/JRuby/Jython這樣的語言。我們享受着JVM帶來跨平臺“一次編譯到處執行”臺的便利和自動內存回收的安逸。本文從JVM的最小元素類的結構出發,介紹類加載器的工作原理和應用場景,思考類加載器存在的意義。進而描述JVM邏輯內存的分佈和管理方式,同時列舉常用的JVM調優工具和使用方法,最後介紹高級特性JDPA框架和字節碼增強技術,實現熱替換。從微觀到宏觀,從靜態到動態,從基礎到高階介紹JVM的知識體系。

4.類的裝載

4.1類的結構

我們知道不只JAVA文本文件,像Clojure/Groovy/Kotlin/Scala這些文本文件也同樣會經過JDK的編譯器編程成class文件。進入到JVM領域後,其實就跟JAVA沒什麼關係了,JVM只認得class文件,那麼我們需要先了解class這個黑箱裏麪包含的是什麼東西。

JVM規範嚴格定義了CLASS文件的格式,有嚴格的數據結構,下面我們可以觀察一個簡單CLASS文件包含的字段和數據類型。

JVM核心知識體系知識清單(上)

詳細的描述我們可以從JVM規範說明書裏面查閱類文件格式(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html),類的整體佈局如下圖展示的。


JVM核心知識體系知識清單(上)


在我的理解,我想把每個CLASS文件類別成一個一個的數據庫,裏麪包含的常量池/類索引/屬性表集合就像數據庫的表,而且表之間也有關聯,常量池則存放着其他表所需要的所有字面量。瞭解完類的數據結構後,我們需要來觀察JVM是如何使用這些從硬盤上或者網絡傳輸過來的CLASS文件。

4.2加載機制

4.2.1類的入口

在我們探究JVM如何使用CLASS文件之前,我們快速回憶一下編寫好的C語言文件是如何執行的?我們從C的HelloWorld入手看看先。

#include 
int main() {
/* my first program in C */
printf("Hello, World! \n");
return 0;
}

編輯完保存爲hello.c文本文件,然後安裝gcc編譯器(GNU C/C++)

$ gcc hello.c
$ ./a.out
Hello, World!

這個過程就是gcc編譯器將hello.c文本文件編譯成機器指令集,然後讀取到內存直接在計算機的CPU運行。從操作系統層面看的話,就是一個進程的啓動到結束的生命週期。

下面我們看JAVA是怎麼運行的。學習JAVA開發的第一件事就是先下載JDK安裝包,安裝完配置好環境變量,然後寫一個名字爲helloWorld的類,然後編譯執行,我們來觀察一下發生了什麼事情?

先看源碼,有夠簡單了吧。

package com.zooncool.example.theory.jvm;
public class HelloWorld {
public static void main(String[] args) {
System.out.println("my classLoader is " + HelloWorld.class.getClassLoader());
}
}

編譯執行

$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java 
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
my classLoader is sun.misc.Launcher$AppClassLoader@2a139a55

對比C語言在命令行直接運行編譯後的a.out二進制文件,JAVA的則是在命令行執行java classFile,從命令的區別我們知道操作系統啓動的是java進程,而HelloWorld類只是命令行的入參,在操作系統來看java也就是一個普通的應用進程而已,而這個進程就是JVM的執行形態(JVM靜態就是硬盤裏JDK包下的二進制文件集合)。

學習過JAVA的都知道入口方法是public static void main(String[] args),缺一不可,那我猜執行java命令時JVM對該入口方法做了唯一驗證,通過了才允許啓動JVM進程,下面我們來看這個入口方法有啥特點。

去掉public限定

$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java 
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
錯誤: 在類 com.zooncool.example.theory.jvm.HelloWorld 中找不到 main 方法, 請將 main 方法定義爲:
public static void main(String[] args)
否則 JavaFX 應用程序類必須擴展javafx.application.Application

說名入口方法需要被public修飾,當然JVM調用main方法是底層的JNI方法調用不受修飾符影響。

去掉static限定

$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java 
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
錯誤: main 方法不是類 com.zooncool.example.theory.jvm.HelloWorld 中的static, 請將 main 方法定義爲:
public static void main(String[] args)

我們是從類對象調用而不是類創建的對象才調用,索引需要靜態修飾

返回類型改爲int

$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java 
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
錯誤: main 方法必須返回類 com.zooncool.example.theory.jvm.HelloWorld 中的空類型值, 請
將 main 方法定義爲:
public static void main(String[] args)

void返回類型讓JVM調用後無需關心調用者的使用情況,執行完就停止,簡化JVM的設計。

方法簽名改爲main1

$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java 
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
錯誤: 在類 com.zooncool.example.theory.jvm.HelloWorld 中找不到 main 方法, 請將 main 方法定義爲:
public static void main(String[] args)
否則 JavaFX 應用程序類必須擴展javafx.application.Application

這個我也不清楚,可能是約定俗成吧,畢竟C/C++也是用main方法的。

說了這麼多main方法的規則,其實我們關心的只有兩點:

  • HelloWorld類是如何被JVM使用的
  • HelloWorld類裏面的main方法是如何被執行的


關於JVM如何使用HelloWorld下文我們會詳細講到。

我們知道JVM是由C/C++語言實現的,那麼JVM跟CLASS打交道則需要JNI(Java Native Interface)這座橋樑,當我們在命令行執行java時,由C/C++實現的java應用通過JNI找到了HelloWorld裏面符合規範的main方法,然後開始調用。我們來看下java命令的源碼就知道了

JVM核心知識體系知識清單(上)

4.2.2類加載器

上一節我們留了一個核心的環節,就是JVM在執行類的入口之前,首先得找到類再然後再把類裝到JVM實例裏面,也即是JVM進程維護的內存區域內。我們當然知道是一個叫做類加載器的工具把類加載到JVM實例裏面,拋開細節從操作系統層面觀察,那麼就是JVM實例在運行過程中通過IO從硬盤或者網絡讀取CLASS二進制文件,然後在JVM管轄的內存區域存放對應的文件。我們目前還不知道類加載器的實現,但是我們從功能上判斷無非就是讀取文件到內存,這個是很普通也很簡單的操作。

如果類加載器是C/C++實現的話,那麼大概就是如下代碼就可以實現

char *fgets( char *buf, int n, FILE *fp );

如果是JAVA實現,那麼也很簡單

InputStream f = new FileInputStream("theory/jvm/HelloWorld.class");

從操作系統層面看的話,如果只是加載,以上代碼就足以把類文件加載到JVM內存裏面了。但是結果就是亂糟糟的把一堆毫無秩序的類文件往內存裏面扔,沒有良好的管理也沒法用,所以需要我們需要設計一套規則來管理存放內存裏面的CLASS文件,我們稱爲類加載的設計模式或者類加載機制,這個下文會重點解釋。

根據官網的定義A class loader is an object that is responsible for loading classes. 類加載器就是負責加載類的。我們知道啓動JVM的時候會把JRE默認的一些類加載到內存,這部分類使用的加載器是JVM默認內置的由C/C++實現的,比如我們上文加載的HelloWorld.class。但是內置的類加載器有明確的範圍限定,也就是隻能加載指定路徑下的jar包(類文件的集合)。如果只是加載JRE的類,那可玩的花樣就少很多,JRE只是提供了底層所需的類,更多的業務需要我們從外部加載類來支持,所以我們需要指定新的規則,以方便我們加載外部路徑的類文件。

系統默認加載器

Bootstrap class loader

作用:啓動類加載器,加載JDK核心類

類加載器:C/C++實現

類加載路徑: /jre/lib

URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/resources.jar
...
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/rt.jar

實現原理:本地方法由C++實現

Extensions class loader

作用:擴展類加載器,加載JAVA擴展類庫。

類加載器:JAVA實現

類加載路徑:/jre/lib/ext

System.out.println(System.getProperty("java.ext.dirs"));
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/ext:

實現原理:擴展類加載器ExtClassLoader本質上也是URLClassLoader

Launcher.java

JVM核心知識體系知識清單(上)

JVM核心知識體系知識清單(上)

System class loader

作用:系統類加載器,加載應用指定環境變量路徑下的類

類加載器:sun.misc.Launcher$AppClassLoader

類加載路徑:-classpath下面的所有類

實現原理:系統類加載器AppClassLoader本質上也是URLClassLoader

Launcher.java

JVM核心知識體系知識清單(上)

通過上文運行HelloWorld我們知道JVM系統默認加載的類大改是1560個,如下圖

JVM核心知識體系知識清單(上)

自定義類加載器

內置類加載器只加載了最少需要的核心JAVA基礎類和環境變量下的類,但是我們應用往往需要依賴第三方中間件來完成額外的業務,那麼如何把它們的類加載進來就顯得格外重要了。幸好JVM提供了自定義類加載器,可以很方便的完成自定義操作,最終目的也是把外部的類文件加載到JVM內存。通過繼承ClassLoader類並且複寫findClass和loadClass方法就可以達到自定義獲取CLASS文件的目的。

首先我們看ClassLoader的核心方法loadClass

JVM核心知識體系知識清單(上)

通過複寫loadClass方法,我們甚至可以讀取一份加了密的文件,然後在內存裏面解密,這樣別人反編譯你的源碼也沒用,因爲class是經過加密的,也就是理論上我們通過自定義類加載器可以做到爲所欲爲,但是有個重要的原則下文介紹類加載器設計模式會提到。

一下給出一個自定義類加載器極簡的案例,來說明自定義類加載器的實現。

JVM核心知識體系知識清單(上)

JVM核心知識體系知識清單(上)

執行結果如下,我們可以看到加載到內存方法區的兩個類的包名+名稱是一樣的,而對應的類加載器卻不一樣,而且輸出被加載類的值也是不一樣的。

JVM核心知識體系知識清單(上)

4.2.3設計模式

現有的加載器分爲內置類加載器和自定義加載器,不管它們是通過C或者JAVA實現的最終都是爲了把外部的CLASS文件加載到JVM內存裏面。那麼我們就需要設計一套規則來管理組織內存裏面的CLASS文件,下面我們就來介紹下通過這套規則如何來協調好內置類加載器和自定義類加載器之間的權責。

我們知道通過自定義類加載器可以幹出很多黑科技,但是有個基本的雷區就是,不能隨便替代JAVA的核心基礎類,或者說即是你寫了一個跟核心類一模一樣的類,JVM也不會使用。你想一下,如果爲所欲爲的你可以把最基礎本的java.lang.Object都換成你自己定義的同名類,然後搞個後門進去,而且JVM還使用的話,那誰還敢用JAVA了是吧,所以我們會介紹一個重要的原則,在此之前我們先介紹一下內置類加載器和自定義類加載器是如何協同的。

雙親委派機制

定義:某個特定的類加載器在接到加載類的請求時,首先將加載任務委託給父類加載器,依次遞歸,如果父類加載器可以完成類加載任務,就成功返回;只有父類加載器無法完成此加載任務時,才自己去加載。

實現:參考上文loadClass方法的源碼和註釋,通過最多三次遞歸可以到啓動類加載器,如果還是找不到這調用自定義方法。

JVM核心知識體系知識清單(上)

雙親委派機制很好理解,目的就是爲了不重複加載已有的類,提高效率,還有就是強制從父類加載器開始逐級搜索類文件,確保核心基礎類優先加載。下面介紹的是破壞雙親委派機制,瞭解爲什麼要破壞這種看似穩固的雙親委派機制。

破壞委派機制

定義:打破類加載自上而上委託的約束。

實現:1、繼承ClassLoader並且重寫loadClass方法體,覆蓋依賴上層類加載器的邏輯;

2、”啓動類加載器”可以指定“線程上下文類加載器”爲任意類加載器,即是“父類加載器”委託“子類加載器”去加載不屬於它加載範圍的類文件;

說明:雙親委派機制的好處上面我們已經提過了,但是由於一些歷史原因(JDK1.2加上雙親委派機制前的JDK1.1就已經存在,爲了向前兼容不得不開這個後門讓1.2版本的類加載器擁有1.1隨意加載的功能)。還有就是JNDI的服務調用機制,例如調用JDBC需要從外部加載相關類到JVM實例的內存空間。

介紹完內置類加載器和自定義類加載器的協同關係後,我們要重點強調上文提到的重要原則。

唯一標識

定義:JVM實例由類加載器+類的全限定包名和類名組成類的唯一標誌。

實現:加載類的時候,JVM 判斷類是否來自相同的加載器,如果相同而且全限定名則直接返回內存已有的類。

說明:上文我們提到如何防止相同類的後門問題,有了這個黃金法則,即使相同的類路徑和類,但是由於是由自定義類加載器加載的,即使編譯通過能被加載到內存,也無法使用,因爲JVM核心類是由內置類加載器加載標誌和使用的,從而保證了JVM的安全加載。通過緩存類加載器和全限定包名和類名作爲類唯一索引,加載重複類則拋異常提示”attempted duplicate class definition for name”。

原理:雙親委派機制父類檢查緩存,源碼我們介紹loadClass方法的時候已經講過,破壞雙親委派的自定義類加載器在加載類二進制字節碼後需要調用defineClass方法,而該方法同樣會從JVM方法區檢索緩存類,存在的話則提示重複定義。

4.2.4加載過程

至此我們已經深刻認識到類加載器的工作原理及其存在的意義,下面我們將介紹類從外部介質加載使用到卸載整個閉環的生命週期。

加載

上文花了不少的篇幅說明瞭類的結構和類是如何被加載到JVM內存裏面的,那究竟什麼時候JVM纔會觸發類加載器去加載外部的CLASS文件呢?通常有如下四種情況會觸發到:

  • 顯式字節碼指令集(new/getstatic/putstatic/invokestatic):對應的場景就是創建對象或者調用到類文件的靜態變量/靜態方法/靜態代碼塊
  • 反射:通過對象反射獲取類對象時
  • 繼承:創建子類觸發父類加載
  • 入口:包含main方法的類首先被加載


JVM只定了類加載器的規範,但卻不明確規定類加載器的目標文件,把加載的具體邏輯充分交給了用戶,包括重硬盤加載的CLASS類到網絡,中間文件等,只要加載進去內存的二進制數據流符合JVM規定的格式,都是合法的。

鏈接

類加載器加載完類到JVM實例的指定內存區域(方法區下文會提到)後,是使用前會經過驗證,準備解析的階段。

  • 驗證:主要包含對類文件對應內存二進制數據的格式、語義關聯、語法邏輯和符合引用的驗證,如果驗證不通過則跑出VerifyError的錯誤。但是該階段並非強制執行,可以通過-Xverify:none來關閉,提高性能。
  • 準備:但我們驗證通過時,內存的方法區存放的是被“緊密壓縮”的數據段,這個時候會對static的變量進行內存分配,也就是擴展內存段的空間,爲該變量匹配對應類型的內存空間,但還未初始化數據,也就是0或者null的值。
  • 解析:我們知道類的數據結構類似一個數據庫,裏面多張不同類型的“表”緊湊的挨在一起,最大的節省類佔用的空間。多數表都會應用到常量池表裏面的字面量,這個時候就是把引用的字面量轉化爲直接的變量空間。比如某一個複雜類變量字面量在類文件裏只佔2個字節,但是通過常量池引用的轉換爲實際的變量類型,需要佔用32個字節。所以經過解析階段後,類在方法區佔用的空間就會膨脹,長得更像一個”類“了。

初始化

方法區經過解析後類已經爲各個變量佔好坑了,初始化就是把變量的初始值和構造方法的內容初始化到變量的空間裏面。這時候我們介質的類二進制文件所定義的內容,已經完全被“翻譯”方法區的某一段內存空間了。萬事俱備只待使用了。

使用

使用呼應了我們加載類的觸發條件,也即是觸發類加載的條件也是類應用的條件,該操作會在初始化完成後進行。

卸載

我們知道JVM有垃圾回收機制(下文會詳細介紹),不需要我們操心,總體上有三個條件會觸發垃圾回收期清理方法區的空間:

  • 類對應實例被回收
  • 類對應加載器被回收
  • 類無反射引用


本節結束我們已經對整個類的生命週期爛熟於胸了,下面我們來介紹類加載機制最核心的幾種應用場景,來加深對類加載技術的認識。

34張架構史上最全技術知識圖譜

相關文章