高級 Java 類教程:類重載指南

已發表: 2022-03-11

在 Java 開發項目中,一個典型的工作流程包括在每次更改類時重新啟動服務器,並且沒有人抱怨它。 這是關於 Java 開發的事實。 從我們使用 Java 的第一天起,我們就一直這樣工作。 但是 Java 類的重載有那麼難實現嗎? 對於熟練的 Java 開發人員來說,解決這個問題是否既具有挑戰性又令人興奮? 在這個 Java 類教程中,我將嘗試解決這個問題,幫助您獲得動態類重新加載的所有好處,並極大地提高您的工作效率。

Java 類的重新加載很少被討論,並且很少有文檔探討這個過程。 我是來改變這一點的。 本 Java 類教程將逐步解釋此過程,並幫助您掌握這種令人難以置信的技術。 請記住,實現 Java 類重新加載需要非常小心,但學習如何實現它將使您成為 Java 開發人員和軟件架構師的大聯盟。 了解如何避免 10 個最常見的 Java 錯誤也沒有什麼壞處。

工作空間設置

本教程的所有源代碼都在此處上傳到 GitHub。

要在學習本教程時運行代碼,您將需要 Maven、Git 以及 Eclipse 或 IntelliJ IDEA。

如果您使用的是 Eclipse:

  • 運行命令mvn eclipse:eclipse生成 Eclipse 的項目文件。
  • 加載生成的項目。
  • 將輸出路徑設置為target/classes

如果您使用 IntelliJ:

  • 導入項目的pom文件。
  • 當您運行任何示例時,IntelliJ 不會自動編譯,因此您必須:
  • 在 IntelliJ 中運行示例,然後每次要編譯時,都必須按Alt+BE
  • 使用run_example*.bat在 IntelliJ 之外運行示例。 將 IntelliJ 的編譯器自動編譯設置為 true。 然後,每次您更改任何 java 文件時,IntelliJ 都會自動編譯它。

示例 1:使用 Java 類加載器重新加載類

第一個示例將使您對 Java 類加載器有一個大致的了解。 這是源代碼。

給定以下User類定義:

 public static class User { public static int age = 10; }

我們可以做到以下幾點:

 public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...

在本教程示例中,將有兩個User類加載到內存中。 userClass1將由 JVM 的默認類加載器加載, userClass2使用DynamicClassLoader加載,DynamicClassLoader 是一個自定義類加載器,其源代碼也在 GitHub 項目中提供,我將在下面詳細介紹。

這是main方法的其餘部分:

 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)); }

和輸出:

 Seems to be the same class: qj.blog.classreloading.example1.StaticInt$User qj.blog.classreloading.example1.StaticInt$User But why there are 2 different class loaders: qj.util.lang.DynamicClassLoader@3941a79c sun.misc.Launcher$AppClassLoader@1f32e575 And different age values: 11 10

正如您在此處看到的,雖然User類具有相同的名稱,但它們實際上是兩個不同的類,它們可以獨立管理和操作。 年齡值雖然聲明為靜態,但存在兩個版本,分別附加到每個類,也可以單獨更改。

在普通的 Java 程序中, ClassLoader是將類引入 JVM 的入口。 當一個類需要加載另一個類時,加載是ClassLoader的任務。

但是,在這個 Java 類示例中,名為DynamicClassLoader的自定義ClassLoader用於加載User類的第二個版本。 如果我們要再次使用默認類加載器而不是DynamicClassLoader (使用命令StaticInt.class.getClassLoader() ),那麼將使用相同的User類,因為所有加載的類都被緩存。

檢查默認 Java ClassLoader 與 DynamicClassLoader 的工作方式是從這個 Java 類教程中受益的關鍵。

DynamicClassLoader

一個普通的 Java 程序中可以有多個類加載器。 加載主類ClassLoader的類是默認類,您可以從您的代碼中創建和使用任意數量的類加載器。 那麼,這就是 Java 中類重載的關鍵。 DynamicClassLoader可能是整個教程中最重要的部分,因此我們必須了解動態類加載是如何工作的,然後才能實現我們的目標。

