Of je Java nu gebruikt om programma’s te ontwikkelen of niet, misschien heb je ooit wel eens van de Java Virtual Machine (JVM) gehoord.
De JVM vormt de kern van het Java-ecosysteem en maakt het mogelijk dat op Java gebaseerde softwareprogramma’s de “write once, run anywhere”-aanpak kunnen volgen. Je kunt Java code schrijven op een machine, en het op elke andere machine uitvoeren met behulp van de JVM.
JVM was oorspronkelijk ontworpen om alleen Java te ondersteunen. In de loop der tijd zijn echter vele andere talen, zoals Scala, Kotlin en Groovy, op het Java-platform geïntroduceerd. Al deze talen staan gezamenlijk bekend als JVM talen.
In dit artikel zullen we meer leren over de JVM, hoe het werkt, en de verschillende componenten waaruit het is opgebouwd.
Voordat we ons gaan verdiepen in de JVM, laten we het concept van een Virtuele Machine (VM) nog eens bekijken.
Een virtuele machine is een virtuele representatie van een fysieke computer. We kunnen de virtuele machine de gastmachine noemen, en de fysieke computer waarop deze draait is de hostmachine.
Op een enkele fysieke machine kunnen meerdere virtuele machines draaien, elk met hun eigen besturingssysteem en toepassingen. Deze virtuele machines zijn van elkaar geïsoleerd.
Wat is de Java Virtual Machine?
In programmeertalen als C en C++ wordt de code eerst gecompileerd tot platform-specifieke machinecode. Deze talen worden gecompileerde talen genoemd.
Bij talen als JavaScript en Python daarentegen voert de computer de instructies direct uit zonder ze te compileren. Deze talen worden geïnterpreteerde talen genoemd.
Java maakt gebruik van een combinatie van beide technieken. Java-code wordt eerst gecompileerd in byte-code om een klassebestand te genereren. Dit klassebestand wordt vervolgens door de Java Virtual Machine geïnterpreteerd voor het onderliggende platform. Hetzelfde klassebestand kan worden uitgevoerd op elke versie van de JVM die op elk platform en besturingssysteem draait.
Gelijk aan virtuele machines, creëert de JVM een geïsoleerde ruimte op een host-machine. Deze ruimte kan worden gebruikt om Java-programma’s uit te voeren, ongeacht het platform of besturingssysteem van de machine.
Java Virtual Machine Architecture
De JVM bestaat uit drie afzonderlijke componenten:
- Class Loader
- Runtime Memory/Data Area
- Execution Engine
Laten we elk van hen eens in meer detail bekijken.
Class Loader
Wanneer u een .java
bronbestand compileert, wordt dit geconverteerd naar byte-code als een .class
bestand. Wanneer je deze class in je programma probeert te gebruiken, laadt de class loader hem in het hoofdgeheugen.
De eerste class die in het geheugen wordt geladen is meestal de class die de main()
methode bevat.
Er zijn drie fasen in het class loading proces: loading, linking, en initialization.
Laden
Laden houdt in dat de binaire representatie (bytecode) van een klasse of interface met een bepaalde naam wordt genomen, en dat op basis daarvan de oorspronkelijke klasse of interface wordt gegenereerd.
Er zijn drie ingebouwde class loaders beschikbaar in Java:
- Bootstrap Class Loader – Dit is de root class loader. Het is de superklasse van Extension Class Loader en laadt de standaard Java-pakketten zoals
java.lang
java.net
java.util
java.io
, enzovoort. Deze pakketten zijn aanwezig in hetrt.jar
bestand en andere core libraries aanwezig in de$JAVA_HOME/jre/lib
directory. - Extension Class Loader – Dit is de subclass van de Bootstrap Class Loader en de superclass van de Application Class Loader. Deze laadt de extensies van standaard Java bibliotheken die aanwezig zijn in de
$JAVA_HOME/jre/lib/ext
directory. - Application Class Loader – Dit is de laatste class loader en de subklasse van Extension Class Loader. Hij laadt de bestanden die op het classpath staan. Standaard is het classpath ingesteld op de huidige directory van de applicatie. Het classpath kan ook worden gewijzigd door de
-classpath
of-cp
command line optie toe te voegen.
De JVM gebruikt de ClassLoader.loadClass()
methode om de class in het geheugen te laden. Hij probeert de klasse te laden op basis van een volledig gekwalificeerde naam.
Als een ouder class loader een class niet kan vinden, delegeert hij het werk naar een child class loader. Als de laatste child class loader de class ook niet kan laden, gooit hij NoClassDefFoundError
of ClassNotFoundException
.
Linking
Nadat een klasse in het geheugen is geladen, ondergaat deze het linking-proces. Bij het linken van een klasse of interface worden de verschillende elementen en afhankelijkheden van het programma samengevoegd.
Het koppelen omvat de volgende stappen:
Verificatie: Deze fase controleert de structurele juistheid van het .class
bestand door het te toetsen aan een set van constraints of regels. Als de verificatie om een of andere reden mislukt, krijgen we een VerifyException
.
Bijv. als de code is gebouwd met Java 11, maar wordt uitgevoerd op een systeem waarop Java 8 is geïnstalleerd, zal de verificatiefase mislukken.
Voorbereiding: In deze fase wijst de JVM geheugen toe voor de statische velden van een klasse of interface, en initialiseert deze met standaardwaarden.
Voorbeeld: u hebt de volgende variabele in uw klasse gedeclareerd:
private static final boolean enabled = true;
Tijdens de voorbereidingsfase wijst de JVM geheugen toe aan de variabele enabled
en stelt de waarde in op de standaardwaarde voor een boolean, namelijk false
.
Oplossing: In deze fase worden symbolische verwijzingen vervangen door directe verwijzingen die aanwezig zijn in de runtime constant pool.
Bijv. verwijzingen naar andere klassen of constante variabelen in andere klassen worden in deze fase opgelost en vervangen door hun werkelijke referenties.
Initialisatie
Initialisatie omvat het uitvoeren van de initialisatiemethode van de klasse of interface (bekend als <clinit>
). Dit kan inhouden het aanroepen van de constructor van de klasse, het uitvoeren van het static block, en het toekennen van waarden aan alle statische variabelen. Dit is de laatste fase van het laden van de klasse.
Bijv. toen we eerder de volgende code declareerden:
private static final boolean enabled = true;
De variabele enabled
werd tijdens de voorbereidingsfase op zijn standaardwaarde false
gezet. In de initialisatiefase krijgt deze variabele de werkelijke waarde true
.
Note: de JVM is multi-threaded. Het kan gebeuren dat meerdere threads tegelijkertijd dezelfde class proberen te initialiseren. Dit kan leiden tot concurrency problemen. Je moet zorgen voor thread safety om te zorgen dat het programma goed werkt in een multi-threaded omgeving.
Runtime Data Area
Er zijn vijf componenten in het runtime data area:
Laten we ze eens stuk voor stuk bekijken.
Method Area
Alle gegevens op klassenniveau, zoals de run-time constant pool, veld- en methodegegevens, en de code voor methoden en constructors, worden hier opgeslagen.
Als het beschikbare geheugen in het methodegebied niet voldoende is voor het opstarten van het programma, gooit de JVM een OutOfMemoryError
.
Voorbeeld: u hebt de volgende class-definitie:
public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}
In dit codevoorbeeld worden de gegevens op veldniveau, zoals name
en age
en de constructordetails in het methodegebied geladen.
Het methodegebied wordt gemaakt bij het opstarten van de virtuele machine, en er is maar één methodegebied per JVM.
Heap Area
Alle objecten en hun bijbehorende instantievariabelen worden hier opgeslagen. Dit is het runtime data gebied van waaruit geheugen voor alle klasse-instanties en arrays wordt gealloceerd.
Voorbeeld: u declareert de volgende instantie:
Employee employee = new Employee();
In dit codevoorbeeld wordt een instantie van Employee
gecreëerd en in het heap-gebied geladen.
De heap wordt gecreëerd bij het opstarten van de virtuele machine, en er is maar één heap-gebied per JVM.
Opmerking: Omdat de Methode- en Heap-gebieden hetzelfde geheugen delen voor meerdere threads, zijn de hier opgeslagen gegevens niet thread safe.
Stack-gebied
Wanneer een nieuwe thread in de JVM wordt aangemaakt, wordt tegelijkertijd ook een aparte runtime stack aangemaakt. Alle lokale variabelen, methode-aanroepen en gedeeltelijke resultaten worden in het stackgebied opgeslagen.
Als de verwerking die in een thread wordt gedaan een grotere stackgrootte vereist dan wat beschikbaar is, gooit de JVM een StackOverflowError
.
Voor elke methode-aanroep wordt een entry gemaakt in het stackgeheugen, dat het Stack Frame wordt genoemd. Als de methode-aanroep is voltooid, wordt de Stack Frame vernietigd.
De Stack Frame is onderverdeeld in drie subdelen:
- Lokale variabelen – Elk frame bevat een array van variabelen die bekend staan als de lokale variabelen. Alle lokale variabelen en hun waarden worden hier opgeslagen. De lengte van deze array wordt bepaald tijdens het compileren.
- Operand Stack – Elk frame bevat een last-in-first-out (LIFO) stack die bekend staat als de operand stack. Deze fungeert als een runtime werkruimte voor het uitvoeren van eventuele tussenliggende operaties. De maximale diepte van deze stack wordt bepaald tijdens het compileren.
- Frame Data – Alle symbolen die overeenkomen met de methode worden hier opgeslagen. Hier wordt ook de catch block informatie opgeslagen in geval van exceptions.
Bij wijze van voorbeeld stel dat je de volgende code hebt:
double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}
In dit codevoorbeeld worden variabelen als answers
en score
in de Local Variables array geplaatst. De Operand Stack bevat de variabelen en operatoren die nodig zijn om de wiskundige berekeningen van aftrekken en delen uit te voeren.
Opmerking: aangezien het Stack-gebied niet wordt gedeeld, is het inherent thread-veilig.
Program Counter (PC) Registers
De JVM ondersteunt meerdere threads tegelijk. Elke thread heeft zijn eigen PC Register, waarin het adres van de op dat moment uitvoerende JVM instructie wordt opgeslagen. Zodra de instructie is uitgevoerd, wordt het PC-register bijgewerkt met de volgende instructie.
Native Method Stacks
De JVM bevat stacks die native methoden ondersteunen. Deze methoden zijn geschreven in een andere taal dan Java, zoals C en C++. Voor elke nieuwe thread wordt ook een aparte native method stack toegewezen.
Execution Engine
Als de bytecode eenmaal in het hoofdgeheugen is geladen, en de details beschikbaar zijn in het runtime data gebied, is de volgende stap het uitvoeren van het programma. De Execution Engine zorgt hiervoor door de code in elke class uit te voeren.
Hoewel, voordat het programma wordt uitgevoerd, moet de bytecode worden omgezet in machinetaal instructies. De JVM kan een interpreter of een JIT-compiler gebruiken voor de execution engine.
Interpreter
De interpreter leest en voert de bytecode-instructies regel voor regel uit. Vanwege de regel-voor-regel-uitvoering is de interpreter relatief trager.
Een ander nadeel van de interpreter is dat wanneer een methode meerdere malen wordt aangeroepen, elke keer een nieuwe interpretatie nodig is.
JIT Compiler
De JIT Compiler ondervangt het nadeel van de interpreter. De Execution Engine gebruikt eerst de interpreter om de byte-code uit te voeren, maar wanneer hij herhaalde code vindt, gebruikt hij de JIT-compiler.
De JIT compiler compileert dan de gehele bytecode en verandert deze in native machine code. Deze native machinecode wordt direct gebruikt voor herhaalde methode-aanroepen, waardoor de prestaties van het systeem worden verbeterd.
De JIT-compiler bestaat uit de volgende onderdelen:
- Intermediate Code Generator – genereert intermediate code
- Code Optimizer – optimaliseert de intermediate code voor betere prestaties
- Target Code Generator – converteert intermediate code naar native machine code
- Profiler – vindt de hotspots (code die herhaaldelijk wordt uitgevoerd)
Om het verschil tussen interpreter en JIT-compiler beter te begrijpen, stel dat u de volgende code heeft:
int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);
Een interpreter haalt voor elke iteratie in de lus de waarde van sum
op uit het geheugen, voegt de waarde van i
eraan toe, en schrijft het terug naar het geheugen. Dit is een kostbare operatie, omdat het geheugen telkens wordt benaderd als de lus wordt binnengegaan.
De JIT-compiler zal echter herkennen dat deze code een HotSpot heeft, en zal er optimalisaties op uitvoeren. Hij slaat een lokale kopie van sum
op in het PC-register voor de thread en voegt de waarde van i
er steeds aan toe in de lus. Zodra de lus is voltooid, wordt de waarde sum
terug naar het geheugen geschreven.
Note: een JIT-compiler heeft meer tijd nodig om de code te compileren dan de interpreter om de code regel voor regel te interpreteren. Als je een programma maar één keer gaat uitvoeren, is het beter om de interpreter te gebruiken.
Garbage Collector
De Garbage Collector (GC) verzamelt en verwijdert objecten van de heap waarnaar niet verwezen wordt. Het is het proces van het automatisch terugwinnen van runtime ongebruikt geheugen door ze te vernietigen.
Garbage collection maakt Java geheugen efficiënt, omdat het de objecten waarnaar niet verwezen wordt uit het heap geheugen verwijdert en ruimte vrij maakt voor nieuwe objecten. Het omvat twee fasen:
- Mark – in deze stap identificeert de GC de ongebruikte objecten in het geheugen
- Sweep – in deze stap verwijdert de GC de objecten die tijdens de vorige fase zijn geïdentificeerd
Garbage Collections wordt automatisch door de JVM gedaan op gezette tijden en hoeft niet apart te worden afgehandeld. Het kan ook worden gestart door System.gc()
aan te roepen, maar de uitvoering is niet gegarandeerd.
De JVM bevat 3 verschillende typen garbage collectors:
- Serial GC – Dit is de eenvoudigste implementatie van GC, en is ontworpen voor kleine applicaties die op single-threaded omgevingen draaien. Het gebruikt een enkele thread voor garbage collection. Wanneer het wordt uitgevoerd, leidt het tot een “stop de wereld”-gebeurtenis waarbij de hele toepassing wordt gepauzeerd. Het JVM argument om Serial Garbage Collector te gebruiken is
-XX:+UseSerialGC
- Parallel GC – Dit is de standaard implementatie van GC in de JVM, en is ook bekend als Throughput Collector. Het gebruikt meerdere threads voor garbage collection, maar pauzeert nog steeds de applicatie tijdens het draaien. Het JVM argument om Parallel Garbage Collector te gebruiken is
-XX:+UseParallelGC
. - Garbage First (G1) GC – G1GC is ontworpen voor multi-threaded applicaties die een grote heap grootte beschikbaar hebben (meer dan 4GB). Het verdeelt de heap in een aantal regio’s van gelijke grootte, en gebruikt meerdere threads om ze te scannen. G1GC identificeert de regio’s met de meeste vuilnis en voert eerst vuilnisverzameling uit op die regio. Het JVM-argument om G1 Garbage Collector te gebruiken is
-XX:+UseG1GC
Note: Er is een ander type garbage collector, genaamd Concurrent Mark Sweep (CMS) GC. Deze is echter sinds Java 9 afgeschreven en in Java 14 volledig verwijderd ten gunste van G1GC.
Java Native Interface (JNI)
Op sommige momenten is het nodig om native (niet-Java) code te gebruiken (bijvoorbeeld C/C++). Dit kan het geval zijn wanneer we moeten communiceren met hardware, of om het geheugenbeheer en de prestatiebeperkingen in Java te overwinnen. Java ondersteunt de uitvoering van native code via de Java Native Interface (JNI).
JNI fungeert als een brug voor het toestaan van de ondersteunende pakketten voor andere programmeertalen zoals C, C++, enzovoort. Dit is vooral handig in gevallen waarin je code moet schrijven die niet volledig door Java wordt ondersteund, zoals sommige platform-specifieke functies die alleen in C kunnen worden geschreven.
Je kunt het sleutelwoord native
gebruiken om aan te geven dat de methode-implementatie zal worden verzorgd door een native bibliotheek. U moet ook System.loadLibrary()
aanroepen om de gedeelde native library in het geheugen te laden, en de functies ervan beschikbaar te maken voor Java.
Native Method Libraries
Native Method Libraries zijn bibliotheken die zijn geschreven in andere programmeertalen, zoals C, C++, en assembly. Deze bibliotheken zijn meestal aanwezig in de vorm van .dll
of .so
bestanden. Deze native libraries kunnen via JNI worden geladen.
Common JVM Errors
- ClassNotFoundExcecption – Dit treedt op wanneer de Class Loader klassen probeert te laden met
Class.forName()
ClassLoader.loadClass()
ofClassLoader.findSystemClass()
maar er geen definitie voor de klasse met de opgegeven naam wordt gevonden. - NoClassDefFoundError – Deze treedt op als een compiler de class met succes heeft gecompileerd, maar de Class Loader het class-bestand in runtime niet kan lokaliseren.
- OutOfMemoryError – Dit gebeurt wanneer de JVM een object niet kan toewijzen omdat het geheugen op is, en er geen geheugen meer beschikbaar kon worden gemaakt door de garbage collector.
- StackOverflowError – Dit treedt op als de JVM ruimte te kort komt bij het aanmaken van nieuwe stack frames tijdens het verwerken van een thread.
Conclusie
In dit artikel hebben we de architectuur van de Java Virtual Machine en de verschillende onderdelen ervan besproken. Vaak graven we niet diep in de interne mechanica van de JVM of geven we er niet om hoe het werkt terwijl onze code werkt.
Alleen als er iets fout gaat, en we de JVM moeten tweaken of een geheugenlek moeten repareren, proberen we de interne mechanismen te begrijpen.
Dit is ook een zeer populaire vraag in sollicitatiegesprekken, zowel op junior als senior niveau voor backend functies. Een goed begrip van de JVM helpt je om betere code te schrijven en valkuilen met betrekking tot stack- en geheugenfouten te vermijden.
Bedankt dat je tot nu toe bij me bent gebleven. Ik hoop dat je het artikel leuk vond. Je kunt met me in contact komen op LinkedIn, waar ik regelmatig discussieer over technologie en het leven. Neem ook een kijkje bij een aantal van mijn andere artikelen en mijn YouTube-kanaal. Veel leesplezier. 🙂