Articles

Tutorial JVM – Spiegazione dell’architettura della Java Virtual Machine per principianti

Posted on

Se avete usato Java per sviluppare programmi o no, potreste aver sentito parlare della Java Virtual Machine (JVM) prima o poi.

La JVM è il cuore dell’ecosistema Java e rende possibile ai programmi software basati su Java di seguire l’approccio “scrivi una volta, esegui ovunque”. È possibile scrivere codice Java su una macchina ed eseguirlo su qualsiasi altra macchina usando la JVM.

JVM è stata inizialmente progettata per supportare solo Java. Tuttavia, nel corso del tempo, molti altri linguaggi come Scala, Kotlin e Groovy sono stati adottati sulla piattaforma Java. Tutti questi linguaggi sono conosciuti collettivamente come linguaggi JVM.

In questo articolo, impareremo di più sulla JVM, come funziona e i vari componenti di cui è fatta.

Prima di addentrarci nella JVM, rivediamo il concetto di macchina virtuale (VM).

Una macchina virtuale è una rappresentazione virtuale di un computer fisico. Possiamo chiamare la macchina virtuale la macchina ospite, e il computer fisico su cui gira è la macchina host.

Una singola macchina fisica può eseguire più macchine virtuali, ciascuna con il proprio sistema operativo e applicazioni. Queste macchine virtuali sono isolate l’una dall’altra.

Cos’è la Java Virtual Machine?

Nei linguaggi di programmazione come C e C++, il codice viene prima compilato in codice macchina specifico della piattaforma. Questi linguaggi sono chiamati linguaggi compilati.

D’altra parte, in linguaggi come JavaScript e Python, il computer esegue direttamente le istruzioni senza doverle compilare. Questi linguaggi sono chiamati linguaggi interpretati.

Java usa una combinazione di entrambe le tecniche. Il codice Java viene prima compilato in codice byte per generare un file di classe. Questo file di classe viene poi interpretato dalla Java Virtual Machine per la piattaforma sottostante. Lo stesso file di classe può essere eseguito su qualsiasi versione di JVM in esecuzione su qualsiasi piattaforma e sistema operativo.

Simile alle macchine virtuali, la JVM crea uno spazio isolato su una macchina host. Questo spazio può essere usato per eseguire programmi Java indipendentemente dalla piattaforma o dal sistema operativo della macchina.

Architettura della macchina virtuale Java

La JVM consiste di tre componenti distinti:

  1. Class Loader
  2. Runtime Memory/Data Area
  3. Execution Engine

Diamo un’occhiata a ciascuno di essi più in dettaglio.

Class Loader

Quando compilate un file sorgente .java, esso viene convertito in codice byte come file .class. Quando provate ad usare questa classe nel vostro programma, il caricatore di classi la carica nella memoria principale.

La prima classe ad essere caricata in memoria è di solito la classe che contiene il metodo main().

Ci sono tre fasi nel processo di caricamento della classe: caricamento, collegamento e inizializzazione.

Caricamento

Il caricamento consiste nel prendere la rappresentazione binaria (bytecode) di una classe o interfaccia con un nome particolare, e generare la classe o interfaccia originale da questa.

Ci sono tre caricatori di classe integrati disponibili in Java:

  • Bootstrap Class Loader – Questo è il caricatore di classe principale. È la superclasse di Extension Class Loader e carica i pacchetti Java standard come java.langjava.netjava.utiljava.io e così via. Questi pacchetti sono presenti all’interno del file rt.jar e altre librerie di base presenti nella directory $JAVA_HOME/jre/lib.
  • Extension Class Loader – Questa è la sottoclasse del Bootstrap Class Loader e la superclasse dell’Application Class Loader. Questo carica le estensioni delle librerie Java standard che sono presenti nella directory $JAVA_HOME/jre/lib/ext.
  • Application Class Loader – Questo è l’ultimo caricatore di classi e la sottoclasse di Extension Class Loader. Carica i file presenti nel classpath. Per impostazione predefinita, il classpath è impostato sulla directory corrente dell’applicazione. Il classpath può anche essere modificato aggiungendo l’opzione -classpath o -cp della linea di comando.

La JVM usa il metodo ClassLoader.loadClass() per caricare la classe in memoria. Cerca di caricare la classe sulla base di un nome pienamente qualificato.

Se un caricatore di classi padre non è in grado di trovare una classe, delega il lavoro ad un caricatore di classi figlio. Se anche l’ultimo caricatore di classi figlio non è in grado di caricare la classe, lancia NoClassDefFoundError o ClassNotFoundException.

