Die Architektur der Java VM

SeminarthemenDie Architektur der Java VMI.• II. Strukturen der Java VM • III.IV.V.VI.VII.

II. Strukturen der Java VM

↑ oben

II. 1 Komponenten der JVM

Da für die meisten der Komponenten in der Fachliteratur bestimmte englische Namen gebrächlich sind, werden diese auch in diesem Artikel verwendet, um Missverständnisse zu vermeiden. Einige dieser Namen leiten sich dabei auch von einer gleichnamigen Java-Klasse ab. Für Komponenten, dessen Bezeichnung nicht einheitlich gebraucht wird, wird entsprechend ein deutscher Name verwendet.

Einordnung der JVM als VM

Bei der Java VM handelt es sich um eine Stack-Maschine. D.h. die Operanden der meisten Befehle werden auf einen Verarbeitungs-Stack erwartet, zur Ausführung von diesem geholt, und das Ergebnis auch auf diesem Stack hinterlegt. So kann der Bytecode sehr kompakt gehalten werden, da die meisten Befehle keine oder impliziete Parameter haben. Dieser zur Ausführung verwendete Stack, ist bei der JVM ein 32-Bit LIFO Stack, der Operand-Stack (siehe Abb. 2) genannt wird, und Teil des in Abb. 1 sichtbare JVM-Stacks ist.

Der Java-Bytecode, der im .class-Dateiformat gespeichert wird, wird klassenweise vom ClassLoader geladen, und ggf. vom Verifier überprüft, bevor eine interne Darstellung der Klasse in der Methode Area abgelegt wird (Darstellungsfrom ist dem JVM-Designer überlassen). Die in Bytecode implementierten Methoden einer Klasse werden, wie angedeutet, über den JVM-Stack/Operand-Stack ausgeführt. Eine Klassen wird dabei erst dann dynamisch (zur Laufzeit) geladen, wenn sie benötigt wird.

Neben dem Java-Bytecode kann die JVM auch nativen Code einbeziehen. Dazu werden native Programme bei Bedarf als Methoden vom Native Methode Linker gelinkt, in der Native Methode Area abgelegt, und auf dem Native Stack des aktiven Threads ausgeführt. Dies ist ein herkömmlicher LIFO-Stack, um verschachtelte Funktionsaufrufe zu organisieren. Als Schnittstelle zwischen Java und nativen Methoden dient heute meist das JNI (Java Native Interface), mit dem C oder C++ Programme als Shard Library (plattformspeziefisch) eingebunden werden können.

Objekte (Instanzen von Klassen-Typen, die zur Laufzeit erzeugt werden), werden auf dem Heap abgelegt, der beim Start der JVM angelegt wird. Die interne Darstellung eines Objekts ist auch hier nicht festgeschrieben, und somit dem JVM-Designer überlassen. Der Heap wird von einem automatisch Speichermanager verwaltet, der Gargabe Collector genannt wird. Dieser identifiziert Objekte, welche nicht länger benötigt werden, und gibt deren Speicherbereiche wieder frei.

Die JVM ist, wie die Java-Sprache auch, konzeptionel streng typisiert, besitzt zudem einige zusätzliche interne Typen. Trotz der strengen Typisierung existiert jedoch nur ein Zeigertyp (reference), da Typüberprüfungen einmalig beim Laden einer Klasse vom Verifier durchgeführt werden. Die Typinformationen werden intern erhalten, aber zur Laufzeit nur bei expliziet im Bytecode kodierten Casts und Typüberprüfungen kontrolliert. Vorausgesetzt der Verifier ist fehlerlos implementiert, ist Java trotzdem jederzeit typsicher!

Java Laufzeit-System (Java Runtime Environment - JRE)

Die JVM-Spezifikation unterscheidet zwischen einer Java VM und einem Java Laufzeit-System. Unter einer JVM versteht die Spezifikation eine abstrakte Rechenmaschine, die entwurfen wurde, um Java-Bytecode zu verarbeiten. Funktionsfähige Implementierungen dieser Spezifikation heißen Java-Laufzeit-System, und benötigen zusätzlich zur VM auch die grundlegenden mit der VM verwobenen Klassen, welche auch Teil der API (Application and Programming Interface, "Programmierschnittstelle") sind. Zum JRE gehören die Klassen der Pakete (und deren Unterpackete):

