Articles

JVM Tutorial – Java Virtual Machine Architektur für Anfänger erklärt

Posted on

Ob Sie Java zur Programmentwicklung verwendet haben oder nicht, Sie haben vielleicht schon einmal von der Java Virtual Machine (JVM) gehört.

Die JVM ist das Herzstück des Java-Ökosystems und macht es möglich, dass Java-basierte Softwareprogramme dem Ansatz „write once, run anywhere“ folgen. Sie können Java-Code auf einer Maschine schreiben und ihn auf jeder anderen Maschine unter Verwendung der JVM ausführen.

JVM wurde ursprünglich nur für Java entwickelt. Im Laufe der Zeit wurden jedoch viele andere Sprachen wie Scala, Kotlin und Groovy in die Java-Plattform aufgenommen. Alle diese Sprachen werden als JVM-Sprachen bezeichnet.

In diesem Artikel erfahren wir mehr über die JVM, ihre Funktionsweise und die verschiedenen Komponenten, aus denen sie besteht.

Bevor wir uns mit der JVM beschäftigen, wollen wir das Konzept einer virtuellen Maschine (VM) noch einmal betrachten.

Eine virtuelle Maschine ist eine virtuelle Darstellung eines physischen Computers. Eine virtuelle Maschine ist eine virtuelle Repräsentation eines physischen Computers.

Eine einzelne physische Maschine kann mehrere virtuelle Maschinen ausführen, jede mit ihrem eigenen Betriebssystem und Anwendungen. Diese virtuellen Maschinen sind voneinander isoliert.

Was ist die Java Virtual Machine?

In Programmiersprachen wie C und C++ wird der Code zunächst in plattformspezifischen Maschinencode übersetzt. Diese Sprachen werden als kompilierte Sprachen bezeichnet.

Bei Sprachen wie JavaScript und Python hingegen führt der Computer die Anweisungen direkt aus, ohne sie zu kompilieren. Diese Sprachen werden als interpretierte Sprachen bezeichnet.

Java verwendet eine Kombination aus beiden Techniken. Java-Code wird zunächst in Bytecode kompiliert, um eine Klassendatei zu erzeugen. Diese Klassendatei wird dann von der Java Virtual Machine für die zugrunde liegende Plattform interpretiert. Dieselbe Klassendatei kann auf jeder Version der JVM ausgeführt werden, die auf jeder Plattform und jedem Betriebssystem läuft.

Ähnlich wie bei virtuellen Maschinen erzeugt die JVM einen isolierten Bereich auf einem Host-Rechner. In diesem Bereich können Java-Programme ausgeführt werden, unabhängig von der Plattform oder dem Betriebssystem der Maschine.

Architektur der Java Virtual Machine

Die JVM besteht aus drei verschiedenen Komponenten:

  1. Class Loader
  2. Laufzeitspeicher/Datenbereich
  3. Execution Engine

Schauen wir uns die einzelnen Bereiche einmal genauer an.

Klassenlader

Wenn Sie eine .java-Quelldatei kompilieren, wird sie als .class-Datei in Bytecode umgewandelt. Wenn Sie versuchen, diese Klasse in Ihrem Programm zu verwenden, lädt der Klassenlader sie in den Hauptspeicher.

Die erste Klasse, die in den Speicher geladen wird, ist normalerweise die Klasse, die die main()-Methode enthält.

Es gibt drei Phasen im Prozess des Klassenladens: Laden, Binden und Initialisierung.

Laden

Beim Laden wird die binäre Repräsentation (Bytecode) einer Klasse oder Schnittstelle mit einem bestimmten Namen genommen und daraus die ursprüngliche Klasse oder Schnittstelle erzeugt.

Es gibt drei eingebaute Klassenlader in Java:

  • Bootstrap Class Loader – Dies ist der Root Class Loader. Er ist die Oberklasse des Extension Class Loader und lädt die Standard-Java-Pakete wie java.langjava.netjava.utiljava.io, und so weiter. Diese Pakete befinden sich in der rt.jar-Datei und andere Kernbibliotheken im $JAVA_HOME/jre/lib-Verzeichnis.
  • Extension Class Loader – Dies ist die Unterklasse des Bootstrap Class Loader und die Oberklasse des Application Class Loader. Dieser lädt die Erweiterungen der Standard-Java-Bibliotheken, die sich im $JAVA_HOME/jre/lib/ext-Verzeichnis befinden.
  • Application Class Loader – Dies ist der letzte Klassenlader und die Unterklasse des Extension Class Loader. Er lädt die Dateien, die auf dem Klassenpfad vorhanden sind. Standardmäßig ist der Klassenpfad auf das aktuelle Verzeichnis der Anwendung eingestellt. Der Klassenpfad kann auch durch Hinzufügen der -classpath oder -cp Kommandozeilenoption geändert werden.

