Fortgeschrittenes Java-Klassen-Tutorial: Eine Anleitung zum Neuladen von Klassen

Veröffentlicht: 2022-03-11

Ein typischer Workflow in Java-Entwicklungsprojekten besteht darin, den Server bei jeder Klassenänderung neu zu starten, und niemand beschwert sich darüber. Das ist eine Tatsache über die Java-Entwicklung. So arbeiten wir seit unserem ersten Tag mit Java. Aber ist das Neuladen von Java-Klassen so schwierig zu erreichen? Und könnte dieses Problem für erfahrene Java-Entwickler sowohl herausfordernd als auch spannend zu lösen sein? In diesem Java-Klassen-Tutorial werde ich versuchen, das Problem anzugehen, Ihnen dabei zu helfen, alle Vorteile des spontanen Neuladens von Klassen zu nutzen und Ihre Produktivität enorm zu steigern.

Das Neuladen von Java-Klassen wird nicht oft diskutiert, und es gibt sehr wenig Dokumentation, die diesen Prozess untersucht. Ich bin hier, um das zu ändern. Dieses Java-Klassen-Tutorial bietet eine schrittweise Erklärung dieses Prozesses und hilft Ihnen, diese unglaubliche Technik zu beherrschen. Denken Sie daran, dass das Implementieren des Neuladens von Java-Klassen sehr viel Sorgfalt erfordert, aber wenn Sie lernen, wie es geht, werden Sie sowohl als Java-Entwickler als auch als Softwarearchitekt in den oberen Ligen spielen. Es schadet auch nicht zu verstehen, wie man die 10 häufigsten Java-Fehler vermeidet.

Arbeitsbereich einrichten

Der gesamte Quellcode für dieses Tutorial wird hier auf GitHub hochgeladen.

Um den Code auszuführen, während Sie diesem Tutorial folgen, benötigen Sie Maven, Git und entweder Eclipse oder IntelliJ IDEA.

Wenn Sie Eclipse verwenden:

  • Führen Sie den Befehl mvn eclipse:eclipse aus, um die Projektdateien von Eclipse zu generieren.
  • Laden Sie das generierte Projekt.
  • Ausgabepfad auf target/classes setzen.

Wenn Sie IntelliJ verwenden:

  • Importieren Sie die pom -Datei des Projekts.
  • IntelliJ wird nicht automatisch kompiliert, wenn Sie ein Beispiel ausführen, also müssen Sie entweder:
  • Führen Sie die Beispiele in IntelliJ aus, dann müssen Sie jedes Mal, wenn Sie kompilieren möchten, Alt+BE drücken
  • Führen Sie die Beispiele außerhalb von IntelliJ mit run_example*.bat . Stellen Sie die automatische Kompilierung des Compilers von IntelliJ auf „true“ ein. Jedes Mal, wenn Sie eine Java-Datei ändern, kompiliert IntelliJ sie automatisch.

Beispiel 1: Neuladen einer Klasse mit Java Class Loader

Das erste Beispiel vermittelt Ihnen ein allgemeines Verständnis des Java-Klassenladers. Hier ist der Quellcode.

Angesichts der folgenden User :

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

Wir können Folgendes tun:

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

In diesem Lernbeispiel werden zwei User in den Speicher geladen. userClass1 wird vom Standard-Klassenlader der JVM geladen, und userClass2 mit dem DynamicClassLoader , einem benutzerdefinierten Klassenlader, dessen Quellcode ebenfalls im GitHub-Projekt bereitgestellt wird und den ich im Folgenden ausführlich beschreiben werde.

Hier ist der Rest der 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)); }

Und die Ausgabe:

 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

Wie Sie hier sehen können, sind die User , obwohl sie denselben Namen haben, eigentlich zwei verschiedene Klassen, und sie können unabhängig voneinander verwaltet und manipuliert werden. Der Alterswert, obwohl als statisch deklariert, existiert in zwei Versionen, die jeder Klasse separat zugeordnet sind, und kann auch unabhängig geändert werden.

In einem normalen Java-Programm ist ClassLoader das Portal, das Klassen in die JVM bringt. Wenn eine Klasse das Laden einer anderen Klasse erfordert, ist es die Aufgabe des ClassLoader , das Laden durchzuführen.

