JVM 核心知識體系

1.問題

  • 一、如何理解類文件結構佈局?html

  • 二、如何應用類加載器的工做原理進行將應用展轉騰挪?java

  • 三、熱部署與熱替換有何區別,如何隔離類衝突?git

  • 四、JVM如何管理內存,有何內存淘汰機制?github

  • 五、JVM執行引擎的工做機制是什麼?web

  • 六、JVM調優應該遵循什麼原則,使用什麼工具?算法

  • 七、JPDA架構是什麼,如何應用代碼熱替換?spring

  • 八、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 {
            //一、獲取擴展類加載器
            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;
         //二、獲取擴展類加載器實現
         public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {
               if (instance == null) {
                    Class var0 = Launcher.ExtClassLoader.class;
                    synchronized(Launcher.ExtClassLoader.class) {
                        if (instance == null) {
                            //三、構造擴展類加載器
                            instance = createExtClassLoader();
                        }
                    }
                }
                return instance;
         } 
        //四、構造擴展類加載器具體實現
        private static Launcher.ExtClassLoader createExtClassLoader() throws IOException {
            try {
                return (Launcher.ExtClassLoader)AccessController.doPrivileged(new PrivilegedExceptionAction<Launcher.ExtClassLoader>() {
                    public Launcher.ExtClassLoader run() throws IOException {
                        //五、獲取擴展類加載器加載目標類的目錄
                        File[] var1 = Launcher.ExtClassLoader.getExtDirs();
                        int var2 = var1.length;
                        for(int var3 = 0; var3 < var2; ++var3) {
                            MetaIndex.registerDirectory(var1[var3]);
                        }
                        //七、構造擴展類加載器
                        return new Launcher.ExtClassLoader(var1);
                    }
                });
            } catch (PrivilegedActionException var1) {
                throw (IOException)var1.getException();
            }
        }
        //六、擴展類加載器目錄路徑
        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;
        }
        //八、擴展類加載器構造方法
        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 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方法,咱們甚至能夠讀取一份加了密的文件,而後在內存裏面解密,這樣別人反編譯你的源碼也沒用,由於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方法,實現本身的邏輯
        @Override
        public 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
0

4.2.3設計模式

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

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

  • 雙親委派機制

    定義:某個特定的類加載器在接到加載類的請求時,首先將加載任務委託給父類加載器,依次遞歸,若是父類加載器能夠完成類加載任務,就成功返回;只有父類加載器沒法完成此加載任務時,才本身去加載。

    實現:參考上文loadClass方法的源碼和註釋,經過最多三次遞歸能夠到啓動類加載器,若是仍是找不到這調用自定義方法。

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

  • 破壞委派機制

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

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

    二、」啓動類加載器」能夠指定「線程上下文類加載器」爲任意類加載器,便是「父類加載器」委託「子類加載器」去加載不屬於它加載範圍的類文件;

    說明:雙親委派機制的好處上面咱們已經提過了,可是因爲一些歷史緣由(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的模塊化的對比和關聯,後面有時間會開個專題專門介紹模塊化技術。

爲了讓學習變得輕鬆、高效,今天給你們免費分享一套阿里架構師傳授的一套教學資源。幫助你們在成爲架構師的道路上披荊斬棘。

這套視頻課程詳細講解了(Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,JVM性能優化、分佈式架構)等這些成爲架構師必備的內容!加扣羣:926611793 便可免費領取

從類加載器技術應用的角度切入咱們知道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提供了許多內存配置項,咱們能夠使用它們來設置內存大小及其比例,經常使用的以下:

VM Switch 描述
- 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:+UseG1GC

7.2.3GC記錄

要嚴格監視應用程序運行情況,咱們應始終檢查JVM的垃圾收集性能,使用如下參數,咱們能夠記錄 GC 活動:

-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=< number of log files > 
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log

UseGCLogFileRotation指定日誌文件滾動的政策,就像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的空間中使用正確的命令。例如,若是咱們想在內存不足時從新啓動服務器,咱們能夠設置參數:

-XX:OnOutOfMemoryError="shutdown -r"
  • 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 {
    @GetMapping
    public 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 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,結束調試,執行完畢
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 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;
#查看變量,咱們發現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.總結

爲了讓學習變得輕鬆、高效,今天給你們免費分享一套阿里架構師傳授的一套教學資源。幫助你們在成爲架構師的道路上披荊斬棘。

這套視頻課程詳細講解了(Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,JVM性能優化、分佈式架構)等這些成爲架構師必備的內容!加扣羣:926611793 便可免費領取

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

相關文章
相關標籤/搜索