Collegamento

Dopo che una classe è stata caricata in memoria, subisce il processo di collegamento. Collegare una classe o un’interfaccia comporta la combinazione dei diversi elementi e dipendenze del programma insieme.

Il collegamento include le seguenti fasi:

Verifica: Questa fase controlla la correttezza strutturale del file .class controllandolo contro un insieme di vincoli o regole. Se la verifica fallisce per qualche motivo, si ottiene un VerifyException.

Per esempio, se il codice è stato costruito usando Java 11, ma viene eseguito su un sistema che ha installato Java 8, la fase di verifica fallirà.

Preparazione: In questa fase, la JVM alloca la memoria per i campi statici di una classe o interfaccia, e li inizializza con valori predefiniti.

Per esempio, supponiamo che abbiate dichiarato la seguente variabile nella vostra classe:

private static final boolean enabled = true;

Durante la fase di preparazione, la JVM alloca la memoria per la variabile enabled e imposta il suo valore al valore di default per un booleano, che è false.

Risoluzione: In questa fase, i riferimenti simbolici sono sostituiti da riferimenti diretti presenti nel pool di costanti di runtime.

Per esempio, se avete riferimenti ad altre classi o variabili costanti presenti in altre classi, vengono risolti in questa fase e sostituiti con i loro riferimenti reali.

Inizializzazione

L’inizializzazione comporta l’esecuzione del metodo di inizializzazione della classe o dell’interfaccia (noto come <clinit>). Questo può includere la chiamata al costruttore della classe, l’esecuzione del blocco statico e l’assegnazione di valori a tutte le variabili statiche. Questa è la fase finale del caricamento della classe.

Per esempio, quando abbiamo dichiarato il seguente codice prima:

private static final boolean enabled = true;

La variabile enabled è stata impostata al suo valore predefinito di false durante la fase di preparazione. Nella fase di inizializzazione, a questa variabile viene assegnato il suo valore attuale di true.

Nota: la JVM è multi-thread. Può succedere che più threads cerchino di inizializzare la stessa classe allo stesso tempo. Questo può portare a problemi di concorrenza. È necessario gestire la sicurezza dei thread per assicurare che il programma funzioni correttamente in un ambiente multi-thread.

Area dati runtime

Ci sono cinque componenti all’interno dell’area dati runtime:

Guardiamo ciascuno singolarmente.

Area dei metodi

Tutti i dati a livello di classe, come il pool di costanti a tempo di esecuzione, i dati dei campi e dei metodi, e il codice dei metodi e dei costruttori, sono memorizzati qui.

Se la memoria disponibile nell’area dei metodi non è sufficiente per l’avvio del programma, la JVM lancia un OutOfMemoryError.

Per esempio, supponiamo di avere la seguente definizione di classe:

public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}

In questo esempio di codice, i dati a livello di campo come name e age e i dettagli del costruttore sono caricati nell’area metodo.

L’area dei metodi viene creata all’avvio della macchina virtuale, e c’è solo un’area dei metodi per JVM.

Heap Area

Tutti gli oggetti e le loro variabili di istanza corrispondenti sono memorizzati qui. Questa è l’area dati di run-time da cui viene allocata la memoria per tutte le istanze di classe e gli array.

Per esempio si supponga di dichiarare la seguente istanza:

Employee employee = new Employee();

In questo esempio di codice, un’istanza di Employee viene creata e caricata nell’area heap.

L’heap viene creata all’avvio della macchina virtuale, e c’è solo un’area heap per JVM.

Nota: Poiché le aree Method e Heap condividono la stessa memoria per più thread, i dati memorizzati qui non sono thread safe.

Stack Area

Ogni volta che viene creato un nuovo thread nella JVM, viene creato anche uno stack runtime separato. Tutte le variabili locali, le chiamate di metodo e i risultati parziali sono memorizzati nell’area dello stack.

Se l’elaborazione effettuata in un thread richiede una dimensione dello stack maggiore di quella disponibile, la JVM lancia un StackOverflowError.

Per ogni chiamata di metodo, viene fatta una voce nella memoria dello stack che è chiamata Stack Frame. Quando la chiamata del metodo è completa, lo Stack Frame viene distrutto.