Die JVM verwendet die ClassLoader.loadClass() Methode zum Laden der Klasse in den Speicher. Sie versucht, die Klasse anhand eines voll qualifizierten Namens zu laden.

Wenn ein Parent Class Loader eine Klasse nicht finden kann, delegiert er die Arbeit an einen Child Class Loader. Wenn auch der letzte Child-Class-Loader nicht in der Lage ist, die Klasse zu laden, wirft er ein NoClassDefFoundError oder ClassNotFoundException.

Verknüpfung

Nachdem eine Klasse in den Speicher geladen wurde, durchläuft sie den Prozess der Verknüpfung. Beim Linken einer Klasse oder Schnittstelle werden die verschiedenen Elemente und Abhängigkeiten des Programms miteinander verbunden.

Das Linken umfasst die folgenden Schritte:

Verifikation: In dieser Phase wird die strukturelle Korrektheit der .class-Datei überprüft, indem sie gegen einen Satz von Einschränkungen oder Regeln geprüft wird. Wenn die Verifizierung aus irgendeinem Grund fehlschlägt, erhalten wir ein VerifyException.

Wenn der Code zum Beispiel mit Java 11 erstellt wurde, aber auf einem System ausgeführt wird, auf dem Java 8 installiert ist, wird die Verifizierungsphase fehlschlagen.

Vorbereitung: In dieser Phase allokiert die JVM Speicher für die statischen Felder einer Klasse oder Schnittstelle und initialisiert sie mit Standardwerten.

Angenommen, Sie haben die folgende Variable in Ihrer Klasse deklariert:

private static final boolean enabled = true;

In der Vorbereitungsphase weist die JVM Speicher für die Variable enabled zu und setzt ihren Wert auf den Standardwert für einen Boolean, der false ist.

Auflösung: In dieser Phase werden symbolische Referenzen durch direkte Referenzen ersetzt, die im Konstantenpool der Laufzeit vorhanden sind.

Beispielsweise werden Referenzen auf andere Klassen oder konstante Variablen, die in anderen Klassen vorhanden sind, in dieser Phase aufgelöst und durch ihre tatsächlichen Referenzen ersetzt.

Initialisierung

Bei der Initialisierung wird die Initialisierungsmethode der Klasse oder Schnittstelle (bekannt als <clinit>) ausgeführt. Dies kann den Aufruf des Konstruktors der Klasse, die Ausführung des statischen Blocks und die Zuweisung von Werten an alle statischen Variablen beinhalten. Dies ist die abschließende Phase des Klassenladens.

Beispielsweise haben wir vorhin folgenden Code deklariert:

private static final boolean enabled = true;

Die Variable enabled wurde in der Vorbereitungsphase auf den Standardwert false gesetzt. In der Initialisierungsphase wird dieser Variable der tatsächliche Wert von true zugewiesen.

Hinweis: Die JVM ist multi-threaded. Es kann vorkommen, dass mehrere Threads gleichzeitig versuchen, die gleiche Klasse zu initialisieren. Dies kann zu Gleichzeitigkeitsproblemen führen. Sie müssen die Thread-Sicherheit handhaben, um sicherzustellen, dass das Programm in einer Multi-Thread-Umgebung ordnungsgemäß funktioniert.

Laufzeitdatenbereich

Es gibt fünf Komponenten innerhalb des Laufzeitdatenbereichs:

Lassen Sie uns jede Komponente einzeln betrachten.

Methodenbereich

Alle Daten auf Klassenebene, wie der Konstantenpool zur Laufzeit, Feld- und Methodendaten sowie der Code für Methoden und Konstruktoren, werden hier gespeichert.

Wenn der im Methodenbereich verfügbare Speicher für den Programmstart nicht ausreicht, wirft die JVM ein OutOfMemoryError.