ClassLoader的默認行為不同,我們的DynamicClassLoader繼承了更激進的策略。 一個普通的類加載器會給它的父ClassLoader優先級,並且只加載它的父類不能加載的類。 這適用於正常情況,但不適用於我們的情況。 相反, DynamicClassLoader將嘗試查看其所有類路徑並在放棄對其父類的權限之前解析目標類。

在上面的示例中, DynamicClassLoader僅使用一個類路徑創建: "target/classes" (在我們的當前目錄中),因此它能夠加載駐留在該位置的所有類。 對於所有不在其中的類,它必須引用父類加載器。 比如我們需要在我們的StaticInt類中加載String類,而我們的類加載器無法訪問我們JRE文件夾中的rt.jar ,所以會使用父類加載器的String類。

以下代碼來自DynamicClassLoader的父類AggressiveClassLoader ,並顯示了此行為的定義位置。

 byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }

注意DynamicClassLoader的以下屬性:

  • 加載的類與默認類加載器加載的其他類具有相同的性能和其他屬性。
  • DynamicClassLoader可以與其所有加載的類和對像一起進行垃圾收集。

由於能夠加載和使用同一類的兩個版本,我們現在正在考慮轉儲舊版本並加載新版本來替換它。 在下一個示例中,我們將這樣做……不斷地。

示例 2:不斷地重新加載一個類

下一個 Java 示例將向您展示 JRE 可以永遠加載和重新加載類,轉儲舊類並收集垃圾,並從硬盤驅動器加載全新的類並投入使用。 這是源代碼。

