JVM核心知识体系
1.問題
-
1、如何理解類文件結構布局?
-
2、如何應用類加載器的工作原理進行將應用輾轉騰挪?
-
3、熱部署與熱替換有何區別,如何隔離類沖突?
-
4、JVM如何管理內存,有何內存淘汰機制?
-
5、JVM執行引擎的工作機制是什么?
-
6、JVM調優應該遵循什么原則,使用什么工具?
-
7、JPDA架構是什么,如何應用代碼熱替換?
-
8、JVM字節碼增強技術有哪些?
2.關鍵詞
類結構,類加載器,加載,鏈接,初始化,雙親委派,熱部署,隔離,堆,棧,方法區,計數器,內存回收,執行引擎,調優工具,JVMTI,JDWP,JDI,熱替換,字節碼,ASM,CGLIB,DCEVM
3.全文概要(文末有驚喜,PC端閱讀代碼更佳)
作為三大工業級別語言之一的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文件包含的字段和數據類型。
ClassFile {u4 magic;u2 minor_version;u2 major_version;u2 constant_pool_count;cp_info constant_pool[constant_pool_count-1];u2 access_flags;u2 this_class;u2 super_class;u2 interfaces_count;u2 interfaces[interfaces_count];u2 fields_count;field_info fields[fields_count];u2 methods_count;method_info methods[methods_count];u2 attributes_count;attribute_info attributes[attributes_count]; }詳細的描述我們可以從JVM規范說明書里面查閱類文件格式(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html),類的整體布局如下圖展示的。
在我的理解,我想把每個CLASS文件類別成一個一個的數據庫,里面包含的常量池/類索引/屬性表集合就像數據庫的表,而且表之間也有關聯,常量池則存放著其他表所需要的所有字面量。了解完類的數據結構后,我們需要來觀察JVM是如何使用這些從硬盤上或者網絡傳輸過來的CLASS文件。
4.2加載機制
4.2.1類的入口
在我們探究JVM如何使用CLASS文件之前,我們快速回憶一下編寫好的C語言文件是如何執行的?我們從C的HelloWorld入手看看先。
#include <stdio.h>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命令的源碼就知道了
/* * Get the application's main class. */ if (jarfile != 0) { mainClassName = GetMainClassName(env, jarfile); ... ... mainClass = LoadClass(env, classname); if(mainClass == NULL) { /* exception occured */ ... ... /* Get the application's main method */ mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V"); ... ... {/* Make sure the main method is public */ jint mods; jmethodID mid; jobject obj = (*env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE); ... ... /* Build argument array */ mainArgs = NewPlatformStringArray(env, argv, argc); if (mainArgs == NULL) { ReportExceptionDescription(env); goto leave; } /* Invoke main method. */ (*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);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
//構造方法返回擴展類加載器 public Launcher() {//定義擴展類加載器Launcher.ExtClassLoader var1;try {//1、獲取擴展類加載器var1 = Launcher.ExtClassLoader.getExtClassLoader();} catch (IOException var10) {throw new InternalError("Could not create extension class loader", var10);}... }//擴展類加載器 static class ExtClassLoader extends URLClassLoader {private static volatile Launcher.ExtClassLoader instance;//2、獲取擴展類加載器實現public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {if (instance == null) {Class var0 = Launcher.ExtClassLoader.class;synchronized(Launcher.ExtClassLoader.class) {if (instance == null) {//3、構造擴展類加載器instance = createExtClassLoader();}}}return instance;} //4、構造擴展類加載器具體實現private static Launcher.ExtClassLoader createExtClassLoader() throws IOException {try {return (Launcher.ExtClassLoader)AccessController.doPrivileged(new PrivilegedExceptionAction<Launcher.ExtClassLoader>() {public Launcher.ExtClassLoader run() throws IOException {//5、獲取擴展類加載器加載目標類的目錄File[] var1 = Launcher.ExtClassLoader.getExtDirs();int var2 = var1.length;for(int var3 = 0; var3 < var2; ++var3) {MetaIndex.registerDirectory(var1[var3]);}//7、構造擴展類加載器return new Launcher.ExtClassLoader(var1);}});} catch (PrivilegedActionException var1) {throw (IOException)var1.getException();}}//6、擴展類加載器目錄路徑private static File[] getExtDirs() {String var0 = System.getProperty("java.ext.dirs");File[] var1;if (var0 != null) {StringTokenizer var2 = new StringTokenizer(var0, File.pathSeparator);int var3 = var2.countTokens();var1 = new File[var3];for(int var4 = 0; var4 < var3; ++var4) {var1[var4] = new File(var2.nextToken());}} else {var1 = new File[0];}return var1;}//8、擴展類加載器構造方法public ExtClassLoader(File[] var1) throws IOException {super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);} } -
System class loader
作用:系統類加載器,加載應用指定環境變量路徑下的類
類加載器:sun.misc.Launcher$AppClassLoader
類加載路徑:-classpath下面的所有類
實現原理:系統類加載器AppClassLoader本質上也是URLClassLoader
Launcher.java
//構造方法返回系統類加載器 public Launcher() {try {//獲取系統類加載器this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);} catch (IOException var9) {throw new InternalError("Could not create application class loader", var9);} } static class AppClassLoader extends URLClassLoader {final URLClassPath ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);//系統類加載器實現邏輯public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {//類比擴展類加載器,相似的邏輯final String var1 = System.getProperty("java.class.path");final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {public Launcher.AppClassLoader run() {URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);return new Launcher.AppClassLoader(var1x, var0);}});}//系統類加載器構造方法AppClassLoader(URL[] var1, ClassLoader var2) {super(var1, var2, Launcher.factory);this.ucp.initLookupCache(this);} }
通過上文運行HelloWorld我們知道JVM系統默認加載的類大改是1560個,如下圖
自定義類加載器
內置類加載器只加載了最少需要的核心JAVA基礎類和環境變量下的類,但是我們應用往往需要依賴第三方中間件來完成額外的業務,那么如何把它們的類加載進來就顯得格外重要了。幸好JVM提供了自定義類加載器,可以很方便的完成自定義操作,最終目的也是把外部的類文件加載到JVM內存。通過繼承ClassLoader類并且復寫findClass和loadClass方法就可以達到自定義獲取CLASS文件的目的。
首先我們看ClassLoader的核心方法loadClass
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loaded,看緩存有沒有沒有才去找Class<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {//先看是不是最頂層,如果不是則parent為空,然后獲取父類if (parent != null) {c = parent.loadClass(name, false);} else {//如果為空則說明應用啟動類加載器,讓它去加載c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// If still not found, then invoke findClass in order//如果還是沒有就調用自己的方法,確保調用自己方法前都使用了父類方法,如此遞歸三次到頂long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;} } protected Class<?> findClass(String name) throws ClassNotFoundException {throw new ClassNotFoundException(name); }通過復寫loadClass方法,我們甚至可以讀取一份加了密的文件,然后在內存里面解密,這樣別人反編譯你的源碼也沒用,因為class是經過加密的,也就是理論上我們通過自定義類加載器可以做到為所欲為,但是有個重要的原則下文介紹類加載器設計模式會提到。
一下給出一個自定義類加載器極簡的案例,來說明自定義類加載器的實現。
package com.zooncool.example.theory.jvm; import java.io.FileInputStream; import static java.lang.System.out;public class ClassIsolationPrinciple {public static void main(String[] args) {try {String className = "com.zooncool.example.theory.jvm.ClassIsolationPrinciple$Demo"; //定義要加載類的全限定名Class<?> class1 = Demo.class; //第一個類又系統默認類加載器加載//第二個類MyClassLoader為自定義類加載器,自定義的目的是覆蓋加載類的邏輯Class<?> class2 = new MyClassLoader("target/classes").loadClass(className);out.println("-----------------class name-----------------");out.println(class1.getName());out.println(class2.getName());out.println("-----------------classLoader name-----------------");out.println(class1.getClassLoader());out.println(class2.getClassLoader());Demo.example = 1;//這里修改的系統類加載器加載的那個類的對象,而自定義加載器加載進去的類的對象保持不變,也即是同時存在內存,但沒有修改example的值。out.println("-----------------field value-----------------");out.println(class1.getDeclaredField("example").get(null));out.println(class2.getDeclaredField("example").get(null));} catch (ClassNotFoundException e) {e.printStackTrace();} catch (NoSuchFieldException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();}}public static class Demo {public static int example = 0;}public static class MyClassLoader extends ClassLoader{private String classPath;public MyClassLoader(String classPath) {this.classPath = classPath;}//自定義類加載器繼承了ClassLoader,稱為一個可以加載類的加載器,同時覆蓋了loadClass方法,實現自己的邏輯@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {if(!name.contains("java.lang")){//排除掉加載系統默認需要加載的內心類,因為些類只能又默認類加載器去加載,第三方加載會拋異常,具體原因下文解釋byte[] data = new byte[0];try {data = loadByte(name);} catch (Exception e) {e.printStackTrace();}return defineClass(name,data,0,data.length);}else{return super.loadClass(name);}}//把影片的二進制類文件讀入內存字節流private byte[] loadByte(String name) throws Exception {name = name.replaceAll("\.", "/");String dir = classPath + "/" + name + ".class";FileInputStream fis = new FileInputStream(dir);int len = fis.available();byte[] data = new byte[len];fis.read(data);fis.close();return data;}} }執行結果如下,我們可以看到加載到內存方法區的兩個類的包名+名稱是一樣的,而對應的類加載器卻不一樣,而且輸出被加載類的值也是不一樣的。
-----------------class name----------------- com.zooncool.example.theory.jvm.ClassIsolationPrinciple2$Demo com.zooncool.example.theory.jvm.ClassIsolationPrinciple2$Demo -----------------classLoader name----------------- sun.misc.Launcher$AppClassLoader@18b4aac2 com.zooncool.example.theory.jvm.ClassIsolationPrinciple2$MyClassLoader@511d50c0 -----------------field value----------------- 1 04.2.3設計模式
現有的加載器分為內置類加載器和自定義加載器,不管它們是通過C或者JAVA實現的最終都是為了把外部的CLASS文件加載到JVM內存里面。那么我們就需要設計一套規則來管理組織內存里面的CLASS文件,下面我們就來介紹下通過這套規則如何來協調好內置類加載器和自定義類加載器之間的權責。
我們知道通過自定義類加載器可以干出很多黑科技,但是有個基本的雷區就是,不能隨便替代JAVA的核心基礎類,或者說即是你寫了一個跟核心類一模一樣的類,JVM也不會使用。你想一下,如果為所欲為的你可以把最基礎本的java.lang.Object都換成你自己定義的同名類,然后搞個后門進去,而且JVM還使用的話,那誰還敢用JAVA了是吧,所以我們會介紹一個重要的原則,在此之前我們先介紹一下內置類加載器和自定義類加載器是如何協同的。
-
雙親委派機制
定義:某個特定的類加載器在接到加載類的請求時,首先將加載任務委托給父類加載器,依次遞歸,如果父類加載器可以完成類加載任務,就成功返回;只有父類加載器無法完成此加載任務時,才自己去加載。
實現:參考上文loadClass方法的源碼和注釋,通過最多三次遞歸可以到啟動類加載器,如果還是找不到這調用自定義方法。
雙親委派機制很好理解,目的就是為了不重復加載已有的類,提高效率,還有就是強制從父類加載器開始逐級搜索類文件,確保核心基礎類優先加載。下面介紹的是破壞雙親委派機制,了解為什么要破壞這種看似穩固的雙親委派機制。
-
破壞委派機制
定義:打破類加載自上而上委托的約束。
實現: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有垃圾回收機制(下文會詳細介紹),不需要我們操心,總體上有三個條件會觸發垃圾回收期清理方法區的空間:
-
類對應實例被回收
-
類對應加載器被回收
-
類無反射引用
本節結束我們已經對整個類的生命周期爛熟于胸了,下面我們來介紹類加載機制最核心的幾種應用場景,來加深對類加載技術的認識。
4.3應用場景
通過前文的剖析我們已經非常清楚類加載器的工作原理,那么我們該如何利用類加載器的特點,最大限度的發揮它的作用呢?
4.3.1熱部署
背景
熱部署這個詞匯我們經常聽說也經常提起,但是卻很少能夠準確的描述出它的定義。說到熱部署我們第一時間想到的可能是生產上的機器更新代碼后無需重啟應用容器就能更新服務,這樣的好處就是服務無需中斷可持續運行,那么與之對應的冷部署當然就是要重啟應用容器實例了。還有可能會想到的是使用IDE工具開發時不需要重啟服務,修改代碼后即時生效,這看起來可能都是服務無需重啟,但背后的運行機制確截然不同,首先我們需要對熱部署下一個準確的定義。
-
熱部署(Hot Deployment):熱部署是應用容器自動更新應用的一種能力。
首先熱部署應用容器擁有的一種能力,這種能力是容器本身設計出來的,跟具體的IDE開發工具無關。而且熱部署無需重啟服務器,應用可以保持用戶態不受影響。上文提到我們開發環境使用IDE工具通常也可以設置無需重啟的功能,有別于熱部署的是此時我們應用的是JVM的本身附帶的熱替換能力(HotSwap)。熱部署和熱替換是兩個完全不同概念,在開發過程中也常常相互配合使用,導致我們很多人經常混淆概念,所以接下來我們來剖析熱部署的實現原理,而熱替換的高級特性我們會在下文字節碼增強的章節中介紹。
原理
從熱部署的定義我們知道它是應用容器蘊含的一項能力,要達到的目的就是在服務沒有重啟的情況下更新應用,也就是把新的代碼編譯后產生的新類文件替換掉內存里的舊類文件。結合前文我們介紹的類加載器特性,這似乎也不是很難,分兩步應該可以完成。由于同一個類加載器只能加載一次類文件,那么新增一個類加載器把新的類文件加載進內存。此時內存里面同時存在新舊的兩個類(類名路徑一樣,但是類加載器不一樣),要做的就是如何使用新的類,同時卸載舊的類及其對象,完成這兩步其實也就是熱部署的過程了。也即是通過使用新的類加載器,重新加載應用的類,從而達到新代碼熱部署。
實現
理解了熱部署的工作原理,下面通過一系列極簡的例子來一步步實現熱部署,為了方便讀者演示,以下例子我盡量都在一個java文件里面完成所有功能,運行的時候復制下去就可以跑起來。
-
實現自定義類加載器
參考4.2.2中自定義類加載器區別系統默認加載器的案例,從該案例實踐中我們可以將相同的類(包名+類名),不同”版本“(類加載器不一樣)的類同時加載進JVM內存方法區。
-
替換自定義類加載器
既然一個類通過不同類加載器可以被多次加載到JVM內存里面,那么類的經過修改編譯后再加載進內存。有別于上一步給出的例子只是修改對象的值,這次我們是直接修改類的內容,從應用的視角看其實就是應用更新,那如何做到在線程運行不中斷的情況下更換新類呢?
下面給出的也是一個很簡單的例子,ClassReloading啟動main方法通過死循環不斷創建類加載器,同時不斷加載類而且執行類的方法。注意new MyClassLoader(“target/classes”)的路徑更加編譯的class路徑來修改,其他直接復制過去就可以執行演示了。
package com.zooncool.example.theory.jvm; import java.io.FileInputStream; import java.lang.reflect.InvocationTargetException; public class ClassReloading {public static void main(String[] args)throws NoSuchMethodException, ClassNotFoundException, IllegalAccessException, InstantiationException,InvocationTargetException, InterruptedException {for (;;){//用死循環讓線程持續運行未中斷狀態//通過反射調用目標類的入口方法String className = "com.zooncool.example.theory.jvm.ClassReloading$User";Class<?> target = new MyClassLoader("target/classes").loadClass(className);//加載進來的類,通過反射調用execute方法target.getDeclaredMethod("execute").invoke(targetClass.newInstance());//HelloWorld.class.getDeclaredMethod("execute").invoke(HelloWorld.class.newInstance());//如果換成系統默認類加載器的話,因為雙親委派原則,默認使用應用類加載器,而且能加載一次//休眠是為了在刪除舊類編譯新類的這段時間內不執行加載動作//不然會找不到類文件Thread.sleep(10000);}}//自定義類加載器加載的目標類public static class User {public void execute() throws InterruptedException {//say();ask();}public void ask(){System.out.println("what is your name");}public void say(){System.out.println("my name is lucy");}}//下面是自定義類加載器,跟第一個例子一樣,可略過public static class MyClassLoader extends ClassLoader{...} }ClassReloading線程執行過程不斷輪流注釋say()和ask()代碼,然后編譯類,觀察程序輸出。
如下輸出結果,我們可以看出每一次循環調用都新創建一個自定義類加載器,然后通過反射創建對象調用方法,在修改代碼編譯后,新的類就會通過反射創建對象執行新的代碼業務,而主線程則一直沒有中斷運行。讀到這里,其實我們已經基本觸達了熱部署的本質了,也就是實現了手動無中斷部署。但是缺點就是需要我們手動編譯代碼,而且內存不斷新增類加載器和對象,如果速度過快而且頻繁更新,還可能造成堆溢出,下一個例子我們將增加一些機制來保證舊的類和對象能被垃圾收集器自動回收。
what is your name what is your name what is your name//修改代碼,編譯新類 my name is lucy my name is lucy what is your name//修改代碼,編譯新類-
回收自定義類加載器
通常情況下類加載器會持有該加載器加載過的所有類的引用,所有如果類是經過系統默認類加載器加載的話,那就很難被垃圾收集器回收,除非符合根節點不可達原則才會被回收。
下面繼續給出一個很簡單的例子,我們知道ClassReloading只是不斷創建新的類加載器來加載新類從而更新類的方法。下面的例子我們模擬WEB應用,更新整個應用的上下文Context。下面代碼本質上跟上個例子的功能是一樣的,只不過我們通過加載Model層、DAO層和Service層來模擬web應用,顯得更加真實。
package com.zooncool.example.theory.jvm; import java.io.FileInputStream; import java.lang.reflect.InvocationTargetException; //應用上下文熱加載 public class ContextReloading {public static void main(String[] args)throws NoSuchMethodException, ClassNotFoundException, IllegalAccessException, InstantiationException,InvocationTargetException, InterruptedException {for (;;){Object context = newContext();//創建應用上下文invokeContext(context);//通過上下文對象context調用業務方法Thread.sleep(5000);}}//創建應用的上下文,context是整個應用的GC roots,創建完返回對象之前調用init()初始化對象public static Object newContext()throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException,InvocationTargetException {String className = "com.zooncool.example.theory.jvm.ContextReloading$Context";//通過自定義類加載器加載Context類Class<?> contextClass = new MyClassLoader("target/classes").loadClass(className);Object context = contextClass.newInstance();//通過反射創建對象contextClass.getDeclaredMethod("init").invoke(context);//通過反射調用初始化方法init()return context;}//業務方法,調用context的業務方法showUser()public static void invokeContext(Object context)throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {context.getClass().getDeclaredMethod("showUser").invoke(context);}public static class Context{private UserService userService = new UserService();public String showUser(){return userService.getUserMessage();}//初始化對象public void init(){UserDao userDao = new UserDao();userDao.setUser(new User());userService.setUserDao(userDao);}}public static class UserService{private UserDao userDao;public String getUserMessage(){return userDao.getUserName();}public void setUserDao(UserDao userDao) {this.userDao = userDao;}}public static class UserDao{private User user;public String getUserName(){//關鍵操作,運行main方法后切換下面方法,編譯后下一次調用生效return user.getName();//return user.getFullName();}public void setUser(User user) {this.user = user;}}public static class User{private String name = "lucy";private String fullName = "hank.lucy";public String getName() {System.out.println("my name is " + name);return name;}public String getFullName() {System.out.println("my full name is " + fullName);return name;}}//跟之前的類加載器一模一樣,可以略過public static class MyClassLoader extends ClassLoader{...} }輸出結果跟上一個例子相似,可以自己運行試試。我們更新業務方法編譯通過后,無需重啟main方法,新的業務就能生效,而且也解決了舊類卸載的核心問題,因為context的應用對象的跟節點,context是由我們自定義類加載器所加載,由于User/Dao/Service都是依賴context,所以其類也是又自定義類加載器所加載。根據GC roots原理,在創建新的自定義類加載器之后,舊的類加載器已經沒有任何引用鏈可訪達,符合GC回收規則,將會被GC收集器回收釋放內存。至此已經完成應用熱部署的流程,但是細心的朋友可能會發現,我們熱部署的策略是整個上下文context都替換成新的,那么用戶的狀態也將無法保留。而實際情況是我們只需要動態更新某些模塊的功能,而不是全局。這個其實也好辦,就是我們從業務上把需要熱部署的由自定義類加載器加載,而持久化的類資源則由系統默認類加載器去完成。
-
自動加載類加載器
其實設計到代碼設計優雅問題,基本上我們拿出設計模式23章經對號入座基本可以解決問題,畢竟這是前人經過千萬實踐錘煉出來的軟件構建內功心法。那么針對我們熱部署的場景,如果想把熱部署細節封裝出來,那代理模式無疑是最符合要求的,也就是咱們弄出個代理對象來面向用戶,把類加載器的更替,回收,隔離等細節都放在代理對象里面完成,而對于用戶來說是透明無感知的,那么終端用戶體驗起來就是純粹的熱部署了。至于如何實現自動熱部署,方式也很簡單,監聽我們部署的目錄,如果文件時間和大小發生變化,則判斷應用需要更新,這時候就觸發類加載器的創建和舊對象的回收,這個時候也可以引入觀察者模式來實現。由于篇幅限制,本例子就留給讀者朋友自行設計,相信也是不難完成的。
案例
上一節我們深入淺出的從自定義類加載器的開始引入,到實現多個類加載器加載同個類文件,最后完成舊類加載器和對象的回收,整個流程闡述了熱部署的實現細節。那么這一節我們介紹現有實現熱部署的通用解決方案,本質就是對上文原理的實現,加上性能和設計上的優化,注意本節我們應用的只是類加載器的技術,后面章節還會介紹的字節碼層面的底層操作技術。
-
OSGI
OSGI(Open Service Gateway Initiative)是一套開發和部署應用程序的java框架。我們從官網可以看到OSGI其實是一套規范,好比Servlet定義了服務端對于處理來自網絡請求的一套規范,比如init,service,destroy的生命周期。然后我們通過實行這套規范來實現與客戶端的交互,在調用init初始化完Servlet對象后通過多線程模式使用service響應網絡請求。如果從響應模式比較我們還可以了解下Webflux的規范,以上兩種都是處理網絡請求的方式,當然你舉例說CGI也是一種處理網絡請求的規范,CGI采用的是多進程方式來處理網絡請求,我們暫時不對這兩種規范進行優劣評價,只是說明在處理網絡請求的場景下可以采用不同的規范來實現。
好了現在回到OSGi,有了上面的鋪墊,相信對我們理解OSGI大有幫助。我們說OSGI首先是一種規范,既然是規范我們就要看看都規范了啥,比如Servlet也是一種規范,它規范了生命周期,規定應用容器中WEB-INF/classes目錄或WEB-INF/lib目錄下的jar包才會被Web容器處理。同樣OSGI的實現框架對管轄的Bundle下面的目錄組織和文本格式也有嚴格規范,更重要的是OSGI對模塊化架構生命周期的管理。而模塊化也不只是把系統拆分成不同的JAR包形成模塊而已,真正的模塊化必須將模塊中類的引入/導出、隱藏、依賴、版本管理貫穿到生命周期管理中去。
定義:OSGI是脫胎于(OSGI Alliance)技術聯盟由一組規范和對應子規范共同定義的JAVA動態模塊化技術。實現該規范的OSGI框架(如Apache Felix)使應用程序的模塊能夠在本地或者網絡中實現端到端的通信,目前已經發布了第7版。OSGI有很多優點諸如熱部署,類隔離,高內聚,低耦合的優勢,但同時也帶來了性能損耗,而且基于OSGI目前的規范繁多復雜,開發門檻較高。
組成:執行環境,安全層,模塊層,生命周期層,服務層,框架API
核心服務:
事件服務(Event Admin Service),
包管理服務(Package Admin Service)
日志服務(Log Service)
配置管理服務(Configuration Admin Service)
HTTP服務(HTTP Service)
用戶管理服務(User Admin Service)
設備訪問服務(Device Access Service)
IO連接器服務(IO Connector Service)
聲明式服務(Declarative Services)
其他OSGi標準服務
本節我們討論的核心是熱部署,所以我們不打算在這里講解全部得OSGI技術,在上文實現熱部署后我們重點來剖析OSGI關于熱部署的機制。至于OSGI模塊化技術和java9的模塊化的對比和關聯,后面有時間會開個專題專門介紹模塊化技術。
從類加載器技術應用的角度切入我們知道OSGI規范也是打破雙親委派機制,除了框架層面需要依賴JVM默認類加載器之外,其他Bundle(OSGI定義的模塊單元)都是由各自的類加載器來加載,而OSGI框架就負責模塊生命周期,模塊交互這些核心功能,同時創建各個Bundle的類加載器,用于直接加載Bundle定義的jar包。由于打破雙親委派模式,Bundle類加載器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加復雜的網狀結構(因為各個Bundle之間有相互依賴關系),當收到類加載請求時,OSGi將按照下面的順序進行類搜索:
1)將以java.*開頭的類委派給父類加載器加載。
2)否則,將委派列表名單內(比如sun或者javax這類核心類的包加入白名單)的類委派給父類加載器加載。
3)否則,將Import列表中的類委派給Export這個類的Bundle的類加載器加載。
4)否則,查找當前Bundle的ClassPath,使用自己的類加載器加載。
5)否則,查找類是否在自己的Fragment Bundle(OSGI框架緩存包)中,如果在,則委派給Fragment Bundle的類加載器加載。
6)否則,查找Dynamic Import列表的Bundle,委派給對應Bundle的類加載器加載。
7)否則,類查找失敗。
這一系列的類加載操作,其實跟我們上節實現的自定義類加載技術本質上是一樣的,只不過實現OSGI規范的框架需要提供模塊之間的注冊通信組件,還有模塊的生命周期管理,版本管理。OSGI也只是JVM上面運行的一個普通應用實例,只不過通過模塊內聚,版本管理,服務依賴一系列的管理,實現了模塊的即時更新,實現了熱部署。
其他熱部署解決方案多數也是利用類加載器的特點做文章,當然不止是類加載器,還會應用字節碼技術,下面我們主要簡單列舉應用類加載器實現的熱部署解決方案。
-
Groovy
Groovy兼顧動態腳本語言的功能,使用的時候無外乎也是通過GroovyClassLoader來加載腳本文件,轉為JVM的類對象。那么每次更新groovy腳本就可以動態更新應用,也就達到了熱部署的功能了。
Class groovyClass = classLoader.parseClass(new GroovyCodeSource(sourceFile)); GroovyObject instance = (GroovyObject)groovyClass.newInstance();//proxy-
Clojure
-
JSP
JSP其實翻譯為Servlet后也是由對應新的類加載器去加載,這跟我們上節講的流程一模一樣,所以這里就補展開講解了。
介紹完熱部署技術,可能很多同學對熱部署的需求已經沒有那么強烈,畢竟熱部署過程中帶來的弊端也不容忽視,比如替換舊的類加載器過程會產生大量的內存碎片,導致JVM進行高負荷的GC工作,反復進行熱部署還會導致JVM內存不足而導致內存溢出,有時候甚至還不如直接重啟應用來得更快一點,而且隨著分布式架構的演進和微服務的流行,應用重啟也早就實現服務編排化,配合豐富的部署策略,也可以同樣保證系統穩定持續服務,我們更多的是通過熱部署技術來深刻認識到JVM加載類的技術演進。
4.3.2類隔離
背景
先介紹一下類隔離的背景,我們費了那么大的勁設計出類加載器,如果只是用于加載外部類字節流那就過于浪費了。通常我們的應用依賴不同的第三方類庫經常會出現不同版本的類庫,如果只是使用系統內置的類加載器的話,那么一個類庫只能加載唯一的一個版本,想加載其他版本的時候會從緩存里面發現已經存在而停止加載。但是我們的不同業務以來的往往是不同版本的類庫,這時候就會出現ClassNotFoundException。為什么只有運行的是才會出現這個異常呢,因為編譯的時候我們通常會使用MAVEN等編譯工具把沖突的版本排除掉。另外一種情況是WEB容器的內核依賴的第三方類庫需要跟應用依賴的第三方類庫隔離開來,避免一些安全隱患,不然如果共用的話,應用升級依賴版本就會導致WEB容器不穩定。
基于以上的介紹我們知道類隔離實在是剛需,那么接下來介紹一下如何實現這個剛需。
原理
首先我們要了解一下原理,其實原理很簡單,真的很簡單,請允許我總結為“唯一標識原理”。我們知道內存里面定位類實例的坐標<類加載器,類全限定名>。那么由這兩個因子組合起來我們可以得出一種普遍的應用,用不同類加載器來加載類相同類(類全限定名一致,版本不一致)是可以實現的,也就是在JVM看來,有相同類全名的類是完全不同的兩個實例,但是在業務視角我們卻可以視為相同的類。
public static void main(String[] args) {Class<?> userClass1 = User.class;Class<?> userClass2 = new DynamicClassLoader("target/classes").load("qj.blog.classreloading.example1.StaticInt$User");out.println("Seems to be the same class:");out.println(userClass1.getName());out.println(userClass2.getName());out.println();out.println("But why there are 2 different class loaders:");out.println(userClass1.getClassLoader());out.println(userClass2.getClassLoader());out.println();User.age = 11;out.println("And different age values:");out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1));out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2)); }public static class User {public static int age = 10; }實現
原理很簡單,比如我們知道Spring容器本質就是一個生產和管理bean的集合對象,但是卻包含了大量的優秀設計模式和復雜的框架實現。同理隔離容器雖然原理很簡單,但是要實現一個高性能可擴展的高可用隔離容器,卻不是那么簡單。我們上文談的場景是在內存運行的時候才發現問題,介紹內存隔離技術之前,我們先普及更為通用的沖突解決方法。
-
沖突排除
沖突總是先發生在編譯時期,那么基本Maven工具可以幫我們完成大部分的工作,Maven的工作模式就是將我們第三方類庫的所有依賴都依次檢索,最終排除掉產生沖突的jar包版本。
-
沖突適配
當我們無法通過簡單的排除來解決的時候,另外一個方法就是重新裝配第三方類庫,這里我們要介紹一個開源工具jarjar (https://github.com/shevek/jarjar)。該工具包可以通過字節碼技術將我們依賴的第三方類庫重命名,同時修改代碼里面對第三方類庫引用的路徑。這樣如果出現同名第三方類庫的話,通過該“硬編碼”的方式修改其中一個類庫,從而消除了沖突。
-
沖突隔離
上面兩種方式在小型系統比較適合,也比較敏捷高效。但是對于分布式大型系統的話,通過硬編碼方式來解決沖突就難以完成了。辦法就是通過隔離容器,從邏輯上區分類庫的作用域,從而對內存的類進行隔離。
5.內存管理
5.1內存結構
5.1.1邏輯分區
JVM內存從應用邏輯上可分為如下區域。
-
程序計數器:字節碼行號指示器,每個線程需要一個程序計數器
-
虛擬機棧:方法執行時創建棧幀(存儲局部變量,操作棧,動態鏈接,方法出口)編譯時期就能確定占用空間大小,線程請求的棧深度超過jvm運行深度時拋StackOverflowError,當jvm棧無法申請到空閑內存時拋OutOfMemoryError,通過-Xss,-Xsx來配置初始內存
-
本地方法棧:執行本地方法,如操作系統native接口
-
堆:存放對象的空間,通過-Xmx,-Xms配置堆大小,當堆無法申請到內存時拋OutOfMemoryError
-
方法區:存儲類數據,常量,常量池,靜態變量,通過MaxPermSize參數配置
-
對象訪問:初始化一個對象,其引用存放于棧幀,對象存放于堆內存,對象包含屬性信息和該對象父類、接口等類型數據(該類型數據存儲在方法區空間,對象擁有類型數據的地址)
而實際上JVM內存分類實際上的物理分區還有更為詳細,整體上分為堆內存和非堆內存,具體介紹如下。
5.1.2 內存模型
堆內存
堆內存是運行時的數據區,從中分配所有java類實例和數組的內存,可以理解為目標應用依賴的對象。堆在JVM啟動時創建,并且在應用程序運行時可能會增大或減小。可以使用-Xms 選項指定堆的大小。堆可以是固定大小或可變大小,具體取決于垃圾收集策略。可以使用-Xmx選項設置最大堆大小。默認情況下,最大堆大小設置為64 MB。
JVM堆內存在物理上分為兩部分:新生代和老年代。新生代是為分配新對象而保留堆空間。當新生代占用完時,Minor GC垃圾收集器會對新生代區域執行垃圾回收動作,其中在新生代中生活了足夠長的所有對象被遷移到老年代,從而釋放新生代空間以進行更多的對象分配。此垃圾收集稱為 Minor GC。新生代分為三個子區域:伊甸園Eden區和兩個幸存區S0和S1。
關于新生代內存空間:
-
大多數新創建的對象都位于Eden區內存空間
-
當Eden區填滿對象時,執行Minor GC并將所有幸存對象移動到其中一個幸存區空間
-
Minor GC還會檢查幸存區對象并將其移動到其他幸存者空間,也即是幸存區總有一個是空的
-
在多次GC后還存活的對象被移動到老年代內存空間。至于經過多少次GC晉升老年代則由參數配置,通常為15
當老年區填滿時,老年區同樣會執行垃圾回收,老年區還包含那些經過多Minor GC后還存活的長壽對象。垃圾收集器在老年代內存中執行的回收稱為Major GC,通常需要更長的時間。
非堆內存
JVM的堆以外內存稱為非堆內存。也即是JVM自身預留的內存區域,包含JVM緩存空間,類結構如常量池、字段和方法數據,方法,構造方法。類非堆內存的默認最大大小為64 MB。可以使用-XX:MaxPermSize VM選項更改此選項,非堆內存通常包含如下性質的區域空間:
-
元空間(Metaspace)
在Java 8以上版本已經沒有Perm Gen這塊區域了,這也意味著不會再由關于“java.lang.OutOfMemoryError:PermGen”內存問題存在了。與駐留在Java堆中的Perm Gen不同,Metaspace不是堆的一部分。類元數據多數情況下都是從本地內存中分配的。默認情況下,元空間會自動增加其大小(直接又底層操作系統提供),而Perm Gen始終具有固定的上限。可以使用兩個新標志來設置Metaspace的大小,它們是:“ -?XX:MetaspaceSize?”和“?-XX:MaxMetaspaceSize?”。Metaspace背后的含義是類的生命周期及其元數據與類加載器的生命周期相匹配。也就是說,只要類加載器處于活動狀態,元數據就會在元數據空間中保持活動狀態,并且無法釋放。
-
代碼緩存
運行Java程序時,它以分層方式執行代碼。在第一層,它使用客戶端編譯器(C1編譯器)來編譯代碼。分析數據用于服務器編譯的第二層(C2編譯器),以優化的方式編譯該代碼。默認情況下,Java 7中未啟用分層編譯,但在Java 8中啟用了分層編譯。實時(JIT)編譯器將編譯的代碼存儲在稱為代碼緩存的區域中。它是一個保存已編譯代碼的特殊堆。如果該區域的大小超過閾值,則該區域將被刷新,并且GC不會重新定位這些對象。Java 8中已經解決了一些性能問題和編譯器未重新啟用的問題,并且在Java 7中避免這些問題的解決方案之一是將代碼緩存的大小增加到一個永遠不會達到的程度。
-
方法區
方法區域是Perm Gen中空間的一部分,用于存儲類結構(運行時常量和靜態變量)以及方法和構造函數的代碼。
-
內存池
內存池由JVM內存管理器創建,用于創建不可變對象池。內存池可以屬于Heap或Perm Gen,具體取決于JVM內存管理器實現。
-
常量池
常量包含類運行時常量和靜態方法,常量池是方法區域的一部分。
-
Java堆棧內存
Java堆棧內存用于執行線程。它們包含特定于方法的特定值,以及對從該方法引用的堆中其他對象的引用。
-
Java堆內存配置項
Java提供了許多內存配置項,我們可以使用它們來設置內存大小及其比例,常用的如下:
| -?Xms | 用于在JVM啟動時設置初始堆大小 |
| -Xmx | 用于設置最大堆大小 |
| -Xmn | 設置新生區的大小,剩下的空間用于老年區 |
| -XX:PermGen | 用于設置永久區存初始大小 |
| -XX:MaxPermGen | 用于設置Perm Gen的最大尺寸 |
| -XX:SurvivorRatio | 提供Eden區域的比例 |
| -XX:NewRatio | 用于提供老年代/新生代大小的比例,默認值為2 |
5.2垃圾回收
5.2.1垃圾回收策略
流程
垃圾收集是釋放堆中的空間以分配新對象的過程。垃圾收集器是JVM管理的進程,它可以查看內存中的所有對象,并找出程序任何部分未引用的對象,刪除并回收空間以分配給其他對象。通常會經過如下步驟:
-
標記:標記哪些對象被使用,哪些已經是無法觸達的無用對象
-
刪除:刪除無用對象并回收要分配給其他對象
-
壓縮:性能考慮,在刪除無用的對象后,會將所有幸存對象集中移動到一起,騰出整段空間
策略
虛擬機棧、本地棧和程序計數器在編譯完畢后已經可以確定所需內存空間,程序執行完畢后也會自動釋放所有內存空間,所以不需要進行動態回收優化。JVM內存調優主要針對堆和方法區兩大區域的內存。通常對象分為Strong、sfot、weak和phantom四種類型,強引用不會被回收,軟引用在內存達到溢出邊界時回收,弱引用在每次回收周期時回收,虛引用專門被標記為回收對象,具體回收策略如下:
-
對象優先在Eden區分配:
-
新生對象回收策略Minor GC(頻繁)
-
老年代對象回收策略Full GC/Major GC(慢)
-
大對象直接進入老年代:超過3m的對象直接進入老年區 -XX:PretenureSizeThreshold=3145728(3M)
-
長期存貨對象進入老年區:
Survivor區中的對象經歷一次Minor GC年齡增加一歲,超過15歲進入老年區
-XX:MaxTenuringThreshold=15 -
動態對象年齡判定:設置Survivor區對象占用一半空間以上的對象進入老年區
算法
垃圾收集有如下常用的算法:
-
標記-清除
-
復制
-
標記-整理
-
分代收集(新生用復制,老年用標記-整理)
5.2.2 垃圾回收器
分類
-
serial收集器:單線程,主要用于client模式
-
ParNew收集器:多線程版的serial,主要用于server模式
-
Parallel Scavenge收集器:線程可控吞吐量(用戶代碼時間/用戶代碼時間+垃圾收集時間),自動調節吞吐量,用戶新生代內存區
-
Serial Old收集器:老年版本serial
-
Parallel Old收集器:老年版本Parallel Scavenge
-
CMS(Concurrent Mark Sweep)收集器:停頓時間短,并發收集
-
G1收集器:分塊標記整理,不產生碎片
配置
-
串行GC(-XX:+ UseSerialGC):串行GC使用簡單的標記-掃描-整理方法,用于新生代和老年代的垃圾收集,即Minor和Major GC
-
并行GC(-XX:+ UseParallelGC):并行GC與串行GC相同,不同之處在于它為新生代垃圾收集生成N個線程,其中N是系統中的CPU核心數。我們可以使用-XX:ParallelGCThreads = n JVM選項來控制線程數
-
并行舊GC(-XX:+ UseParallelOldGC):這與Parallel GC相同,只是它為新生代和老年代垃圾收集使用多個線程
-
并發標記掃描(CMS)收集器(-XX:+ UseConcMarkSweepGC):CMS也稱為并發低暫停收集器。它為老年代做垃圾收集。CMS收集器嘗試通過在應用程序線程內同時執行大多數垃圾收集工作來最小化由于垃圾收集而導致的暫停。年輕一代的CMS收集器使用與并行收集器相同的算法。我們可以使用-XX限制CMS收集器中的線程數 :ParallelCMSThreads = n
-
G1垃圾收集器(-XX:+ UseG1GC):G1從長遠看要是替換CMS收集器。G1收集器是并行,并發和遞增緊湊的低暫停垃圾收集器。G1收集器不像其他收集器那樣工作,并且沒有年輕和老一代空間的概念。它將堆空間劃分為多個大小相等的堆區域。當調用垃圾收集器時,它首先收集具有較少實時數據的區域,因此稱為“Garbage First”也即是G1
6.執行引擎
6.1執行流程
類加載器加載的類文件字節碼數據流由基于JVM指令集架構的執行引擎來執行。執行引擎以指令為單位讀取Java字節碼。我們知道匯編執行的流程是CPU執行每一行的匯編指令,同樣JVM執行引擎就像CPU一個接一個地執行機器命令。字節碼的每個命令都包含一個1字節的OpCode和附加的操作數。執行引擎獲取一個OpCode并使用操作數執行任務,然后執行下一個OpCode。但Java是用人們可以理解的語言編寫的,而不是用機器直接執行的語言編寫的。因此執行引擎必須將字節碼更改為JVM中的機器可以執行的語言。字節碼可以通過以下兩種方式之一轉化為合適的語言。
-
解釋器:逐個讀取,解釋和執行字節碼指令。當它逐個解釋和執行指令時,它可以快速解釋一個字節碼,但是同時也只能相對緩慢的地執行解釋結果,這是解釋語言的缺點。
-
JIT(實時)編譯器:引入了JIT編譯器來彌補解釋器的缺點。執行引擎首先作為解釋器運行,并在適當的時候,JIT編譯器編譯整個字節碼以將其更改為本機代碼。之后,執行引擎不再解釋該方法,而是直接使用本機代碼執行。本地代碼中的執行比逐個解釋指令要快得多。由于本機代碼存儲在高速緩存中,因此可以快速執行編譯的代碼。
但是,JIT編譯器編譯代碼需要花費更多的時間,而不是解釋器逐個解釋代碼。因此,如果代碼只執行一次,最好是選擇解釋而不是編譯。因此,使用JIT編譯器的JVM在內部檢查方法執行的頻率,并僅在頻率高于某個級別時編譯方法。
JVM規范中未定義執行引擎的運行方式。因此,JVM廠商使用各種技術改進其執行引擎,并引入各種類型的JIT編譯器。 大多數JIT編譯器運行如下圖所示:
JIT編譯器將字節碼轉換為中間級表達式IR,以執行優化,然后將表達式轉換為本機代碼。Oracle Hotspot VM使用名為Hotspot Compiler的JIT編譯器。它被稱為Hotspot,因為Hotspot Compiler通過分析搜索需要以最高優先級進行編譯的“Hotspot”,然后將熱點編譯為本機代碼。如果不再頻繁調用編譯了字節碼的方法,換句話說,如果該方法不再是熱點,則Hotspot VM將從緩存中刪除本機代碼并以解釋器模式運行。Hotspot VM分為服務器VM和客戶端VM,兩個VM使用不同的JIT編譯器。
大多數Java性能改進都是通過改進執行引擎來實現的。除了JIT編譯器之外,還引入了各種優化技術,因此可以不斷改進JVM性能。初始JVM和最新JVM之間的最大區別是執行引擎。
下面我們通過下圖可以看出JAVA執行的流程。
6.2棧幀結構
每個方法調用開始到執行完成的過程,對應這一個棧幀在虛擬機棧里面從入棧到出棧的過程。
-
棧幀包含:局部變量表,操作數棧,動態連接,方法返回
-
方法調用:方法調用不等于方法執行,而且確定調用方法的版本。
-
方法調用字節碼指令:invokestatic,invokespecial,invokevirtual,invokeinterface
-
靜態分派:靜態類型,實際類型,編譯器重載時通過參數的靜態類型來確定方法的版本。(選方法)
-
動態分派:invokevirtual指令把類方法符號引用解析到不同直接引用上,來確定棧頂的實際對象(選對象)
-
單分派:靜態多分派,相同指令有多個方法版本。
-
多分派:動態單分派,方法接受者只能確定唯一一個。
下圖是JVM實例執行方法是的內存布局。
6.3早期編譯
-
javac編譯器:解析與符號表填充,注解處理,生成字節碼
-
java語法糖:語法糖有助于代碼開發,但是編譯后就會解開糖衣,還原到基礎語法的class二進制文件
重載要求方法具備不同的特征簽名(不包括返回值),但是class文件中,只要描述不是完全一致的方法就可以共存。
6.4晚期編譯
HotSpot虛擬機內的即時編譯
解析模式 -Xint
編譯模式 -Xcomp
混合模式 Mixed mode
分層編譯:解釋執行 -> C1(Client Compiler)編譯 -> C2編譯(Server Compiler)
觸發條件:基于采樣的熱點探測,基于計數器的熱點探測
7.性能調優
7.1調優原則
我們知道調優的前提是,程序沒有達到我們的預期要求,那么第一步要做的是衡量我們的預期。程序不可能十全十美,我們要做的是通過各種指標來衡量系統的性能,最終整體達到我們的要求。
7.1.1 環境
首先我們要了解系統的運行環境,包括操作系統層面的差異,JVM版本,位數,乃至于硬件的時鐘周期,總線設計甚至機房溫度,都可能是我們需要考慮的前置條件。
7.1.2 度量
首先我們要先給出系統的預期指標,在特定的硬件/軟件的配置,然后給出目標指標,比如系統整體輸出接口的QPS,RT,或者更進一層,IO讀寫,cpu的load指標,內存的使用率,GC情況都是我們需要預先考察的對象。
7.1.3 監測
確定了環境前置條件,分析了度量指標,第三步是通過工具來監測指標,下一節提供了常用JVM調優工具,可以通過不同工具的組合來發現定位問題,結合JVM的工作機制已經操作系統層面的調度流程,按圖索驥來發現問題,找出問題后才能進行優化。
7.1.4 原則
總體的調優原則如下圖
圖片來源《Java Performance》
7.2 調優參數
上節給出了JVM性能調優的原則,我們理清思路后應用不同的JVM工具來發現系統存在的問題,下面列舉的是常用的JVM參數,通過這些參數指標可以更快的幫助我們定位出問題所在。
7.2.1內存查詢
最常見的與性能相關的做法之一是根據應用程序要求初始化堆內存。這就是我們應該指定最小和最大堆大小的原因。以下參數可用于實現它:
-Xms<heap size>[unit] -Xmx<heap size>[unit]unit表示要初始化內存(由堆大小表示)的單元。單位可以標記為GB的“g”,MB的“m”和KB的“k”。例如JVM分配最小2 GB和最大5 GB:
-Xms2G -Xmx5G從Java 8開始Metaspace的大小未被定義,一旦達到限制JVM會自動增加它,為了避免不必要的不穩定性,我們可以設置Metaspace大小:
-XX:MaxMetaspaceSize=<metaspace size>[unit]默認情況下YG的最小大小為1310?MB,最大大小不受限制,我們可以明確地指定它們:
-XX:NewSize=<young size>[unit] -XX:MaxNewSize=<young size>[unit]7.2.2垃圾回收
JVM有四種類型的GC實現:
-
串行垃圾收集器
-
并行垃圾收集器
-
CMS垃圾收集器
-
G1垃圾收集器
可以使用以下參數聲明這些實現:
-XX:+UseSerialGC -XX:+UseParallelGC -XX:+USeParNewGC -XX:+UseG1GC7.2.3GC記錄
要嚴格監視應用程序運行狀況,我們應始終檢查JVM的垃圾收集性能,使用以下參數,我們可以記錄GC活動:
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=< number of log files > -XX:GCLogFileSize=< file size >[ unit ] -Xloggc:/path/to/gc.logUseGCLogFileRotation指定日志文件滾動的政策,就像log4j的,s4lj等?NumberOfGCLogFiles表示單個應用程序記錄生命周期日志文件的最大數量。GCLogFileSize指定文件的最大大小。?loggc表示其位置。這里要注意的是,還有兩個可用的JVM參數(-XX:+ PrintGCTimeStamps和-XX:+ PrintGCDateStamps),可用于在GC日志中打印日期時間戳。
7.2.4內存溢出
大型應用程序面臨內存不足的錯誤是很常見的,這是一個非常關鍵的場景,很難復制以解決問題。
這就是JVM帶有一些參數的原因,這些參數將堆內存轉儲到一個物理文件中,以后可以用它來查找泄漏:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./java_pid<pid>.hprof -XX:OnOutOfMemoryError="< cmd args >;< cmd args >" -XX:+UseGCOverheadLimit這里有幾點需要注意:
-
在OutOfMemoryError的情況下,?HeapDumpOnOutOfMemoryError指示JVM將堆轉儲到物理文件中
-
HeapDumpPath表示要寫入文件的路徑; 任何文件名都可以給出; 但是如果JVM在名稱中找到?標記,則導致內存不足錯誤的進程ID將以?.hprof格式附加到文件名
-
OnOutOfMemoryError用于發出緊急命令,以便在出現內存不足錯誤時執行; 應該在cmd args的空間中使用正確的命令。例如,如果我們想在內存不足時重新啟動服務器,我們可以設置參數:
-
UseGCOverheadLimit是一種策略,用于限制在拋出?OutOfMemory錯誤之前在GC中花費的VM時間的比例
7.2.5其他配置
-
-server:啟用“Server Hotspot VM”; 默認情況下,此參數在64位JVM中使用
-
-XX:+ UseStringDeduplication: Java 8引入了這個JVM參數,通過創建相同?String的太多實例來減少不必要的內存使用?;?這通過將重復的?String值減少到單個全局char []數組來優化堆內存
-
-XX:+ UseLWPSynchronization:設置基于?LWP(輕量級進程)的同步策略而不是基于線程的同步
-
-XX:LargePageSizeInBytes:設置用于Java堆的大頁面大小; 它采用GB / MB / KB的參數; 通過更大的頁面大小,我們可以更好地利用虛擬內存硬件資源; 但是這可能會導致?PermGen的空間大小增加,從而可以強制減小Java堆空間的大小
-
-XX:MaxHeapFreeRatio:設置?GC后堆的最大自由百分比,以避免收縮
-
-XX:MinHeapFreeRatio:設置?GC后堆的最小自由百分比以避免擴展,監視堆使用情況
-
-XX:SurvivorRatio:Eden區 /幸存者空間大小的比例
-
-XX:+ UseLargePages:如果系統支持,則使用大頁面內存; 如果使用此JVM參數,OpenJDK 7往往會崩潰
-
-XX:+ UseStringCache:啟用字符串池中可用的常用分配字符串的緩存
-
-XX:+ UseCompressedStrings:對?String對象使用?byte []類型,可以用純ASCII格式表示
-
-XX:+ OptimizeStringConcat:它盡可能優化字符串連接操作
7.3 調優工具
7.3.1命令行工具
-
虛擬機進程狀況工具:jps -lvm
-
診斷命令工具:jcmd
用來發送診斷命令請求到JVM,這些請求是控制Java的運行記錄,它必須在運行JVM的同一臺機器上使用,并且具有用于啟動JVM的相同有效用戶和分組,可以使用以下命令創建堆轉儲(hprof轉儲):
jcmd?GC.heap_dump filename =
-
虛擬機統計信息監視工具:jstat
提供有關運行的應用程序的性能和資源消耗的信息。在診斷性能問題時,可以使用該工具,特別是與堆大小調整和垃圾回收相關的問題。jstat不需要虛擬機啟動任何特殊配置。
jstat -gc pid interval count
-
java配置信息工具:jinfo
jinfo -flag pid
-
java內存映像工具:jmap
用于生成堆轉儲文件
jmap -dump:format=b,file=java.bin pid
-
虛擬機堆轉儲快照分析工具:jhat
jhat file 分析堆轉儲文件,通過瀏覽器訪問分析文件
-
java堆棧跟蹤工具:jstack
用于生成虛擬機當前時刻的線程快照threaddump或者Javacore
jstack [ option ] vmid
-
堆和CPU分析工具:HPROF
HPROF是每個JDK版本附帶的堆和CPU分析工具。它是一個動態鏈接庫(DLL),它使用Java虛擬機工具接口(JVMTI)與JVM連接。該工具將分析信息以ASCII或二進制格式寫入文件或套接字。HPROF工具能夠顯示CPU使用情況,堆分配統計信息和監視爭用配置文件。此外,它還可以報告JVM中所有監視器和線程的完整堆轉儲和狀態。在診斷問題方面,HPROF在分析性能,鎖爭用,內存泄漏和其他問題時非常有用。
java -agentlib:hprof = heap = sites target.class
7.3.2可視化工具
-
jconsole
-
jvisualvm
8.字節增強
我們從類加載的應用介紹了熱部署和類隔離兩大應用場景,但是基于類加載器的技術始終只是獨立于JVM內核功能而存在的,也就是所有實現都只是基于最基礎的類加載機制,并無應用其他JVM 高級特性,本章節我們開始從字節增強的層面介紹JVM的一些高級特性。
說到字節增強我們最先想到的是字節碼,也就是本文最開頭所要研究的class文件,任何合法的源碼編譯成class后被類加載器加載進JVM的方法區,也就是以字節碼的形態存活在JVM的內存空間。這也就是我們為什么現有講明白類的結構和加載過程,而字節碼增強技術不只是在內存里面對class的字節碼進行操縱,更為復雜的是class聯動的上下游對象生命周期的管理。
首先我們回憶一下我們開發過程中最為熟悉的一個場景就是本地debug調試代碼。可能很多同學都已經習慣在IDE上對某句代碼打上斷點,然后逐步往下追蹤代碼執行的步驟。我們進一步想想,這個是怎么實現的,是一股什么樣的力量能把已經跑起來的線程踩下剎車,一步一步往前挪?我們知道線程運行其實就是在JVM的棧空間上不斷的把代碼對應的JVM指令集不斷的送到CPU執行。那能阻止這個流程的力量也肯定是發生在JVM范圍內,所以我們可以很輕松的預測到這肯定是JVM提供的機制,而不是IDE真的有這樣的能力,只不過是JVM把這種能力封裝成接口暴露出去,然后提供給IDE調用,而IDE只不過是通過界面交互來調用這些接口而已。那么下面我們就來介紹JVM這種重要的能力。
8.1JPDA
上面所講的JVM提供的程序運行斷點能力,其實JVM提供的一個工具箱JVMTI(JVM TOOL Interface)提供的接口,而這個工具箱是一套叫做JPDA的架構定義的,本節我們就來聊聊JPDA。
JPDA(Java Platform Debugger Architecture)Java平臺調試架構,既不是一個應用程序,也不是調試工具,而是定義了一系列設計良好的接口和協議用于調試java代碼,我們將會從三個層面來講解JPDA。
8.1.1概念
-
JVMTI
JVMTI(Java Virtual Machine Tool Interface)Java 虛擬機調試接口,處于最底層,是我們上文所提到的JVM開放的能力,JPDA規定了JDK必須提供一個叫做JVMTI(Java6之前是由JVMPI和JVMDI組成,Java6開始廢棄掉統一為JVMTI)的工具箱,也就是定義了一系列接口能力,比如獲取棧幀、設置斷點、斷點響應等接口,具體開放的能力參考JVMDI官方API文檔。
-
JDWP
JDWP(Java Debug Wire Protocol)Java 調試連線協議,存在在中間層,定義信息格式,定義調試者和被調試程序之間請求的協議轉換,位于JDI下一層,JDI更為抽象,JDWP則關注實現。也就是說JVM定義好提供的能力,但是如何調用JVM提供的接口也是需要規范的,就比如我們Servlet容器也接收正確合法的HTTP請求就可以成功調用接口。JPDA同樣也規范了調用JVMTI接口需要傳入數據的規范,也就是請求包的格式,類別HTTP的數據包格式。但是JPDA并不關心請求來源,也就是說只要調用JVMTI的請求方式和數據格式對了就可以,不論是來做遠程調用還是本地調用。JDWP制定了調試者和被調試應用的字節流動機制,但沒有限定具體實現,可以是遠程的socket連接,或者本機的共享內存,當然還有自定義實現的通信協議。既然只是規范了調用協議,并不局限請求來源,而且也沒限制語言限制,所以非java語言只要發起調用符合規范就可以,這個大大豐富了異構應用場景,具體的協議細節可以參考JDWP官方規范文檔。
-
JDI
JDI(Java Debug Interface)Java調試接口處在最上層,基于Java開發的調試接口,也就是我們調試客戶端,客戶端代碼封裝在jdk下面tools.jar的com.sun.jdi包里面,java程序可以直接調用的接口集合,具體提供的功能可以參考JDI官方API文檔。
8.1.2原理
介紹完JPDA的架構體系后,我們了解到JAVA調試平臺各個層級的作用,這一節我們更近一步講解JPDA各個層面的工作原理,以及三個層級結合起來時如何交互的。
JVMTI
我們JVMTI是JVM提供的一套本地接口,包含了非常豐富的功能,我們調試和優化代碼需要操作JVM,多數情況下就是調用到JVMTI,從官網我們可以看到,JVMTI包含了對JVM線程/內存/堆/棧/類/方法/變量/事件/定時器處理等的20多項功能。但其實我們通常不是直接調用JVMTI,而是創建一個代理客戶端,我們可以自由的定義對JVMTI的操作然后打包到代理客戶端里面如libagent.so。當目標程序執行時會啟動JVM,這個時候在目標程序運行前會加載代理客戶端,所以代理客戶端是跟目標程序運行在同一個進程上。這樣一來外部請求就通過代理客戶端間接調用到JVMTI,這樣的好處是我們可以在客戶端Agent里面定制高級功能,而且代理客戶端編譯打包成一個動態鏈接庫之后可以復用,提高效率。我們簡單描述一下代理客戶端Agent的工作流程。
建立代理客戶端首先需要定義Agent的入口函數,猶如Java類的main方法一樣:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);然后JVM在啟動的時候就會把JVMTI的指針JavaVM傳給代理的入口函數,options則是傳參,有了這個指針后代理就可以充分調用JVMTI的函數了。
//設置斷點,參數是調試目標方法和行數位置 jvmtiError SetBreakpoint(jvmtiEnv* env,jmethodID method,jlocation location); //當目標程序執行到指定斷點,目標線程則被掛起 jvmtiError SuspendThread(jvmtiEnv* env,jthread thread);當然除了JVM啟動時可以加載代理,運行過程中也是可以的,這個下文我們講字節碼增強還會再說到。
JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved);有興趣的同學可以自己動手寫一個Agent試試,通過調用JVMTI接口可以實現自己定制化的調試工具。
JDWP
上文我們知道調用JVMTI需要建立一個代理客戶端,但是假如我建立了包含通用功能的Agent想開發出去給所有調試器使用,有一種方式是資深開發者通過閱讀我的文檔后進行開發調用,還有另外一種方式就是我在我的Agent里面加入了JDWP協議模塊,這樣調試器就可以不用關心我的接口細節,只需按照閱讀的協議發起請求即可。JDWP是調試器和JVM中間的協議規范,類似HTTP協議一樣,JDWP也定義規范了握手協議和報文格式。
調試器發起請求的握手流程:
1)調試器發送一段包含“JDWP-Handshake”的14個bytes的字符串
2)JVM回復同樣的內容“JDWP-Handshake”
完成握手流程后就可以像HTTP一樣向JVM的代理客戶端發送請求數據,同時回復所需參數。請求和回復的數據幀也有嚴格的結構,請求的數據格式為Command Packet,回復的格式為Reply Packet,包含包頭和數據兩部分,具體格式參考官網。實際上JDWP卻是也是通過建立代理客戶端來實現報文格式的規范,也就是JDWP Agent 里面的JDWPTI實現了JDWP對協議的定義。JDWP的功能是由JDWP傳輸接口(Java Debug Wire Protocol Transport Interface)實現的,具體流程其實跟JVMTI差不多,也是講JDWPTI編譯打包成代理庫后,在JVM啟動的時候加載到目標進程。那么調試器調用的過程就是JDWP Agent接收到請求后,調用JVMTI Agent,JDWP負責定義好報文數據,而JDWPTI則是具體的執行命令和響應事件。
JDI
前面已經解釋了JVMTI和JDWP的工作原理和交互機制,剩下的就是搞清楚面向用戶的JDI是如何運行的。首先JDI位于JPDA的最頂層入口,它的實現是通過JAVA語言編寫的,所以可以理解為Java調試客戶端對JDI接口的封裝調用,比如我們熟悉的IDE界面啟動調試,或者JAVA的命令行調試客戶端JDB。
通常我們設置好目標程序的斷點之后啟動程序,然后通過調試器啟動程序之前,調試器會先獲取JVM管理器,然后通過JVM管理器對象virtualMachineManager獲取連接器Connector,調試器與虛擬機獲得鏈接后就可以啟動目標程序了。如下代碼:
VirtualMachineManager virtualMachineManager = Bootstrap.virtualMachineManager();JDI完成調試需要實現的功能有三個模塊:數據、鏈接、事件
-
數據
調試器要調試的程序在目標JVM上,那么調試之前肯定需要將目標程序的執行環境同步過來,不然我們壓根就不知道要調試什么,所以需要一種鏡像機制,把目標程序的堆棧方法區包含的數據以及接收到的事件請求都映射到調試器上面。那么JDI的底層接口Mirror就是干這樣的事,具體數據結構可以查詢文檔。
-
鏈接
我們知道調試器跟目標JVM直接的通訊是雙向的,所以鏈接雙方都可以發起。一個調試器可以鏈接多個目標JVM,但是一個目標虛擬機只能提供給一個調試器,不然就亂套了不知道聽誰指令了。JDI定義三種鏈接器:啟動鏈接器(LaunchingConnector)、依附鏈接器(AttachingConnector)、監聽鏈接器(ListeningConnector)和。分別對應的場景是目標程序JVM啟動時發起鏈接、調試器中途請求接入目標程序JVM和調試器監聽到被調試程序返回請求時發起的鏈接。
-
事件
也就是調試過程中對目標JVM返回請求的響應。
講解完JPDA體系的實現原理,我們再次梳理一下調試的整個流程:
調試器 —> JDI客戶端 —> JDWP Agent—> JVMTI Agent —>> JVMTI —> Application
8.1.3 實現
現在我們已經對整個JPDA結構有了深入理解,接下來我們就通過對這些樸素的原理來實現程序的斷點調試。當然我們不會在這里介紹從IDE的UI斷點調試的過程,因為對這套是使用已經非常熟悉了,我們知道IDE的UI斷點調試本質上是調試器客戶端對JDI的調用,那我們就通過一個調試的案例來解釋一下這背后的原理。
搭建服務
首先我們需要先搭建一個可供調試的web服務,這里我首選springboot+來搭建,通過官網生成樣例project或者maven插件都可以,具體的太基礎的就不在這里演示,該服務只提供一個Controller包含的一個簡單方法。如果使用Tomcat部署,則可以通過自有的開關catalina jpda start來啟動debug模式。
package com.zooncool.debug.rest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController("/debug") public class DebugController {@GetMappingpublic String ask(@RequestParam("name") String name) {String message = "are you ok?" + name;return message;} }啟動服務
搭建好服務之后我們先啟動服務,我們通過maven來啟動服務,其中涉及到的一些參數下面解釋。
mvn spring-boot:run -Drun.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8001" 或者 mvn spring-boot:run -Drun.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8001"-
mvn:maven的腳本命令這個不用解釋
-
Spring-boot:run:啟動springboot工程
-
-Drun.jvmArguments:執行jvm環境的參數,里面的參數值才是關鍵
-
-Xdebug
Xdebug開啟調試模式,為非標準參數,也就是可能在其他JVM上面是不可用的,Java5之后提供了標準的執行參數agentlib,下面兩種參數同樣可以開啟debug模式,但是在JIT方面有所差異,這里先不展開。
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8001
java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8001
-
Xrunjdwp/jdwp=transport:表示連接模式是本地內存共享還是遠程socket連接
-
server:y表示打開socket監聽調試器的請求;n表示被調試程序像客戶端一樣主動連接調試器
-
suspend:y表示被調試程序需要等到調試器的連接請求之后才能啟動運行,在此之前都是掛起的,n表示被調試程序無需等待直接運行。
-
address:被調試程序啟動debug模式后監聽請求的地址和端口,地址缺省為本地。
執行完上述命令后,就等著我們調試器的請求接入到目標程序了。
調試接入
我們知道java的調試器客戶端為jdb,下面我們就使用jdb來接入我們的目標程序。
#jdb 通過attach參數選擇本地目標程序,同時附上目標程序的源碼,回想之前我們講到的JDI的鏡像接口,就是把目標程序的堆棧結構同步過來,如果能我們提供的源碼對應上,那就可以在源碼上面顯示斷點標志 $ jdb -attach localhost:8001 -sourcepath /Users/linzhenhua/Documents/repositories/practice/stackify-master/remote-debugging/src/main/java/ 設置未捕獲的java.lang.Throwable 設置延遲的未捕獲的java.lang.Throwable 正在初始化jdb...#stop,選擇對應方法設置斷點 > stop in com.zooncool.debug.rest.DebugController.ask(java.lang.String) 設置斷點com.zooncool.debug.rest.DebugController.ask(java.lang.String)#如果我們設置不存在的方法為斷點,則會有錯誤提示 > stop in com.zooncool.debug.rest.DebugController.ask2(java.lang.String) 無法設置斷點com.zooncool.debug.rest.DebugController.ask2(java.lang.String): com.zooncool.debug.rest.DebugController中沒有方法ask2#這時候我們已經設置完斷點,就可以發起個HTTP請求 #http://localhost:7001/remote-debugging/debug/ask?name=Jack #發起請求后我們回到jdb控制臺,觀察是否命中斷點 > 斷點命中: "線程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=14 bci=0 14 String message = "are you ok?" + name;#list,對照源碼,確實是進入ask方法第一行命中斷點,也就是14行,這時候我們可以查看源碼 http-nio-7001-exec-5[1] list 10 @RestController("/debug") 11 public class DebugController { 12 @GetMapping 13 public String ask(@RequestParam("name") String name) { 14 => String message = "are you ok?" + name; 15 return message; 16 } 17 }#locals,觀察完源碼,我們想獲取name的傳參,跟URL傳入的一致 http-nio-7001-exec-5[1] locals 方法參數: name = "Jack" 本地變量:#print name,打印入參 http-nio-7001-exec-5[1] print namename = "Jack"#where,查詢方法調用的棧幀,從web容器入口調用方法到目標方法的調用鏈路 http-nio-7001-exec-5[1] where[1] com.zooncool.debug.rest.DebugController.ask (DebugController.java:14)...[55] java.lang.Thread.run (Thread.java:748) #step,下一步到下一行代碼 http-nio-7001-exec-5[1] step > 已完成的步驟: "線程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=15 bci=20 15 return message;#step up,完成當前方法的調用 http-nio-7001-exec-5[1] step up > 已完成的步驟: "線程=http-nio-7001-exec-5", sun.reflect.NativeMethodAccessorImpl.invoke(), 行=62 bci=103#cont,結束調試,執行完畢 http-nio-7001-exec-5[1] cont > #clear,完成調試任務,清除斷點 > clear 斷點集:斷點com.zooncool.debug.rest.DebugController.ask(java.lang.String)斷點com.zooncool.debug.rest.DebugController.ask2(java.lang.String) #選擇一個斷點刪除 > clear com.zooncool.debug.rest.DebugController.ask(java.lang.String) 已刪除: 斷點com.zooncool.debug.rest.DebugController.ask(java.lang.String)我們已經完成了命令行調試的全部流程,stop/list/locals/print name/where/step/step up/cont/clear這些命令其實就是IDE的UI后臺調用的腳本。而這些腳本就是基于JDI層面的接口所提供的能力,下面我們還有重點觀察一個核心功能,先從頭再設置一下斷點。
#stop,選擇對應方法設置斷點 > stop in com.zooncool.debug.rest.DebugController.ask(java.lang.String) 設置斷點com.zooncool.debug.rest.DebugController.ask(java.lang.String) #這時候我們已經設置完斷點,就可以發起個HTTP請求 #http://localhost:7001/remote-debugging/debug/ask?name=Jack #發起請求后我們回到jdb控制臺,觀察是否命中斷點 > 斷點命中: "線程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=14 bci=0 14 String message = "are you ok?" + name; #print name,打印入參 http-nio-7001-exec-5[1] print namename = "Jack" #如果這個時候我們想替換掉Jack,換成Lucy http-nio-7001-exec-6[1] set name = "Lucy" name = "Lucy" = "Lucy" #進入下一步 http-nio-7001-exec-6[1] step > 已完成的步驟: "線程=http-nio-7001-exec-6", com.zooncool.debug.rest.DebugController.ask(), 行=15 bci=20 15 return message; #查看變量,我們發現name的值已經被修改了 http-nio-7001-exec-6[1] locals 方法參數: name = "Lucy" 本地變量: message = "are you ok?Lucy"至此我們已經完成了JPDA的原理解析到調試實踐,也理解了JAVA調試的工作機制,其中留下一個重要的彩蛋就是通過JPDA進入調試模式,我們可以動態的修改JVM內存對象和類的內容,這也講引出下文我們要介紹的字節碼增強技術。
8.2 熱替換
8.2.1概念
終于來到熱替換這節了,前文我們做了好多鋪墊,介紹熱替換之前我們稍稍回顧一下熱部署。我們知道熱部署是“獨立”于JVM之外的一門對類加載器應用的技術,通常是應用容器借助自定義類加載器的迭代,無需重啟JVM缺能更新代碼從而達到熱部署,也就是說熱部署是JVM之外容器提供的一種能力。而本節我們介紹的熱替換技術是實打實JVM提供的能力,是JVM提供的一種能夠實時更新內存類結構的一種能力,這種實時更新JVM方法區類結構的能力當然也是無需重啟JVM實例。
熱替換HotSwap是Sun公司在Java 1.4版本引入的一種新實驗性技術,也就是上一節我們介紹JPDA提到的調試模式下可以動態替換類結構的彩蛋,這個功能被集成到JPDA框架的接口集合里面,首先我們定義好熱替換的概念。
熱替換(HotSwap):使用字節碼增強技術替換JVM內存里面類的結構,包括對應類的對象,而不需要重啟虛擬機。
8.2.2原理
前文從宏觀上介紹了JVM實例的內存布局和垃圾回收機制,微觀上也解釋了類的結構和類加載機制,上一節又學習了JAVA的調試框架,基本上我們對JVM的核心模塊都已經摸透了,剩下的就是攻克字節碼增強的技術了。而之前講的字節碼增強技術也僅僅是放在JPDA里面作為實驗性技術,而且僅僅局限在方法體和變量的修改,無法動態修改方法簽名或者增刪方法,因為字節碼增強涉及到垃圾回收機制,類結構變更,對象引用,即時編譯等復雜問題。在HotSwap被引進后至今,JCP也未能通過正式的字節碼增強實現。
JAVA是一門靜態語言,而字節碼增強所要達的效果就是讓Java像動態語言一樣跑起來,無需重啟服務器。下面我們介紹字節碼增強的基本原理。
-
反射代理
反射代理不能直接修改內存方法區的字節碼,但是可以抽象出一層代理,通過內存新增實例來實現類的更新
-
原生接口
jdk上層提供面向java語言的字節碼增強接口java.lang.instrument,通過實現ClassFileTransformer接口來操作JVM方法區的類文件字節碼。
-
JVMTI代理
JVM的JVMTI接口包含了操作方法區類文件字節碼的函數,通過創建代理,將JVMTI的指針JavaVM傳給代理,從而擁有JVM 本地操作字節碼的方法引用。
-
類加載器織入
字節碼增強接口加上類加載器的織入,結合起來也是一種熱替換技術。
-
JVM增強
直接新增JVM分支,增加字節碼增強功能。
8.2.3實現
但是盡管字節碼增強是一門復雜的技術,這并不妨礙我們進一步的探索,下面我們介紹幾種常見的實現方案。
-
Instrumentation
-
AspectJ
-
ASM
-
DCEVM
-
JREBEL
-
CGLIB
-
javassist
-
BCEL
具體的我會挑兩個具有代表性的工具深入講解,篇幅所限,這里就補展開了。
9.總結
JVM是程序發展至今的一顆隗寶,是程序設計和工程實現的完美結合。JVM作為作為三大工業級程序語言為首JAVA的根基,本文試圖在瀚如煙海的JVM海洋中找出其中最耀眼的冰山,并力求用簡潔的邏輯線索把各個冰山串起來,在腦海中對JVM的觀感有更加立體的認識。更近一步的認識JVM對程序設計的功力提示大有裨益,而本文也只是將海平面上的冰山鏈接起來,但這只是冰山一角,JVM更多的底層設計和實現細節還遠遠沒有涉及到,而且也不乏知識盲區而沒有提及到的,路漫漫其修遠兮,JVM本身也在不斷的推陳出新,借此機會總結出JVM的核心體系,以此回顧對JVM知識的查漏補缺,也是一次JVM的認知升級。最后還是例牌來兩張圖結束JVM的介紹,希望對更的同學有幫助。
?
from:https://www.javazhiyin.com/34166.html?
總結
- 上一篇: 深入理解 Java 内存模型 JMM
- 下一篇: 《深入理解java虚拟机》笔记1——Ja