java.lang
Klasse und Pakete, die für die grundlegendsten Mechanismen der JVM und der Java-Sprache benötigt werden, und mit JVM und Plattform verbunden sind, wie:
java.util
Klassen für Datenstrukturen, Zeitangaben und Internationalisierung - z.B:
java.io
Klassen für die Ein- Ausgabe von Daten(-stömen) Streams - z.B:
 
.class
User Bytecode
.class
JRE (API) Bytecode
Native Libraries
     
Java Laufzeit-System
Thread 1
PC
JVM-Stack
Native Stack
Thread n
PC
JVM-Stack
Native Stack
ClassLoader, Verifier (JNI), Native Methode Linker
   
Methode Area Heap
automatische Speicherverwaltung, enthält Objekte
Native Methode Area
       
Execution Engine
 
Betriebssystem
 
Hardware
1: Komponenten und Einbindung eines typischen Java Laufzeit-Systems Die weiß dargetsellten Komponenten sind verarbeitende Elemente, die grau dargestellten Speicherstrukturen. Gestrichelt umrahmte Elemente sind optional.
↑ oben

II. 2 Typen der JVM

Einfache (primitive) Typen

Integraltypen
sind identisch mit den in der Java-Sprache definierten Typen und werden mit Ausnahme von char als vorzeichenbehaftete Integer in Zweierkomplementdarstellung gespeichert. Einzelne char's werden als vorzeichenloser Integer dargestellt, die Uft-16-Zeichen repräsentieren. Da long-Werte 64 Bit breit sind, die JVM aber 32-Bit Worte verarbeitet, werden Werte vom Typ long durch 2 Wörter dargestellt. Die Typen byte, short und char werden zudem intern für Berechnungen automatisch zu int erweitert, als solche verarbeitet, und anschließend entsprechend dem Ausgangstyp abgeschnitten und zurückkonvertiert.
Name Bit Initalwert Wertebereich (inclusiv)
von bis
byte80-128 (-27)127 (27-1)
short160-32768 (-215)32767 (27-1)
int320-2147483648 (-231)2147483647 (231-1)
long640L-9223372036854775808 (-263)9223372036854775807 (263-1)
char16*\u00000 (\u0000)656635 (\uFFFF)
Fließkommatypen
sind konzeptionell durch den IEEE 754 Standard für Gleitkommaarithmetik mit einfacher Genauigkeit (32-Bit) und doppelte Genauigkeit (64-Bit) festgelegt. Dieser definiert neben den reelen Zahlen (im jeweiligen Wertebereich) auch die Werte: Wie long-Werte belegen auch double-Werte 2 JVM-Worte. Zusätzlich zu den zur Java-Sprache analogen standard value sets für float- und double-Werte, die auch für die Kodierung in .class-Dateien vorgeschriebenen sind, kann die JVM noch ein extended float value set und ein extended double value set unterstützen. Der entscheidenden Unterschiede zwischen den in der JVM verwendeten value sets und dem IEEE 754 Standard sind: Die optionalen extended value sets und die damit zusammenhängenden Besonderheiten werden in diesem Artikel nicht weiter berücksichtigt!
Name Bit Initalwert Wertebereich (inclusiv)
von bis
float320.0f± 1.40239846-45± 3.40282347+38
double640.0d± 4.94065645841246544-324± 1.79769313486231570+308
Der boolean-Typ
Zwar definiert die JVM den Typ boolean. Dieser wird aber intern so gut wie garnicht unterstützt. Stattdessen werden boolsche Werte in int umgewandelt und dann verarbeitet. Wie schon in C entspricht false = 0 und true = 1. Compiler müssen diese bereits entsprechend als 0 oder 1 kodieren. Auch für boolean-Arrays existieren keine eigenen Befehle. Diese werden mit den Befehlen baload und bastore für byte-Arrays angefasst. Der boolean-Typ ist vermutlich ausschließlich zur Typüberprüfung durch den Verifier definiert.
Der returnAddress-Typ
wird von der JVM für die jsr und ret Befehle zur Unterstützung von Unterprogrammen verwendet. Es wird dabei ein konstanter byte-Offeset zu dem Befehl des Bytecodes angegeben, bei welchem die Ausführung am Ende des Unterprogramms fortgesetzt werden soll. Dieser ganzzahlige vom Compiler berechnete Wert entspricht keinem Integraltyp der Java-Sprache, und kann auch nicht vom Programmierer zur Laufzeit beeinflusst werden.

