Läuft überall: Die Java Virtual Machine im Überblick

Seite 3: Sicher ist sicher

Inhaltsverzeichnis

Eine Besonderheit der JVM ist, dass sie sich nicht darauf verlässt, dass der hauseigene Java-Compiler eine Klasse übersetzt hat. Ein Grund dafür könnte in der Historie von Java liegen, die von der Idee geprägt war, auszuführenden Code von irgendwoher über das Netzwerk zu übertragen. Code, der – wenn auch nur kurzfristig – nicht mehr der Kontrolle der Erzeugers unterliegt, kann manipuliert worden sein. Also muss nicht nur sicher gestellt sein, dass der Code unverändert sein Ziel erreicht, sondern (unveränderter) Code nicht mehr darf, als ihm zugestanden wird.

Vor der Ausführung überprüft der Bytecode Verifier zunächst, ob eine Klasse überhaupt der definierten, äußeren Form genügt. Unter anderem muss die Klassendatei mit der hexadezimalen Byte-Sequenz CA FE BA BE beginnen und die richtige Versionsnummer haben. Anschließend überprüft das System grob, ob die Klasse den inneren Einschränkungen genügt. Dazu gehört, dass keine zwei Methoden denselben Namen und dieselbe Signatur haben und die Oberklasse eine Ableitung zulässt, also nicht final ist.

Erst anschließend lässt sich die Klasse überhaupt verwenden. Aber vor jeder tatsächlichen Ausführung prüft der Bytecode Verifier Zug um Zug weitere Eigenschaften und gleicht beispielsweise erst beim Aufruf einer Methode die Größe des Stacks und die Anzahl der lokalen Variablen mit dem Bytecode ab – ohne ihn auszuführen. Das System führt die Instruktionen erst aus, wenn sichergestellt ist, dass sämtliche auf dem Stack abzulegenden Werte zu der jeweiligen Instruktion passen und mit dem auszuführenden Code kein Schindluder getrieben werden kann.

Zudem stellt die JVM sicher, dass Klassen nur auf diejenigen Elemente einer anderen Klasse zugreifen dürfen, die dafür bestimmt sind. Privates ist nur der Klasse selbst vorbehalten, Öffentliches kann von allen Objekten gelesen und geschrieben werden, einiges ist nur Klassen vorbehalten, die aus dem selben Paket stammen oder in einer Vererbungsbeziehung zueinander stehen. Mit Java 9 lassen sich Klassen darüber hinaus noch in Module packen, die anderen Modulen nur dann Zugriff auf Elemente gewähren, wenn diese zusätzlich explizit exportiert werden.

Unabhängig davon ist jede Klasse noch einem logischen Besitzer zugeordnet (dem Class Loader), der darüber entscheidet, woher die noch zu ladenden Klassen kommen sollen und welche Rechte der Code hat. Während Ersteres ermöglicht, innerhalb der JVM sogar verschiedene Versionen ein und derselben Klasse auszuführen, hat Letzteres nur insoweit mit der JVM zu tun, dass diese darüber Buch führt, wer welche Klasse geladen hat.

Nach dem Laden einer Klasse interpretiert die JVM den Code Byte für Byte. Aufgrund der Stack-orientierten Struktur und des einfachen Formats des Bytecode geht das relativ schnell vonstatten. Trifft die JVM dabei auf eine noch unbekannte Klasse, lädt sie diese unter Berücksichtigung der beschriebenen Sicherheitsmechanismen nach. Alleine um das kurze "Hallo Welt!"-Programm laufen zu lassen, lädt die JVM unter Java 1.8 – Java SE Runtime Environment (Build 1.8.0-b132) mit Java HotSpot 64-Bit Server VM (Build 25.0-b70) unter Mac OS X 10.12.4 – sage und schreibe 426 Klassen. Um einen Webserver ans Laufen zu bringen sind mehrere Tausend Klassen erforderlich.

Die Ausführungsgeschwindigkeit ist trotz dieser Interpretation in vielen Fällen völlig ausreichend. Die Vorteile wiegen die Performanceeinbußen mehr als auf. Durch das dynamische Verhalten lassen sich Klassen zu einem beliebigen Zeitpunkt nachladen. Das bedeutet unter anderem, dass Entwickler die Software auch erst zur Laufzeit konfigurieren können, was sie unter C/C++ nicht mit derselben Leichtigkeit erreichen.

