mattone
dopo mattoneLA COLLANA
IT/EN
← Guida Java

Java 25 · 22/39

21. Classloader Java: trovare una classe mentre il programma gira

Java 25 · Guida completa · Bozza in revisione

Questa guida conserva lo stato di revisione del libro. La verifica editoriale e le prove di comprensione con lettori indipendenti sono ancora da completare.

Cerca in tutta la guida →

Perché una classe deve essere caricata

Quando compiliamo un sorgente Java, javac produce un file .class che contiene bytecode. Questo file non è già una classe in esecuzione nella JVM: l'applicazione deve ancora trovare i byte, controllarli e inserirli nel proprio spazio di esecuzione. Il classloader, o caricatore di classi, partecipa a questo lavoro. È il componente a cui la JVM può chiedere una definizione quando serve. Il suo compito non si esaurisce nel leggere un file: una definizione può provenire da un modulo, da un JAR o, in applicazioni specializzate, da una fonte gestita da un caricatore personalizzato.

Perché caricare le classi a richiesta invece di prepararle tutte all'avvio? Un programma può contenere funzionalità che non verranno mai usate in una certa esecuzione; caricarle tutte avrebbe un costo inutile. Il caricamento appartiene al tempo di esecuzione. Se nel sorgente compare new Servizio(), il compilatore deve conoscere il tipo per verificare il programma, ma non crea l'oggetto e non carica la classe nella JVM durante la compilazione. Sono fasi diverse. Analogamente, Class.forName(nome) esprime una richiesta in cui il nome può arrivare da una stringa a tempo di esecuzione; non è l'unico modo in cui Java carica dinamicamente una classe.

Definizione – Caricamento, collegamento, inizializzazione. Caricare significa ottenere una definizione di classe. Il linking comprende verifiche e preparazione necessarie alla sua integrazione nella JVM; la risoluzione dei riferimenti può avvenire secondo le regole della VM. Inizializzare significa eseguire l'inizializzazione statica secondo le condizioni previste dal linguaggio. Dire semplicemente «la classe viene caricata» non implica che il suo blocco static sia già stato eseguito. Questa distinzione aiuta a interpretare gli esempi di Class.forName, i messaggi di errore e i tempi osservati.

I caricatori forniti dalla piattaforma

Il programma completo osserva il caricatore di una classe dell'applicazione, di java.sql.Driver e di java.util.ArrayList. Con la distribuzione JDK 25 usata per la verifica, la prima è associata al caricatore dell'applicazione, la seconda a quello della piattaforma e la terza al caricatore bootstrap. Nel terzo caso Class.getClassLoader() restituisce null, che l'esempio stampa come bootstrap. Non dobbiamo confondere quel null con l'assenza della classe: ArrayList esiste, ed è proprio il suo caricatore bootstrap a non essere rappresentato come un normale oggetto ClassLoader restituito dal metodo.

Applicazione -> jdk.internal.loader.ClassLoaders$AppClassLoader
java.sql.Driver -> jdk.internal.loader.ClassLoaders$PlatformClassLoader
java.util.ArrayList -> bootstrap

Queste righe documentano l'osservazione su una JVM concreta, non un formato di output da promettere a tutte le implementazioni. I nomi jdk.internal.loader... sono dettagli di implementazione: il codice del lettore non deve dipendere da quelle stringhe. Il contratto pubblico offre ClassLoader.getSystemClassLoader() e ClassLoader.getPlatformClassLoader(), oltre a Class.getClassLoader(). Il caricatore dell'applicazione cerca normalmente classi e risorse del percorso dell'applicazione; quello della piattaforma serve classi della piattaforma; il bootstrap è alla base della gerarchia. Il preciso insieme di classi visibili a ciascuno è influenzato anche dal sistema di moduli introdotto nelle edizioni moderne di Java.