In diesem Java-Klassenbeispiel wird jedoch der benutzerdefinierte ClassLoader mit dem Namen DynamicClassLoader verwendet, um die zweite Version der User -Klasse zu laden. Wenn wir anstelle von DynamicClassLoader wieder den Standard-Klassenlader verwenden (mit dem Befehl StaticInt.class.getClassLoader() ), wird dieselbe User verwendet, da alle geladenen Klassen zwischengespeichert werden.

Die Untersuchung der Funktionsweise des Standard-Java-ClassLoader im Vergleich zu DynamicClassLoader ist der Schlüssel, um von diesem Java-Klassen-Tutorial zu profitieren.

Der DynamicClassLoader

In einem normalen Java-Programm kann es mehrere Classloader geben. Diejenige, die Ihre Hauptklasse, ClassLoader , lädt, ist die Standardklasse, und aus Ihrem Code können Sie so viele Klassenlader erstellen und verwenden, wie Sie möchten. Dies ist also der Schlüssel zum Neuladen von Klassen in Java. Der DynamicClassLoader ist möglicherweise der wichtigste Teil dieses gesamten Tutorials, daher müssen wir verstehen, wie das dynamische Laden von Klassen funktioniert, bevor wir unser Ziel erreichen können.

Im Gegensatz zum Standardverhalten von ClassLoader erbt unser DynamicClassLoader eine aggressivere Strategie. Ein normaler Classloader würde seinem Eltern ClassLoader die Priorität geben und nur Klassen laden, die sein Elternteil nicht laden kann. Das ist für normale Umstände geeignet, aber nicht in unserem Fall. Stattdessen versucht der DynamicClassLoader , alle seine Klassenpfade zu durchsuchen und die Zielklasse aufzulösen, bevor er das Recht an seine Eltern abgibt.

In unserem obigen Beispiel wird der DynamicClassLoader mit nur einem Klassenpfad erstellt: "target/classes" (in unserem aktuellen Verzeichnis), sodass er alle Klassen laden kann, die sich an diesem Speicherort befinden. Für alle Klassen, die dort nicht enthalten sind, muss es auf den übergeordneten Classloader verweisen. Beispielsweise müssen wir die String -Klasse in unsere StaticInt -Klasse laden, und unser Klassenlader hat keinen Zugriff auf die rt.jar in unserem JRE-Ordner, sodass die String -Klasse des übergeordneten Klassenladers verwendet wird.

Der folgende Code stammt von AggressiveClassLoader , der übergeordneten Klasse von DynamicClassLoader , und zeigt, wo dieses Verhalten definiert ist.

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

Beachten Sie die folgenden Eigenschaften von DynamicClassLoader :

  • Die geladenen Klassen haben dieselbe Leistung und andere Attribute wie andere Klassen, die vom standardmäßigen Klassenlader geladen werden.
  • Der DynamicClassLoader kann zusammen mit all seinen geladenen Klassen und Objekten einer Garbage Collection unterzogen werden.

Mit der Möglichkeit, zwei Versionen derselben Klasse zu laden und zu verwenden, denken wir jetzt darüber nach, die alte Version zu löschen und die neue zu laden, um sie zu ersetzen. Im nächsten Beispiel werden wir genau das tun ... kontinuierlich.

Beispiel 2: Kontinuierliches Neuladen einer Klasse

Dieses nächste Java-Beispiel wird Ihnen zeigen, dass die JRE Klassen für immer laden und neu laden kann, wobei alte Klassen abgelegt und der Garbage Collection unterzogen werden und brandneue Klassen von der Festplatte geladen und verwendet werden. Hier ist der Quellcode.

Hier ist die Hauptschleife:

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

Alle zwei Sekunden wird die alte User -Klasse ausgegeben, eine neue geladen und ihre Methode hobby aufgerufen.

Hier ist die Definition der 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"); // } }

Wenn Sie diese Anwendung ausführen, sollten Sie versuchen, den angegebenen Code in der User zu kommentieren und zu entkommentieren. Sie werden sehen, dass immer die neueste Definition verwendet wird.

Hier ist eine Beispielausgabe:

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

Jedes Mal, wenn eine neue Instanz von DynamicClassLoader erstellt wird, lädt sie die User aus dem Ordner „ target/classes “, in dem wir Eclipse oder IntelliJ so eingestellt haben, dass die neueste Klassendatei ausgegeben wird. Alle alten DynamicClassLoader s und alten User werden entkoppelt und dem Garbage Collector unterzogen.