這是主循環:

 public static void main(String[] args) { for (;;) { Class<?> userClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example2.ReloadingContinuously$User"); ReflectUtil.invokeStatic("hobby", userClass); ThreadUtil.sleep(2000); } }

每兩秒鐘,舊的User類將被轉儲,一個新的類將被加載並調用它的方法hobby

這是User類定義:

 @SuppressWarnings("UnusedDeclaration") public static class User { public static void hobby() { playFootball(); // will comment during runtime // playBasketball(); // will uncomment during runtime } // will comment during runtime public static void playFootball() { System.out.println("Play Football"); } // will uncomment during runtime // public static void playBasketball() { // System.out.println("Play Basketball"); // } }

運行此應用程序時,您應該嘗試註釋和取消註釋User類中指示代碼的代碼。 您將看到始終使用最新的定義。

這是一些示例輸出:

 ... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball

每次創建DynamicClassLoader的新實例時,它都會從target/classes文件夾中加載User類,我們在該文件夾中設置了 Eclipse 或 IntelliJ 以輸出最新的類文件。 所有舊的DynamicClassLoader和舊的User類將被取消鏈接並受到垃圾收集器的影響。

高級 Java 開發人員了解動態類重新加載(無論是活動的還是未鏈接的)至關重要。

如果您熟悉 JVM HotSpot,那麼這裡值得注意的是,類結構也可以更改和重新加載: playFootball方法將被移除,而playBasketball方法將被添加。 這與 HotSpot 不同,HotSpot 只允許更改方法內容,或者不能重新加載類。

現在我們能夠重新加載一個類,是時候嘗試一次重新加載多個類了。 讓我們在下一個示例中嘗試一下。

示例 3:重新加載多個類

此示例的輸出將與示例 2 相同,但將展示如何在具有上下文、服務和模型對象的更類似於應用程序的結構中實現此行為。 這個例子的源碼比較大,這裡只展示了一部分。 完整的源代碼在這裡。

這是main方法:

 public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }

和方法createContext

 private static Object createContext() { Class<?> contextClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example3.ContextReloading$Context"); Object context = newInstance(contextClass); invoke("init", context); return context; }

方法invokeHobbyService

 private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }

這是Context類:

 public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }

還有HobbyService類:

 public static class HobbyService { public User user; public void hobby() { user.hobby(); } }

本示例中的Context前面示例中的User類複雜得多:它具有與其他類的鏈接,並且在每次實例化時都會調用init方法。 基本上,它與現實世界應用程序的上下文類(跟踪應用程序的模塊並進行依賴注入)非常相似。 因此,能夠重新加載這個Context類以及它的所有鏈接類是將這項技術應用於現實生活的重要一步。

即使是高級 Java 工程師也很難重新加載 Java 類。

隨著類和對像數量的增加,我們“丟棄舊版本”的步驟也將變得更加複雜。 這也是類重載如此困難的最大原因。 為了可能刪除舊版本,我們必須確保在創建新上下文後,刪除對舊類和對象的所有引用。 我們如何優雅地處理這個問題?

這裡的main方法將持有上下文對象,這是所有需要刪除的東西的唯一鏈接。 如果我們斷開該鏈接,上下文對象和上下文類以及服務對象……都將受到垃圾收集器的影響。

關於為什麼通常類如此持久並且不收集垃圾的一點解釋:

  • 通常,我們將所有類加載到默認的 Java 類加載器中。
  • 類-類加載器關係是一種雙向關係,類加載器還緩存它已加載的所有類。
  • 因此,只要類加載器仍然連接到任何活動線程,一切(所有加載的類)都將不受垃圾收集器的影響。
  • 也就是說,除非我們可以將要重新加載的代碼與默認類加載器已經加載的代碼分開,否則我們的新代碼更改將永遠不會在運行時應用。

通過這個例子,我們看到重新加載所有應用程序的類實際上是相當容易的。 目標只是保持從活動線程到正在使用的動態類加載器的薄的、可刪除的連接。 但是,如果我們希望某些對象(及其類)被重新加載,並且在重新加載週期之間被重用,該怎麼辦? 讓我們看下一個例子。

示例 4:分離持久化和重新加載的類空間

這是源代碼..

main方法:

 public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }

所以你可以看到這裡的技巧是加載ConnectionPool類並在重新加載週期之外對其進行實例化,將其保持在持久空間中,並將引用傳遞給Context對象

createContext方法也有點不同:

 private static Object createContext(ConnectionPool pool) { ExceptingClassLoader classLoader = new ExceptingClassLoader( (className) -> className.contains(".crossing."), "target/classes"); Class<?> contextClass = classLoader.load("qj.blog.classreloading.example4.reloadable.Context"); Object context = newInstance(contextClass); setFieldValue(pool, "pool", context); invoke("init", context); return context; }

從現在開始,我們將在每個循環中重新加載的對象和類稱為“可重新加載空間”,而其他對象和類——在重新加載週期中未回收且未更新的對象和類——稱為“持久空間”。 我們必須非常清楚哪些對像或類位於哪個空間中,從而在這兩個空間之間劃出一條分隔線。

除非處理得當,否則這種 Java 類加載的分離可能會導致失敗。

從圖中可以看出,不僅Context對象和UserService對象引用ConnectionPool對象, ContextUserService類也引用ConnectionPool類。 這是一個非常危險的情況,常常導致混亂和失敗。 ConnectionPool類不能被我們的DynamicClassLoader加載,內存中必須只有一個ConnectionPool類,也就是默認ClassLoader加載的那個。 這就是為什麼在 Java 中設計類重載架構時要小心謹慎的一個例子。

如果我們的DynamicClassLoader不小心加載了ConnectionPool類怎麼辦? 那麼持久化空間中的ConnectionPool對象就不能傳遞給Context對象了,因為Context對象期待的是一個不同類的對象,它也叫ConnectionPool ,但實際上是一個不同的類!

那麼我們如何防止我們的DynamicClassLoader加載ConnectionPool類呢? 這個例子沒有使用DynamicClassLoader ,而是使用了一個名為ExceptingClassLoader的子類,它將基於條件函數將加載傳遞給超級類加載器:

 (className) -> className.contains("$Connection")

如果我們在這裡不使用ExceptingClassLoader ,那麼DynamicClassLoader將加載ConnectionPool類,因為該類位於“ target/classes ”文件夾中。 另一種防止ConnectionPool類被我們的DynamicClassLoader拾取的方法是將ConnectionPool類編譯到不同的文件夾中,可能在不同的模塊中,它將單獨編譯。

選擇空間的規則

現在,Java 類加載工作變得非常混亂。 我們如何確定哪些類應該在持久空間中,哪些類應該在可重新加載空間中? 以下是規則:

  1. 可重載空間中的類可能會引用持久空間中的類,但持久空間中的類可能永遠不會引用可重載空間中的類。 在前面的例子中,可重載的Context類引用了持久化的ConnectionPool類,但是ConnectionPool沒有引用Context
  2. 如果一個類不引用另一個空間中的任何類,則它可以存在於任一空間中。 例如,具有所有靜態方法(如StringUtils )的實用程序類可以在持久空間中加載一次,然後在可重新加載空間中單獨加載。

所以你可以看到規則不是很嚴格。 除了具有跨兩個空間引用的對象的交叉類之外,所有其他類都可以在持久空間或可重新加載空間或兩者中自由使用。 當然,只有可重新加載空間中的類才能享受重新加載循環。

因此,處理了類重載最具挑戰性的問題。 在下一個示例中,我們將嘗試將這種技術應用於一個簡單的 Web 應用程序,並像任何腳本語言一樣享受重新加載 Java 類的樂趣。

示例 5:小電話簿

這是源代碼..

這個示例將與普通 Web 應用程序的外觀非常相似。 它是一個帶有 AngularJS、SQLite、Maven 和 Jetty 嵌入式 Web 服務器的單頁應用程序。

這是 Web 服務器結構中的可重新加載空間:

徹底了解 Web 服務器結構中的可重載空間將幫助您掌握 Java 類加載。

Web 服務器不會保存對真實 servlet 的引用,這些 servlet 必須保留在可重新加載空間中,以便重新加載。 它擁有的是存根 servlet,每次調用它的服務方法時,它都會在實際上下文中解析實際的 servlet 以運行。

此示例還引入了一個新對象ReloadingWebContext ,它向 Web 服務器提供所有值,如普通 Context,但在內部保存對可以由DynamicClassLoader重新加載的實際上下文對象的引用。 正是這個ReloadingWebContext為 Web 服務器提供了存根 servlet。

ReloadingWebContext 在 Java 類重新加載過程中處理到 Web 服務器的存根 servlet。

ReloadingWebContext將是實際上下文的包裝器,並且:

  • 當調用到“/”的 HTTP GET 時,將重新加載實際上下文。
  • 將向 Web 服務器提供存根 servlet。
  • 每次初始化或銷毀實際上下文時都會設置值並調用方法。
  • 可以配置是否重新加載上下文,以及使用哪個類加載器進行重新加載。 這將有助於在生產中運行應用程序。

因為了解我們如何隔離持久空間和可重新加載空間非常重要,所以這裡有兩個跨越兩個空間的類:

Context中對象public F0<Connection> connF的類qj.util.funct.F0

  • 函數對象,每次調用函數時都會返回一個 Connection。 此類駐留在 qj.util 包中,該包從DynamicClassLoader中排除。

Context中對象public F0<Connection> connF的類java.sql.Connection

  • 普通 SQL 連接對象。 此類不駐留在我們的DynamicClassLoader的類路徑中,因此不會被拾取。

概括

在本 Java 類教程中,我們了解瞭如何重新加載單個類、連續重新加載單個類、重新加載多個類的整個空間以及將多個類與必須持久化的類分開重新加載。 使用這些工具,實現可靠的類重新加載的關鍵因素是擁有超級乾淨的設計。 然後你可以自由地操作你的類和整個 JVM。

實現 Java 類重載並不是世界上最簡單的事情。 但是,如果您試一試,並且在某個時候發現您的類正在動態加載,那麼您幾乎已經完成了。 在您為您的系統實現完全出色的清潔設計之前,您幾乎沒有什麼可做的。

祝我的朋友們好運,享受你新發現的超能力!