原來熱載入如此簡單,手動寫一個 Java 熱載入吧

  • 2019 年 10 月 29 日
  • 筆記

1. 什麼是熱載入

熱載入是指可以在不重啟服務的情況下讓更改的程式碼生效,熱載入可以顯著的提升開發以及調試的效率,它是基於 Java 的類載入器實現的,但是由於熱載入的不安全性,一般不會用於正式的生產環境。

2. 熱載入與熱部署的區別

首先,不管是熱載入還是熱部署,都可以在不重啟服務的情況下編譯/部署項目,都是基於 Java 的類載入器實現的。

那麼兩者到底有什麼區別呢?

在部署方式上:

  • 熱部署是在伺服器運行時重新部署項目。
  • 熱載入是在運行時重新載入 class

在實現原理上:

  • 熱部署是直接重新載入整個應用,耗時相對較高。
  • 熱載入是在運行時重新載入 class,後台會啟動一個執行緒不斷檢測你的類是否改變。

在使用場景上:

  • 熱部署更多的是在生產環境使用。
  • 熱載入則更多的是在開發環境上使用。線上由於安全性問題不會使用,難以監控。

3. 類載入五個階段

類的生命周期

可能你已經發現了,圖中一共是7個階段,而不是5個。是因為圖是類的完整生命周期,如果要說只是類載入階段的話,圖裡最後的使用(Using)和卸載(Unloading)並不算在內。

簡單描述一下類載入的五個階段:

  1. 載入階段:找到類的靜態存儲結構,載入到虛擬機,定義數據結構。用戶可以自定義類載入器。

  2. 驗證階段:確保位元組碼是安全的,確保不會對虛擬機的安全造成危害。

  3. 準備階段:確定記憶體布局,確定記憶體遍歷,賦初始值(注意:是初始值,也有特殊情況)。

  4. 解析階段: 將符號變成直接引用。

  5. 初始化階段:調用程式自定義的程式碼。規定有且僅有5種情況必須進行初始化。
    1. new(實例化對象)、getstatic(獲取類變數的值,被final修飾的除外,他的值在編譯器時放到了常量池)、putstatic(給類變數賦值)、invokestatic(調用靜態方法) 時會初始化
    2. 調用子類的時候,發現父類還沒有初始化,則父類需要立即初始化。
    3. 虛擬機啟動,用戶要執行的主類,主類需要立即初始化,如 main 方法。
    4. 使用 java.lang.reflect包的方法對類進行反射調用方法 是會初始化。
    5. 當使用JDK 1.7的動態語言支援時, 如果一個java.lang.invoke.MethodHandle實例最後
      的解析結果REF_getStatic、 REF_putStatic、 REF_invokeStatic的方法句柄, 並且這個方法句柄
      所對應的類沒有進行過初始化, 則需要先觸發其初始化。

要說明的是,類載入的 5 個階段中,只有載入階段是用戶可以自定義處理的,而驗證階段、準備階段、解析階段、初始化階段都是用 JVM 來處理的。

4. 實現類的熱載入

4.1 實現思路

我們怎麼才能手動寫一個類的熱載入呢?根據上面的分析,Java 程式在運行的時候,首先會把 class 類文件載入到 JVM 中,而類的載入過程又有五個階段,五個階段中只有載入階段用戶可以進行自定義處理,所以我們如果能在程式程式碼更改且重新編譯後,讓運行的進程可以實時獲取到新編譯後的 class 文件,然後重新進行載入的話,那麼理論上就可以實現一個簡單的 Java 熱載入

所以我們可以得出實現思路:

  1. 實現自己的類載入器。
  2. 從自己的類載入器中載入要熱載入的類。
  3. 不斷輪訓要熱載入的類 class 文件是否有更新。
  4. 如果有更新,重新載入。

4.2 自定義類載入器

設計 Java 虛擬機的團隊把類的載入階段放到的 JVM 的外部實現( 通過一個類的全限定名來獲取描述此類的二進位位元組流 )。這樣就可以讓程式自己決定如果獲取到類資訊。而實現這個載入動作的程式碼模組,我們就稱之為 「類載入器」。

在 Java 中,類載入器也就是 java.lang.ClassLoader. 所以如果我們想要自己實現一個類載入器,就需要繼承 ClassLoader 然後重寫裡面 findClass的方法,同時因為類載入器是 雙親委派模型實現(也就說。除了一個最頂層的類載入器之外,每個類載入器都要有父載入器,而載入時,會先詢問父載入器能否載入,如果父載入器不能載入,則會自己嘗試載入)所以我們還需要指定父載入器。