Es ist wichtig, dass fortgeschrittene Java-Entwickler das dynamische Neuladen von Klassen verstehen, ob aktiv oder nicht verknüpft.

Wenn Sie sich mit JVM HotSpot auskennen, dann ist hier bemerkenswert, dass die Klassenstruktur auch geändert und neu geladen werden kann: Die Methode playFootball soll entfernt und die Methode playBasketball hinzugefügt werden. Dies unterscheidet sich von HotSpot, bei dem nur Methodeninhalte geändert werden können oder die Klasse nicht neu geladen werden kann.

Jetzt, da wir in der Lage sind, eine Klasse neu zu laden, ist es an der Zeit, zu versuchen, viele Klassen auf einmal neu zu laden. Probieren wir es im nächsten Beispiel aus.

Beispiel 3: Neuladen mehrerer Klassen

Die Ausgabe dieses Beispiels entspricht der von Beispiel 2, zeigt jedoch, wie dieses Verhalten in einer eher anwendungsähnlichen Struktur mit Kontext-, Dienst- und Modellobjekten implementiert werden kann. Der Quellcode dieses Beispiels ist ziemlich umfangreich, daher habe ich hier nur Teile davon gezeigt. Der vollständige Quellcode ist hier.

Hier ist die main :

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

Und die Methode 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; }

Die Methode invokeHobbyService :

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

Und hier ist die Context -Klasse:

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

Und die HobbyService -Klasse:

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

Die Context -Klasse in diesem Beispiel ist viel komplizierter als die User -Klasse in den vorherigen Beispielen: Sie hat Links zu anderen Klassen und die init -Methode muss bei jeder Instanziierung aufgerufen werden. Grundsätzlich ist es den Kontextklassen der realen Anwendung sehr ähnlich (die die Module der Anwendung verfolgen und Abhängigkeitsinjektionen durchführen). Die Möglichkeit, diese Context -Klasse zusammen mit all ihren verknüpften Klassen neu zu laden, ist also ein großer Schritt in Richtung der Anwendung dieser Technik im wirklichen Leben.

Das Neuladen von Java-Klassen ist selbst für fortgeschrittene Java-Ingenieure schwierig.

Mit zunehmender Anzahl von Klassen und Objekten wird auch unser Schritt „Verwerfen alter Versionen“ komplizierter. Dies ist auch der Hauptgrund, warum das Neuladen von Klassen so schwierig ist. Um möglicherweise alte Versionen zu löschen, müssen wir sicherstellen, dass alle Verweise auf die alten Klassen und Objekte gelöscht werden, sobald der neue Kontext erstellt wurde. Wie gehen wir damit elegant um?

Die main hier wird das Kontextobjekt festhalten, und das ist die einzige Verbindung zu all den Dingen, die gelöscht werden müssen. Wenn wir diese Verknüpfung unterbrechen, werden das Kontextobjekt und die Kontextklasse sowie das Dienstobjekt … alle dem Garbage Collector unterzogen.

Eine kleine Erklärung, warum Klassen normalerweise so persistent sind und keinen Müll sammeln:

  • Normalerweise laden wir alle unsere Klassen in den Standard-Java-Classloader.
  • Die Klasse-Klassenlader-Beziehung ist eine bidirektionale Beziehung, wobei der Klassenlader auch alle Klassen, die er geladen hat, zwischenspeichert.
  • Solange also der Classloader noch mit einem Live-Thread verbunden ist, ist alles (alle geladenen Klassen) immun gegen den Garbage Collector.
  • Solange wir den Code, den wir neu laden möchten, nicht von dem Code trennen können, der bereits vom Standard-Klassenlader geladen wurde, werden unsere neuen Codeänderungen niemals während der Laufzeit angewendet.

An diesem Beispiel sehen wir, dass das Neuladen aller Klassen der Anwendung eigentlich ziemlich einfach ist. Das Ziel besteht lediglich darin, eine dünne, löschbare Verbindung vom Live-Thread zum verwendeten dynamischen Klassenlader aufrechtzuerhalten. Was aber, wenn wir möchten, dass einige Objekte (und ihre Klassen) nicht neu geladen und zwischen den Neuladezyklen wiederverwendet werden? Schauen wir uns das nächste Beispiel an.

Beispiel 4: Trennen von persistenten und neu geladenen Klassenräumen

Hier ist der Quellcode..

Die main :

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

Sie können also sehen, dass der Trick hier darin besteht, die ConnectionPool -Klasse zu laden und sie außerhalb des Neuladezyklus zu instanziieren, sie im persistenten Bereich zu halten und den Verweis auf die Context -Objekte zu übergeben

Die createContext Methode ist auch ein wenig anders:

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

Von nun an nennen wir die Objekte und Klassen, die bei jedem Zyklus neu geladen werden, den „nachladbaren Raum“ und andere – die Objekte und Klassen, die während der Nachladezyklen nicht recycelt und nicht erneuert werden – den „persistenden Raum“. Wir müssen uns sehr klar darüber sein, welche Objekte oder Klassen in welchem ​​Raum bleiben, und so eine Trennlinie zwischen diesen beiden Räumen ziehen.

Wenn dies nicht richtig gehandhabt wird, kann diese Trennung des Ladens von Java-Klassen zu Fehlern führen.

Wie aus dem Bild ersichtlich, verweisen nicht nur das Context -Objekt und das UserService Objekt auf das ConnectionPool -Objekt, sondern die Context und UserService Klassen verweisen auch auf die ConnectionPool -Klasse. Dies ist eine sehr gefährliche Situation, die oft zu Verwirrung und Fehlschlägen führt. Die ConnectionPool -Klasse darf nicht von unserem DynamicClassLoader geladen werden, es darf nur eine ConnectionPool -Klasse im Speicher vorhanden sein, die vom Standard ClassLoader geladen wird. Dies ist ein Beispiel dafür, warum es so wichtig ist, beim Entwerfen einer Architektur zum erneuten Laden von Klassen in Java vorsichtig zu sein.

Was passiert, wenn unser DynamicClassLoader versehentlich die ConnectionPool -Klasse lädt? Dann kann das ConnectionPool -Objekt aus dem persistenten Bereich nicht an das Context -Objekt übergeben werden, weil das Context -Objekt ein Objekt einer anderen Klasse erwartet, die ebenfalls ConnectionPool heißt, aber tatsächlich eine andere Klasse ist!

Wie verhindern wir also, dass unser DynamicClassLoader die ConnectionPool -Klasse lädt? Anstatt DynamicClassLoader zu verwenden, verwendet dieses Beispiel eine Unterklasse davon mit dem Namen: ExceptingClassLoader , die das Laden basierend auf einer Bedingungsfunktion an den Super-Classloader weiterleitet:

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

Wenn wir ExceptingClassLoader hier nicht verwenden, würde der DynamicClassLoader die ConnectionPool -Klasse laden, da sich diese Klasse im Ordner „ target/classes “ befindet. Eine andere Möglichkeit, um zu verhindern, dass die ConnectionPool -Klasse von unserem DynamicClassLoader wird, besteht darin, die ConnectionPool -Klasse in einen anderen Ordner zu kompilieren, möglicherweise in ein anderes Modul, und sie wird separat kompiliert.

Regeln für die Raumwahl

Jetzt wird der Job zum Laden der Java-Klasse wirklich verwirrend. Wie bestimmen wir, welche Klassen im dauerhaften Bereich und welche Klassen im nachladbaren Bereich sein sollten? Hier sind die Regeln:

  1. Eine Klasse im nachladbaren Bereich kann auf eine Klasse im persistenten Bereich verweisen, aber eine Klasse im persistenten Bereich darf niemals auf eine Klasse im nachladbaren Bereich verweisen. Im vorherigen Beispiel verweist die nachladbare Context -Klasse auf die persistente ConnectionPool -Klasse, aber ConnectionPool hat keinen Verweis auf Context
  2. Eine Klasse kann in beiden Räumen existieren, wenn sie auf keine Klasse im anderen Raum verweist. Beispielsweise kann eine Utility-Klasse mit allen statischen Methoden wie StringUtils einmal in den persistenten Bereich geladen und separat in den nachladbaren Bereich geladen werden.

Sie können also sehen, dass die Regeln nicht sehr restriktiv sind. Mit Ausnahme der sich kreuzenden Klassen, die Objekte aufweisen, die über die beiden Spaces hinweg referenziert werden, können alle anderen Klassen entweder im persistenten Space oder im nachladbaren Space oder in beiden frei verwendet werden. Natürlich werden nur Klassen im wiederaufladbaren Raum in den Genuss kommen, mit Nachladezyklen nachgeladen zu werden.

Das schwierigste Problem beim Neuladen von Klassen ist also gelöst. Im nächsten Beispiel werden wir versuchen, diese Technik auf eine einfache Webanwendung anzuwenden und Spaß daran haben, Java-Klassen wie jede Skriptsprache neu zu laden.

Beispiel 5: Kleines Telefonbuch

Hier ist der Quellcode..

Dieses Beispiel wird dem Aussehen einer normalen Webanwendung sehr ähnlich sein. Es ist eine Single-Page-Anwendung mit AngularJS, SQLite, Maven und Jetty Embedded Web Server.

Hier ist der nachladbare Bereich in der Struktur des Webservers:

Ein gründliches Verständnis des nachladbaren Speicherplatzes in der Struktur des Webservers wird Ihnen helfen, das Laden von Java-Klassen zu meistern.

Der Webserver enthält keine Verweise auf die echten Servlets, die im nachladbaren Bereich bleiben müssen, um neu geladen zu werden. Was es enthält, sind Stub-Servlets, die bei jedem Aufruf ihrer Dienstmethode das tatsächliche Servlet im tatsächlichen Kontext zur Ausführung auflösen.

Dieses Beispiel führt auch ein neues Objekt ReloadingWebContext ein, das dem Webserver alle Werte wie ein normaler Context bereitstellt, aber intern Verweise auf ein tatsächliches Kontextobjekt enthält, das von einem DynamicClassLoader neu geladen werden kann. Es ist dieser ReloadingWebContext , der Stub-Servlets für den Webserver bereitstellt.

ReloadingWebContext verarbeitet Stub-Servlets für den Webserver im Neuladeprozess der Java-Klasse.

Der ReloadingWebContext ist der Wrapper des eigentlichen Kontexts und:

  • Lädt den aktuellen Kontext neu, wenn ein HTTP GET an „/“ aufgerufen wird.
  • Stellt dem Webserver Stub-Servlets zur Verfügung.
  • Setzt jedes Mal Werte und ruft Methoden auf, wenn der eigentliche Kontext initialisiert oder zerstört wird.
  • Kann so konfiguriert werden, dass der Kontext neu geladen wird oder nicht, und welcher Classloader zum Neuladen verwendet wird. Dies hilft beim Ausführen der Anwendung in der Produktion.

Da es sehr wichtig ist zu verstehen, wie wir den dauerhaften Speicherplatz und den nachladbaren Speicherplatz isolieren, sind hier die beiden Klassen, die zwischen den beiden Räumen kreuzen:

Klasse qj.util.funct.F0 für Objekt public F0<Connection> connF in Context

  • Funktionsobjekt, gibt jedes Mal eine Verbindung zurück, wenn die Funktion aufgerufen wird. Diese Klasse befindet sich im Paket qj.util, das vom DynamicClassLoader ausgeschlossen ist.

Klasse java.sql.Connection für Objekt public F0<Connection> connF in Context

  • Normales SQL-Verbindungsobjekt. Diese Klasse befindet sich nicht im Klassenpfad unseres DynamicClassLoader , sodass sie nicht erfasst wird.

Zusammenfassung

In diesem Java-Klassen-Tutorial haben wir gesehen, wie man eine einzelne Klasse neu lädt, eine einzelne Klasse kontinuierlich neu lädt, einen ganzen Raum mit mehreren Klassen neu lädt und mehrere Klassen getrennt von Klassen neu lädt, die beibehalten werden müssen. Bei diesen Werkzeugen ist der Schlüsselfaktor für ein zuverlässiges Nachladen der Klasse ein super sauberes Design. Dann können Sie Ihre Klassen und die gesamte JVM frei manipulieren.

Die Implementierung des Neuladens von Java-Klassen ist nicht die einfachste Sache der Welt. Aber wenn Sie es versuchen und irgendwann feststellen, dass Ihre Klassen spontan geladen werden, dann haben Sie es schon fast geschafft. Es bleibt nur noch sehr wenig zu tun, bevor Sie ein absolut hervorragendes, sauberes Design für Ihr System erzielen können.

Viel Glück meine Freunde und genieße deine neu entdeckte Superkraft!