Nei JDK precedenti a Java 9 comparivano un extension classloader, cartelle come jre/lib/ext e l'archivio rt.jar. Quel modello non descrive Java 25: ora incontriamo il platform classloader e l'immagine runtime modulare. Se leggiamo documentazione o progetti più vecchi, possiamo riconoscere quei nomi storici, ma non dobbiamo cercare rt.jar né configurare un meccanismo di estensioni rimosso. La relazione fra genitori e figli rimane utile; cambiano i componenti concreti che occupano alcuni posti nella gerarchia.

              bootstrap
                  ↑
             piattaforma
                  ↑
             applicazione
                  ↑
        eventuale caricatore figlio

Figura 21.1 – Relazione di delega tipica. La freccia punta verso il genitore consultato prima dal figlio. Non rappresenta ereditarietà fra le classi caricate. Un caricatore personalizzato può scegliere comportamenti speciali; la figura descrive il modello predefinito che studiamo qui.

Delega fra classloader in Java 25
Figura 21.2 – Il percorso della richiesta e della risposta nei caricatori integrati.

La gerarchia di delega non coincide con la gerarchia delle classi Java dei caricatori. Per esempio, nell'API pubblica URLClassLoader estende SecureClassLoader, che estende ClassLoader: questa è una relazione fra tipi. Dire invece che il caricatore dell'applicazione consulta come genitore quello della piattaforma descrive una relazione fra oggetti caricatori durante una ricerca. Non possiamo dedurre dal nome platform che la sua classe Java estenda la classe concreta del caricatore bootstrap; quest'ultimo non compare neppure come un normale oggetto restituito da Class.getClassLoader() per le classi che definisce. La distinzione evita di leggere come un diagramma di ereditarietà una freccia che indica una richiesta di delega.

Ereditarietà delle classi API e delega fra caricatori
Figura 21.3 – A sinistra un esempio di ereditarietà pubblica verificabile nell'API; a destra la relazione di delega tipica dei caricatori integrati.

La delega, passo dopo passo

Supponiamo che il caricatore dell'applicazione riceva il nome binario java.util.ArrayList. Prima controlla se ha già caricato quella definizione. Se non l'ha, la normale implementazione di loadClass chiede al genitore; la ricerca sale fino al bootstrap, che può trovare la classe. Il risultato torna lungo la catena. Il caricatore dell'applicazione non definisce una seconda copia di ArrayList. Se invece il nome appartiene a una classe dell'applicazione che i genitori non conoscono, il controllo torna al caricatore dell'applicazione, che prova findClass. È la logica detta parent first.

L'algoritmo pubblico di ClassLoader.loadClass(String, boolean) segue tre passaggi essenziali: findLoadedClass controlla una definizione già caricata dal caricatore corrente; il genitore o il bootstrap riceve la richiesta; infine findClass è il punto in cui il caricatore corrente cerca una definizione propria. L'argomento resolve chiede anche la risoluzione tramite resolveClass quando è true. Non è corretto interpretare false come «la classe non sarà caricata»: il metodo può caricarla e definirla ugualmente, rimandando soltanto quell'operazione di risoluzione. Inoltre, codice concorrente può chiedere classi allo stesso caricatore: l'implementazione sincronizza il lavoro con un lock di caricamento appropriato. Il capitolo 20A ha mostrato perché un singolo CAS protegge una sola decisione sullo stato; il caricamento comprende invece ricerca, delega, definizione e risoluzione. Non va sostituito meccanicamente con un contatore atomico.

Schema – Una richiesta che risale e ridiscende. Il figlio controlla ciò che ha già definito; il genitore prova a trovare la classe; se il genitore fallisce, il figlio cerca nei propri byte. La risposta positiva o l'eccezione torna al chiamante. La delega protegge le classi fondamentali e facilita la condivisione di definizioni, ma non garantisce che nel processo esista una sola definizione per ogni nome.

La frase finale è importante. L'identità di una classe nella JVM dipende dal nome binario e dal caricatore che l'ha definita. Due caricatori distinti possono definire due classi con lo stesso nome; per la JVM sono tipi diversi. Questa possibilità è utile, per esempio, quando un contenitore isola componenti o versioni di librerie, ma richiede grande attenzione nel passare oggetti da un contesto all'altro. Una ClassCastException fra due nomi apparentemente uguali può nascere proprio da questa differenza di caricatori. La delega evita duplicazioni nel caso normale della stessa catena; non rende però unico un nome di classe in tutta la JVM.

