愛鋒貝

標題: JVM核心知識體系 [打印本頁]

作者: 希慕阿巴阿巴    時間: 2023-2-8 17:06
標題: JVM核心知識體系
1.問題

2.關鍵詞

類結構,類加載器,加載,鏈接,初始化,雙親委派,熱部署,隔離,堆,棧,方法區(qū),計數(shù)器,內(nèi)存回收,執(zhí)行引擎,調優(yōu)工具,JVMTI,JDWP,JDI,熱替換,字節(jié)碼,ASM,CGLIB,DCEVM
3.全文概要(文末有驚喜,PC端閱讀代碼更佳)

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

4.1類的結構

我們知道不只JAVA文本文件,像Clojure/Groovy/Kotlin/Scala這些文本文件也同樣會經(jīng)過JDK的編譯器編程成class文件。進入到JVM領域后,其實就跟JAVA沒什么關系了,JVM只認得class文件,那么我們需要先了解class這個黑箱里面包含的是什么東西。
JVM規(guī)范嚴格定義了CLASS文件的格式,有嚴格的數(shù)據(jù)結構,下面我們可以觀察一個簡單CLASS文件包含的字段和數(shù)據(jù)類型。
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規(guī)范說明書里面查閱類文件格式(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html),類的整體布局如下圖展示的。

(, 下載次數(shù): 9)

在我的理解,我想把每個CLASS文件類別成一個一個的數(shù)據(jù)庫,里面包含的常量池/類索引/屬性表集合就像數(shù)據(jù)庫的表,而且表之間也有關聯(lián),常量池則存放著其他表所需要的所有字面量。了解完類的數(shù)據(jù)結構后,我們需要來觀察JVM是如何使用這些從硬盤上或者網(wǎng)絡傳輸過來的CLASS文件。
4.2加載機制

4.2.1類的入口

在我們探究JVM如何使用CLASS文件之前,我們快速回憶一下編寫好的C語言文件是如何執(zhí)行的?我們從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文本文件編譯成機器指令集,然后讀取到內(nèi)存直接在計算機的CPU運行。從操作系統(tǒng)層面看的話,就是一個進程的啟動到結束的生命周期。
下面我們看JAVA是怎么運行的。學習JAVA開發(fā)的第一件事就是先下載JDK安裝包,安裝完配置好環(huán)境變量,然后寫一個名字為helloWorld的類,然后編譯執(zhí)行,我們來觀察一下發(fā)生了什么事情?
先看源碼,有夠簡單了吧。
package com.zooncool.example.theory.jvm;
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("my classLoader is " + HelloWorld.class.getClassLoader());
    }
}
編譯執(zhí)行
$ 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的則是在命令行執(zhí)行java classFile,從命令的區(qū)別我們知道操作系統(tǒng)啟動的是java進程,而HelloWorld類只是命令行的入?yún)?,在操作系統(tǒng)來看java也就是一個普通的應用進程而已,而這個進程就是JVM的執(zhí)行形態(tài)(JVM靜態(tài)就是硬盤里JDK包下的二進制文件集合)。
學習過JAVA的都知道入口方法是public static void main(String[] args),缺一不可,那我猜執(zhí)行java命令時JVM對該入口方法做了唯一驗證,通過了才允許啟動JVM進程,下面我們來看這個入口方法有啥特點。
說名入口方法需要被public修飾,當然JVM調用main方法是底層的JNI方法調用不受修飾符影響。
我們是從類對象調用而不是類創(chuàng)建的對象才調用,索引需要靜態(tài)修飾
void返回類型讓JVM調用后無需關心調用者的使用情況,執(zhí)行完就停止,簡化JVM的設計。
這個我也不清楚,可能是約定俗成吧,畢竟C/C++也是用main方法的。
說了這么多main方法的規(guī)則,其實我們關心的只有兩點:
關于JVM如何使用HelloWorld下文我們會詳細講到。
我們知道JVM是由C/C++語言實現(xiàn)的,那么JVM跟CLASS打交道則需要JNI(Java Native Interface)這座橋梁,當我們在命令行執(zhí)行java時,由C/C++實現(xiàn)的java應用通過JNI找到了HelloWorld里面符合規(guī)范的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類加載器

上一節(jié)我們留了一個核心的環(huán)節(jié),就是JVM在執(zhí)行類的入口之前,首先得找到類再然后再把類裝到JVM實例里面,也即是JVM進程維護的內(nèi)存區(qū)域內(nèi)。我們當然知道是一個叫做類加載器的工具把類加載到JVM實例里面,拋開細節(jié)從操作系統(tǒng)層面觀察,那么就是JVM實例在運行過程中通過IO從硬盤或者網(wǎng)絡讀取CLASS二進制文件,然后在JVM管轄的內(nèi)存區(qū)域存放對應的文件。我們目前還不知道類加載器的實現(xiàn),但是我們從功能上判斷無非就是讀取文件到內(nèi)存,這個是很普通也很簡單的操作。
如果類加載器是C/C++實現(xiàn)的話,那么大概就是如下代碼就可以實現(xiàn)
char *fgets( char *buf, int n, FILE *fp );
如果是JAVA實現(xiàn),那么也很簡單
InputStream f = new FileInputStream("theory/jvm/HelloWorld.class");
從操作系統(tǒng)層面看的話,如果只是加載,以上代碼就足以把類文件加載到JVM內(nèi)存里面了。但是結果就是亂糟糟的把一堆毫無秩序的類文件往內(nèi)存里面扔,沒有良好的管理也沒法用,所以需要我們需要設計一套規(guī)則來管理存放內(nèi)存里面的CLASS文件,我們稱為類加載的設計模式或者類加載機制,這個下文會重點解釋。
根據(jù)官網(wǎng)的定義A class loader is an object that is responsible for loading classes. 類加載器就是負責加載類的。我們知道啟動JVM的時候會把JRE默認的一些類加載到內(nèi)存,這部分類使用的加載器是JVM默認內(nèi)置的由C/C++實現(xiàn)的,比如我們上文加載的HelloWorld.class。但是內(nèi)置的類加載器有明確的范圍限定,也就是只能加載指定路徑下的jar包(類文件的集合)。如果只是加載JRE的類,那可玩的花樣就少很多,JRE只是提供了底層所需的類,更多的業(yè)務需要我們從外部加載類來支持,所以我們需要指定新的規(guī)則,以方便我們加載外部路徑的類文件。
系統(tǒng)默認加載器

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

(, 下載次數(shù): 6)

自定義類加載器

內(nèi)置類加載器只加載了最少需要的核心JAVA基礎類和環(huán)境變量下的類,但是我們應用往往需要依賴第三方中間件來完成額外的業(yè)務,那么如何把它們的類加載進來就顯得格外重要了。幸好JVM提供了自定義類加載器,可以很方便的完成自定義操作,最終目的也是把外部的類文件加載到JVM內(nèi)存。通過繼承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 stats
                sun.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方法,我們甚至可以讀取一份加了密的文件,然后在內(nèi)存里面解密,這樣別人反編譯你的源碼也沒用,因為class是經(jīng)過加密的,也就是理論上我們通過自定義類加載器可以做到為所欲為,但是有個重要的原則下文介紹類加載器設計模式會提到。
一下給出一個自定義類加載器極簡的案例,來說明自定義類加載器的實現(xiàn)。
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;  //第一個類又系統(tǒng)默認類加載器加載
            //第二個類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;//這里修改的系統(tǒng)類加載器加載的那個類的對象,而自定義加載器加載進去的類的對象保持不變,也即是同時存在內(nèi)存,但沒有修改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方法,實現(xiàn)自己的邏輯
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            if(!name.contains("java.lang")){//排除掉加載系統(tǒng)默認需要加載的內(nèi)心類,因為些類只能又默認類加載器去加載,第三方加載會拋異常,具體原因下文解釋
                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);
            }
        }
        //把影片的二進制類文件讀入內(nèi)存字節(jié)流
        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;
        }
    }
}
執(zhí)行結果如下,我們可以看到加載到內(nèi)存方法區(qū)的兩個類的包名+名稱是一樣的,而對應的類加載器卻不一樣,而且輸出被加載類的值也是不一樣的。
-----------------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
0
4.2.3設計模式

現(xiàn)有的加載器分為內(nèi)置類加載器和自定義加載器,不管它們是通過C或者JAVA實現(xiàn)的最終都是為了把外部的CLASS文件加載到JVM內(nèi)存里面。那么我們就需要設計一套規(guī)則來管理組織內(nèi)存里面的CLASS文件,下面我們就來介紹下通過這套規(guī)則如何來協(xié)調好內(nèi)置類加載器和自定義類加載器之間的權責。
我們知道通過自定義類加載器可以干出很多黑科技,但是有個基本的雷區(qū)就是,不能隨便替代JAVA的核心基礎類,或者說即是你寫了一個跟核心類一模一樣的類,JVM也不會使用。你想一下,如果為所欲為的你可以把最基礎本的java.lang.Object都換成你自己定義的同名類,然后搞個后門進去,而且JVM還使用的話,那誰還敢用JAVA了是吧,所以我們會介紹一個重要的原則,在此之前我們先介紹一下內(nèi)置類加載器和自定義類加載器是如何協(xié)同的。


(, 下載次數(shù): 8)

雙親委派機制很好理解,目的就是為了不重復加載已有的類,提高效率,還有就是強制從父類加載器開始逐級搜索類文件,確保核心基礎類優(yōu)先加載。下面介紹的是破壞雙親委派機制,了解為什么要破壞這種看似穩(wěn)固的雙親委派機制。
介紹完內(nèi)置類加載器和自定義類加載器的協(xié)同關系后,我們要重點強調上文提到的重要原則。
4.2.4加載過程

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

上文花了不少的篇幅說明了類的結構和類是如何被加載到JVM內(nèi)存里面的,那究竟什么時候JVM才會觸發(fā)類加載器去加載外部的CLASS文件呢?通常有如下四種情況會觸發(fā)到:
JVM只定了類加載器的規(guī)范,但卻不明確規(guī)定類加載器的目標文件,把加載的具體邏輯充分交給了用戶,包括重硬盤加載的CLASS類到網(wǎng)絡,中間文件等,只要加載進去內(nèi)存的二進制數(shù)據(jù)流符合JVM規(guī)定的格式,都是合法的。
鏈接

類加載器加載完類到JVM實例的指定內(nèi)存區(qū)域(方法區(qū)下文會提到)后,是使用前會經(jīng)過驗證,準備解析的階段。
初始化

方法區(qū)經(jīng)過解析后類已經(jīng)為各個變量占好坑了,初始化就是把變量的初始值和構造方法的內(nèi)容初始化到變量的空間里面。這時候我們介質的類二進制文件所定義的內(nèi)容,已經(jīng)完全被“翻譯”方法區(qū)的某一段內(nèi)存空間了。萬事俱備只待使用了。
使用

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

我們知道JVM有垃圾回收機制(下文會詳細介紹),不需要我們操心,總體上有三個條件會觸發(fā)垃圾回收期清理方法區(qū)的空間:
本節(jié)結束我們已經(jīng)對整個類的生命周期爛熟于胸了,下面我們來介紹類加載機制最核心的幾種應用場景,來加深對類加載技術的認識。
4.3應用場景

通過前文的剖析我們已經(jīng)非常清楚類加載器的工作原理,那么我們該如何利用類加載器的特點,最大限度的發(fā)揮它的作用呢?
4.3.1熱部署

背景

熱部署這個詞匯我們經(jīng)常聽說也經(jīng)常提起,但是卻很少能夠準確的描述出它的定義。說到熱部署我們第一時間想到的可能是生產(chǎn)上的機器更新代碼后無需重啟應用容器就能更新服務,這樣的好處就是服務無需中斷可持續(xù)運行,那么與之對應的冷部署當然就是要重啟應用容器實例了。還有可能會想到的是使用IDE工具開發(fā)時不需要重啟服務,修改代碼后即時生效,這看起來可能都是服務無需重啟,但背后的運行機制確截然不同,首先我們需要對熱部署下一個準確的定義。
首先熱部署應用容器擁有的一種能力,這種能力是容器本身設計出來的,跟具體的IDE開發(fā)工具無關。而且熱部署無需重啟服務器,應用可以保持用戶態(tài)不受影響。上文提到我們開發(fā)環(huán)境使用IDE工具通常也可以設置無需重啟的功能,有別于熱部署的是此時我們應用的是JVM的本身附帶的熱替換能力(HotSwap)。熱部署和熱替換是兩個完全不同概念,在開發(fā)過程中也常常相互配合使用,導致我們很多人經(jīng)?;煜拍?,所以接下來我們來剖析熱部署的實現(xiàn)原理,而熱替換的高級特性我們會在下文字節(jié)碼增強的章節(jié)中介紹。
原理

從熱部署的定義我們知道它是應用容器蘊含的一項能力,要達到的目的就是在服務沒有重啟的情況下更新應用,也就是把新的代碼編譯后產(chǎn)生的新類文件替換掉內(nèi)存里的舊類文件。結合前文我們介紹的類加載器特性,這似乎也不是很難,分兩步應該可以完成。由于同一個類加載器只能加載一次類文件,那么新增一個類加載器把新的類文件加載進內(nèi)存。此時內(nèi)存里面同時存在新舊的兩個類(類名路徑一樣,但是類加載器不一樣),要做的就是如何使用新的類,同時卸載舊的類及其對象,完成這兩步其實也就是熱部署的過程了。也即是通過使用新的類加載器,重新加載應用的類,從而達到新代碼熱部署。
實現(xiàn)

理解了熱部署的工作原理,下面通過一系列極簡的例子來一步步實現(xiàn)熱部署,為了方便讀者演示,以下例子我盡量都在一個java文件里面完成所有功能,運行的時候復制下去就可以跑起來。
參考4.2.2中自定義類加載器區(qū)別系統(tǒng)默認加載器的案例,從該案例實踐中我們可以將相同的類(包名+類名),不同”版本“(類加載器不一樣)的類同時加載進JVM內(nèi)存方法區(qū)。
既然一個類通過不同類加載器可以被多次加載到JVM內(nèi)存里面,那么類的經(jīng)過修改編譯后再加載進內(nèi)存。有別于上一步給出的例子只是修改對象的值,這次我們是直接修改類的內(nèi)容,從應用的視角看其實就是應用更新,那如何做到在線程運行不中斷的情況下更換新類呢?
下面給出的也是一個很簡單的例子,ClassReloading啟動main方法通過死循環(huán)不斷創(chuàng)建類加載器,同時不斷加載類而且執(zhí)行類的方法。注意new MyClassLoader(“target/classes”)的路徑更加編譯的class路徑來修改,其他直接復制過去就可以執(zhí)行演示了。
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 (;;){//用死循環(huán)讓線程持續(xù)運行未中斷狀態(tài)
            //通過反射調用目標類的入口方法
            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());
            //如果換成系統(tǒng)默認類加載器的話,因為雙親委派原則,默認使用應用類加載器,而且能加載一次
            //休眠是為了在刪除舊類編譯新類的這段時間內(nèi)不執(zhí)行加載動作
            //不然會找不到類文件
            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線程執(zhí)行過程不斷輪流注釋say()和ask()代碼,然后編譯類,觀察程序輸出。
如下輸出結果,我們可以看出每一次循環(huán)調用都新創(chuàng)建一個自定義類加載器,然后通過反射創(chuàng)建對象調用方法,在修改代碼編譯后,新的類就會通過反射創(chuàng)建對象執(zhí)行新的代碼業(yè)務,而主線程則一直沒有中斷運行。讀到這里,其實我們已經(jīng)基本觸達了熱部署的本質了,也就是實現(xiàn)了手動無中斷部署。但是缺點就是需要我們手動編譯代碼,而且內(nèi)存不斷新增類加載器和對象,如果速度過快而且頻繁更新,還可能造成堆溢出,下一個例子我們將增加一些機制來保證舊的類和對象能被垃圾收集器自動回收。
what is your name
what is your name
what is your name//修改代碼,編譯新類
my name is lucy
my name is lucy
what is your name//修改代碼,編譯新類
通常情況下類加載器會持有該加載器加載過的所有類的引用,所有如果類是經(jīng)過系統(tǒng)默認類加載器加載的話,那就很難被垃圾收集器回收,除非符合根節(jié)點不可達原則才會被回收。
下面繼續(xù)給出一個很簡單的例子,我們知道ClassReloading只是不斷創(chuàng)建新的類加載器來加載新類從而更新類的方法。下面的例子我們模擬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();//創(chuàng)建應用上下文
            invokeContext(context);//通過上下文對象context調用業(yè)務方法
            Thread.sleep(5000);
        }
    }
    //創(chuàng)建應用的上下文,context是整個應用的GC roots,創(chuàng)建完返回對象之前調用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();//通過反射創(chuàng)建對象
        contextClass.getDeclaredMethod("init").invoke(context);//通過反射調用初始化方法init()
        return context;
    }
    //業(yè)務方法,調用context的業(yè)務方法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{
        ...
    }
}
輸出結果跟上一個例子相似,可以自己運行試試。我們更新業(yè)務方法編譯通過后,無需重啟main方法,新的業(yè)務就能生效,而且也解決了舊類卸載的核心問題,因為context的應用對象的跟節(jié)點,context是由我們自定義類加載器所加載,由于User/Dao/Service都是依賴context,所以其類也是又自定義類加載器所加載。根據(jù)GC roots原理,在創(chuàng)建新的自定義類加載器之后,舊的類加載器已經(jīng)沒有任何引用鏈可訪達,符合GC回收規(guī)則,將會被GC收集器回收釋放內(nèi)存。至此已經(jīng)完成應用熱部署的流程,但是細心的朋友可能會發(fā)現(xiàn),我們熱部署的策略是整個上下文context都替換成新的,那么用戶的狀態(tài)也將無法保留。而實際情況是我們只需要動態(tài)更新某些模塊的功能,而不是全局。這個其實也好辦,就是我們從業(yè)務上把需要熱部署的由自定義類加載器加載,而持久化的類資源則由系統(tǒng)默認類加載器去完成。
其實設計到代碼設計優(yōu)雅問題,基本上我們拿出設計模式23章經(jīng)對號入座基本可以解決問題,畢竟這是前人經(jīng)過千萬實踐錘煉出來的軟件構建內(nèi)功心法。那么針對我們熱部署的場景,如果想把熱部署細節(jié)封裝出來,那代理模式無疑是最符合要求的,也就是咱們弄出個代理對象來面向用戶,把類加載器的更替,回收,隔離等細節(jié)都放在代理對象里面完成,而對于用戶來說是透明無感知的,那么終端用戶體驗起來就是純粹的熱部署了。至于如何實現(xiàn)自動熱部署,方式也很簡單,監(jiān)聽我們部署的目錄,如果文件時間和大小發(fā)生變化,則判斷應用需要更新,這時候就觸發(fā)類加載器的創(chuàng)建和舊對象的回收,這個時候也可以引入觀察者模式來實現(xiàn)。由于篇幅限制,本例子就留給讀者朋友自行設計,相信也是不難完成的。
案例

上一節(jié)我們深入淺出的從自定義類加載器的開始引入,到實現(xiàn)多個類加載器加載同個類文件,最后完成舊類加載器和對象的回收,整個流程闡述了熱部署的實現(xiàn)細節(jié)。那么這一節(jié)我們介紹現(xiàn)有實現(xiàn)熱部署的通用解決方案,本質就是對上文原理的實現(xiàn),加上性能和設計上的優(yōu)化,注意本節(jié)我們應用的只是類加載器的技術,后面章節(jié)還會介紹的字節(jié)碼層面的底層操作技術。
OSGI(Open Service Gateway Initiative)是一套開發(fā)和部署應用程序的java框架。我們從官網(wǎng)可以看到OSGI其實是一套規(guī)范,好比Servlet定義了服務端對于處理來自網(wǎng)絡請求的一套規(guī)范,比如init,service,destroy的生命周期。然后我們通過實行這套規(guī)范來實現(xiàn)與客戶端的交互,在調用init初始化完Servlet對象后通過多線程模式使用service響應網(wǎng)絡請求。如果從響應模式比較我們還可以了解下Webflux的規(guī)范,以上兩種都是處理網(wǎng)絡請求的方式,當然你舉例說CGI也是一種處理網(wǎng)絡請求的規(guī)范,CGI采用的是多進程方式來處理網(wǎng)絡請求,我們暫時不對這兩種規(guī)范進行優(yōu)劣評價,只是說明在處理網(wǎng)絡請求的場景下可以采用不同的規(guī)范來實現(xiàn)。
好了現(xiàn)在回到OSGi,有了上面的鋪墊,相信對我們理解OSGI大有幫助。我們說OSGI首先是一種規(guī)范,既然是規(guī)范我們就要看看都規(guī)范了啥,比如Servlet也是一種規(guī)范,它規(guī)范了生命周期,規(guī)定應用容器中WEB-INF/classes目錄或WEB-INF/lib目錄下的jar包才會被Web容器處理。同樣OSGI的實現(xiàn)框架對管轄的Bundle下面的目錄組織和文本格式也有嚴格規(guī)范,更重要的是OSGI對模塊化架構生命周期的管理。而模塊化也不只是把系統(tǒng)拆分成不同的JAR包形成模塊而已,真正的模塊化必須將模塊中類的引入/導出、隱藏、依賴、版本管理貫穿到生命周期管理中去。
定義:OSGI是脫胎于(OSGI Alliance)技術聯(lián)盟由一組規(guī)范和對應子規(guī)范共同定義的JAVA動態(tài)模塊化技術。實現(xiàn)該規(guī)范的OSGI框架(如Apache Felix)使應用程序的模塊能夠在本地或者網(wǎng)絡中實現(xiàn)端到端的通信,目前已經(jīng)發(fā)布了第7版。OSGI有很多優(yōu)點諸如熱部署,類隔離,高內(nèi)聚,低耦合的優(yōu)勢,但同時也帶來了性能損耗,而且基于OSGI目前的規(guī)范繁多復雜,開發(fā)門檻較高。
組成:執(zhí)行環(huán)境,安全層,模塊層,生命周期層,服務層,框架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標準服務