最後根據傳入的類路徑,載入類的程式碼看下面。

package net.codingme.box.classloader;    import java.io.ByteArrayOutputStream;  import java.io.File;  import java.io.FileInputStream;    /**   * <p>   * 自定義 Java類載入器來實現Java 類的熱載入   *   * @Author niujinpeng   * @Date 2019/10/24 23:22   */  public class MyClasslLoader extends ClassLoader {        /** 要載入的 Java 類的 classpath 路徑 */      private String classpath;        public MyClasslLoader(String classpath) {          // 指定父載入器          super(ClassLoader.getSystemClassLoader());          this.classpath = classpath;      }        @Override      protected Class<?> findClass(String name) throws ClassNotFoundException {          byte[] data = this.loadClassData(name);          return this.defineClass(name, data, 0, data.length);      }        /**       * 載入 class 文件中的內容       *       * @param name       * @return       */      private byte[] loadClassData(String name) {          try {              // 傳進來是帶包名的              name = name.replace(".", "//");              FileInputStream inputStream = new FileInputStream(new File(classpath + name + ".class"));              // 定義位元組數組輸出流              ByteArrayOutputStream baos = new ByteArrayOutputStream();              int b = 0;              while ((b = inputStream.read()) != -1) {                  baos.write(b);              }              inputStream.close();              return baos.toByteArray();          } catch (Exception e) {              e.printStackTrace();          }          return null;      }  }

4.3 定義要類型熱載入的類

我們假設某個介面(BaseManager.java)下的某個方法(logic)要進行熱載入處理。

首先定義介面資訊。

package net.codingme.box.classloader;    /**   * <p>   * 實現這個介面的子類,需要動態更新。也就是熱載入   *   * @Author niujinpeng   * @Date 2019/10/24 23:29   */  public interface BaseManager {        public void logic();  }

寫一個這個介面的實現類。

package net.codingme.box.classloader;    import java.time.LocalTime;    /**   * <p>   * BaseManager 這個介面的子類要實現類的熱載入功能。   *   * @Author niujinpeng   * @Date 2019/10/24 23:30   */  public class MyManager implements BaseManager {        @Override      public void logic() {          System.out.println(LocalTime.now() + ": Java類的熱載入");      }  }

後面我們要做的就是讓這個類可以通過我們的 MyClassLoader 進行自定義載入。類的熱載入應當只有在類的資訊被更改然後重新編譯之後進行重新載入。所以為了不意義的重複載入,我們需要判斷 class 是否進行了更新,所以我們需要記錄 class 類的修改時間,以及對應的類資訊。

所以編譯一個類用來記錄某個類對應的某個類載入器以及上次載入的 class 的修改時間。

package net.codingme.box.classloader;    /**   * <p>   * 封裝載入類的資訊   *   * @Author niujinpeng   * @Date 2019/10/24 23:32   */  public class LoadInfo {        /** 自定義的類載入器 */      private MyClasslLoader myClasslLoader;        /** 記錄要載入的類的時間戳-->載入的時間 */      private long loadTime;        /** 需要被熱載入的類 */      private BaseManager manager;        public LoadInfo(MyClasslLoader myClasslLoader, long loadTime) {          this.myClasslLoader = myClasslLoader;          this.loadTime = loadTime;      }        public MyClasslLoader getMyClasslLoader() {          return myClasslLoader;      }        public void setMyClasslLoader(MyClasslLoader myClasslLoader) {          this.myClasslLoader = myClasslLoader;      }        public long getLoadTime() {          return loadTime;      }        public void setLoadTime(long loadTime) {          this.loadTime = loadTime;      }        public BaseManager getManager() {          return manager;      }        public void setManager(BaseManager manager) {          this.manager = manager;      }  }

4.4 熱載入獲取類資訊

在實現思路里,我們知道輪訓檢查 class 文件是不是被更新過,所以每次調用要熱載入的類時,我們都要進行檢查類是否被更新然後決定要不要重新載入。為了方便這步的獲取操作,可以使用一個簡單的工廠模式進行封裝。

要注意是載入 class 文件需要指定完整的路徑,所以類中定義了 CLASS_PATH 常量。

package net.codingme.box.classloader;    import java.io.File;  import java.lang.reflect.InvocationTargetException;  import java.util.HashMap;  import java.util.Map;    /**   * <p>   * 載入 manager 的工廠   *   * @Author niujinpeng   * @Date 2019/10/24 23:38   */  public class ManagerFactory {        /** 記錄熱載入類的載入資訊 */      private static final Map<String, LoadInfo> loadTimeMap = new HashMap<>();        /** 要載入的類的 classpath */      public static final String CLASS_PATH = "D:\IdeaProjectMy\lab-notes\target\classes\";        /** 實現熱載入的類的全名稱(包名+類名 ) */      public static final String MY_MANAGER = "net.codingme.box.classloader.MyManager";        public static BaseManager getManager(String className) {          File loadFile = new File(CLASS_PATH + className.replaceAll("\.", "/") + ".class");          // 獲取最後一次修改時間          long lastModified = loadFile.lastModified();          System.out.println("當前的類時間:" + lastModified);          // loadTimeMap 不包含 ClassName 為 key 的資訊,證明這個類沒有被載入,要載入到 JVM          if (loadTimeMap.get(className) == null) {              load(className, lastModified);          } // 載入類的時間戳變化了,我們同樣要重新載入這個類到 JVM。          else if (loadTimeMap.get(className).getLoadTime() != lastModified) {              load(className, lastModified);          }          return loadTimeMap.get(className).getManager();      }        /**       * 載入 class ,快取到 loadTimeMap       *       * @param className       * @param lastModified       */      private static void load(String className, long lastModified) {          MyClasslLoader myClasslLoader = new MyClasslLoader(className);          Class loadClass = null;          // 載入          try {              loadClass = myClasslLoader.loadClass(className);          } catch (ClassNotFoundException e) {              e.printStackTrace();          }            BaseManager manager = newInstance(loadClass);          LoadInfo loadInfo = new LoadInfo(myClasslLoader, lastModified);          loadInfo.setManager(manager);          loadTimeMap.put(className, loadInfo);      }        /**       * 以反射的方式創建 BaseManager 的子類對象       *       * @param loadClass       * @return       */      private static BaseManager newInstance(Class loadClass) {          try {              return (BaseManager)loadClass.getConstructor(new Class[] {}).newInstance(new Object[] {});          } catch (InstantiationException e) {              e.printStackTrace();          } catch (IllegalAccessException e) {              e.printStackTrace();          } catch (InvocationTargetException e) {              e.printStackTrace();          } catch (NoSuchMethodException e) {              e.printStackTrace();          }          return null;      }  }

4.5 熱載入測試

直接寫一個執行緒不斷的檢測要熱載入的類是不是已經更改需要重新載入,然後運行測試即可。

package net.codingme.box.classloader;    /**   * <p>   *   * 後台啟動一條執行緒,不斷檢測是否要刷新重新載入,實現了熱載入的類   *   * @Author niujinpeng   * @Date 2019/10/24 23:53   */  public class MsgHandle implements Runnable {      @Override      public void run() {          while (true) {              BaseManager manager = ManagerFactory.getManager(ManagerFactory.MY_MANAGER);              manager.logic();              try {                  Thread.sleep(2000);              } catch (InterruptedException e) {                  e.printStackTrace();              }          }      }  }

主執行緒:

package net.codingme.box.classloader;    public class ClassLoadTest {      public static void main(String[] args) {          new Thread(new MsgHandle()).start();      }  }

程式碼已經全部準備好了,最後一步,可以啟動測試了。如果你是用的是 Eclipse ,直接啟動就行了;如果是 IDEA ,那麼你需要 DEBUG 模式啟動(IDEA 對熱載入有一定的限制)。

啟動後看到控制台不斷的輸出:

00:08:13.018: Java類的熱載入  00:08:15.018: Java類的熱載入

這時候我們隨便更改下 MyManager 類的 logic 方法的輸出內容然後保存。

@Override  public void logic() {       System.out.println(LocalTime.now() + ": Java類的熱載入 Oh~~~~");  }

可以看到控制台的輸出已經自動更改了(IDEA 在更改後需要按 CTRL + F9)。

程式碼已經放到Github: https://github.com/niumoo/lab-notes/

<完>

個人網站:https://www.codingme.net
如果你喜歡這篇文章,可以關注公眾號,文章第一時間直達 。
關注公眾號回復資源可以沒有套路的獲取全網最火的的 Java 核心知識整理&面試資料。