Lo Stack Frame è diviso in tre sottoparti:

  • Variabili locali – Ogni frame contiene un array di variabili note come variabili locali. Tutte le variabili locali e i loro valori sono memorizzati qui. La lunghezza di questo array è determinata in fase di compilazione.
  • Stack Operand – Ogni frame contiene uno stack last-in-first-out (LIFO) conosciuto come il suo stack operand. Questo agisce come uno spazio di lavoro di runtime per eseguire qualsiasi operazione intermedia. La profondità massima di questo stack è determinata in fase di compilazione.
  • Frame Data – Tutti i simboli corrispondenti al metodo sono memorizzati qui. Questo memorizza anche le informazioni del blocco catch in caso di eccezioni.

Per esempio si supponga di avere il seguente codice:

double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}

In questo esempio di codice, le variabili come answers e score sono posizionate nell’array Local Variables. L’Operand Stack contiene le variabili e gli operatori necessari per eseguire i calcoli matematici di sottrazione e divisione.

Nota: Poiché la Stack Area non è condivisa, è intrinsecamente thread safe.

Registri del contatore di programma (PC)

La JVM supporta più thread contemporaneamente. Ogni thread ha il proprio registro PC che contiene l’indirizzo dell’istruzione JVM in esecuzione. Una volta che l’istruzione viene eseguita, il registro PC viene aggiornato con l’istruzione successiva.

Stack di metodi nativi

La JVM contiene stack che supportano metodi nativi. Questi metodi sono scritti in un linguaggio diverso da Java, come C e C++. Per ogni nuovo thread, viene anche allocato uno stack separato di metodi nativi.

Execution Engine

Una volta che il bytecode è stato caricato nella memoria principale, e i dettagli sono disponibili nell’area dati di runtime, il passo successivo è eseguire il programma. L’Execution Engine si occupa di questo eseguendo il codice presente in ogni classe.

Tuttavia, prima di eseguire il programma, il bytecode deve essere convertito in istruzioni del linguaggio macchina. La JVM può usare un interprete o un compilatore JIT per il motore di esecuzione.

Interprete

L’interprete legge ed esegue le istruzioni bytecode linea per linea. A causa dell’esecuzione linea per linea, l’interprete è relativamente più lento.

Un altro svantaggio dell’interprete è che quando un metodo è chiamato più volte, ogni volta è richiesta una nuova interpretazione.

Compilatore JIT

Il compilatore JIT supera lo svantaggio dell’interprete. L’Execution Engine usa prima l’interprete per eseguire il codice byte, ma quando trova del codice ripetuto, usa il compilatore JIT.

Il compilatore JIT quindi compila l’intero bytecode e lo cambia in codice macchina nativo. Questo codice macchina nativo viene usato direttamente per le chiamate di metodo ripetute, il che migliora le prestazioni del sistema.

Il compilatore JIT ha i seguenti componenti:

  1. Generatore di codice intermedio – genera codice intermedio
  2. Ottimizzatore di codice – ottimizza il codice intermedio per una migliore prestazione
  3. Generatore di codice target – converte il codice intermedio in codice macchina nativo
  4. Profiler – trova gli hotspot (codice che viene eseguito ripetutamente)

Per capire meglio la differenza tra interprete e compilatore JIT, si supponga di avere il seguente codice:

int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);

Un interprete recupererà il valore di sum dalla memoria per ogni iterazione del ciclo, vi aggiungerà il valore di i e lo scriverà nuovamente in memoria. Questa è un’operazione costosa perché accede alla memoria ogni volta che entra nel ciclo.

Tuttavia, il compilatore JIT riconoscerà che questo codice ha un HotSpot, ed eseguirà delle ottimizzazioni su di esso. Memorizzerà una copia locale di sum nel registro PC per il thread e continuerà ad aggiungere il valore di i ad esso nel ciclo. Una volta che il ciclo è completo, scriverà il valore di sum in memoria.

Nota: un compilatore JIT impiega più tempo a compilare il codice che l’interprete a interpretarlo riga per riga. Se avete intenzione di eseguire un programma solo una volta, usare l’interprete è meglio.

Garbage Collector

Il Garbage Collector (GC) raccoglie e rimuove gli oggetti non referenziati dall’area heap. È il processo di recupero della memoria inutilizzata a runtime automaticamente distruggendoli.

Garbage collection rende la memoria Java efficiente perché rimuove gli oggetti non referenziati dalla memoria heap e rende lo spazio libero per i nuovi oggetti. Comporta due fasi:

  1. Mark – in questa fase, il GC identifica gli oggetti inutilizzati in memoria
  2. Sweep – in questa fase, il GC rimuove gli oggetti identificati durante la fase precedente

Garbage Collections è fatto automaticamente dalla JVM a intervalli regolari e non ha bisogno di essere gestito separatamente. Può anche essere attivata chiamando System.gc(), ma l’esecuzione non è garantita.