(, 下載次數(shù): 7)

本節(jié)我們討論的核心是熱部署,所以我們不打算在這里講解全部得OSGI技術,在上文實現(xiàn)熱部署后我們重點來剖析OSGI關于熱部署的機制。至于OSGI模塊化技術和java9的模塊化的對比和關聯(lián),后面有時間會開個專題專門介紹模塊化技術。
從類加載器技術應用的角度切入我們知道OSGI規(guī)范也是打破雙親委派機制,除了框架層面需要依賴JVM默認類加載器之外,其他Bundle(OSGI定義的模塊單元)都是由各自的類加載器來加載,而OSGI框架就負責模塊生命周期,模塊交互這些核心功能,同時創(chuàng)建各個Bundle的類加載器,用于直接加載Bundle定義的jar包。由于打破雙親委派模式,Bundle類加載器不再是雙親委派模型中的樹狀結構,而是進一步發(fā)展為更加復雜的網(wǎng)狀結構(因為各個Bundle之間有相互依賴關系),當收到類加載請求時,OSGi將按照下面的順序進行類搜索:
1)將以java.*開頭的類委派給父類加載器加載。
2)否則,將委派列表名單內(nèi)(比如sun或者javax這類核心類的包加入白名單)的類委派給父類加載器加載。
3)否則,將Import列表中的類委派給Export這個類的Bundle的類加載器加載。
4)否則,查找當前Bundle的ClassPath,使用自己的類加載器加載。
5)否則,查找類是否在自己的Fragment Bundle(OSGI框架緩存包)中,如果在,則委派給Fragment Bundle的類加載器加載。
6)否則,查找Dynamic Import列表的Bundle,委派給對應Bundle的類加載器加載。
7)否則,類查找失敗。
這一系列的類加載操作,其實跟我們上節(jié)實現(xiàn)的自定義類加載技術本質上是一樣的,只不過實現(xiàn)OSGI規(guī)范的框架需要提供模塊之間的注冊通信組件,還有模塊的生命周期管理,版本管理。OSGI也只是JVM上面運行的一個普通應用實例,只不過通過模塊內(nèi)聚,版本管理,服務依賴一系列的管理,實現(xiàn)了模塊的即時更新,實現(xiàn)了熱部署。
其他熱部署解決方案多數(shù)也是利用類加載器的特點做文章,當然不止是類加載器,還會應用字節(jié)碼技術,下面我們主要簡單列舉應用類加載器實現(xiàn)的熱部署解決方案。
Groovy兼顧動態(tài)腳本語言的功能,使用的時候無外乎也是通過GroovyClassLoader來加載腳本文件,轉為JVM的類對象。那么每次更新groovy腳本就可以動態(tài)更新應用,也就達到了熱部署的功能了。
Class groovyClass = classLoader.parseClass(new GroovyCodeSource(sourceFile));
GroovyObject instance = (GroovyObject)groovyClass.newInstance();//proxy
介紹完熱部署技術,可能很多同學對熱部署的需求已經(jīng)沒有那么強烈,畢竟熱部署過程中帶來的弊端也不容忽視,比如替換舊的類加載器過程會產(chǎn)生大量的內(nèi)存碎片,導致JVM進行高負荷的GC工作,反復進行熱部署還會導致JVM內(nèi)存不足而導致內(nèi)存溢出,有時候甚至還不如直接重啟應用來得更快一點,而且隨著分布式架構的演進和微服務的流行,應用重啟也早就實現(xiàn)服務編排化,配合豐富的部署策略,也可以同樣保證系統(tǒng)穩(wěn)定持續(xù)服務,我們更多的是通過熱部署技術來深刻認識到JVM加載類的技術演進。
4.3.2類隔離

背景

先介紹一下類隔離的背景,我們費了那么大的勁設計出類加載器,如果只是用于加載外部類字節(jié)流那就過于浪費了。通常我們的應用依賴不同的第三方類庫經(jīng)常會出現(xiàn)不同版本的類庫,如果只是使用系統(tǒng)內(nèi)置的類加載器的話,那么一個類庫只能加載唯一的一個版本,想加載其他版本的時候會從緩存里面發(fā)現(xiàn)已經(jīng)存在而停止加載。但是我們的不同業(yè)務以來的往往是不同版本的類庫,這時候就會出現(xiàn)ClassNotFoundException。為什么只有運行的是才會出現(xiàn)這個異常呢,因為編譯的時候我們通常會使用MAVEN等編譯工具把沖突的版本排除掉。另外一種情況是WEB容器的內(nèi)核依賴的第三方類庫需要跟應用依賴的第三方類庫隔離開來,避免一些安全隱患,不然如果共用的話,應用升級依賴版本就會導致WEB容器不穩(wěn)定。
基于以上的介紹我們知道類隔離實在是剛需,那么接下來介紹一下如何實現(xiàn)這個剛需。
原理

首先我們要了解一下原理,其實原理很簡單,真的很簡單,請允許我總結為“唯一標識原理”。我們知道內(nèi)存里面定位類實例的坐標<類加載器,類全限定名>。那么由這兩個因子組合起來我們可以得出一種普遍的應用,用不同類加載器來加載類相同類(類全限定名一致,版本不一致)是可以實現(xiàn)的,也就是在JVM看來,有相同類全名的類是完全不同的兩個實例,但是在業(yè)務視角我們卻可以視為相同的類。
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;
}
實現(xiàn)

原理很簡單,比如我們知道Spring容器本質就是一個生產(chǎn)和管理bean的集合對象,但是卻包含了大量的優(yōu)秀設計模式和復雜的框架實現(xiàn)。同理隔離容器雖然原理很簡單,但是要實現(xiàn)一個高性能可擴展的高可用隔離容器,卻不是那么簡單。我們上文談的場景是在內(nèi)存運行的時候才發(fā)現(xiàn)問題,介紹內(nèi)存隔離技術之前,我們先普及更為通用的沖突解決方法。
5.內(nèi)存管理

5.1內(nèi)存結構

5.1.1邏輯分區(qū)

JVM內(nèi)存從應用邏輯上可分為如下區(qū)域。
而實際上JVM內(nèi)存分類實際上的物理分區(qū)還有更為詳細,整體上分為堆內(nèi)存和非堆內(nèi)存,具體介紹如下。
5.1.2 內(nèi)存模型

堆內(nèi)存

堆內(nèi)存是運行時的數(shù)據(jù)區(qū),從中分配所有java類實例和數(shù)組的內(nèi)存,可以理解為目標應用依賴的對象。堆在JVM啟動時創(chuàng)建,并且在應用程序運行時可能會增大或減小??梢允褂?Xms 選項指定堆的大小。堆可以是固定大小或可變大小,具體取決于垃圾收集策略。可以使用-Xmx選項設置最大堆大小。默認情況下,最大堆大小設置為64 MB。
JVM堆內(nèi)存在物理上分為兩部分:新生代和老年代。新生代是為分配新對象而保留堆空間。當新生代占用完時,Minor GC垃圾收集器會對新生代區(qū)域執(zhí)行垃圾回收動作,其中在新生代中生活了足夠長的所有對象被遷移到老年代,從而釋放新生代空間以進行更多的對象分配。此垃圾收集稱為 Minor GC。新生代分為三個子區(qū)域:伊甸園Eden區(qū)和兩個幸存區(qū)S0和S1。

(, 下載次數(shù): 7)

關于新生代內(nèi)存空間:
當老年區(qū)填滿時,老年區(qū)同樣會執(zhí)行垃圾回收,老年區(qū)還包含那些經(jīng)過多Minor GC后還存活的長壽對象。垃圾收集器在老年代內(nèi)存中執(zhí)行的回收稱為Major GC,通常需要更長的時間。
非堆內(nèi)存

JVM的堆以外內(nèi)存稱為非堆內(nèi)存。也即是JVM自身預留的內(nèi)存區(qū)域,包含JVM緩存空間,類結構如常量池、字段和方法數(shù)據(jù),方法,構造方法。類非堆內(nèi)存的默認最大大小為64 MB??梢允褂?XX:MaxPermSize VM選項更改此選項,非堆內(nèi)存通常包含如下性質的區(qū)域空間:
在Java 8以上版本已經(jīng)沒有Perm Gen這塊區(qū)域了,這也意味著不會再由關于“java.lang.OutOfMemoryError:PermGen”內(nèi)存問題存在了。與駐留在Java堆中的Perm Gen不同,Metaspace不是堆的一部分。類元數(shù)據(jù)多數(shù)情況下都是從本地內(nèi)存中分配的。默認情況下,元空間會自動增加其大小(直接又底層操作系統(tǒng)提供),而Perm Gen始終具有固定的上限??梢允褂脙蓚€新標志來設置Metaspace的大小,它們是:“ - XX:MetaspaceSize ”和“ -XX:MaxMetaspaceSize ”。Metaspace背后的含義是類的生命周期及其元數(shù)據(jù)與類加載器的生命周期相匹配。也就是說,只要類加載器處于活動狀態(tài),元數(shù)據(jù)就會在元數(shù)據(jù)空間中保持活動狀態(tài),并且無法釋放。
運行Java程序時,它以分層方式執(zhí)行代碼。在第一層,它使用客戶端編譯器(C1編譯器)來編譯代碼。分析數(shù)據(jù)用于服務器編譯的第二層(C2編譯器),以優(yōu)化的方式編譯該代碼。默認情況下,Java 7中未啟用分層編譯,但在Java 8中啟用了分層編譯。實時(JIT)編譯器將編譯的代碼存儲在稱為代碼緩存的區(qū)域中。它是一個保存已編譯代碼的特殊堆。如果該區(qū)域的大小超過閾值,則該區(qū)域將被刷新,并且GC不會重新定位這些對象。Java 8中已經(jīng)解決了一些性能問題和編譯器未重新啟用的問題,并且在Java 7中避免這些問題的解決方案之一是將代碼緩存的大小增加到一個永遠不會達到的程度。
方法區(qū)域是Perm Gen中空間的一部分,用于存儲類結構(運行時常量和靜態(tài)變量)以及方法和構造函數(shù)的代碼。
內(nèi)存池由JVM內(nèi)存管理器創(chuàng)建,用于創(chuàng)建不可變對象池。內(nèi)存池可以屬于Heap或Perm Gen,具體取決于JVM內(nèi)存管理器實現(xiàn)。
常量包含類運行時常量和靜態(tài)方法,常量池是方法區(qū)域的一部分。
Java堆棧內(nèi)存用于執(zhí)行線程。它們包含特定于方法的特定值,以及對從該方法引用的堆中其他對象的引用。
Java提供了許多內(nèi)存配置項,我們可以使用它們來設置內(nèi)存大小及其比例,常用的如下:
VM Switch描述
- Xms用于在JVM啟動時設置初始堆大小
-Xmx用于設置最大堆大小
-Xmn設置新生區(qū)的大小,剩下的空間用于老年區(qū)
-XX:PermGen用于設置永久區(qū)存初始大小
-XX:MaxPermGen用于設置Perm Gen的最大尺寸
-XX:SurvivorRatio提供Eden區(qū)域的比例
-XX:NewRatio用于提供老年代/新生代大小的比例,默認值為2
5.2垃圾回收

5.2.1垃圾回收策略

流程

垃圾收集是釋放堆中的空間以分配新對象的過程。垃圾收集器是JVM管理的進程,它可以查看內(nèi)存中的所有對象,并找出程序任何部分未引用的對象,刪除并回收空間以分配給其他對象。通常會經(jīng)過如下步驟:
策略

虛擬機棧、本地棧和程序計數(shù)器在編譯完畢后已經(jīng)可以確定所需內(nèi)存空間,程序執(zhí)行完畢后也會自動釋放所有內(nèi)存空間,所以不需要進行動態(tài)回收優(yōu)化。JVM內(nèi)存調優(yōu)主要針對堆和方法區(qū)兩大區(qū)域的內(nèi)存。通常對象分為Strong、sfot、weak和phantom四種類型,強引用不會被回收,軟引用在內(nèi)存達到溢出邊界時回收,弱引用在每次回收周期時回收,虛引用專門被標記為回收對象,具體回收策略如下:
算法

垃圾收集有如下常用的算法:
5.2.2 垃圾回收器

分類

配置

6.執(zhí)行引擎

6.1執(zhí)行流程

類加載器加載的類文件字節(jié)碼數(shù)據(jù)流由基于JVM指令集架構的執(zhí)行引擎來執(zhí)行。執(zhí)行引擎以指令為單位讀取Java字節(jié)碼。我們知道匯編執(zhí)行的流程是CPU執(zhí)行每一行的匯編指令,同樣JVM執(zhí)行引擎就像CPU一個接一個地執(zhí)行機器命令。字節(jié)碼的每個命令都包含一個1字節(jié)的OpCode和附加的操作數(shù)。執(zhí)行引擎獲取一個OpCode并使用操作數(shù)執(zhí)行任務,然后執(zhí)行下一個OpCode。但Java是用人們可以理解的語言編寫的,而不是用機器直接執(zhí)行的語言編寫的。因此執(zhí)行引擎必須將字節(jié)碼更改為JVM中的機器可以執(zhí)行的語言。字節(jié)碼可以通過以下兩種方式之一轉化為合適的語言。
但是,JIT編譯器編譯代碼需要花費更多的時間,而不是解釋器逐個解釋代碼。因此,如果代碼只執(zhí)行一次,最好是選擇解釋而不是編譯。因此,使用JIT編譯器的JVM在內(nèi)部檢查方法執(zhí)行的頻率,并僅在頻率高于某個級別時編譯方法。

(, 下載次數(shù): 7)

JVM規(guī)范中未定義執(zhí)行引擎的運行方式。因此,JVM廠商使用各種技術改進其執(zhí)行引擎,并引入各種類型的JIT編譯器。 大多數(shù)JIT編譯器運行如下圖所示:

(, 下載次數(shù): 7)

JIT編譯器將字節(jié)碼轉換為中間級表達式IR,以執(zhí)行優(yōu)化,然后將表達式轉換為本機代碼。Oracle Hotspot VM使用名為Hotspot Compiler的JIT編譯器。它被稱為Hotspot,因為Hotspot Compiler通過分析搜索需要以最高優(yōu)先級進行編譯的“Hotspot”,然后將熱點編譯為本機代碼。如果不再頻繁調用編譯了字節(jié)碼的方法,換句話說,如果該方法不再是熱點,則Hotspot VM將從緩存中刪除本機代碼并以解釋器模式運行。Hotspot VM分為服務器VM和客戶端VM,兩個VM使用不同的JIT編譯器。
大多數(shù)Java性能改進都是通過改進執(zhí)行引擎來實現(xiàn)的。除了JIT編譯器之外,還引入了各種優(yōu)化技術,因此可以不斷改進JVM性能。初始JVM和最新JVM之間的最大區(qū)別是執(zhí)行引擎。
下面我們通過下圖可以看出JAVA執(zhí)行的流程。

(, 下載次數(shù): 9)

6.2棧幀結構

每個方法調用開始到執(zhí)行完成的過程,對應這一個棧幀在虛擬機棧里面從入棧到出棧的過程。
下圖是JVM實例執(zhí)行方法是的內(nèi)存布局。

(, 下載次數(shù): 6)

6.3早期編譯

6.4晚期編譯

HotSpot虛擬機內(nèi)的即時編譯
解析模式 -Xint
編譯模式 -Xcomp
混合模式 Mixed mode
分層編譯:解釋執(zhí)行 -> C1(Client Compiler)編譯 -> C2編譯(Server Compiler)
觸發(fā)條件:基于采樣的熱點探測,基于計數(shù)器的熱點探測
7.性能調優(yōu)

7.1調優(yōu)原則

我們知道調優(yōu)的前提是,程序沒有達到我們的預期要求,那么第一步要做的是衡量我們的預期。程序不可能十全十美,我們要做的是通過各種指標來衡量系統(tǒng)的性能,最終整體達到我們的要求。
7.1.1 環(huán)境

首先我們要了解系統(tǒng)的運行環(huán)境,包括操作系統(tǒng)層面的差異,JVM版本,位數(shù),乃至于硬件的時鐘周期,總線設計甚至機房溫度,都可能是我們需要考慮的前置條件。
7.1.2 度量

首先我們要先給出系統(tǒng)的預期指標,在特定的硬件/軟件的配置,然后給出目標指標,比如系統(tǒng)整體輸出接口的QPS,RT,或者更進一層,IO讀寫,cpu的load指標,內(nèi)存的使用率,GC情況都是我們需要預先考察的對象。
7.1.3 監(jiān)測

確定了環(huán)境前置條件,分析了度量指標,第三步是通過工具來監(jiān)測指標,下一節(jié)提供了常用JVM調優(yōu)工具,可以通過不同工具的組合來發(fā)現(xiàn)定位問題,結合JVM的工作機制已經(jīng)操作系統(tǒng)層面的調度流程,按圖索驥來發(fā)現(xiàn)問題,找出問題后才能進行優(yōu)化。
7.1.4 原則

總體的調優(yōu)原則如下圖

(, 下載次數(shù): 6)

圖片來源《Java Performance》
7.2 調優(yōu)參數(shù)

上節(jié)給出了JVM性能調優(yōu)的原則,我們理清思路后應用不同的JVM工具來發(fā)現(xiàn)系統(tǒng)存在的問題,下面列舉的是常用的JVM參數(shù),通過這些參數(shù)指標可以更快的幫助我們定位出問題所在。
7.2.1內(nèi)存查詢

最常見的與性能相關的做法之一是根據(jù)應用程序要求初始化堆內(nèi)存。這就是我們應該指定最小和最大堆大小的原因。以下參數(shù)可用于實現(xiàn)它:
-Xms<heap size>[unit] -Xmx<heap size>[unit]
unit表示要初始化內(nèi)存(由堆大小表示)的單元。單位可以標記為GB的“g”,MB的“m”和KB的“k”。例如JVM分配最小2 GB和最大5 GB:
-Xms2G -Xmx5G
從Java 8開始Metaspace的大小未被定義,一旦達到限制JVM會自動增加它,為了避免不必要的不穩(wěn)定性,我們可以設置Metaspace大?。?br /> -XX:MaxMetaspaceSize=<metaspace size>[unit]
默認情況下YG的最小大小為1310 MB,最大大小不受限制,我們可以明確地指定它們:
-XX:NewSize=<young size>[unit]
-XX:MaxNewSize=<young size>[unit]
7.2.2垃圾回收

JVM有四種類型的GC實現(xiàn):
可以使用以下參數(shù)聲明這些實現(xiàn):
-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+USeParNewGC
-XX:+UseG1GC
7.2.3GC記錄

要嚴格監(jiān)視應用程序運行狀況,我們應始終檢查JVM的垃圾收集性能,使用以下參數(shù),我們可以記錄GC活動:
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=< number of log files >
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log
UseGCLogFileRotation指定日志文件滾動的政策,就像log4j的,s4lj等 NumberOfGCLogFiles表示單個應用程序記錄生命周期日志文件的最大數(shù)量。GCLogFileSize指定文件的最大大小。 loggc表示其位置。這里要注意的是,還有兩個可用的JVM參數(shù)(-XX:+ PrintGCTimeStamps-XX:+ PrintGCDateStamps),可用于在GC日志中打印日期時間戳。
7.2.4內(nèi)存溢出

大型應用程序面臨內(nèi)存不足的錯誤是很常見的,這是一個非常關鍵的場景,很難復制以解決問題。
這就是JVM帶有一些參數(shù)的原因,這些參數(shù)將堆內(nèi)存轉儲到一個物理文件中,以后可以用它來查找泄漏:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./java_pid<pid>.hprof
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"
-XX:+UseGCOverheadLimit
這里有幾點需要注意:
-XX:OnOutOfMemoryError="shutdown -r"
7.2.5其他配置

7.3 調優(yōu)工具

7.3.1命令行工具

7.3.2可視化工具

8.字節(jié)增強

我們從類加載的應用介紹了熱部署和類隔離兩大應用場景,但是基于類加載器的技術始終只是獨立于JVM內(nèi)核功能而存在的,也就是所有實現(xiàn)都只是基于最基礎的類加載機制,并無應用其他JVM 高級特性,本章節(jié)我們開始從字節(jié)增強的層面介紹JVM的一些高級特性。
說到字節(jié)增強我們最先想到的是字節(jié)碼,也就是本文最開頭所要研究的class文件,任何合法的源碼編譯成class后被類加載器加載進JVM的方法區(qū),也就是以字節(jié)碼的形態(tài)存活在JVM的內(nèi)存空間。這也就是我們?yōu)槭裁船F(xiàn)有講明白類的結構和加載過程,而字節(jié)碼增強技術不只是在內(nèi)存里面對class的字節(jié)碼進行操縱,更為復雜的是class聯(lián)動的上下游對象生命周期的管理。
首先我們回憶一下我們開發(fā)過程中最為熟悉的一個場景就是本地debug調試代碼??赡芎芏嗤瑢W都已經(jīng)習慣在IDE上對某句代碼打上斷點,然后逐步往下追蹤代碼執(zhí)行的步驟。我們進一步想想,這個是怎么實現(xiàn)的,是一股什么樣的力量能把已經(jīng)跑起來的線程踩下剎車,一步一步往前挪?我們知道線程運行其實就是在JVM的??臻g上不斷的把代碼對應的JVM指令集不斷的送到CPU執(zhí)行。那能阻止這個流程的力量也肯定是發(fā)生在JVM范圍內(nèi),所以我們可以很輕松的預測到這肯定是JVM提供的機制,而不是IDE真的有這樣的能力,只不過是JVM把這種能力封裝成接口暴露出去,然后提供給IDE調用,而IDE只不過是通過界面交互來調用這些接口而已。那么下面我們就來介紹JVM這種重要的能力。
8.1JPDA

上面所講的JVM提供的程序運行斷點能力,其實JVM提供的一個工具箱JVMTI(JVM TOOL Interface)提供的接口,而這個工具箱是一套叫做JPDA的架構定義的,本節(jié)我們就來聊聊JPDA。
JPDA(Java Platform Debugger Architecture)Java平臺調試架構,既不是一個應用程序,也不是調試工具,而是定義了一系列設計良好的接口和協(xié)議用于調試java代碼,我們將會從三個層面來講解JPDA。
8.1.1概念



(, 下載次數(shù): 8)

8.1.2原理

介紹完JPDA的架構體系后,我們了解到JAVA調試平臺各個層級的作用,這一節(jié)我們更近一步講解JPDA各個層面的工作原理,以及三個層級結合起來時如何交互的。
JVMTI

我們JVMTI是JVM提供的一套本地接口,包含了非常豐富的功能,我們調試和優(yōu)化代碼需要操作JVM,多數(shù)情況下就是調用到JVMTI,從官網(wǎng)我們可以看到,JVMTI包含了對JVM線程/內(nèi)存/堆/棧/類/方法/變量/事件/定時器處理等的20多項功能。但其實我們通常不是直接調用JVMTI,而是創(chuàng)建一個代理客戶端,我們可以自由的定義對JVMTI的操作然后打包到代理客戶端里面如libagent.so。當目標程序執(zhí)行時會啟動JVM,這個時候在目標程序運行前會加載代理客戶端,所以代理客戶端是跟目標程序運行在同一個進程上。這樣一來外部請求就通過代理客戶端間接調用到JVMTI,這樣的好處是我們可以在客戶端Agent里面定制高級功能,而且代理客戶端編譯打包成一個動態(tài)鏈接庫之后可以復用,提高效率。我們簡單描述一下代理客戶端Agent的工作流程。
建立代理客戶端首先需要定義Agent的入口函數(shù),猶如Java類的main方法一樣:
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
然后JVM在啟動的時候就會把JVMTI的指針JavaVM傳給代理的入口函數(shù),options則是傳參,有了這個指針后代理就可以充分調用JVMTI的函數(shù)了。
//設置斷點,參數(shù)是調試目標方法和行數(shù)位置
jvmtiError SetBreakpoint(jvmtiEnv* env,jmethodID method,jlocation location);
//當目標程序執(zhí)行到指定斷點,目標線程則被掛起
jvmtiError SuspendThread(jvmtiEnv* env,jthread thread);
當然除了JVM啟動時可以加載代理,運行過程中也是可以的,這個下文我們講字節(jié)碼增強還會再說到。
JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved);
有興趣的同學可以自己動手寫一個Agent試試,通過調用JVMTI接口可以實現(xiàn)自己定制化的調試工具。
JDWP

上文我們知道調用JVMTI需要建立一個代理客戶端,但是假如我建立了包含通用功能的Agent想開發(fā)出去給所有調試器使用,有一種方式是資深開發(fā)者通過閱讀我的文檔后進行開發(fā)調用,還有另外一種方式就是我在我的Agent里面加入了JDWP協(xié)議模塊,這樣調試器就可以不用關心我的接口細節(jié),只需按照閱讀的協(xié)議發(fā)起請求即可。JDWP是調試器和JVM中間的協(xié)議規(guī)范,類似HTTP協(xié)議一樣,JDWP也定義規(guī)范了握手協(xié)議和報文格式。
調試器發(fā)起請求的握手流程:
1)調試器發(fā)送一段包含“JDWP-Handshake”的14個bytes的字符串
2)JVM回復同樣的內(nèi)容“JDWP-Handshake”
完成握手流程后就可以像HTTP一樣向JVM的代理客戶端發(fā)送請求數(shù)據(jù),同時回復所需參數(shù)。請求和回復的數(shù)據(jù)幀也有嚴格的結構,請求的數(shù)據(jù)格式為Command Packet,回復的格式為Reply Packet,包含包頭和數(shù)據(jù)兩部分,具體格式參考官網(wǎng)。實際上JDWP卻是也是通過建立代理客戶端來實現(xiàn)報文格式的規(guī)范,也就是JDWP Agent 里面的JDWPTI實現(xiàn)了JDWP對協(xié)議的定義。JDWP的功能是由JDWP傳輸接口(Java Debug Wire Protocol Transport Interface)實現(xiàn)的,具體流程其實跟JVMTI差不多,也是講JDWPTI編譯打包成代理庫后,在JVM啟動的時候加載到目標進程。那么調試器調用的過程就是JDWP Agent接收到請求后,調用JVMTI Agent,JDWP負責定義好報文數(shù)據(jù),而JDWPTI則是具體的執(zhí)行命令和響應事件。
JDI