Angenommen, Sie haben zum Beispiel folgende Klassendefinition:

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

In diesem Codebeispiel werden die Daten der Feldebene wie name und age sowie die Konstruktordetails in den Methodenbereich geladen.

Der Methodenbereich wird beim Start der virtuellen Maschine angelegt, und es gibt nur einen Methodenbereich pro JVM.

Heap-Bereich

Hier werden alle Objekte und die zugehörigen Instanzvariablen gespeichert. Dies ist der Laufzeitdatenbereich, aus dem der Speicher für alle Klasseninstanzen und Arrays allokiert wird.

Angenommen, Sie deklarieren die folgende Instanz:

Employee employee = new Employee();

In diesem Codebeispiel wird eine Instanz von Employee erstellt und in den Heap-Bereich geladen.

Der Heap wird beim Start der virtuellen Maschine erstellt, und es gibt nur einen Heap-Bereich pro JVM.

Hinweis: Da sich der Methoden- und der Heap-Bereich denselben Speicher für mehrere Threads teilen, sind die hier gespeicherten Daten nicht thread-sicher.

Stack-Bereich

Wenn ein neuer Thread in der JVM erzeugt wird, wird gleichzeitig auch ein separater Laufzeit-Stack angelegt. In diesem Stack-Bereich werden alle lokalen Variablen, Methodenaufrufe und Teilergebnisse gespeichert.

Wenn die Verarbeitung, die in einem Thread stattfindet, eine größere Stack-Größe erfordert, als zur Verfügung steht, wirft die JVM ein StackOverflowError.

Für jeden Methodenaufruf wird ein Eintrag im Stack-Speicher vorgenommen, der Stack-Frame genannt wird. Wenn der Methodenaufruf abgeschlossen ist, wird der Stack-Frame zerstört.

Der Stack-Frame ist in drei Teilbereiche unterteilt:

  • Lokale Variablen – Jeder Frame enthält ein Array von Variablen, die als seine lokalen Variablen bekannt sind. Hier werden alle lokalen Variablen und ihre Werte gespeichert. Die Länge dieses Arrays wird zur Kompilierzeit festgelegt.
  • Operandenstapel – Jeder Frame enthält einen Last-In-First-Out (LIFO)-Stapel, der als Operandenstapel bezeichnet wird. Dieser dient als Laufzeit-Arbeitsbereich, um alle Zwischenoperationen durchzuführen. Die maximale Tiefe dieses Stacks wird zur Kompilierzeit festgelegt.
  • Frame-Daten – Hier werden alle Symbole gespeichert, die der Methode entsprechen. Hier werden auch die Catch-Block-Informationen im Falle von Exceptions gespeichert.

Angenommen, Sie haben folgenden Code:

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

In diesem Code-Beispiel werden Variablen wie answers und score im Array Local Variables abgelegt. Der Operandenstapel enthält die Variablen und Operatoren, die benötigt werden, um die mathematischen Berechnungen der Subtraktion und Division durchzuführen.

Hinweis: Da der Stapelbereich nicht gemeinsam genutzt wird, ist er von Natur aus thread-sicher.

Programmzähler (PC)-Register

Die JVM unterstützt mehrere Threads zur gleichen Zeit. Jeder Thread hat sein eigenes PC-Register, das die Adresse der aktuell ausgeführten JVM-Anweisung enthält. Sobald die Anweisung ausgeführt ist, wird das PC-Register mit der nächsten Anweisung aktualisiert.

Native Methodenstapel

Die JVM enthält Stapel, die native Methoden unterstützen. Diese Methoden sind in einer anderen Sprache als Java geschrieben, z. B. in C und C++. Für jeden neuen Thread wird ebenfalls ein separater nativer Methodenstapel allokiert.

Execution Engine

Nachdem der Bytecode in den Hauptspeicher geladen wurde und Details im Laufzeitdatenbereich verfügbar sind, ist der nächste Schritt die Ausführung des Programms. Die Execution Engine übernimmt dies, indem sie den in jeder Klasse vorhandenen Code ausführt.

Bevor das Programm jedoch ausgeführt wird, muss der Bytecode in Maschinensprache-Anweisungen umgewandelt werden. Die JVM kann einen Interpreter oder einen JIT-Compiler für die Execution Engine verwenden.

Interpreter