Dennoch ist die Performance ein nicht zu unterschätzender Faktor beim Betrieb einer Anwendung. Damit Java mit anderen Systemen konkurrieren kann, bedient man sich eines eleganten Vorgehens: Die Übersetzung des Bytecode in nativ ausgeführten Maschinencode erfolgt nur, wenn es sich zu lohnen scheint. Das ist etwa bei Schleifen der Fall: Die häufige Ausführung derselben Codefragmente wiegen die Übersetzungskosten relativ schnell auf.

Die enorme Qualität der Just-in-time-Übersetzungen solcher Hot Spots ist nicht zu unterschätzen, weil die JVM dabei auf Informationen zugreifen kann, die typischerweise bei der Übersetzungszeit noch nicht zur Verfügung stehen. Da sie über jede geladene Klasse Bescheid weiß, kann sie Optimierungen vornehmen, die sonst nicht möglich wären oder nur für eine Übergangszeit gelten. Beim Ausführen des Codes einer abstrakten Klasse, von der die JVM weiß, dass nur genau eine konkrete implementierende Klasse im System existiert, kann sie deren Code getrost überall hineinkopieren (inlining), um die aufwendigen dynamischen Aufrufe zu eliminieren. Erst wenn sie eine weitere Implementierung der abstrakten Klasse lädt, müsste die JVM die Änderungen wieder rückgängig machen und eine konservativere Optimierung vornehmen.

Optimierungen dieser Art, die gelegentlich sogar C++-Entwickler vor Neid erblassen lassen, gehen so weit, dass die JVM bei einer Schleife feststellen kann, dass sie die Werte gar nicht benötigt und in dem Fall die Schleife vollständig entfernt. Diese Cleverness erschwert es leider, bei einzelnen Codeteilen zu messen, ob eine Variante schneller ist als eine andere. Beim Isolieren des zu messenden Bereich kann es passieren, dass die JVM den Code ausnahmslos interpretiert, weil sie ihn vor der Messung nicht oft genug durchlaufen hat. Ebenso könnte sie Regelmäßigkeiten erkannt und durch Konstanten ersetzt haben oder den Code vollständig eliminieren, weil das Ergebnis nicht genutzt wird.

Eigentlich bietet die JVM alles, was Java-Programmierer brauchen, aber Java ist nunmal nicht die einzige Sprache, die auf der JVM laufen soll. Statisch typisierte Programmiersprachen, die im Vorfeld alles Wissenswerte kennen, haben in der Regel keine Probleme, ihren Syntaxbaum auf den Bytecode abzubilden. Problematischer wird es bei den dynamischen Sprachen, die etwa bei a + b überhaupt nicht wissen, welche Typen a und b haben werden.

Eine Implementierung dafür scheint auf den ersten Blick unmöglich, weil die JVM immer genauestens prüft, ob die Parameter wirklich zu dem Befehl passen. Aber auch hierfür gibt es seit Java 7 eine Lösung in der JVM: Das Invoke Dynamic (Indy). Mit Hilfe der Indy-Anweisung lässt sich der konkrete Aufruf auch zur Laufzeit festlegen.

Dazu wird im Bytecode nicht wie sonst der Aufruf einer konkreten Methode (invokevirtual) oder einer konkreten Prozedur (invokestatic) eingebettet, sondern ein dynamischer Aufruf über eine Beschreibung getätigt (invokedynamic). Letztere enthält insbesondere die (unveränderliche) verantwortliche Stelle, die Auskunft darüber gibt, was unter den gegebenen Bedingungen (beispielsweise in Abhängigkeiten der konkret übergebenen Werten) zu tun ist. An dieser Stelle lässt sich eine Art Referenz der aufzurufenden Methode liefern

Das Interessante daran ist, dass sich die Gültigkeitsdauer dieser Methodenreferenz festlegen lässt. Sie kann nur für das eine Mal gelten, aber auch für alle zukünftigen Aufrufe. Ebenso ist möglich, dass sie nur solange gilt, bis eine bestimmte Bedingung eintritt. Entwickler haben damit ein mächtiges Mittel an der Hand, das ihnen sogar ermöglicht, eigene Aufrufmechanismen zu implementieren. Und das Beste daran ist, dass die JVM immer noch alle Laufzeitoptimierungen vornehmen kann, weil sie immer genau weiß, mit was sie es zu tun hat, wenn auch eventuell nur temporär.