前面已經(jīng)解釋了JVMTI和JDWP的工作原理和交互機制,剩下的就是搞清楚面向用戶的JDI是如何運行的。首先JDI位于JPDA的最頂層入口,它的實現(xiàn)是通過JAVA語言編寫的,所以可以理解為Java調試客戶端對JDI接口的封裝調用,比如我們熟悉的IDE界面啟動調試,或者JAVA的命令行調試客戶端JDB。
通常我們設置好目標程序的斷點之后啟動程序,然后通過調試器啟動程序之前,調試器會先獲取JVM管理器,然后通過JVM管理器對象virtualMachineManager獲取連接器Connector,調試器與虛擬機獲得鏈接后就可以啟動目標程序了。如下代碼:
VirtualMachineManager virtualMachineManager = Bootstrap.virtualMachineManager();
JDI完成調試需要實現(xiàn)的功能有三個模塊:數(shù)據(jù)、鏈接、事件
講解完JPDA體系的實現(xiàn)原理,我們再次梳理一下調試的整個流程:
調試器 —> JDI客戶端 —> JDWP Agent—> JVMTI Agent —>> JVMTI —> Application
8.1.3 實現(xiàn)

現(xiàn)在我們已經(jīng)對整個JPDA結構有了深入理解,接下來我們就通過對這些樸素的原理來實現(xiàn)程序的斷點調試。當然我們不會在這里介紹從IDE的UI斷點調試的過程,因為對這套是使用已經(jīng)非常熟悉了,我們知道IDE的UI斷點調試本質上是調試器客戶端對JDI的調用,那我們就通過一個調試的案例來解釋一下這背后的原理。
搭建服務

首先我們需要先搭建一個可供調試的web服務,這里我首選springboot+來搭建,通過官網(wǎng)生成樣例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 {
    @GetMapping
    public String ask(@RequestParam("name") String name) {
        String message = "are you ok?" + name;
        return message;
    }
}
啟動服務

搭建好服務之后我們先啟動服務,我們通過maven來啟動服務,其中涉及到的一些參數(shù)下面解釋。
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"
執(zhí)行完上述命令后,就等著我們調試器的請求接入到目標程序了。
調試接入

我們知道java的調試器客戶端為jdb,下面我們就使用jdb來接入我們的目標程序。
#jdb 通過attach參數(shù)選擇本地目標程序,同時附上目標程序的源碼,回想之前我們講到的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

#這時候我們已經(jīng)設置完斷點,就可以發(fā)起個HTTP請求
#http://localhost:7001/remote-debugging/debug/ask?name=Jack
#發(fā)起請求后我們回到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
方法參數(shù):
name = "Jack"
本地變量:

#print name,打印入?yún)?br /> http-nio-7001-exec-5[1] print name
name = "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,結束調試,執(zhí)行完畢
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)
我們已經(jīng)完成了命令行調試的全部流程,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)
#這時候我們已經(jīng)設置完斷點,就可以發(fā)起個HTTP請求
#http://localhost:7001/remote-debugging/debug/ask?name=Jack
#發(fā)起請求后我們回到jdb控制臺,觀察是否命中斷點
> 斷點命中: "線程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=14 bci=0
14            String message = "are you ok?" + name;
#print name,打印入?yún)?br /> http-nio-7001-exec-5[1] print name
name = "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;
#查看變量,我們發(fā)現(xiàn)name的值已經(jīng)被修改了
http-nio-7001-exec-6[1] locals
方法參數(shù):
name = "Lucy"
本地變量:
message = "are you ok?Lucy"
至此我們已經(jīng)完成了JPDA的原理解析到調試實踐,也理解了JAVA調試的工作機制,其中留下一個重要的彩蛋就是通過JPDA進入調試模式,我們可以動態(tài)的修改JVM內(nèi)存對象和類的內(nèi)容,這也講引出下文我們要介紹的字節(jié)碼增強技術。
8.2 熱替換

8.2.1概念

終于來到熱替換這節(jié)了,前文我們做了好多鋪墊,介紹熱替換之前我們稍稍回顧一下熱部署。我們知道熱部署是“獨立”于JVM之外的一門對類加載器應用的技術,通常是應用容器借助自定義類加載器的迭代,無需重啟JVM缺能更新代碼從而達到熱部署,也就是說熱部署是JVM之外容器提供的一種能力。而本節(jié)我們介紹的熱替換技術是實打實JVM提供的能力,是JVM提供的一種能夠實時更新內(nèi)存類結構的一種能力,這種實時更新JVM方法區(qū)類結構的能力當然也是無需重啟JVM實例。
熱替換HotSwap是Sun公司在Java 1.4版本引入的一種新實驗性技術,也就是上一節(jié)我們介紹JPDA提到的調試模式下可以動態(tài)替換類結構的彩蛋,這個功能被集成到JPDA框架的接口集合里面,首先我們定義好熱替換的概念。
熱替換(HotSwap):使用字節(jié)碼增強技術替換JVM內(nèi)存里面類的結構,包括對應類的對象,而不需要重啟虛擬機。
8.2.2原理

前文從宏觀上介紹了JVM實例的內(nèi)存布局和垃圾回收機制,微觀上也解釋了類的結構和類加載機制,上一節(jié)又學習了JAVA的調試框架,基本上我們對JVM的核心模塊都已經(jīng)摸透了,剩下的就是攻克字節(jié)碼增強的技術了。而之前講的字節(jié)碼增強技術也僅僅是放在JPDA里面作為實驗性技術,而且僅僅局限在方法體和變量的修改,無法動態(tài)修改方法簽名或者增刪方法,因為字節(jié)碼增強涉及到垃圾回收機制,類結構變更,對象引用,即時編譯等復雜問題。在HotSwap被引進后至今,JCP也未能通過正式的字節(jié)碼增強實現(xiàn)。
JAVA是一門靜態(tài)語言,而字節(jié)碼增強所要達的效果就是讓Java像動態(tài)語言一樣跑起來,無需重啟服務器。下面我們介紹字節(jié)碼增強的基本原理。
8.2.3實現(xiàn)

但是盡管字節(jié)碼增強是一門復雜的技術,這并不妨礙我們進一步的探索,下面我們介紹幾種常見的實現(xiàn)方案。
具體的我會挑兩個具有代表性的工具深入講解,篇幅所限,這里就補展開了。
9.總結

JVM是程序發(fā)展至今的一顆隗寶,是程序設計和工程實現(xiàn)的完美結合。JVM作為作為三大工業(yè)級程序語言為首JAVA的根基,本文試圖在瀚如煙海的JVM海洋中找出其中最耀眼的冰山,并力求用簡潔的邏輯線索把各個冰山串起來,在腦海中對JVM的觀感有更加立體的認識。更近一步的認識JVM對程序設計的功力提示大有裨益,而本文也只是將海平面上的冰山鏈接起來,但這只是冰山一角,JVM更多的底層設計和實現(xiàn)細節(jié)還遠遠沒有涉及到,而且也不乏知識盲區(qū)而沒有提及到的,路漫漫其修遠兮,JVM本身也在不斷的推陳出新,借此機會總結出JVM的核心體系,以此回顧對JVM知識的查漏補缺,也是一次JVM的認知升級。最后還是例牌來兩張圖結束JVM的介紹,希望對更的同學有幫助。

(, 下載次數(shù): 7)

(, 下載次數(shù): 8)

-----------------------------




歡迎光臨 愛鋒貝 (http://7gfy2te7.cn/) Powered by Discuz! X3.4