D3kjd3Di38lk323nnm 1405 C Grundlagen zur Java Virtual Machine Dieser Anhang stellt einige Grundlagen zur Java Virtual Machine und zur Ausführung von Java-Programmen vor. C.1 Wissenswertes rund um die Java Virtual Machine Java ist eine Programmiersprache, die in einer Laufzeitumgebung, der Java Virtual Machine (JVM), ausgeführt wird. Im Gegensatz zu anderen Programmiersprachen, etwa C++, wird der Sourcecode beim Kompilieren mit dem Java-Compiler javac nicht in die Maschinensprache des jeweiligen Computers übersetzt, sondern in eine Zwischensprache, den plattformunabhängigen Bytecode. Der Name rührt daher, dass die Instruktionen in Form von Bytes codiert sind. Dieser Bytecode wird nicht direkt vom Prozessor des Rechners ausgeführt. Stattdessen handelt es sich beim Bytecode um Befehle für einen speziellen virtuellen Computer, nämlich für die Java Virtual Machine. Diese stellt einen Computer im Computer dar und ermöglicht so eine Abstraktion von der darunterliegenden Hardware. Die Plattformunabhängigkeit von Java wird dadurch erreicht, dass für jedes Betriebssystem eine eigenständige JVM existiert, die Befehle im Bytecode-Format ausführt. Abbildung C-1 deutet den prinzipiellen Ablauf an. Abbildung C-1 Ablauf beim Kompilieren und Ausführen eines Java-Programms C.1.1 Ausführung eines Java-Programms Für die Ausführung eines Java-Programms haben früher die ersten JVMs den Bytecode Instruktion für Instruktion interpretiert und abgearbeitet. Aufgrund der durchaus effizienten Arbeitsweise des Bytecode-Interpreters konnte damit bereits eine einigermaßen akzeptable Ausführungsgeschwindigkeit erzielt werden, die jedoch deutlich unter Michael Inden, Der Weg zum Java-Profi, dpunkt.verlag, ISBN 978-3-86490-203-1 1406 C Grundlagen zur Java Virtual Machine der Geschwindigkeit kompilierter Programme lag. Als Folge davon haben die Ausführungszeiten dieser ersten JVMs lange Zeit das Gerücht genährt, Java-Programme würden (zu) langsam ablaufen. Kompilierte C++-Programme, die in die jeweilige Maschinensprache des Zielrechners übersetzt werden, waren bis zum Erscheinen von JDK 1.4 bzw. JDK 5 performanter. In den letzten Jahren wurden aber immer leistungsfähigere JVMs entwickelt, sodass sich die Ausführungsgeschwindigkeit von Java-Programmen immer mehr derjenigen kompilierter C++-Programme angenähert bzw. mit aktuellen JVMs diese sogar teilweise überflügelt hat. Heutzutage sind JVMs also extrem leistungsfähig. Das wird unter anderem dadurch erreicht, dass während bzw. parallel zu der eigentlichen Programmausführung der Bytecode in Maschinensprache übersetzt wird. Man spricht von einem Just-inTime-Compiler (kurz JIT). Führt man diese Transformation für den gesamten Bytecode durch, so kann das allerdings recht aufwendig werden. Für selten durchlaufene Programmteile wiegt somit der erzielte Geschwindigkeitsgewinn bei der Ausführung der kompilierten Anweisungen nicht den zeitlichen Aufwand zur Transformation auf. Demnach ist diese Form der Optimierung manchmal sogar kontraproduktiv und langsamer als eine Ausführung per Interpreter. Aktuelle JVMs nutzen daher eine intelligentere Vorgehensweise bei der Programmausführung, nämlich das sogenannte HotspotOptimierungsverfahren.1 Hierbei werden die häufig durchlaufenen Programmteile (die Hotspots) erkannt und nur diese kompiliert und optimiert.2 Abschnitt 22.1.3 geht auf einige Optimierungen im Detail ein. C.1.2 Sicherheit und Speicherverwaltung Um Java-Programme möglichst robust zu machen, wurden verschiedene Sicherheitsmechanismen in die Sprache integriert. Insbesondere wurde die Speicherverwaltung so gestaltet, dass man sich als Entwickler kaum darum kümmern muss: Der per new angeforderte Speicher für Objekte muss nicht explizit freigegeben werden. Das wird stattdessen automatisch durch eine spezielle Komponente der JVM, den Garbage Collector, erledigt. Fehler durch zu früh oder mehrmals freigegebene Speicherbereiche sind damit ausgeschlossen. Allerdings verbleibt das Problem von nicht freigegebenem Speicher. Derartige Memory Leaks sind in Java im Vergleich zu Sprachen mit manuellem Speichermanagement eher selten, da die JVM diese automatisch sehr zuverlässig verhindert. Es gibt jedoch Spezialfälle, für die dies nicht gilt, nämlich genau dann, wenn 1 Die Ausführungsmodi der JVM lassen sich über JVM-Aufrufparameter steuern: -Xint aktiviert den Interpreter-Modus. Ohne diese Angabe erfolgt eine Ausführung im Hotspot-Modus. 2 JDK 7 beinhaltet einige derart extreme Optimierungen, die sogar zum Teil Probleme bereiten: In namhaften Projekten wie Apache Lucene Core und Solr beobachtet man JVMAbstürze oder auch Berechnungsfehler. Diese Probleme entstehen dadurch, dass mitunter zu forsch optimiert wird und die erzielten Resultate nicht immer fehlerfrei sind (siehe dazu http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=7044738). Einige Fehler wurden mit dem ersten Update vom JDK 7 behoben. Andere Optimierungen sind immer noch mit Vorsicht zu genießen, etwa die Aufrufoption -XX:AggressiveOpts. C.1 Wissenswertes rund um die Java Virtual Machine 1407 nicht alle Referenzen auf Objekte korrekt freigegeben werden, etwa weil eine andere Programmkomponente eine Referenz weiterhin speichert. Details zur Garbage Collection beschreibt Abschnitt 8.5. Achtung: Speicherverwaltung Zwar befreit der Garbage Collector den Entwickler von der expliziten Speicherverwaltung und vermeidet mögliche Fehler einer manuellen Speicherfreigabe. Allerdings existieren verschiedene Varianten der Garbage Collection, die sich auf die Performance auswirken und bei Bedarf mit Bedacht gewählt werden sollten. Bei nicht zufriedenstellender Performance ist die Analyse der Garbage-CollectionVorgänge mithilfe eines geeigneten Tools, etwa dem zuvor im Buch vorgestellten VisualVM (vgl. Abschnitt 22.1.4), sehr hilfreich, um mögliche Probleme aufspüren und beheben zu können. Ein Schutz vor Fehlern beim Speicherzugriff wird durch verschiedene Sicherheitsmechanismen erreicht. Zum einen kann man mit Referenzen nur auf Objekte zugreifen, nicht aber mit dem Referenzwert rechnen, wie dies bei einigen anderen Sprachen möglich ist. Auch Bereichsüberschreitungen bei Array-Zugriffen werden von der JVM verhindert – als Folge werden automatisch IndexOutOfBoundsExceptions ausgelöst. Diese Sicherheitsmechanismen verhindern sowohl das versehentliche als auch das mutwillige Auslesen oder Beschreiben von Speicherbereichen, die eigentlich nicht adressierbar sein sollten. Selbst beim Auftreten derartiger Zugriffsprobleme verbleibt die JVM in einem definierten, arbeitsfähigen Zustand, und man kann kontrolliert über Exception Handling auf die Fehlersituation reagieren. Im besten Fall existiert dazu ein catch-Block zur Fehlerbehandlung. Dieser wird durch die JVM angesprungen, sodass die dortigen Anweisungen ausgeführt werden. Eine Fehlersituation bewirkt demnach nur, dass die Anweisungen, die der Exception auslösenden Programmstelle folgen, nicht mehr ausgeführt werden und stattdessen die Ausführung des Programms mit dem umgebenden catch-Block fortgesetzt wird. Findet sich kein passender catch-Block zur Fehlerbehandlung, d. h., wird die Exception nicht im Programm behandelt, so führt dies zu einem Abbruch des Programms, jedoch nicht zu einem Absturz der JVM. C.1.3 Sicherheit und Classloading Beim Start einer JVM ist immer eine Klasse anzugeben, deren main()-Methode ausgeführt werden soll, wie hier für eine Klasse MyClass gezeigt: java MyClass Zunächst muss der Bytecode der entsprechenden Klasse in die JVM geladen werden. Dazu dient eine Instanz eines sogenannten ClassLoaders, der selbst eine Java-Klasse ist. Diese spezielle Klasse lädt die Klassendateien (.class-Dateien). Was so alles an Klassen geladen wird, wenn Sie ein Java-Programm starten, sehen Sie durch Angabe der JVM-Option -verbose beim Aufruf: Michael Inden, Der Weg zum Java-Profi, dpunkt.verlag, ISBN 978-3-86490-203-1 1408 C Grundlagen zur Java Virtual Machine java -verbose MyClass Anhand der Ausgabe erkennt man, dass zunächst die wichtigsten Interfaces und Klassen des JDKs geladen werden: [Opened [Loaded [Loaded [Loaded [Loaded [Loaded ... [Loaded [Loaded [Loaded ... C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.Object from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.io.Serializable from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.Comparable from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.CharSequence from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.String from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.Class from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.Cloneable from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] java.lang.ClassLoader from C:\Programme\Java\jdk1.7.0\jre\lib\rt.jar] Bei diesen Ladevorgängen der Klassendateien finden einige Prüfungen statt, um zu verhindern, dass Systemklassen verändert werden – insbesondere der ClassLoader selbst. Anschließend wird durch die Komponente Bytecode Verifier sichergestellt, dass keine ungültigen Bytecode-Instruktionen in der .class-Datei enthalten sind. Derartige Instruktionen könnten durch Übertragungsfehler oder mutwillige Veränderungen am Bytecode entstehen. Beispielsweise besitzt die JVM den Befehl iadd zum Addieren von int-Zahlen. Würde dieser mit zwei float-Werten ausgeführt, käme es zu Problemen in der JVM. Der Bytecode Verifier prüft nun den gesamten Bytecode nach derartigen Verstößen. Wird ein solcher entdeckt, so wird ein java.lang.VerifyError ausgelöst und das Programm beendet. Damit wird verhindert, dass Instruktionen ausgeführt werden, die möglicherweise eine Fehlfunktion der JVM auslösen könnten. Werden Klassen per Netzwerk oder aus anderen potenziell gefährlichen Quellen geladen, so ist es aus Sicherheitsgründen zum Schutz vor externen Angriffen wünschenswert, dass für diese Klassen eingeschränkte Rechte gelten. Genau das wird innerhalb der JVM durch eine Instanz der Klasse SecurityManager sichergestellt. Dieser kann einem ausgeführten Programm (bzw. dessen Klassen) zur Laufzeit gewisse Aktionen erlauben oder verbieten. Beispielsweise sind für derart geladene Klassen standardmäßig Dateisystemoperationen verboten. Das ist sinnvoll, um den Rechner, der das Programm ausführt, vor Angriffen aus dem Netz zu schützen.