Der Interpreter liest die Bytecode-Anweisungen zeilenweise und führt sie aus. Durch die zeilenweise Ausführung ist der Interpreter vergleichsweise langsamer.

Ein weiterer Nachteil des Interpreters ist, dass bei mehrfachem Aufruf einer Methode jedes Mal eine neue Interpretation erforderlich ist.

JIT-Compiler

Der JIT-Compiler überwindet den Nachteil des Interpreters. Die Execution Engine verwendet zunächst den Interpreter, um den Bytecode auszuführen, aber wenn sie einen sich wiederholenden Code findet, verwendet sie den JIT-Compiler.

Der JIT-Compiler kompiliert dann den gesamten Bytecode und wandelt ihn in nativen Maschinencode um. Dieser native Maschinencode wird direkt für wiederholte Methodenaufrufe verwendet, was die Performance des Systems verbessert.

Der JIT-Compiler besteht aus den folgenden Komponenten:

  1. Zwischencode-Generator – erzeugt Zwischencode
  2. Code-Optimierer – optimiert den Zwischencode für eine bessere Leistung
  3. Zielcode-Generator – wandelt den Zwischencode in nativen Maschinencode um
  4. Profiler – findet die Hotspots (Code, der wiederholt ausgeführt wird)

Um den Unterschied zwischen Interpreter und JIT-Compiler besser zu verstehen, nehmen Sie den folgenden Code an:

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

Ein Interpreter holt für jede Iteration in der Schleife den Wert von sum aus dem Speicher, addiert den Wert von i dazu und schreibt ihn zurück in den Speicher. Dies ist eine kostspielige Operation, da bei jedem Eintritt in die Schleife auf den Speicher zugegriffen wird.

Der JIT-Compiler erkennt jedoch, dass dieser Code einen HotSpot hat, und führt Optimierungen an ihm durch. Er speichert eine lokale Kopie von sum im PC-Register für den Thread und addiert in der Schleife immer wieder den Wert von i dazu. Sobald die Schleife beendet ist, wird der Wert von sum zurück in den Speicher geschrieben.

Hinweis: Ein JIT-Compiler benötigt mehr Zeit, um den Code zu kompilieren, als der Interpreter, um den Code Zeile für Zeile zu interpretieren. Wenn Sie ein Programm nur einmal ausführen wollen, ist die Verwendung des Interpreters besser.

Garbage Collector

Der Garbage Collector (GC) sammelt und entfernt nicht referenzierte Objekte aus dem Heap-Bereich. Es ist der Prozess der automatischen Rückgewinnung des zur Laufzeit ungenutzten Speichers durch dessen Zerstörung.

Garbage Collection macht Java speichereffizient, weil es die nicht referenzierten Objekte aus dem Heap-Speicher entfernt und Platz für neue Objekte schafft. Sie umfasst zwei Phasen:

  1. Mark – in diesem Schritt identifiziert die GC die unbenutzten Objekte im Speicher
  2. Sweep – in diesem Schritt entfernt die GC die in der vorherigen Phase identifizierten Objekte

Garbage Collections wird von der JVM in regelmäßigen Abständen automatisch durchgeführt und muss nicht gesondert behandelt werden. Es kann auch durch den Aufruf von System.gc() ausgelöst werden, aber die Ausführung ist nicht garantiert.

Die JVM enthält 3 verschiedene Typen von Garbage Collectors:

  1. Serielle GC – Dies ist die einfachste Implementierung von GC und ist für kleine Anwendungen gedacht, die in Single-Thread-Umgebungen laufen. Sie verwendet einen einzelnen Thread für die Garbage Collection. Wenn sie ausgeführt wird, führt sie zu einem „Stop the world“-Ereignis, bei dem die gesamte Anwendung angehalten wird. Das JVM-Argument zur Verwendung von Serial Garbage Collector lautet -XX:+UseSerialGC
  2. Parallel GC – Dies ist die Standardimplementierung von GC in der JVM und wird auch als Throughput Collector bezeichnet. Sie verwendet mehrere Threads für die Garbage Collection, pausiert aber trotzdem die Anwendung, wenn sie läuft. Das JVM-Argument zur Verwendung von Parallel Garbage Collector lautet -XX:+UseParallelGC.
  3. Garbage First (G1) GC – G1GC wurde für Multi-Thread-Anwendungen entwickelt, die eine große Heap-Größe zur Verfügung haben (mehr als 4 GB). Es partitioniert den Heap in eine Reihe von gleich großen Regionen und verwendet mehrere Threads, um diese zu scannen. G1GC identifiziert die Regionen mit dem meisten Garbage und führt die Garbage Collection für diese Region zuerst durch. Das JVM-Argument für die Verwendung von G1 Garbage Collector lautet -XX:+UseG1GC