La JVM contiene 3 diversi tipi di garbage collector:

  1. GC seriale – Questa è l’implementazione più semplice di GC, ed è progettata per piccole applicazioni in esecuzione su ambienti a thread singolo. Utilizza un singolo thread per la garbage collection. Quando viene eseguito, porta ad un evento “ferma il mondo” dove l’intera applicazione viene messa in pausa. L’argomento della JVM per utilizzare il Serial Garbage Collector è -XX:+UseSerialGC
  2. Parallel GC – Questa è l’implementazione predefinita del GC nella JVM, ed è anche conosciuta come Throughput Collector. Utilizza più thread per la garbage collection, ma mette comunque in pausa l’applicazione durante l’esecuzione. L’argomento della JVM per usare il Parallel Garbage Collector è -XX:+UseParallelGC.
  3. Garbage First (G1) GC – G1GC è stato progettato per applicazioni multi-thread che hanno una grande dimensione di heap disponibile (più di 4GB). Suddivide l’heap in un insieme di regioni di uguale dimensione e utilizza più thread per scansionarle. G1GC identifica le regioni con più spazzatura ed esegue la garbage collection su quella regione per prima. L’argomento della JVM per usare G1 Garbage Collector è -XX:+UseG1GC

Nota: Esiste un altro tipo di garbage collector chiamato Concurrent Mark Sweep (CMS) GC. Tuttavia, è stato deprecato da Java 9 e completamente rimosso in Java 14 in favore di G1GC.

Java Native Interface (JNI)

A volte, è necessario usare codice nativo (non Java) (per esempio, C/C++). Questo può essere nei casi in cui abbiamo bisogno di interagire con l’hardware, o per superare la gestione della memoria e i vincoli di prestazione in Java. Java supporta l’esecuzione di codice nativo attraverso la Java Native Interface (JNI).

JNI agisce come un ponte per permettere i pacchetti di supporto per altri linguaggi di programmazione come C, C++, e così via. Questo è particolarmente utile nei casi in cui è necessario scrivere codice che non è interamente supportato da Java, come alcune caratteristiche specifiche della piattaforma che possono essere scritte solo in C.

Puoi usare la parola chiave native per indicare che l’implementazione del metodo sarà fornita da una libreria nativa. Sarà inoltre necessario invocare System.loadLibrary() per caricare la libreria nativa condivisa in memoria, e rendere le sue funzioni disponibili a Java.

Biblioteche di metodi nativi

Le librerie di metodi nativi sono librerie che sono scritte in altri linguaggi di programmazione, come C, C++ e assembly. Queste librerie sono solitamente presenti sotto forma di file .dll o .so. Queste librerie native possono essere caricate attraverso JNI.

Erori comuni della JVM

  • ClassNotFoundExcecption – Questo si verifica quando il Class Loader sta cercando di caricare le classi usando Class.forName()ClassLoader.loadClass() o ClassLoader.findSystemClass() ma nessuna definizione per la classe con il nome specificato viene trovata.
  • NoClassDefFoundError – Questo si verifica quando un compilatore ha compilato con successo la classe, ma il Class Loader non è in grado di individuare il file di classe in fase di esecuzione.
  • OutOfMemoryError – Questo si verifica quando la JVM non può allocare un oggetto perché ha finito la memoria, e nessun’altra memoria potrebbe essere resa disponibile dal garbage collector.
  • StackOverflowError – Si verifica se la JVM esaurisce lo spazio durante la creazione di nuovi frame dello stack durante l’elaborazione di un thread.

Conclusione

In questo articolo, abbiamo discusso l’architettura della Java Virtual Machine e i suoi vari componenti. Spesso non scaviamo a fondo nella meccanica interna della JVM o ci preoccupiamo di come funziona mentre il nostro codice lavora.

È solo quando qualcosa va storto, e abbiamo bisogno di modificare la JVM o correggere una perdita di memoria, che cerchiamo di capire i suoi meccanismi interni.

Questa è anche una domanda molto popolare nei colloqui, sia a livello junior che senior per ruoli di backend. Una profonda comprensione della JVM aiuta a scrivere codice migliore e ad evitare le insidie legate agli errori di stack e di memoria.

Grazie per essere stato con me finora. Spero che l’articolo vi sia piaciuto. Potete connettervi con me su LinkedIn dove discuto regolarmente di tecnologia e vita. Date anche un’occhiata ad alcuni dei miei altri articoli e al mio canale YouTube. Buona lettura 🙂

.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *