Esercitazione avanzata di classe Java: una guida al ricaricamento della classe
Pubblicato: 2022-03-11Nei progetti di sviluppo Java, un flusso di lavoro tipico prevede il riavvio del server ad ogni cambio di classe e nessuno se ne lamenta. Questo è un dato di fatto sullo sviluppo di Java. Abbiamo lavorato così sin dal nostro primo giorno con Java. Ma è così difficile ricaricare la classe Java? E questo problema potrebbe essere impegnativo ed entusiasmante da risolvere per sviluppatori Java esperti? In questo tutorial di classe Java, cercherò di affrontare il problema, aiutarti a ottenere tutti i vantaggi del ricaricamento della classe al volo e aumentare immensamente la tua produttività.
Il ricaricamento delle classi Java non viene spesso discusso e c'è pochissima documentazione che esplora questo processo. Sono qui per cambiarlo. Questo tutorial sulle classi Java fornirà una spiegazione passo passo di questo processo e ti aiuterà a padroneggiare questa incredibile tecnica. Tieni presente che l'implementazione del ricaricamento delle classi Java richiede molta cura, ma imparare a farlo ti metterà nei grandi campionati, sia come sviluppatore Java che come architetto software. Inoltre non farà male capire come evitare i 10 errori Java più comuni.
Configurazione dello spazio di lavoro
Tutto il codice sorgente per questo tutorial viene caricato su GitHub qui.
Per eseguire il codice mentre segui questo tutorial, avrai bisogno di Maven, Git ed Eclipse o IntelliJ IDEA.
Se stai usando Eclipse:
- Esegui il comando
mvn eclipse:eclipse
per generare i file di progetto di Eclipse. - Carica il progetto generato.
- Imposta il percorso di output su
target/classes
.
Se stai usando IntelliJ:
- Importa il file
pom
del progetto. - IntelliJ non eseguirà la compilazione automatica durante l'esecuzione di un esempio, quindi devi:
- Esegui gli esempi all'interno di IntelliJ, quindi ogni volta che vuoi compilare, dovrai premere
Alt+BE
- Esegui gli esempi all'esterno di IntelliJ con
run_example*.bat
. Imposta la compilazione automatica del compilatore di IntelliJ su true. Quindi, ogni volta che modifichi un file java, IntelliJ lo compilerà automaticamente.
Esempio 1: ricaricare una classe con Java Class Loader
Il primo esempio ti darà una comprensione generale del caricatore di classi Java. Ecco il codice sorgente.
Data la seguente definizione di classe User
:
public static class User { public static int age = 10; }
Possiamo fare quanto segue:
public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...
In questo esempio di tutorial, ci saranno due classi User
caricate nella memoria. userClass1
verrà caricato dal caricatore di classi predefinito della JVM e userClass2
utilizzando DynamicClassLoader
, un caricatore di classi personalizzato il cui codice sorgente è fornito anche nel progetto GitHub e che descriverò in dettaglio di seguito.
Ecco il resto del metodo 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)); }
E l'uscita:
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
Come puoi vedere qui, sebbene le classi User
abbiano lo stesso nome, in realtà sono due classi diverse e possono essere gestite e manipolate indipendentemente. Il valore dell'età, sebbene dichiarato statico, esiste in due versioni, allegate separatamente a ciascuna classe, e può essere modificato anche indipendentemente.
In un normale programma Java, ClassLoader
è il portale che porta le classi nella JVM. Quando una classe richiede il caricamento di un'altra classe, è compito di ClassLoader
eseguire il caricamento.
Tuttavia, in questo esempio di classe Java, il ClassLoader
personalizzato denominato DynamicClassLoader
viene utilizzato per caricare la seconda versione della classe User
. Se invece di DynamicClassLoader
, dovessimo utilizzare nuovamente il caricatore di classi predefinito (con il comando StaticInt.class.getClassLoader()
), verrà utilizzata la stessa classe User
, poiché tutte le classi caricate vengono memorizzate nella cache.
Il DynamicClassLoader
Ci possono essere più classloader in un normale programma Java. Quella che carica la tua classe principale, ClassLoader
, è quella predefinita e dal tuo codice puoi creare e utilizzare tutti i caricatori di classi che desideri. Questa, quindi, è la chiave per ricaricare le classi in Java. Il DynamicClassLoader
è forse la parte più importante di questo intero tutorial, quindi dobbiamo capire come funziona il caricamento dinamico delle classi prima di poter raggiungere il nostro obiettivo.
A differenza del comportamento predefinito di ClassLoader
, il nostro DynamicClassLoader
eredita una strategia più aggressiva. Un normale classloader darebbe al suo genitore ClassLoader
la priorità e caricherebbe solo le classi che il suo genitore non può caricare. Questo è adatto a circostanze normali, ma non nel nostro caso. Invece, DynamicClassLoader
cercherà di esaminare tutti i suoi percorsi di classe e risolvere la classe di destinazione prima che rinunci al suo genitore.
Nel nostro esempio sopra, DynamicClassLoader
viene creato con un solo percorso di classe: "target/classes"
(nella nostra directory corrente), quindi è in grado di caricare tutte le classi che risiedono in quella posizione. Per tutte le classi non presenti, dovrà fare riferimento al classloader padre. Ad esempio, dobbiamo caricare la classe String
nella nostra classe StaticInt
e il nostro caricatore di classi non ha accesso a rt.jar
nella nostra cartella JRE, quindi verrà utilizzata la classe String
del caricatore di classi padre.
Il codice seguente proviene da AggressiveClassLoader
, la classe padre di DynamicClassLoader
e mostra dove è definito questo comportamento.
byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }
Prendere nota delle seguenti proprietà di DynamicClassLoader
:
- Le classi caricate hanno le stesse prestazioni e altri attributi delle altre classi caricate dal caricatore di classi predefinito.
-
DynamicClassLoader
può essere sottoposto a Garbage Collection insieme a tutte le classi e gli oggetti caricati.
Con la possibilità di caricare e utilizzare due versioni della stessa classe, ora stiamo pensando di scaricare la vecchia versione e caricare quella nuova per sostituirla. Nel prossimo esempio, lo faremo... continuamente.
Esempio 2: Ricaricare continuamente una classe
Questo prossimo esempio Java ti mostrerà che JRE può caricare e ricaricare le classi per sempre, con le vecchie classi scaricate e Garbage Collection, e le classi nuove di zecca caricate dal disco rigido e messe in uso. Ecco il codice sorgente.
Ecco il ciclo principale:
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); } }
Ogni due secondi, la vecchia classe User
verrà scaricata, ne verrà caricata una nuova e verrà invocato il suo metodo hobby
.
Ecco la definizione della classe 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"); // } }
Durante l'esecuzione di questa applicazione, dovresti provare a commentare e decommentare il codice indicato nella classe User
. Vedrai che verrà sempre utilizzata la definizione più recente.
Ecco alcuni esempi di output:
... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball
Ogni volta che viene creata una nuova istanza di DynamicClassLoader
, caricherà la classe User
dalla cartella target/classes
, dove abbiamo impostato Eclipse o IntelliJ per l'output del file di classe più recente. Tutte le vecchie classi DynamicClassLoader
e User
precedenti verranno scollegate e soggette al Garbage Collector.
Se hai familiarità con JVM HotSpot, è degno di nota qui che la struttura della classe può anche essere modificata e ricaricata: il metodo playFootball
deve essere rimosso e il metodo playBasketball
aggiunto. Questo è diverso da HotSpot, che consente di modificare solo il contenuto del metodo o non è possibile ricaricare la classe.
Ora che siamo in grado di ricaricare una classe, è il momento di provare a ricaricare più classi contemporaneamente. Proviamolo nel prossimo esempio.
Esempio 3: ricaricare più classi
L'output di questo esempio sarà lo stesso dell'esempio 2, ma mostrerà come implementare questo comportamento in una struttura più simile a un'applicazione con contesto, servizio e oggetti modello. Il codice sorgente di questo esempio è piuttosto grande, quindi ne ho mostrato solo alcune parti qui. Il codice sorgente completo è qui.
Ecco il metodo main
:
public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }
E il metodo 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; }
Il metodo invokeHobbyService
:
private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }
Ed ecco la classe Context
:
public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }
E la classe HobbyService
:

public static class HobbyService { public User user; public void hobby() { user.hobby(); } }
La classe Context
in questo esempio è molto più complicata della classe User
negli esempi precedenti: ha collegamenti ad altre classi e ha il metodo init
da chiamare ogni volta che viene istanziata. Fondamentalmente, è molto simile alle classi di contesto dell'applicazione del mondo reale (che tiene traccia dei moduli dell'applicazione e fa l'iniezione di dipendenza). Quindi essere in grado di ricaricare questa classe Context
insieme a tutte le sue classi collegate è un grande passo verso l'applicazione di questa tecnica alla vita reale.
Con l'aumentare del numero di classi e oggetti, anche la nostra fase di "eliminazione delle vecchie versioni" diventerà più complicata. Questo è anche il motivo principale per cui ricaricare le classi è così difficile. Per eliminare eventualmente le vecchie versioni dovremo assicurarci che, una volta creato il nuovo contesto, vengano eliminati tutti i riferimenti alle vecchie classi e oggetti. Come gestiamo questo con eleganza?
Il metodo main
qui avrà una sospensione dell'oggetto contesto e questo è l'unico collegamento a tutte le cose che devono essere eliminate. Se interrompiamo quel collegamento, l'oggetto di contesto, la classe di contesto e l'oggetto di servizio … saranno tutti soggetti al Garbage Collector.
Una piccola spiegazione sul perché normalmente le classi sono così persistenti e non vengono raccolte spazzatura:
- Normalmente, carichiamo tutte le nostre classi nel classloader Java predefinito.
- La relazione classe-caricatore di classi è una relazione bidirezionale, con il caricatore di classi che memorizza nella cache anche tutte le classi che ha caricato.
- Quindi, finché il caricatore di classi è ancora connesso a qualsiasi thread attivo, tutto (tutte le classi caricate) sarà immune al Garbage Collector.
- Detto questo, a meno che non possiamo separare il codice che vogliamo ricaricare dal codice già caricato dal caricatore di classi predefinito, le nostre nuove modifiche al codice non verranno mai applicate durante il runtime.
Con questo esempio, vediamo che ricaricare tutte le classi dell'applicazione è in realtà piuttosto semplice. L'obiettivo è semplicemente quello di mantenere una connessione sottile e rilasciabile dal thread attivo al caricatore di classi dinamico in uso. Ma cosa succede se vogliamo che alcuni oggetti (e le loro classi) non vengano ricaricati e vengano riutilizzati tra i cicli di ricarica? Diamo un'occhiata al prossimo esempio.
Esempio 4: Separazione degli spazi di classe persistenti e ricaricati
Ecco il codice sorgente..
Il metodo main
:
public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }
Quindi puoi vedere che il trucco qui è caricare la classe ConnectionPool
e crearne un'istanza al di fuori del ciclo di ricaricamento, mantenendola nello spazio persistente e passando il riferimento agli oggetti Context
Anche il metodo createContext
è leggermente diverso:
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; }
D'ora in poi, chiameremo gli oggetti e le classi che vengono ricaricati ad ogni ciclo "spazio ricaricabile" e altri - gli oggetti e le classi non riciclati e non rinnovati durante i cicli di ricarica - "spazio persistente". Dovremo essere molto chiari su quali oggetti o classi stanno in quale spazio, tracciando così una linea di separazione tra questi due spazi.
Come si vede dall'immagine, non solo l'oggetto Context
e l'oggetto UserService
riferiscono all'oggetto ConnectionPool
, ma anche le classi Context
e UserService
si riferiscono alla classe ConnectionPool
. Questa è una situazione molto pericolosa che spesso porta a confusione e fallimento. La classe ConnectionPool
non deve essere caricata dal nostro DynamicClassLoader
, deve esserci una sola classe ConnectionPool
in memoria, che è quella caricata dal ClassLoader
predefinito. Questo è un esempio del motivo per cui è così importante fare attenzione quando si progetta un'architettura di ricarica delle classi in Java.
Cosa succede se il nostro DynamicClassLoader
carica accidentalmente la classe ConnectionPool
? Quindi l'oggetto ConnectionPool
dallo spazio persistente non può essere passato all'oggetto Context
, perché l'oggetto Context
si aspetta un oggetto di una classe diversa, anch'essa denominata ConnectionPool
, ma in realtà è una classe diversa!
Quindi, come possiamo impedire al nostro DynamicClassLoader
di caricare la classe ConnectionPool
? Invece di usare DynamicClassLoader
, questo esempio usa una sua sottoclasse denominata: ExceptingClassLoader
, che passerà il caricamento al super classloader in base a una funzione di condizione:
(className) -> className.contains("$Connection")
Se non utilizziamo ExceptingClassLoader
qui, DynamicClassLoader
caricherebbe la classe ConnectionPool
perché quella classe risiede nella cartella " target/classes
". Un altro modo per evitare che la classe ConnectionPool
venga rilevata dal nostro DynamicClassLoader
è compilare la classe ConnectionPool
in una cartella diversa, magari in un modulo diverso, e verrà compilata separatamente.
Regole per la scelta dello spazio
Ora, il lavoro di caricamento della classe Java diventa davvero confuso. Come determiniamo quali classi dovrebbero essere nello spazio persistente e quali classi nello spazio ricaricabile? Ecco le regole:
- Una classe nello spazio ricaricabile può fare riferimento a una classe nello spazio persistente, ma una classe nello spazio persistente potrebbe non fare mai riferimento a una classe nello spazio ricaricabile. Nell'esempio precedente, la classe
Context
ricaricabile fa riferimento alla classeConnectionPool
persistente, maConnectionPool
non ha alcun riferimento aContext
- Una classe può esistere in entrambi gli spazi se non fa riferimento a nessuna classe nell'altro spazio. Ad esempio, una classe di utilità con tutti i metodi statici come
StringUtils
può essere caricata una volta nello spazio persistente e caricata separatamente nello spazio ricaricabile.
Quindi puoi vedere che le regole non sono molto restrittive. Fatta eccezione per le classi incrociate che hanno oggetti referenziati nei due spazi, tutte le altre classi possono essere utilizzate liberamente nello spazio persistente o nello spazio ricaricabile o in entrambi. Naturalmente, solo le classi nello spazio ricaricabile potranno essere ricaricate con cicli di ricarica.
Quindi viene affrontato il problema più impegnativo con la ricarica della classe. Nel prossimo esempio, proveremo ad applicare questa tecnica a una semplice applicazione web e divertiremo a ricaricare le classi Java proprio come qualsiasi linguaggio di scripting.
Esempio 5: Piccola rubrica
Ecco il codice sorgente..
Questo esempio sarà molto simile a come dovrebbe apparire una normale applicazione web. È un'applicazione a pagina singola con AngularJS, SQLite, Maven e Jetty Embedded Web Server.
Ecco lo spazio ricaricabile nella struttura del server web:
Il web server non conterrà riferimenti ai servlet reali, che devono rimanere nello spazio ricaricabile, in modo da essere ricaricati. Ciò che contiene sono servlet stub, che, con ogni chiamata al suo metodo di servizio, risolveranno il servlet effettivo nel contesto effettivo da eseguire.
Questo esempio introduce anche un nuovo oggetto ReloadingWebContext
, che fornisce al server Web tutti i valori come un normale Context, ma contiene internamente riferimenti a un oggetto di contesto effettivo che può essere ricaricato da un DynamicClassLoader
. È questo ReloadingWebContext
che fornisce servlet stub al server web.
Il ReloadingWebContext
sarà il wrapper del contesto reale e:
- Ricaricherà il contesto effettivo quando viene chiamato un HTTP GET su "/".
- Fornirà servlet stub al server web.
- Imposterà valori e invocherà metodi ogni volta che il contesto effettivo viene inizializzato o distrutto.
- Può essere configurato per ricaricare o meno il contesto e quale classloader viene utilizzato per ricaricare. Ciò sarà di aiuto durante l'esecuzione dell'applicazione in produzione.
Poiché è molto importante capire come isoliamo lo spazio persistente e lo spazio ricaricabile, ecco le due classi che si incrociano tra i due spazi:
Classe qj.util.funct.F0
per l'oggetto public F0<Connection> connF
in Context
- Oggetto funzione, restituirà una connessione ogni volta che viene richiamata la funzione. Questa classe risiede nel pacchetto qj.util, che è escluso da
DynamicClassLoader
.
Classe java.sql.Connection
per l'oggetto public F0<Connection> connF
in Context
- Oggetto di connessione SQL normale. Questa classe non risiede nel percorso di classe del nostro
DynamicClassLoader
, quindi non verrà prelevata.
Sommario
In questo tutorial sulle classi Java, abbiamo visto come ricaricare una singola classe, ricaricare una singola classe continuamente, ricaricare un intero spazio di più classi e ricaricare più classi separatamente dalle classi che devono essere mantenute. Con questi strumenti, il fattore chiave per ottenere una ricarica affidabile della classe è avere un design super pulito. Quindi puoi manipolare liberamente le tue classi e l'intera JVM.
L'implementazione del ricaricamento delle classi Java non è la cosa più semplice al mondo. Ma se ci provi e ad un certo punto scopri che le tue lezioni vengono caricate al volo, allora sei quasi arrivato. Rimarrà ben poco da fare prima che tu possa ottenere un design pulito e assolutamente superbo per il tuo sistema.
Buona fortuna amici miei e godetevi il vostro ritrovato superpotere!