Hinweis: Es gibt einen anderen Typ von Garbage Collector, der Concurrent Mark Sweep (CMS) GC genannt wird. Er ist jedoch seit Java 9 veraltet und wurde in Java 14 zugunsten von G1GC komplett entfernt.

Java Native Interface (JNI)

Zuweilen ist es notwendig, nativen (nicht-Java) Code (z. B. C/C++) zu verwenden. Dies kann der Fall sein, wenn wir mit Hardware interagieren müssen, oder um die Speicherverwaltung und die Leistungsbeschränkungen in Java zu überwinden. Java unterstützt die Ausführung von nativem Code über das Java Native Interface (JNI).

JNI fungiert als Brücke, um die unterstützenden Pakete für andere Programmiersprachen wie C, C++ und so weiter zu ermöglichen. Dies ist besonders hilfreich in Fällen, in denen Sie Code schreiben müssen, der nicht vollständig von Java unterstützt wird, wie z. B. einige plattformspezifische Funktionen, die nur in C geschrieben werden können.

Sie können das Schlüsselwort native verwenden, um anzugeben, dass die Methodenimplementierung von einer nativen Bibliothek bereitgestellt wird. Sie müssen auch System.loadLibrary() aufrufen, um die freigegebene native Bibliothek in den Speicher zu laden und ihre Funktionen für Java verfügbar zu machen.

Native Methodenbibliotheken

Native Methodenbibliotheken sind Bibliotheken, die in anderen Programmiersprachen wie C, C++ und Assembler geschrieben sind. Diese Bibliotheken liegen meist in Form von .dll oder .so Dateien vor. Diese nativen Bibliotheken können über JNI geladen werden.

Gängige JVM-Fehler

  • ClassNotFoundExcecption – Dies tritt auf, wenn der Class Loader versucht, Klassen mit Class.forName()ClassLoader.loadClass() oder ClassLoader.findSystemClass() zu laden, aber keine Definition für die Klasse mit dem angegebenen Namen gefunden wird.
  • NoClassDefFoundError – Dies tritt auf, wenn ein Compiler die Klasse erfolgreich kompiliert hat, aber der Class Loader nicht in der Lage ist, die Klassendatei zur Laufzeit zu finden.
  • OutOfMemoryError – Dies tritt auf, wenn die JVM ein Objekt nicht allozieren kann, weil der Speicher aufgebraucht ist und vom Garbage Collector kein weiterer Speicher zur Verfügung gestellt werden konnte.
  • StackOverflowError – Dies tritt auf, wenn der JVM beim Anlegen neuer Stack-Frames während der Verarbeitung eines Threads der Platz ausgeht.

Abschluss

In diesem Artikel haben wir die Architektur der Java Virtual Machine und ihre verschiedenen Komponenten besprochen. Oft tauchen wir nicht tief in die interne Mechanik der JVM ein oder kümmern uns nicht darum, wie sie arbeitet, während unser Code läuft.

Nur wenn etwas schief läuft und wir die JVM optimieren oder ein Speicherleck beheben müssen, versuchen wir, ihre interne Mechanik zu verstehen.

Dies ist auch eine sehr beliebte Interviewfrage, sowohl auf Junior- als auch auf Senior-Ebene für Backend-Rollen. Ein tiefes Verständnis der JVM hilft Ihnen, besseren Code zu schreiben und Fallstricke im Zusammenhang mit Stack- und Speicherfehlern zu vermeiden.

Danke, dass Sie mir bis hierher gefolgt sind. Hoffentlich hat Ihnen der Artikel gefallen. Sie können sich mit mir auf LinkedIn verbinden, wo ich regelmäßig über Technik und das Leben diskutiere. Werfen Sie auch einen Blick auf einige meiner anderen Artikel und meinen YouTube-Kanal. Viel Spaß beim Lesen. 🙂

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.