Fortgeschrittenes Java-Klassen-Tutorial: Eine Anleitung zum Neuladen von Klassen
Veröffentlicht: 2022-03-11Ein 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.
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.
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.
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.
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:
- 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 persistenteConnectionPool
-Klasse, aberConnectionPool
hat keinen Verweis aufContext
- 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:
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.
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!