Referenz-Typen

Die Werte von reference-Typen entsprechen Referenzen auf zur Laufzeit erzeugte Objekt-Instanzen. Dabei werden vier verschiedene Gruppen unterschieden, die jedoch alle durch Instanzen der finalen System-Klasse java.lang.Class dargestellt werden:

Eine reference kann auch die spezielle Referenz null sein, die keinen Laufzeittyp hat, aber zu allen reference-Typen gecastet werden kann. Welchen Wert die null-Referenz hat, schreibt die Spezifikation nicht vor. Dies begründet sich auch durch die ifnull- und ifnonnull-Befehle, durch welche eine konkrete Bennenung des null-Werts überflüssig wird, da somit eine Referenz auf dem Stack bereits auf Gleich- und Ungleicheit mit null geprüft werden kann.

↑ oben

II. 3 JVM-Stack, Threads und Frames

Jeder Thread besitzt einen privaten Java-Virtual-Machine-Stack, auf dem Frames organisiert werden. Für jeden Methodenaufruf wird ein Frame erzeugt, auf den Stack des aktuellen Threads gebracht und die Kontrolle an den erzeugten Frame übertragen. Das PC-Register des Threads verweist während der Ausführung auf den nächsten Befehl des Bytecodes. Nachdem die Methode ausgeführt wurde, wird der Frame wieder vom JVM-Stack entfernt, und der Rückgabewert (falls vorhanden) auf den Operand-Stack des aufrufenden Frames abgelegt.

JVM-Stack
Frame (Ebene 1)
Lokale Variablen Operand-Stack RCP-Referenz
2: Komponenten des JVM-Stacks im Detail
Operand-Stack (OS)
Auf dem Operanden-Stack werden die Befehle der JVM ausgefürt. Das macht die JVM zur Stack-Maschine.
Lokale Variablen (LV)
Die lokalen Variablen entsprechen virtuellen Registern einer Methode. Parameterübergabe: Die lokalen Variablen werden beim Erzeugen eines neuen Frames mit den aktuellen Parametern der aufgerufenen Methode belegt, die dazu vom OS des aufrufenden Frames entnommen werden. Die vom OS entnommenen Werte werden dabei immer kopiert. Bei reference-Typen entspricht dies der Übergabeart "by Reference". Welcher Methoden-Parameter in welcher LV abgelegt wird, hängt von der Art der Methode ab:
Statische Methoden
p1
p2
pn
l1
ln
Nicht statische Methoden
t
p1
p2
pn
l1
ln
RCP-Referenz (RCP-Ref)
Enthält einen Referenz auf den Runtime Constant Pool der Klasse dessen Methode aufgerufen wurde. Diese Referenz wird verwendet, um dynamisches Binden von Methoden und Feldern (Variablen) zu realisieren. Der Bytecode der Methode aus der class-Datei referenziert dort aufgerufene Methoden und Felder über eine symbolische Referenz – einen Index in den Constant Pool der Datei. Dynamisches Binden übersetzt symbolische Referenz (bei erster Verwendung) in konkrete Referenzen auf die entsprechende Methode oder das entsprechende Feld. Eine konkrete Referenz entspricht einem Offset oder Zeiger in die Laufzeitdatenstruktur in der Methode Area.
Programm-Count-Register (PC)
Die JVM kann das Ausführen mehrerer Threads unterstützen. Jeder Thread hat ein eigenenes PC-Register und führt immer eine Methode zur Zeit aus. Bei nicht nativen Methoden (Bytecode-Implementierung) enthält das PC-Register eine Offset vom Methodenanfang der aktuellen Methode, der auf den gerade ausgeführten Befehl verweist. Bei der Ausführung nativer Methoden ist der Inhalt des PC-Registers undefiniert. Es ist breit genug, um eine returnAddress oder einen nativen Zeiger zu enthalten.

Da die Größe des Operand-Stack und die Anzahl der lokalen Variaben bereits zur Compilte-Zeit für jede Methode feststehen, kann die benötigte Speicher für ein Frame leicht bestimmt werden. Je nach Implementierung addieren sich noch eine Bereich konstanter Länger für zusätzliche interne Verwaltungsdaten (aktuelle Methode, aktueller Frame, aktuelle Klasse, …) zur Größe eines Frames hinzu.

Speicherbereich

Beispiel:

Das folgende Java-Code würde beim Aufruf von foo() in Thread 1 bzw. method() in Thread n zu der in Abb. 3 dargestellten Situation auf den Stacks führen. Die aktuell arbeitenden Frames sind active() und subMethode(). CP-Ref 1.1 und 1.2 würden auf den RCP der Klasse X verweisen, CP-Ref 1.3 auf den der Klasse Y.

class X { …
	void foo() {
		calledByFoo();
		…
	}
	void calledByFoo() {
		int x = Y.active();
		…
	}
}
class Y { …
	static int active() { … }
}
Thread 1
PC 1
JVM-Stack 1
foo()
Frame 1.1
LV 1.1
OS 1.1
RCP-Ref 1.1
 
calledByFoo()
Frame 1.2
LV 1.2
OS 1.2
RCP-Ref 1.2
 
active()
Frame 1.3
LV 1.3
OS 1.3
RCP-Ref 1.3
Thread n
PC n
JVM-Stack n
method()
Frame n.1
LV n.1
OS n.1
RCP-Ref 2.1
 
subMethode()
Frame n.2
LV n.2
OS n.2
RCP-Ref 2.1
3: JVM-Stack-Frames und Threads in einem JRE
↑ oben

II. 4 Inhalt der Methode-Area

Alle Threads der JVM teilen sich eine Methode-Area, die dem Bereich für compilierten Code von konventionellen Sprachen entspricht. Sie enthält eine implemenetierungsabhängige interne Darstellung jeder geladenen Klasse. Eine Klasse wird dynamisch geladen wenn sie benötigt wird, indem zunächst ein ClassLoader eine Bytecode-Repräsentation (gewöhnlich der Inhalt der .class-Datei) erzeugt, und diese an die JVM weitergibt, welche die Information darin aufbereitet und daraus einen Eintrag in der Methode-Area angelgt. Der Eintrag enthält auch Referenzen auf den ClassLoader, der die Klasse definiert hat.

Methode-Area
Klasse A
Runtime Constant Pool
Numerische Konstanten
String Konstanten
(Symbolische) Referenzen auf
Felder, Methoden, Klassen
Felder
Methoden (Code)
Attibutte
Klasse N
RCP
Felder
Methoden (Code)
Attibutte
4: Aufbau der Methode-Area
Runtime Constant Pool (RCP)
Der RCP ist eine Laufzeitrepräsentation (Array) des in der class-Datei enthaltenen Constant Pools (CP), in dem Da an dieser Stelle der Aufbau des Constant Pools noch unbekannt ist, wird der Runtime Constant Pool als Laufzeit-Datenstruktur später im Kapitel IV. genauer beschrieben.
Felder
Enthält die Werte aller Felder einer (dieser) Klasse. Felder werden über Einträge in RCPs von Klassen referenziert.
Methoden (Code)
Enthält die Implementierung aller Methoden einer (dieser) Klasse. Je nach Implementierung werden hier auch weitere Informationen, wie etwa die Exceptions-Tabelle, enthalten sein. Methoden werden über Einträge im RCPs von Klassen referenziert.
Attribute
Enthät die Attribute einer (dieser) Klasse. Da Attribute über ihren String-Namen identifiziert werden, verwenden die meisten Implementierungen eine Hash-Map. Ob die Attribute von Feldern und Methoden ebenfalls hier enthalten sind oder seperat zusammen mit dem Feldwert bzw. Methodenimplementierung ist nicht festgelegt.

Speicherbereich

↑ oben

II. 5 Der Heap

Der Heap-Speicher ist der Laufzeit-Datenspeicher der JVM und wird von allen Threads verwendet, um Instanzen von Klassen oder Arrays zu speichern. Die Darstellung von Objekten ist nicht durch die Spezifikation festgelegt. Es sind verschiedene Formen denkbar, die vor allem darauf abziehlen, möglichst

Automatische Speicherverwaltung (Garbage Collecetor)

Der Speicher des Heap wird dabei von einer automatischen Speicherverwaltung, dem Garbage Collector (GC) verwaltet, der nicht mehr benötigte Objekte perioisch einsammelt. Bei der Erzeugung neuer Objekt wird zwar expliziet im Bytecode Speicher vom Heap reserviert. Dieser kann jedoch nicht expliziet wieder freigegeben werden. Die Speicherfreigabe obliegt dem Garbage Collector, der nicht mehr benötigte Objekte automatisch nach einer bestimmten Strategie bestimmt. Diese kann je nach Anforderungen an die jeweiligen JVM gewählt werden. Zum Beispiel:

Reference Counting
Funktionsweise: Vorteile: Nachteile:
Mark and Sweep
Funktionsweise:
  1. Immer erreichbare Objekte (Konstanten) als Wurzel setzen
  2. Ausgehend von den Wurzelobjekten alle erreichbaren Objekte markieren
  3. Alle nicht markierten Objekte können freigegeben werden
  4. Markeirungen löschen, weiter bei 2.
Vorteile: Nachteile:

Automatische Speicherverwaltung aus Sicht des Programmierers

Grundsätzlich ist der Gabrabe Collector ein unabhängiger Prozess dessen Aktivität nicht vorhergesagt, deaktiviert oder eingeschränkt werden kann. Sie kann einzig über System.gc() ausgelöst werden. Ein Programmierer sollte daher im im Hinterkopf haben, dass

Speicherbereich

↑ oben

II. 6 Java ClassLoader

Damit eine intere Darstellung einer Klasse in der Methode-Area erzeugt werden kann, muss diese zuvor von einem ClassLoader geladen werden. Dieser gibt den geladenen Bytecode dann an die JVM weiter, welche eine implementierungsabhängige intere Darstellung erzeugt. Hierbei werden verschiedene ClassLoader verwendet:

JVM (Execution Engine)
erfragt unbekannte Klasse
SystemClassLoader
nicht gefunden: delegiert an
ExtendsionClassLoader
nicht gefunden: delegiert an
BootStrapClassLoader
löst aus: ClassNotFoundException

Die JVM erfragt unbekannte Klassen über den SystemClassLoader. Findet dieser die gesuchte Klasse nicht in der ClassPath-Umgebung delegiert er die Anfrage an den ExtensionClassLoader weiter, welcher ihm unbekannte Klassen seinerseits an den BootStrapClassLoader delegiert. Kennt auch diese die gesuchte Klasse nicht, wird eine ClassNotFoundException ausgelöst.

Die Verwendeung verschiedener ClassLoader hat die Ziele:

Jedes JRE enthält dazu wenigstens:

BootStrapClassLoader
Ist ein notwendiger Bestandteil der JVM, und läd die vertrauenswürdigen Klasse, die zum JVM-Kern gehören, wie die Klassen in den Paketen java.*, javax.*
ExtendsionClassLoader
Läd Klassen der Erweitungspakete des Java Runtime Environment (JRE).
SystemClassLoader
Wird auch als ApplicationClassLoader bezeichnet, und läd die durch die ClassPath-Systemumgebung bekannten Klassen.

Außerdem können auch eigene ClassLoader erstellt werden. Diese auch als UserClassLoader oder CustomClassLoader bekannten Klassen sind jedoch kein explizierter Teil der JVM. Sie sind Unterklassen von java.lang.ClassLoader und werden wie normale Klassen compiliert, initialisiert und instanziert.

Namensräume

Jeder ClassLoader hat einen eigenen Namensraum. D.h. die Klasse Foo, die von ClassLoaderX geladen wurde, wäre ungleich einer Klasse Foo, die von ClassLoaderY geladen wurde, selbst dann, wenn sie aus der selben class-Datei erzeugt würde.

Array-Klassen

Die Klassen von Arrays werden nicht von einem ClassLoader geladen, sondern zur Laufzeit direkt von der JVM selbst erzeugt.

↑ oben

II. 7 Die Execution-Engine

Die Execution-Engine ist der virtuelle Pozessor der JVM. Im Zusammenspiel mit dem Java-Stack und dem PC-Register jedes Threads führt er die Befehle des Bytecode aus. Die grundsätzliche Ablauf der Ausführung entspricht dem einer gewöhnlichen Stack-Maschine:

  1. Bytecode-Befehl bei PC-Offset laden.
  2. Befehlsabhängig zusätzliche Operanden vom Operanden-Stack holen.
  3. Umwandlung des Befehls (inkl. Operatoren) in nativen Maschinencode und/oder BS-Funktionsaufrufe.
  4. Ausführen des nativen Maschinencodes/BS-Funktionaufrufs auf dem physikalischen Prozessor.
  5. Ggf. Rückgabewert(e) auf den Operanden-Stack bringen.
  6. PC um aktuelle Befehlslänge (1 Byte Opcode + Operandenlänge) inkrementieren, dann wieder 1.

Eine einfache JVM besteht abstrakt betrachtet aus einem großen Case-Verteiler, der für jeden Befehl des Bytecodes äquivaltente Maschienencodeaufrufe beinhaltet. Die Ausführung ansich wird heute meist über verschiedene Strategien optimiert, die im Folgenden kurz skizziert werden:

JIT-Compiler

Bei der Just-In-Time Code-Generierung wird der Bytecode einer Methode zur Laufzeit in nativen Maschinencode übersetzt, welcher bei jedem weiteren Aufruf der Methode direkt verwendet wird. Grundsätzlich kann ein JIT-Compiler auch schnelleren nativen Maschinencode erzeugen, als es herkömmliche (AOT-)Compiler können, da er "Closed-World"-Annahmen treffen kann.

Bytecode-Compiler

Der Bytecode-Compiler ist eine Weiterentwicklung des JIT-Compilers. Die aktuelle HotSpot-Technologie kompiliert den Bytecode zur Laufzeit in nativen Maschinencode und optimiert diesen abhängig von der verwendeten Plattform. Diese Optimierung findet dabei nach und nach statt, was den Effekt hat, dass Programmteile nach mehrmaliger Abarbeitung schneller werden. Nicht selten ist die Ausführung des Java-Bytecodes dann genau so schnell oder sogar schneller als herkömmlich compilierte Programme.

Eine detailiertere Betrachtung der Abläufe innerhalb der JVM bietet das Kapitel IV. Die Java VM zur Laufzeit.

↑ oben

II. 8 Einbettung und Ausführung von nativem Funktionen

Die Möglichkeit native Funktionen oder Biblioteken zu nutzen ist Voraussetzung für eine Zusammenarbeit zwischen dem JRE und einem darunter liegenden Betriebssystem. Ein JRE, das ohne BS direkt auf der Hardware arbeitet, unterstützt meist auch keine nativen Bibliotheken. Wenn diese jedoch unterstützt werden, verwendet die JVM den Native Methode Linker, um zur Laufzeit externe Bibliotheken einzubinden, und in der Native Methode Area abzulegen. Jeder Thread verfügt dann über einen zusätzlichen Native Stack. Dies ist ein herkömmlicher Kellerspeicher wie ihn auch C nutzen würde, um Aufrufhierachien zu verwalten. Das PC-Register des Threads darf bei der Ausführung nativer Funktionen auch die dort üblichen Zeiger auf den nächsten nativen Befehl enthalten. Es ist dafür entsprechend breit angelegt.

JNI

Das JNI vereinheitlicht Parameterübergabe, Aufruf und Rückgabe zwischen den nativen Funktionen und der JVM-Welt. Auch auf die Objekte auf dem Heap kann in den nativen Methoden über das JNI zugegriffen werden.

Die Unterstützung nativer Bibliotheken ist ein sehr weitreichendes Feld, auf das an dieser Stelle nicht weiter eingegangen wird.

→ weiter…

SeminarthemenDie Architektur der Java VMI.• II. Strukturen der Java VM • III.IV.V.VI.VII.