Vedere la gerarchia senza dipendere dagli indirizzi

Il programma chiama getParent() sul caricatore di sistema e stampa il risultato. Una JVM può rappresentare l'oggetto con il nome di una classe interna e un suffisso simile a un indirizzo; quel suffisso cambia fra esecuzioni e non ha valore didattico. Il controllo significativo è che, nella configurazione comune, il genitore del caricatore di sistema sia quello della piattaforma. getParent() può inoltre essere null quando si arriva al livello bootstrap. Un test robusto confronterebbe gli oggetti restituiti dalle API pubbliche, non le loro rappresentazioni testuali.

La ricerca delle risorse segue un problema vicino ma distinto. getResource("configurazione.txt") cerca un elemento sul percorso visibile al caricatore e restituisce un URL o null; getResourceAsStream può offrire direttamente uno stream da chiudere. La risorsa non è per forza un file ordinario del filesystem, soprattutto se si trova in un JAR o in un'immagine modulare. Per questo il percorso va scritto con separatori / e interpretato rispetto alla radice di ricerca prevista dall'API, invece di concatenare a mano un percorso assoluto dipendente dal sistema operativo. Se la risorsa è obbligatoria, un risultato null deve diventare un errore gestito con un messaggio utile, non una NullPointerException nella lettura successiva.

Classe non trovata: eccezione o errore?

ClassNotFoundException e NoClassDefFoundError segnalano problemi diversi, anche se entrambi possono avere a che fare con una classe non disponibile. ClassNotFoundException è un'eccezione controllata: un metodo come Class.forName("esempio.Mancante") o loadClass("esempio.Mancante") riceve un nome e può non trovare la definizione richiesta. Il chiamante deve gestire o dichiarare l'eccezione. Nel programma precedente usiamo Class.forName("java.util.ArrayList"), che trova una classe reale e stampa il suo nome; sostituendo una stringa inesistente si può osservare ClassNotFoundException in modo controllato.

NoClassDefFoundError è invece un Error che può comparire quando la JVM ha bisogno di una definizione necessaria all'esecuzione ma non riesce a renderla disponibile. Per vederlo, compiliamo A e B, poi togliamo A.class prima di eseguire B, che usa direttamente new A(). Il bytecode di B contiene un riferimento ad A; all'esecuzione la definizione manca. La diagnostica può includere una ClassNotFoundException come causa, ma il fallimento osservato dal codice che usa B è NoClassDefFoundError. Può esserci anche un errore di inizializzazione precedente che lasci la classe inutilizzabile; quindi non riduciamo il significato del tipo al solo «file cancellato».

Possiamo ripetere il primo caso con un programma breve. La stringa indica una classe che non abbiamo compilato; perciò l'eccezione viene catturata e il programma resta in controllo della situazione:

public class ClasseMancante {
    public static void main(String[] args) {
        try {
            Class.forName("esempio.plugin.Assente");
        } catch (ClassNotFoundException e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

L'output è ClassNotFoundException: esempio.plugin.Assente. Anche ClassLoader.loadClass può segnalare la stessa eccezione. Se vogliamo provare separatamente il caricatore di sistema e quello della piattaforma, dobbiamo dare a ciascuna chiamata il proprio try o continuare dopo il primo errore: tre richieste nello stesso blocco try si fermerebbero alla prima eccezione. Il messaggio mostra il nome richiesto, mentre le righe della traccia dello stack dipendono da JDK e ambiente e non sono un output da memorizzare.

Per il secondo caso usiamo due sorgenti separati, con B che usa A direttamente:

public class A {
    public String messaggio() {
        return "A disponibile";
    }
}
public class B {
    public static void main(String[] args) {
        System.out.println(new A().messaggio());
    }
}

Compilando entrambi, java -cp build/errori B stampa A disponibile. Dopo avere spostato A.class fuori dal percorso di esecuzione, lo stesso comando fallisce con NoClassDefFoundError: A. Il sorgente di A può rimanere dov'è: durante l'esecuzione la JVM cerca la definizione compilata, non ricompila automaticamente quel sorgente. Per tenere l'esperimento isolato, dalla directory esempi/C21 possiamo usare questi comandi e ripristinare il file alla fine:

mkdir -p build/errori
javac --release 25 -d build/errori errori/A.java errori/B.java
java -cp build/errori B
mv build/errori/A.class build/A.class.spostata
java -cp build/errori B
mv build/A.class.spostata build/errori/A.class

Il secondo avvio deve fallire: in questo solo esperimento il suo codice d'uscita diverso da zero è il risultato cercato. La traccia completa può cambiare fra versioni della JVM, mentre la distinzione fra una richiesta per nome non soddisfatta e una definizione necessaria che manca resta il punto da capire.

Esercizio guidato – Due tempi diversi. Si compilino due classi nello stesso percorso di output. Si esegua B una prima volta con entrambe presenti, poi si sposti temporaneamente A.class fuori da quel percorso e si ripeta l'esecuzione. Si ripristini il file al termine. Il risultato dipende dalla fase di esecuzione, non dal fatto che il sorgente B.java sia ancora disponibile. È un esperimento da svolgere in una cartella di prova, senza rimuovere librerie del progetto.

Un'altra differenza utile riguarda l'inizializzazione. La forma a un argomento di Class.forName carica e inizializza la classe richiesta secondo il suo contratto. La forma con tre argomenti permette di scegliere il booleano di inizializzazione e il caricatore. ClassLoader.loadClass normalmente carica senza richiedere direttamente l'inizializzazione. In seguito, un'operazione che richiede l'uso attivo della classe può inizializzarla. Non è dunque corretto etichettare Class.forName come «caricamento dinamico» e new come «caricamento statico» se con quest'ultimo si suggerisce un caricamento a tempo di compilazione: entrambi arrivano a una JVM in esecuzione, ma esprimono richieste diverse.

Personalizzare un caricatore senza perdere la delega

Esistono casi in cui le definizioni arrivano da una fonte che il caricatore standard non conosce: un sistema di plugin, un archivio speciale o una generazione controllata di bytecode. La libreria offre la classe astratta ClassLoader. Nel caso comune si estende il caricatore e si ridefinisce findClass(String name), lasciando a loadClass il comportamento di delega. findClass deve trovare i byte corrispondenti al nome richiesto e chiamare defineClass con un array valido. Se non trova la definizione, lancia ClassNotFoundException. Il risultato della ricerca va controllato, le risorse aperte vanno chiuse e occorre definire quali byte considerare affidabili.

Approfondimento – Che cos'è il weaving. Il weaving è la trasformazione del bytecode di una classe per aggiungere o modificare un comportamento, per esempio una misura dei tempi o un controllo all'ingresso di un metodo. Può avvenire dopo la compilazione, modificando i file .class, oppure durante il caricamento tramite strumenti adatti. Un caricatore personalizzato può partecipare al secondo percorso, ma il semplice esempio che segue non modifica il bytecode: legge una definizione già compilata da una directory diversa. Separare i due meccanismi evita di confondere «trovare byte» e «trasformare byte».

Un caricatore personalizzato che possiamo provare

Facciamo ora un esperimento completo. La classe CaricatoreDaDirectory cerca un file .class sotto la directory indicata all'avvio. L'altro sorgente, Messaggio, viene compilato in una directory separata: non deve trovarsi nel class path dell'applicazione, altrimenti il caricatore padre potrebbe trovarlo prima del nostro findClass. Questa separazione è la condizione che permette di osservare davvero il nuovo caricatore.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class CaricatoreDaDirectory extends ClassLoader {
    private final Path directory;

    public CaricatoreDaDirectory(Path directory, ClassLoader parent) {
        super(parent);
        this.directory = directory.toAbsolutePath().normalize();
    }

    @Override
    protected Class<?> findClass(String nome) throws ClassNotFoundException {
        Path file = directory.resolve(nome.replace('.', '/') + ".class").normalize();
        if (!file.startsWith(directory)) {
            throw new ClassNotFoundException(nome);
        }
        try {
            byte[] bytecode = Files.readAllBytes(file);
            return defineClass(nome, bytecode, 0, bytecode.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(nome, e);
        }
    }

    public static void main(String[] args) throws ReflectiveOperationException {
        if (args.length != 1) {
            throw new IllegalArgumentException("Indicare la directory del plugin");
        }
        ClassLoader caricatore = new CaricatoreDaDirectory(
                Path.of(args[0]), CaricatoreDaDirectory.class.getClassLoader());
        Class<?> tipo = Class.forName("esempio.plugin.Messaggio", true, caricatore);
        Object istanza = tipo.getDeclaredConstructor().newInstance();
        System.out.println(tipo.getMethod("testo").invoke(istanza));
        System.out.println(tipo.getClassLoader().getClass().getSimpleName());
    }
}

Il nome binario esempio.plugin.Messaggio diventa il percorso esempio/plugin/Messaggio.class sotto la directory ricevuta dal costruttore. Files.readAllBytes chiude internamente il file dopo la lettura; se il file non esiste o la lettura fallisce, findClass conserva la causa dentro ClassNotFoundException. Il controllo con normalize evita che il percorso calcolato esca banalmente dalla directory scelta, ma non trasforma questa classe in una barriera di sicurezza per input ostili. defineClass verifica che i byte rappresentino una classe compatibile con il nome richiesto; gli errori di formato non vengono finti come «file mancante».

Il piccolo plugin contiene un metodo pubblico e non ha dipendenze applicative:

package esempio.plugin;

public class Messaggio {
    public String testo() {
        return "ciao dal plugin";
    }
}

Dalla directory esempi/C21 possiamo compilare e avviare così, con due percorsi di output distinti:

mkdir -p build/app build/plugin
javac --release 25 -d build/app CaricatoreDaDirectory.java
javac --release 25 -d build/plugin plugin/esempio/plugin/Messaggio.java
java -cp build/app CaricatoreDaDirectory build/plugin

Il risultato atteso è ciao dal plugin, seguito da CaricatoreDaDirectory. La prima riga viene dal metodo del plugin; la seconda mostra il tipo del caricatore che ha definito quella classe. Se compilassimo Messaggio anche in build/app, il modello parent first potrebbe farla definire dal padre, e la seconda riga cambierebbe. L'esempio rende quindi osservabili sia il caricamento da byte sia l'effetto della delega.

defineClass non è un metodo di ricerca. Trasforma byte di una classe valida in un oggetto Class<?> associato al caricatore che lo chiama; può fallire se il formato è invalido o se sono violati vincoli del runtime. findLoadedClass serve a evitare di definire nuovamente, nello stesso caricatore, una classe già definita; getParent rende osservabile la catena di delega. Per spiegare la relazione fra questi metodi, non serve copiare l'implementazione interna del JDK riga per riga: basta seguire la richiesta dal nome ai byte e poi all'oggetto Class, distinguendo il contratto pubblico dai dettagli privati dell'implementazione.

Metodo Domanda a cui risponde Risultato rilevante
loadClass(nome) Quale classe visibile corrisponde al nome? Class<?> oppure ClassNotFoundException
findLoadedClass(nome) Questo caricatore l'ha già definita? Class<?> oppure null
findClass(nome) Questo caricatore sa reperire i byte? Class<?> oppure ClassNotFoundException
defineClass(nome, byte[], ...) Questi byte definiscono una classe valida qui? un oggetto Class<?> oppure un errore di definizione
getParent() A chi delega normalmente questo caricatore? un ClassLoader oppure null al livello bootstrap
getResource(nome) Quale risorsa visibile ha quel nome? un URL oppure null

La tabella distingue l'azione di ciascun metodo dalla risposta possibile. findLoadedClass non cerca sul filesystem: esamina ciò che il caricatore ha già definito. findClass, nell'implementazione di base, non inventa una sorgente di byte: se vogliamo leggere da un archivio particolare, dobbiamo fornire il comportamento. defineClass è protetto e finale nelle forme di uso comune: il caricatore personalizzato lo invoca, non ne riscrive il significato. Anche getResource va trattato come una ricerca facoltativa, perché un URL nullo è una risposta prevista.

Un'applicazione modulare aggiunge un altro livello al ragionamento. Il sistema di moduli stabilisce quali pacchetti un modulo esporta e quali moduli sono leggibili da un altro; il caricatore stabilisce da quale definizione proviene una classe. Le due domande interagiscono ma non si sostituiscono. Trovare i byte di una classe non implica poter usare qualsiasi suo tipo pubblico da qualsiasi modulo, né poter accedere tramite reflection a ogni membro privato. Questa distinzione sarà utile quando, nel capitolo sulla reflection, incontreremo opens e InaccessibleObjectException.

Pensiamo a un plugin che implementa un'interfaccia Servizio condivisa. Se l'applicazione e il plugin vedono la stessa definizione dell'interfaccia grazie a un caricatore comune, l'applicazione può ricevere l'istanza come Servizio. Se invece il plugin carica una propria copia della classe dell'interfaccia, con lo stesso nome ma un caricatore differente, il cast può fallire. Prima di modificare la politica di delega conviene quindi decidere quali API sono comuni e quali classi devono restare isolate. La figura a frecce non è solo teoria: spiega una regola concreta per progettare confini fra componenti.

Approfondimento – Isolamento e fiducia. Caricare byte ricevuti da una fonte esterna non rende quel codice automaticamente sicuro. Un caricatore personalizzato è uno strumento di risoluzione e di identità dei tipi, non una sandbox generale. Anche una scelta di caricamento child first, adottata talvolta per isolare plugin, richiede una progettazione accurata: non deve permettere di sostituire classi della piattaforma o rompere le API condivise. Nel nostro esempio manteniamo la delega al genitore; i byte del plugin vanno comunque considerati codice eseguibile con i permessi del processo.

Dalla classe caricata alla reflection

Una volta ottenuto un oggetto Class<?>, il programma può chiedere informazioni sul tipo e, quando il contratto di accesso lo consente, usare la Reflection API. Il caricatore risponde alla domanda «quale definizione di classe sto usando?»; la reflection risponde a domande come «quali costruttori, metodi o annotazioni espone?». La differenza fra identità dei tipi e semplice uguaglianza dei nomi rimane essenziale. Se un plugin definisce un'interfaccia comune con un caricatore incompatibile con quello dell'applicazione, i due lati potrebbero vedere nomi uguali ma tipi diversi. Prima di tornare all'oggetto Class nel capitolo 24, il capitolo 23 propone un approfondimento facoltativo sulla Vector API: affronta un problema indipendente e può essere letto anche dopo la reflection.

Per verificare

Spiega perché ArrayList.class.getClassLoader() restituisce null nell'esempio, quale passaggio del modello predefinito permette a una classe dell'applicazione di essere trovata dopo il fallimento del genitore, e perché due caricatori possono produrre tipi distinti con lo stesso nome. Verifica la seconda risposta nel programma con caricatore da directory, mantenendo il plugin fuori dal class path dell'applicazione: se lo inserisci nel percorso sbagliato, quale caricatore lo trova? Se sai prevedere e spiegare la differenza, la figura della delega è diventata uno strumento di ragionamento anziché un disegno da ricordare a memoria.

Prova gli esempi

Per eseguire i programmi serve JDK 25. Puoi scaricare i singoli file Java collegati nel capitolo oppure il progetto completo, che contiene istruzioni e uno script di avvio. Le spiegazioni confrontano anche l’output atteso: prevedilo prima di eseguire il programma.

Massimiliano Tarquini · CC BY-NC 4.0

Torna all’inizio ↑