Software-Engineering Seminar Implementierung eines Java Runtime Systems Autoren: Christian Riekenberg ([email protected]) Matthias von der Grün ([email protected]) Betreuender Dozent: Olaf Herden BA-Horb Studienrichtung Informationstechnik Projektbearbeitungszeit: November 2005 Implementierung eines Java Runtime Systems Abstract Bei der Implementation einer Java Virtual Machine (JVM), werden verschiedene Techniken eingesetzt, die nicht nur Plattformunabhängigkeit ermöglichen sollen, sondern auch Java als Programmiersprache sicher machen sollen. Ein wichtiger Aspekt ist hierbei die Kapselung der Speicherverwaltung vor dem Programmierer. Es wird veranschaulicht, wie dies mit unterschiedlichsten Techniken wie Bytecode-Interpretierung einer Runtime oder dem Garbage Collector ermöglicht wird. Christian Riekenberg Matthias von der Grün 2 / 21 05.01.2006 1. Einführung.......................................................................................................................4 1.1. Geschichtlicher Hintergrund....................................................................................4 1.2. Prinzip der plattformunabhängigen Programmierung ..............................................4 1.3. Verbreitung von Interpreter-Sprachen.....................................................................5 2. Bestandteile einer Implementierung ................................................................................6 2.1. ClassLoader ...........................................................................................................6 2.2. Der Interpreter ........................................................................................................7 2.3. Bytecode Verifizierung ............................................................................................8 2.4. Built-In Classes.......................................................................................................9 2.5. Native Methoden.....................................................................................................9 2.6. Threads ................................................................................................................10 2.7. Just-In-Time Compiler...........................................................................................11 2.7.1. Vorteile der JIT-Kompilierung........................................................................11 2.7.2. Nachteile der JIT-Kompilierung .....................................................................11 2.7.3. Implementierungen von JIT Compiler............................................................12 3. Garbage Collector .........................................................................................................12 3.1. Aufgaben ..............................................................................................................12 3.1.1. "Old Object" - Collector .................................................................................13 3.2. Funktionsweise .....................................................................................................13 3.3. Algorithmen ..........................................................................................................13 3.3.1. Mark-Sweep- und Mark-Compact-Algorithmus..............................................13 3.3.2. Algorithmen zur Defragmentierung des Speichers ........................................15 3.4. Konfigurationen.....................................................................................................15 3.4.1. Standard-Konfiguration .................................................................................16 3.4.2. Parallel-Konfiguration....................................................................................16 3.4.3. Concurrent Low Pause Collector...................................................................16 3.4.4. Parallel Scavenge Collector ..........................................................................17 3.4.5. Incremental Garbage Colletor (Train Collector) .............................................17 4. Performance und Optimierung ......................................................................................18 4.1. Hot-Spot-Optimierung ...........................................................................................18 4.2. Kompiliermethoden...............................................................................................18 4.2.1. Client Compiler .............................................................................................19 4.2.2. Server Compiler ............................................................................................19 4.3. Optimierung der Garbage Collection.....................................................................19 4.3.1. Garbage Collection – Generational Copying .................................................19 4.3.2. Inlining frequently-called Methods .................................................................19 5. Zusammenfassung und Zukunftsaussichten .................................................................20 6. Quellenangaben............................................................................................................21 6.1. Literatur- und Abbildungsverzeichnis ....................................................................21 Implementierung eines Java Runtime Systems 1. Einführung 1.1. Geschichtlicher Hintergrund Die Entwicklung von Java ist unter anderem auf den Wunsch zurückzuführen, eine Programmiersprache zu haben, die in Embedded Systems anwendbar sein und dort sicher und robust laufen sollte. Eine der größten Schwächen der sonst so leistungsfähigen Programmiersprache C ist die unsichere Handhabung von Zeigern, die schnell zu Laufzeitfehlern führen kann. Das Projekt „Green Project“ um James Gosling entschied sich daher eine Sprache zu konzipieren, die diese Nachteile umgeht und gleichzeitig für Embedded Systeme (wie z.B. Toaster, Videorecorder oder Fernseher) geeignet ist und dort auch einfach einen Informationsaustausch über Netzwerke ermöglicht. Hierfür wurde das Prinzip einer plattformunabhängigen, interpretierten Sprache gewählt. Die dazu benötigten Runtime Environments, die den passenden Interpreter für jedes System enthalten, organisieren gleichzeitig auch die Speicherverwaltung. Somit ist keine explizite Speicherverwaltung durch den Entwickler mehr notwendig, was einem System mehr Stabilität verleiht. Tools wie der Garbage Collector sorgen u.a. dafür, dass nicht mehr benötigter Speicher freigegeben wird und sich somit eine Anwendung nicht bis zu einem Buffer Overflow aufstaut. 1.2. Prinzip der plattformunabhängigen Programmierung In höheren Programmiersprachen wird die Komplexität der Maschinensprache vor dem Entwickler verborgen. Die erstellten Programme können somit jedoch nicht direkt ausgeführt werden und müssen zunächst in eine Form gewandelt werden, die ein Computer verarbeiten kann. Neben der Kompilierung besteht hier die Möglichkeit der Interpretierung von Sprachen. Interpretierte Sprachen laufen direkt vom aktuellen Quellcode und bieten somit eine Plattformunabhängigkeit, da der für das jeweilige System angepasste Interpreter alle plattformspezifischen Besonderheiten behandelt. Bekannte interpretierte Sprachen sind Basic, Perl, PHP oder Ruby. Shell Skripte und BatchDateien sind Beispiele von einfacheren interpretierten Sprachen. Es gibt verschiedene Stufen der Konzeption einer interpretierten Sprache. Shell Skripte und BatchDateien sind die reinste Form der Interpretation, da diese zeilenweise abgearbeitet werden, während andere durchaus Präprozessor-Anweisungen und Optimierungsschritte des ursprünglichen Code beinhalten können, um die Performance zur Laufzeit zu verbessern. Ein weiterer Vorteil ist die Möglichkeit einen Fehler zur Laufzeit mittels Abbildung 1 : Vorgang eine Java-Kompilierung Debbuging zu beheben. Wenn bei der (Quelle: [JAV05]) Ausführung eines kompilierten Programms ein Fehler auftritt, ist es schwierig herauszufinden, wo dieser sich im Quellcode befindet, da diese Zeile im Code durchaus nicht mehr vorhanden sein kann. Ein interpretierter Code ist auch bei der Ausführung intakt und bei einem Fehler wird direkt zu der entsprechenden Zeile gesprungen. Der Nachteil dieser Methode ist der, dass ein Compiler viele potentielle Fehler im Vorfeld finden und auf Korrektur besteht, ehe das Christian Riekenberg Matthias von der Grün 4 / 21 05.01.2006 Implementierung eines Java Runtime Systems Programm ausgeführt werden kann. Aus diesem Grund sind viele interpretierte Sprachen auch keine reine Interpretation mehr, sondern werden im Vorfeld kompiliert. Der Java Compiler ist ein Beispiel dieser Programmiertechnik. Der reine Quellcode wird durch einen Compiler überprüft und optimiert, jedoch wird bei Java kein Maschinencode erzeugt, sondern ein Bytecode (welcher immer noch plattformunabhängig ist), der durch einen Interpreter ausgeführt wird. Der Interpreter ist die Java Virtual Machine (JVM), die es für die meisten gängigen Systeme gibt. Dieser wird jedoch für die Ausführung benötigt und da ein Code bei jedem Durchlauf von der JVM interpretiert werden muss, ergibt sich eine enorme Verschlechterung in der Performance. Somit ist die Plattformunabhängigkeit immer mit einem Nachteil behaftet, der nicht für jede Anwendung unbeachtet bleiben kann. 1.3. Verbreitung von Interpreter-Sprachen Durch die Verbreitung des Internet besteht ein ständig steigender Bedarf an Homogenität innerhalb von Systemen. Der Aufwand für die Anpassung an sämtlichen Systemen würde den wirtschaftlichen Rahmen sprengen. Daher wird es immer bedeutsamer, dass Programme auf heterogenen Systemen lauffähig sind. Folgende Studie der Internetseite developer.com aus dem Jahre 2004 ergab, dass die Mehrzahl der meist verbreiteten Programmiersprachen vom Typ der interpretierten sind. Position Vorheriges Jahr 1 1 2 2 3 3 4 6 5 5 6 4 7 9 8 11 9 7 10 10 Abbildung 2 (Quelle:[DEV05]) Name Java C C++ PHP (Visual) Basic Perl Delphi/Pascal Python SQL JavaScript Prozent 16,9 16,3 15,3 10,4 10,1 8,4 4,8 4,7 2,8 1,6 Sprachtyp Compiliert / Interpretiert Compiliert Compiliert Interpretiert Interpretiert Interpretiert Compiliert Interpretiert Interpretiert Interpretiert Der große Vorteil der Plattformunabhängigkeit wird in den meisten Fällen also dem großen Nachteil der schlechteren Performance vorgezogen. Die enorme Verbreitung von Java ist somit dem Trend zu verdanken, ein Programm auf möglichst vielen Systemen, mit ein und demselben Code zu betreiben. Christian Riekenberg Matthias von der Grün 5 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2. Bestandteile einer Implementierung 2.1. ClassLoader Ein Grundpfeiler der Java Implementation ist das „ClassLoader“-Konzept. Es sorgt dafür, dass die kompilierten Klassen der Java Runtime zur Verfügung gestellt werden und die Java Runtime somit nichts vom Dateisystem des jeweiligen Systems wissen muss. Sobald eine Klasse aus einer anderen Klasse heraus referenziert wird, sorgt der ClassLoader dafür, dass diese der Runtime mit eigenem Namensraum zur Verfügung gestellt wird. Dazu folgendes Beispiel: class A { static String s = new java.util.Date().toString(); } public static void main( String args[] ) { B b = new B(); } class B { A a; } Wird nun die Klasse A gestartet, werden automatisch auch andere Klassen aufgrund von Referenzen geladen. Da in der main()-Funktion ein Objekt der Klasse B erzeugt wird, ist es nachvollziehbar, dass auch hierfür ein Namensraum durch den class loader bereitgestellt werden muss. Nicht ganz so deutlich ist jedoch, dass auch die statische Variable in der Klasse A dafür sorgt, dass eine weitere Klasse geladen werden muss. Für die Datumsermittelung wird die Klasse java.util.Date benötigt, die sich in der Standard-Bibliothek von Java befindet. Aus dieser Standard-Bibliothek wird auch die Klasse java.lang.Object bezogen, da jede Klasse automatisch von der Object-Klasse erbt und implizit bei der Klassendefinition von A steht : class A extends Object. Für die verschiedensten Klassen stehen unterschiedliche ClassLoader zur Verfügung, die in einer fest definierten Reihenfolge nach der Klasse suchen. Die Klassen der StandardBibliothek, die sogenannten Bootstrap-Klassen befinden sich in einem jar-Archiv (zumeist lib/rt.jar) und werden zuerst durchsucht. Bei ergebnisloser Suche werden die Archive des Erweiterungs-Verzeichnis lib/ext durchsucht. Dies geschieht automatisch, ohne dass ein Pfad hierzu angegeben werden muss. Ist auch hier die Suche erfolglos, wird der Klassenpfad durchlaufen und durchsucht. Wie erwähnt können bei der Suche nach einer Klasse verschiedene ClassLoader involviert sein. Folgende ClassLoader sind für das Laden unterschiedlicher Klassen zuständig: System-ClassLoader AppletClassLoader RMIClassLoader Christian Riekenberg Matthias von der Grün Auch Bootstrap-ClassLoader genannt und ist für die Suche innerhalb der Standard-Bibliothek zuständig Wird verwendet um Java-Applets aus dem Netz zu laden. Lädt entfernte Objekte, die per RMI lokal verfügbar gemacht werden sollen 6 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.2. Der Interpreter Der Interpreter ist das Kernstück der JVM und dient dazu, kompilierte Java-Programme auszuführen, die als Bytecode in einer class-Datei vorliegen. Aus der Konsole wird der Interpreter wie folgt aufgerufen : java Options Classname Arguments Der Interpreter ruft die main()-Methode der angegebenen Klasse auf und wird erst beendet, nachdem alle Threads die innerhalb dieser main()-Methode erstellt wurden, beendet sind. Die angegebenen Argumente stehen in der main()-Methode als String-Array zur Verfügung und können u.a. zur Steuerung des Programms genutzt werden. Die Interpretation kann auf verschiedenen Arten geschehen (siehe Abbildung 3), wobei hier nur der JIT-Compiler (siehe Abschnitt 3) und der normale Java-Interpreter betrachtet werden soll. Abbildung 3: Vorgang der Interpretation (Quelle:[LIN05]) Zum Ausführen der Bytecode Befehle wird ein Java Interpreter verwendet, der die einzelnen Befehle nacheinander interpretiert. Entsprechend der Bedeutung eines Bytecode Befehls wird eine bestimmte Aktion durchgeführt. Dies ist die am meisten bevorzugte Implementierung der virtuellen Java Maschine. Diese Softwareimplementierung ist gegenüber den anderen Möglichkeiten relativ einfach, dafür leidet die Ausführungsgeschwindigkeit darunter. Allerdings ist die Bezeichnung Java Interpreter etwas irreführend, da nicht Java Quelltext sondern Bytecode interpretiert wird, weshalb die Bezeichnung Bytecodeinterpreter hier besser geeignet scheint. Christian Riekenberg Matthias von der Grün 7 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.3. Bytecode Verifizierung Normalerweise wird Java Bytecode durch einen Java-Compiler (wie z.B. javac) erzeugt. Jedoch ist es auch möglich, einen BytecodeAssembler wie Jasmin zu verwenden um nicht verifizierten Bytecode zu erstellen. Bei einem solchen Bytecode ist man Mächtigkeit von Mächtigkeit von Mächtigkeit von Mächtigkeit von in der Lage fehlerhaften Bytecode, der aus Bytecode oder bösartigen Code zu Bytecode der aus Bytecode Java übersetzt wurde erzeugen, da zum einen Java übersetzt wurde dieser Code nicht durch den Java-Compiler überprüft wurde. Die Mächtigkeit von Bytecode ist höher als die von Bytecode der durch Java erzeugt wurde. Dies ist Abbildung 4 : Mächtigkeit von Bytecode bei Applets zu verdeutlichen, denen es nicht gestattet ist Datei-Manipulationen vorzunehmen. Des weiteren werden in Java Klassen erst bei Bedarf geladen, was bedeuten kann, dass zu diesem Zeitpunkt nicht sichergestellt ist, dass die referenzierten Methoden und Klassenvariablen auch wirklich (noch) existieren. Außerdem kann die Klassen-Datei von einem nicht vertrauenswürdigen Compiler erzeugt worden sein, der nicht alle Beschränkungen einhält, die für Klassen-Dateien gelten. Aus diesen Gründen findet beim Laden von Klassen-Dateien eine Überprüfung statt, diese Überprüfung wird in 4 Phasen durchgeführt: 1. Überprüfung, ob das Format der Klassen-Dateien eingehalten wird. 2. Überprüfung von allgemeinen Bedingungen, wie z.B. - Sicherstellung, dass finale Klassen keine Unterklassen besitzen bzw. finale Methoden nicht überschrieben werden. 3. Überprüfung des Bytecodes - Überprüfung, ob zur Laufzeit keine unzulässige Datentypumwandlung stattfindet, - Methoden mit zulässigen Argumenten initialisiert werden, - das kein Stack Over- oder Underflow entsteht. In der dritten Phase, die während der Programmausführung durchgeführt wird, werden bestimmte Bytecode Befehle nach einer Überprüfung durch ihre Quick Variante ersetzt, damit vor der nächsten Ausführung der Befehl nicht noch einmal überprüft wird. So müssen z.B. bei einem Methodenaufruf die Anzahl und die Typen der Parameter überprüft werden. Da dies in der Regel erst zur Laufzeit erfolgen kann, wird nach einer erfolgreichen Überprüfung der Befehl durch seine Quick Variante ersetzt, damit bei einer evtl. weiteren Ausführung dies nicht noch einmal überprüft werden muss. Bei einer fehlerhaften Implementierung des Bytecode Verifiers, erlaubte es bei früheren Internet Explorer Versionen eine unzulässige Typumwandlung vorzunehmen, was ermöglichte Zeiger umzusetzen um so nicht erlaubter Operationen durchzuführen. Dies verdeutlicht wie wichtig die zeitaufwendige Verifizierung ist. Christian Riekenberg Matthias von der Grün 8 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.4. Built-In Classes Bei einer Implementierung der JVM wird eine Bibliothek mit Standardpaketen zur Verfügung gestellt, die viele Routinearbeiten wie z.B. das Verarbeiten von Dateien vereinfachen. Diese sind in der Java API-Dokumentation ausführlich beschrieben (java.sun.com) und es sollen hier nur einige exemplarisch genannt werden: • • • • • java.lang java.io java.util java.applet java.awt Die einzelnen Pakete enthalten Klassen oder Unterpakete, deren Funktionalität so genutzt oder durch überschreiben den eigenen Bedürfnissen angepasst werden können. 2.5. Native Methoden Die Plattformunabhängigkeit von Java kann nicht in jeder Situation aufrechterhalten werden. Was bei der Programmierung von Algorithmen keine Probleme bereitet, erweist sich beim bearbeiten von Dateien oder beim pixelgenauen Ausgeben auf einem Bildschirm als eine Funktionalität, die sehr von der Architektur eines Systems abhängig ist. Aus diesem Grund sind viele Funktionalitäten in native Methoden eingebunden, die meist schnelleres oder hardwarenahes C benutzen. Dies wird selbst in der Standard-API verwendet und ist dort nicht immer ersichtlich. Als Beispiel für eine Native Methode dient read() aus der Klasse java.io.FileInputStream, die ein Byte aus einer Datei liest. Hier hinter verbirgt sich C-Code, der von Java heraus aufgerufen wird. Das Java Native Interface (JNI) wurde aus zwei Gründen implementiert. Der im Beispiel erwähnte Zugriff auf Systemressourcen stellt hierbei den wichtigsten Grund dar. Ein weiterer ist die Steigerung von Performance, die durch Ausführung von optimiertem C-Code in einigen Bereichen erreicht werden kann. Diese Steigerung wird Abbildung 5: Vorgehensweise mit Nativen Methoden jedoch auf Kosten der (Quelle:[STA05]) Portabilität erreicht und eine Verwendung von nativen Methoden führt unweigerlich zur Aufgabe der drei Eigenschaften von Java : einfach, sicher und zuverlässig. Es wird nicht nur die Systemunabhängigkeit aufgegeben, so dass für jedes System die Funktionalität neu implementiert werden muss, sondern auch die Komplexität und Wartbarkeit wird dadurch verschlechtert. Christian Riekenberg Matthias von der Grün 9 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.6. Threads Die ursprüngliche Form der Programmierung sieht einen sequentiellen Programmablauf vor. Es gibt innerhalb der Laufzeit einen eindeutigen Einstiegspunkt, den Programmablauf und einen eindeutigen Ausstiegspunkt. Ein einzelner Thread verhält sich äquivalent zu diesem Verlauf. Ein Thread ist jedoch kein eigenständiges Programm und ist nicht imstande, selbständig ausgeführt zu werden. Es wird innerhalb eines Programms ausgeführt. Der Vorteil ist, dass mehrere Threads parallel laufen können und somit eine Pseudo-Mutlithreading ermöglichen, indem jedem Thread eine definierte Zeit des gesamten Prozesses zur Verfügung gestellt wird. Der Grund, warum Threads in Java implementiert wurden ergibt sich aus den Anforderungen zeitgemäßer Programme. Für einen Webbrowser ist es z.B. unumgänglich mehrere Threads parallel laufen zu lassen, um zeitsynchron mehrere Aufgaben zu erledigen. So wird es ermöglicht, das eine Seite durchsucht wird, während zeitgleich ein Applet geladen und ein Download im Hintergrund läuft. Um dies auch auf einem System mit nur einer CPU zu realisieren, bedarf es interner Abbildung 6: Graphische Darstellung von Regeln, um dieses Pseudo-Threading zu Threads (Quelle:[SUN05]) ermöglichen. Threads können u.a. Prioritäten zugeordnet werden und ein Thread läuft solange bis: • ein Thread mit einer höheren Priorität startet (präemptives Multitasking), • er beendet wird, • er freiwillig an den nächsten Thread weiter gibt, • die Scheduler-Zeit dem Prozess die Prozessorzeit entzieht. Jedoch ist nicht garantiert, dass immer der Thread mit der höchsten Priorität läuft, da bei der Implementierung darauf geachtet wurde, das keine Threads ewig laufen, weil sie zu niedrig priorisiert wurden. Die Priorisierung ist somit vielmehr ein Hinweis auf die Bevorzugung als ein wirklicher Befehl. Ein weiterer Aspekt ist die Möglichkeit, Threads untereinander zu synchronisieren. Sollten mehrere Threads an denselben Daten arbeiten kann es wichtig sein, dass zunächst ein Thread beendet wird bevor ein anderer startet, um die Konsistenz von Daten zu gewährleisten. Dies ist zum Beispiel bei Programmen der Fall, die Daten im- und exportieren. Ein Export ist erst dann möglich, wenn alle Daten korrekt eingelesen wurden. Hierfür biete Java Funktionalitäten wie synchronized, wait oder notiyAll um eine Kommunikation zwischen den einzelnen Threads zu ermöglichen. Die Implementierung von Threads ist jedoch mit Sorgfalt zu verwenden, denn ein zu prozessintensiver oder nicht beendeter Thread kann andere Prozesse blockieren oder zu Systemabstürzen führen. Christian Riekenberg Matthias von der Grün 10 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.7. Just-In-Time Compiler Bei der Just-In-Time-Kompilation ruft der Java Interpreter Methoden auf, die während dem Abarbeiten des Programms den Bytecode in Maschinencode übersetzen. Der Ablauf gliedert sich folgendermaßen: Die erste Methode bildet den Stack auf dem Stack der Registermaschine ab und holt das erste Element in den Akku. Diese Methode ist charakterisiert durch eine schnelle Implementierung und kurze Compilierungszeit, aber auch durch unnötige Speicherzugriffe. Anschließend wird der Code in einer zweiten Methode in Zwischencode mit unendlich vielen Pseudoregistern übersetzt. Die Pseudoregister werden abgebildet auf den Maschinenregistern. Diese Methode sorgt für schnellere Ausführung, die Kompilierung dauert aber länger. Am nachfolgenden Beispiel kann man die Kompilierung eines JIT-Compilers mit einem CCompiler vergleichen: Beispiel: b=a+c*d • JIT-Compiler: 1. Iload a MOV EAX,a MOV EBX,c 2. Iload c 3. Iload d MOV ECX,d IMUL EBX,ECX 4. Imul d 5. Iadd ADD EAX, EBX 6. Istore MOVE b,EAX • Kompilierung mit einem C-Compiler: 1. MOV EAX,c 2. IMUL EAX, d 3. ADD EAX, a 4. MOV b, EAX 2.7.1. Vorteile der JIT-Kompilierung Es wird die Plattformunabhängigkeit bewahrt, weil die Kompilierung erst zur Laufzeit ausgeführt wird. Die Vorteile zusammengefasst: • • plattformunabhängig Schnellere Abarbeitungsgeschwindigkeit als reine Interpretierung 2.7.2. Nachteile der JIT-Kompilierung Es enstehen allerdings auch Nachteile gegenüber einer "herkömmlichen" Kompilierung. Zum einen ist die JIT-Kompilierung oftmals ineffizient, was die Folge einer schnellen Kompilierung ist. Zum anderen besteht ein grundsätzlicher Nachteil darin, dass der kompilierte Code verworfen wird und bei jedem Start neu erstellt werden muss. Weiterhin kann es vorkommen, dass Methoden kompiliert werden, und anschließend eventuell nur einmal aufgerufen werden. Christian Riekenberg Matthias von der Grün 11 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2.7.3. Implementierungen von JIT Compiler Es existieren unterschiedliche Implementierungen für den JIT-Compiler. Die bekanntesten davon sind CACAO, KAFFE und TYA. CACAO ist eine research Java Virtual Machine, die am Institut für Computersprachen der TU Wien 1996 entwickelt wurde. Mit der Version 0.92 ist inzwischen eine relativ stabile Version erreicht worden, mit der z.B. Eclipse oder Jakarta Tomcat laufen. CACAO kann auf verschiedenen Betriebssystemen compiliert werden, wie z.B. Linus, FreeBSD, Darwin, IRIX. Seit 2004 unterliegt CACAO der General Public License(GPL). Die offizielle Referenz für diese Implementierung des JIT Compilers findet man unter [CAC05]. KAFFE wurde von einer freien Entwicklergemeinde implementiert, wird ständig weiterentwickelt und ist kompatibel zum aktuellen Java-Standard. Das Projekt unterliegt der GPL. Zahlreiche Produkte verwenden bereits KAFFE als Laufzeitsystem. KAFFE ist eine eigenständige Entwicklung und baut nicht auf Suns Quellcode auf. Die Vorgaben zur Virtual Machine werden jedoch eingehalten. Die offizielle Referenz für diese Implementierung findet man unter [KAF05]. Ganz ohne Laufzeitsystem kommt TYA aus. Diese Implementierung linkt sich in den Methodenaufruf eines bestehenden Java Interpreters ein. Wird eine Methode aufgerufen, so tritt stattdessen TYA in Kraft. Ist die Methode noch nicht compiliert, wird dies nachgeholt, ansonsten ausgeführt. Auch diese Implementierung ist frei erhältlich und kann jederzeit einund ausgeschaltet werden. Eine Download-Seite zu dieser Implementierung findet man unter [TYA05]. 3. Garbage Collector Übersetzt man den Begriff Garbage Collector in das Deutsche, dann kann man den Begriff Müllabfuhr benutzen. Der Garbage Collector ist folglich eine Art Müllabfuhr bei der Speicherverwaltung. Er löscht alle Objekte einer Applikation, die nicht mehr benötigt werden, oder anders ausgedrückt: Er muss möglichst schnell den Müll einsammeln, den der Programmierer hinterlassen hat. Damit wird dem Programmierer die Aufgabe der Speicherfreigabe abgenommen. Deshalb gibt es in Java keinen Befehl zur Speicherfreigabe, sondern nur zum Erzeugen von Objekten. Im nachfolgenden werden wichtige Aufgaben, die Funktionsweise und unterschiedliche Ausführungen des Garbage Collectors erläutert. 3.1. Aufgaben Der Garbage Collector erfüllt drei wichtige Aufgaben: • • • Auffinden von nicht mehr referenzierten Objekten, Löschen der nicht mehr referenzierten Objekte, Speicherreorganisation. Werden diese Aufgaben durch den Programmierer erledigt, steigt zwar die Effizienz der Applikation, jedoch ist die Fehlerträchtigkeit, dass Speicher nicht freigegeben wird, sehr hoch. Ein Problem des Garbage Collector ist allerdings, dass er nicht, wie oftmals angenommen, alle Objekte, die nicht mehr benötigt werden, freigibt, sondern nur die meisten erkennt, nämlich alle die, die nicht mehr referenziert werden. Existieren also beispielsweise noch Verweise auf nicht mehr benötigte Objekte, dann kann der Garbage Collector diese nicht löschen. Christian Riekenberg Matthias von der Grün 12 / 21 05.01.2006 Implementierung eines Java Runtime Systems Je nach Anwendung muss der Garbage Collector seine Aufgaben unterschiedlich erledigen, bei zeitkritischen Anwendungen beispielsweise muss der Garbage Collector seine Aufgaben in einem eigenen Thread nebenher erledigen, damit das Programm möglichst selten unterbrochen wird. 3.1.1. "Old Object" - Collector Der "Old Object"- Collector wird auch als Mark Compact-Collector bezeichnet. Er beinhaltet zwei Phasen. In der ersten Phase werden alle erreichbaren Objekte markiert. Als Wurzel steht hier jedesmal ein Eintrag auf dem Stack. In der zweiten Phase werden Objekte verschoben, um die Speicherlücken zu beseitigen (Defragmentierung). 3.2. Funktionsweise Analog zu den Aufgaben lässt sich der Prozess des Garbage Collectors in drei Phasen aufteilen. Zuerst werden die Objekte auf dem Heap gesucht, die entfernt werden können. In der zweiten Phase werden diese Objekte gelöscht. In einer letzten optionalen Phase wird der Speicher defragmentiert. Die Garbage Collection findet in zwei unterschiedlichen Teilen statt: • • Minor Collection Major Collection Die Minor Collection kann parallel zur Applikation ausgeführt werden, für die Major Collection hingegen muss die Applikation hingegen unterbrochen werden. 3.3. Algorithmen Für die Art der Garbage Collection existieren eine Vielzahl verschiedener Algorithmen. Zu den gängigsten Implementierungsalgorithmus gehört der "Mark-Sweep-Algorithmus". 3.3.1. Mark-Sweep- und Mark-Compact-Algorithmus Die wichtigsten Schritte dieses Algorithmus können folgendermaßen zusammengefasst werden: 1. "Mark-Phase": Periodisches Durchsuchen des Speichers nach Objekten, die von laufenden Programmen noch erreicht werden können. Dabei werden folgende Schritte ausgeführt: a. b. c. d. Wurzeln werden auf den Stack gelegt Stack wird bearbeitet, bis er leer ist Objektprüfung, wenn das Objekt unmarkiert ist, wird das "Mark-Bit" gesetzt Wenn alle erreichbaren Objekte markiert wurden und der Stack leer ist, kann vom restlichen Speicher angenommen werden, dass er frei ist Christian Riekenberg Matthias von der Grün 13 / 21 05.01.2006 Implementierung eines Java Runtime Systems 2. "Sweep-Phase": a. b. c. d. e. Durchlaufen des Heaps Hinzufügen der unmarkierten Speicherbereiche zu einer Freispeicherliste Zurücksetzen der markierten Bereiche für die nächste Garbage Collection Nachfolgende Allokationen werden aus der Freispeicherliste bedient Defragmentierung des Speichers, um einen möglichst großen freien Speicherbereich zu erreichen Die Wurzelzeiger befinden sich in lokalen Variablen auf dem Stack, in globalen (statischen Variablen) und in Registern. Alle Zeiger verweisen auf Objekten, die auf dem Heap liegen. Hier wird die Erreichbarkeit in Form eines Graphen dargestellt. Ein interner Zähler zählt die Anzahl der Referenzen, die auf ein Objekt verweisen. Sollte dieser Zähler auf 0 dekrementiert werden, deutet es auf ein verwaistest Objekt hin. Diese werden in der Mark-Phase nicht mit dem Mark-Bit versehen und somit in der Sweep-Phase wieder freigegeben. So entstehen jedoch Speicherfragmentierungen, Abbildung 7: Mark-Phase (Quelle:[SUI05]) die sich beim Anlegen neuer Objekte störend auswirken, da eine Suche nach einem passenden Speicherbereich zwischen den Fragmentierungen zeitintensiv ist. Um die Speicherfragmentierungen zu verhindern, geht der Mark-and-Compact-Algorithmus noch einen Schritt weiter. Hierbei werden genau wie beim einfachen Mark-Sweep-Verfahren die Heap-Objekte durch einen Graphen symbolisiert. Um bei der Garbage Collection direkt auch eine Defragmentierung zu erzielen, werden die erreichbaren Objekte zusätzlich in einen freien Speicherbereich kopiert. Die Wurzelzeiger werden anschl. auf den neuen Speicherbereich angepasst. Vor Garbage Collection Abbildung 8: Sweep-Phase (Quelle:[SUI05]) Nach Garbage Collection Abbildung 9: Sweep-Phase (Quelle:[SUI05]) Obwohl das Umbiegen der Wurzelzeiger aufwendig ist, spart es für neu angelegte Objekte Zeit. Da der Speicher nach einem solchen Durchgang quasi defragmentiert ist, kann Speicherbereich für neu anzulegende Objekte schnell allokiert werden. Die Objekte, welche Christian Riekenberg Matthias von der Grün 14 / 21 05.01.2006 Implementierung eines Java Runtime Systems die Bereinigung „überlebt“ haben, befinden sich am Speicheranfang und neuer Speicher kann am Ende dieser Obkjektkette allokiert werden. Während der Garbage Collection befindet sich das System im unstabilen Zustand, was zur Folge hat, dass die Applikation warten muss, bis die Garbage Collection abgeschlossen ist. Das kann zu Performance-Einbußen führen, je größer der Speicherbereich ist, desto drastischer fällt die Performance-Einbuße aus. Für Echtzeit-Anwendungen eignet sich dieses Prinzip nur bedingt, da hierbei die Antwortzeit einer Anwendung sehr hoch sein kann. Zusammenfassend lässt sich die Funktion des Mark-Sweep-Algorithmus wie folgt beschreiben: • • • • Erkennung von "lebenden" Objekten, Entfernen von "toten" Objekten, Erneuern von Referenzen, Defragmentierung des Speichers. 3.3.2. Algorithmen zur Defragmentierung des Speichers Zur Defragmentierung werden unterschiedliche Algorithmen eingesetzt. Hier die bekanntesten: • • • Two-Finger-Algorithmus Lisp 2 – Algorithmus Haddon-Waite Algorithmus Die verschiedenen Algorithmen haben in verschiedenen Einsatzgebieten ihre Stärken und Schwächen. Einige sind auf Optimierung von Speichern getrimmt, die in gleich großen Blöcken aufgeteilt sind, während andere auf selbst generierte Speicher-Tabellen zugreifen. Im nachfogenden wird die Größe des Speichers mit M symbolisiert. Grundsätzlich wird bei den Algorithmen zwischen gleitender und zufälliger Kompaktierung unterschieden. Bei der gleitenden Kompaktierung wird die relative Anordnung der Knoten beibehalten und an den Anfang des Speichers geschoben. Der Lisp2-Algorithmus und der Haddon-Waite-Algorithmus sind für diese Kompaktierungsart Beispiele. Der Lisp2Algorithmus hat eine Laufzeit von O(M), wobei allerdings ein zusätzlicher Zeiger pro Knoten notwendig ist. Der Haddon-Waite-Algorithmus hat eine Laufzeit von O(M log(M)), benötigt dafür aber auch keinen zusätzlichen Speicher. Der Two-Finger-Algorithmus arbeitet auf der Basis zufälliger Kompaktierung, er hat einen Laufzeitaufwand von O(M). Detailliert werden die Verfahren in [PSU05] beschrieben. 3.4. Konfigurationen Der Garbage Collector kann je nach Anwendungsbereich unterschiedlich konfiguriert werden. Folgende Konfigurationen sind möglich: • • • • • Standard (Mark Compact), Parallel, Concurrent Low Pause Collector Parallel Scavenge Collector Incremental Garbage Colletor (Train Collector) Christian Riekenberg Matthias von der Grün 15 / 21 05.01.2006 Implementierung eines Java Runtime Systems 3.4.1. Standard-Konfiguration Die Standard-Konfiguration ist defaultmäßig bei jeder Applikation eingestellt. Bei dieser Konfiguration sollten Pausen keine Rolle spielen. Für Echtzeitanwendungen ist diese Konfiguration ungeeignet. Auf einer Mehr-Prozessor-Maschine ist dieser Algorithmus desto ineffizienter je mehr Prozessoren im Spiel sind. Wird auf einer Mehr-Prozessor-Maschine eine Garbage Collection mit Standard-Konfiguration ausgeführt, dann ist ein Prozessor damit beschäftigt, die Collection auszuführen, alle anderen Prozessoren müssen warten. Die Auslastung ist in solch einem Falle sehr gering. Für eine Ein-Prozessor-Maschine hingegen ist derzeit kein effizienterer Algorithmus bekannt. Zusammenfassend die Merkmale dieser Konfiguration: • • • derzeit effizienteste Konfiguration für Ein-Prozessor-Maschine desto ineffizienter je mehr Prozessoren beteiligt ungeeignet für Echtzeit-Anwendungen. 3.4.2. Parallel-Konfiguration Wenn man Applikationen für Mehrprozessor-Maschinen implementiert, empfiehlt sich die Parallel-Konfiguration. Sie eignet sich weniger für Ein-Prozessor-Systeme, sondern für MehrProzessor-Maschinen mit acht Prozessoren und aufwärts. Wird eine Garbage Collection ausgeführt, dann wird nicht mehr nur ein Prozessor ausgelastet, sondern möglichst alle. Standardmäßig ist die Zahl der Threads für die Garbage Collection gleich der ProzessorAnzahl. Das bedeutet, jeder Prozessor wird bei der Garbage Collection teilweise ausgelastet. Der Entwickler kann die Anzahl der Threads jedoch nach Bedarf verändern. Die Pausen, die durch den Garbage Collector entstehen, werden so minimiert. Der parallel Copy Collector räumt in mehreren Threads gleichzeitig auf und nutzt so die Prozessoren besser. Dadurch steigt die Auslastung der Prozessoren und die Effizienz nimmt zu. Zusammenfassend die Merkmale dieser Konfiguration: • • • • • • ineffizienter für Ein-Prozessor-Maschinen als Standard-Konfiguration effizient für Mehr-Prozessor-Maschinen Minimierung der Pausen auf Mehr-Prozessor-Maschinen arbeitet mit mehreren Threads parallel standardmäßig so viele Threads wie Prozessoren, aber variabel veränderbar ungeeignet für Echtzeit-Anwendungen. 3.4.3. Concurrent Low Pause Collector Eine mögliche Konfiguration für Echtzeitanwendungen ist der Concurrent Low Pause Collector. Hier werden die Pausen der Garbage Collection minimiert, indem alle synchronisierbaren Schritte parallel zur Applikation ausgeführt werden. Lediglich für einige nicht-synchronisierbare Schritte wird die Applikation kurz unterbrochen. Parallel zur Applikation läuft ein Thread, der für das "Müll sammeln" verantwortlich ist. Dazu braucht die Applikation nicht gestoppt werden. Die übrigen Schritte des Garbage Collectors, wie beispielsweise die Defragmentierung werden in kurzen Pausen ausgeführt. Für Mehrprozessor-Systeme kann eine Variante dieser Konfiguration gewählt werden, bei der mehrere Sammel-Threads verwendet werden. Christian Riekenberg Matthias von der Grün 16 / 21 05.01.2006 Implementierung eines Java Runtime Systems Zusammenfassend die Merkmale dieser Konfiguration: • • • • Geeignet für Echtzeitanwendungen Nur nicht-synchronisierbare Collection-Schritte werden in kurzen Pausen ausgeführt parallel zur Applikation läuft ein Thread, der die meisten Schritte der Garbage Collection ausführt Variante für Mehrprozessorsysteme, bei der mehrere Threads parallel zur Applikation ausgeführt werden. 3.4.4. Parallel Scavenge Collector Diese Konfiguration eignet sich ähnlich wie der Concurrent Low Pause Collector für Echtzeitanwendungen. Optimiert ist diese Konfiguration für Mehr-Prozessor-Systene mit mindestens acht Prozessoren. Außerdem ist diese Konfiguration optimiert für die Verwaltung von großen "Young Generation Heaps" im Gigabyte-Bereich. Diese Konfiguration arbeitet ähnlich dem Concurrent Low Pause Collector. Zusammenfassend die Merkmale dieser Konfiguration: • • • • Geeignet für Echtzeitanwendungen Arbeitsweise ähnlich dem Concurrent Low Pause Collector optimiert für Mehr-Prozessor-Systeme mit mindestens acht Prozessoren optimiert für die Verwaltung von großen "Young Generation Heaps" im GigabyteBereich. 3.4.5. Incremental Garbage Colletor (Train Collector) Analog zur Konfiguration "Standard" gibt es auch bei den echtzeitfähigen Konfigurationen eine ähnliche Variante, die als Incremental Garbage Collector bezeichnet wird. Sie hat ihre Vorteile im Bereich der Ein-Prozessor-Maschinen. Es wird versucht, alle Aufgaben in die Minor Collections zu verlagern, damit die Major Collections, auch als "Full Garbage Collection bezeichnet", möglichst minimiert oder sogar ganz vermieden werden können. Als Folge dieser Arbeitsweise verlängern sich die Minor Collections. Zusammenfassend die Merkmale dieser Konfiguration: • • • • • Geeignet für Echtzeitanwendungen Arbeitsweise analog zur Konfiguration "Standard" Verschieben der Aufgaben von Major Collections hin zu Minor Collections Minimerung oder Vermeidung der Major Collections längere Minor Collections. Christian Riekenberg Matthias von der Grün 17 / 21 05.01.2006 Implementierung eines Java Runtime Systems 4. Performance und Optimierung Um die Performance einer Applikation zu beurteilen und sie zu optimieren, ist es wichtig, zuerst festzustellen, welche Zeit eine Applikation für die einzelnen Teilabläufe benötigt. Die benötigte Zeit für einen Programmablauf lässt sich in folgendem Diagramm darstellen: Thread Synchronisation : 19% Native Methoden: 1% 1 2 Allocation und Garbage Collection: 20% 3 Interpretation: 60% 4 Abbildung 10: Teilablauf-Diagramm für eine Applikation 4.1. Hot-Spot-Optimierung Durch die Hot-Spot-Optimierung wird versucht, die besten Eigenschaften von JIT Compiler und Interpreter zu kombinieren. Außerdem werden Profiler eingesetzt, die wiederum unterschiedliche Kompiliermethoden auswählen. Die Arbeitsweise kann man in folgende Schritte unterteilen: 1. Interpretierung des Bytecode von der JVM 2. Optimierung des kompilierten Codes a. Im Fehlerfall kann der kompilierte Code wieder neu interpretiert werden 3. Arbeit des Profilers: a. Sammeln von Informationen über die Performance einer Funktion während des Ablaufs b. Wählen einer passenden Kompilier-Methode nach einer bestimmten Heuristik c. Speichern der kompilierten Methoden im Cache des nativen Maschinencodes d. Bei Aufruf der entsprechenden Methode wird im Cache nach der Methode gesucht, wenn sie nicht gefunden wird, muss sie interpretiert werden 4.2. Kompiliermethoden Der Profiler kann dabei zwischen zwei unterschiedlichen Kompiliermethoden unterscheiden. Zum einen kann entweder den Client-Compiler oder den Server-Compiler wählen. Diese beiden Compiler werden in den folgenden Unterkapiteln beschrieben. Christian Riekenberg Matthias von der Grün 18 / 21 05.01.2006 Implementierung eines Java Runtime Systems 4.2.1. Client Compiler Diese Kompiliermethode eignet sich besonders für kurzlebige, interaktive GUI-Applikationen. Für Applikationen und Applets ergibt sich eine verbesserte Laufzeit. Das hängt im wesentlichen davon ab, weil weniger Optimierungen als beim Server Compiler durchgeführt werden, und weil weniger Zeit für die Analyse benötigt wird. Dadurch reduziert sich die Kompilierungszeit. Deshalb eignet sich diese Methode besonders für den interaktiven Einsatz. Allerdings ist der Client Compiler ineffizienter in der Ausführung als der Server Compiler. 4.2.2. Server Compiler Diese Methode bietet bessere Optimierungsalgorithmen und ist effizienter als der ClientCompiler. Besonders für langlebige Funktionen eignet sich diese Kompiliermethode. Die Register werden dabei durch Graph coloring allokiert, und polymorphe Aufrufstellen werden durch vtables statt durch polymorphic inline caches realisiert. Im Vergleich zum Client-Compiler zeichnet sich der Server-Compiler durch gründlichere Compilierung mit mehr Analyse und mehr Optimierung aus. Das führt zu einer längeren Compilierungszeit, aber auch zu einer effizienteren Ausführung, die besonders für langlebige Funktionen wichtig ist. 4.3. Optimierung der Garbage Collection Um die Garbage Collection zu verbessern und performanter zu gestalten, wird der Speicher zum einen in unterschiedliche Bereiche unterteilt (Garbage Collection-General Copying), zum anderen wird manchmal der Quellcode einer Methode an die Aufrufstelle eingefügt (Inlining frequently-called Methods). Diese beiden Optimierungsarten werden in den nächsten beiden Unterkapiteln näher erläutert. 4.3.1. Garbage Collection – Generational Copying Der Speicher wird bei dieser Optimierungsmethode in verschiedene Bereiche eingeteilt. Zum einen gibt es einen Bereich für Objekte, die noch "jung" sind, also erst vor kurzem erzeugt wurden. Dieser Speicherbereich wird oft gescannt, da die meisten dieser Objekte kurzlebig sind. Nur ca. 5% dieser Objekte sind langlebig. Diese Objekte werden dann in einen anderen Speicherbereich verschoben, der nicht mehr so oft gescannt wird. Hierbei gilt die Regel, dass, je länger die Objekte existieren, desto kleiner die Scannfrequenz ist. 4.3.2. Inlining frequently-called Methods In manchen Fällen kann es von Vorteil sein, wenn der Quellcode einer Methode nicht jedes Mal aufgerufen werden muss. In diesen Fällen kopiert die "Inlining frequently-called Method" den Quellcode an die entsprechende Stelle. Christian Riekenberg Matthias von der Grün 19 / 21 05.01.2006 Implementierung eines Java Runtime Systems 5. Zusammenfassung und Zukunftsaussichten Der größte Vorteil von Java, seine Plattformunabhängigkeit, ist auch der größte Nachteil. Um stabil auf unterschiedlichen Systemen laufen zu können, wird es dem Programmierer nicht erlaubt systemnah zu agieren. Diese Abkapslung und die Realisierung von Plattformunabhängigkeit durch Interpretation eines Codes, der von allen Systemen verwendet werden kann, senkt die Performance bei der Ausführung von Programmen merkbar. Algorithmen zur Speicherbereinigung müssen ebenfalls universell gehalten werden und sind manueller Speicherverwaltung in der Laufzeit zumeist unterlegen. Anwendung mit automatischer Speicherverwaltung sind jedoch weniger fehleranfällig und es kommt seltener zu Runtime-Fehlern. Auch in Zukunft werden sowohl die interpretierten und plattformunabhängigen, als auch die kompilierten und systemabhängigen Sprachen parallel existieren. Der Wunsch nach „Programm once, run everywhere“ kann nicht in allen Bereichen verwirklicht werden. Gerade zeitkritische Echzeit-Anwendungen können nicht immer darauf verzichten auf Systemressourcen zuzugreifen. Jedoch ist bei den wenigsten Programmen Echtzeit gefordert. Die Nutzung von interpretierten Sprachen steht demnach einem großen Teil der Anwendungen frei und wird aufgrund der überwiegenden Vorteile häufig verwendet. Christian Riekenberg Matthias von der Grün 20 / 21 05.01.2006 Implementierung eines Java Runtime Systems 6. Quellenangaben 6.1. Literatur- und Abbildungsverzeichnis [CAC05] Offizielle Hompage der JIT-Implementierung CACAO: http://www.cacaojvm.org/, Abruf November 05 [DAL05] Matthias Kalle Dalheimer: Java Virtual Machine, O'Reilly Verlag, ISBN 3-930673-73-8 [DEV05] Developer : http://www.developer.com/lang/article.php/3390001, Abruf 17.10.2005 [DEV05] Jupitermedia Corporation: Hompage der Firma JupiterMedia. http://www.developer.com/lang/article.php/3390001, Abruf November 05 [GAL05] Galileo Computing : http://www.galileocomputing.de/openbook/javainsel3/javainsel_010002.htm, Abruf 12.10.2005 [JAV05] JavaWorld.com IDG company: http://www.javaworld.com/javaworld/jw-032000/images/java101_fig1.gif, Abruf November 05 [JEC05] www.jeckle.de/vorlesung/java/script.html, Abruf 15.10.2005 [KAF05] Offizielle Hompage der JIT-Implementierung KAFFE: http://www.kaffe.org/, Abruf November 05 [LEM05] L.Lemay, C.Perkins : Java in 21 Tagen. Markt&Technik Verlag, 1999, ISBN 3827255783 [LIN05] Linux New Media AG: Homepage der Firma Linux New Media AG, http://www.linux-magazin.de/Artikel/ausgabe/1997/05/JVM/jvm1.html, Abruf November 05 [LY05] Tim Lindholm / Frank Yellin : The Java Virtual Machine Specification Second Edition – Java Virtual Machine – O’Reilly Verlag –1997 [PSU05] Jens Regenberg, Seminar Garbage Collection: www.ps.uni-sb.de/courses/gcws01/exams/MarkCompact.pdf, Abruf November 05 [STA05] UCLA Department of Statistics; Homepage des “Departement of statistic” der Universität von Kalifornien, http://www.stat.ucla.edu, Abruf November 05 [SUI05] Mark-Sweep-Algorithmus: http://suif.stanford.edu, Abruf November 05 [SUN05] Sun Inc.; Homepage der Firma Sun. http://java.sun.com/, Abruf November 05 [TYA05] Download zu der JIT-Implementierung TYA, Version 1.8: http://www.sax.de/~adlibit/, Abruf November 05 [WIK05] Wikipedia : http://www.wikipedia.de Christian Riekenberg Matthias von der Grün 21 / 21 05.01.2006