MarktundTechnik Home-Page Previous Page TOC Index Next Page See Page



21. Tag:

Hinter der Bühne

von Charles L. Perkins

Heute in der letzten Lektion befassen wir uns mit dem Innenleben des Java-Systems.

Sie erfahren alles über die Java-Vision, die virtuelle Java-Maschine, die Bytecodes, über die so viel geredet wird, den geheimnisvollen Papierkorb und die Sicherheitsaspekte von Java.

Wir beginnen mit dem Gesamtbild.

Das Gesamtbild

Das Java-Team ist sehr ehrgeizig. Sein ultimatives Ziel ist nichts weniger als die Revolutionierung der Art, wie Software geschrieben und verbreitet wird. Es hat mit dem Internet begonnen, das erwartungsgemäß die Heimat des Großteils der interessanten Software der Zukunft sein wird.

Um ein derart ehrgeiziges Ziel zu erreichen, muß sich ein großer Teil der Internet-Programmierschaft hinter ein ebensolches Ziel klemmen, damit die notwendigen Werkzeuge bereitgestellt werden. Die Java-Sprache mit ihren vier Merkmalen (klein, einfach, sicher und stabil) und ihre flexible netzorientierte Umgebung soll der Kernpunkt dieser neuen Legion von Programmierern werden.

Zu diesem Zweck hat Sun Microsystems einen draufgängerischen Schritt unternommen. Was ursprünglich ein Betriebsgeheimnis war, unzählige Millionen von Dollars an Forschungs- und Entwicklungsaufwand gekostet hat und zu hundert Prozent dem Unternehmen gehörte, wurde als offener Technologiestandard freigegeben. Das Unternehmen gab damit eine wertvolle Entwicklung praktisch kostenlos ab und behielt sich nur die Rechte vor, die nötig sind, um den Standard zu pflegen und weiterzuentwickeln.

Je mehr Zeit die Sun-Anwälte zum Nachdenken haben, um so komplexer werden die gesetzlichen Bestimmungen in bezug auf die Java-Sprache. Die rechtlichen Einschränkungen sind zwar immer noch relativ locker, wurden im Vergleich zu den ersten Releases aber straffer angezogen. Hoffen wir, daß dieser Ansatz nicht fortgesetzt wird.

Was ein wirklich offener Standard sein will, muß von mindestens einer ausgezeichneten, kostenlos verfügbaren »Demoimplementierung« unterstützt werden. Sun hat eine Alpha- und inzwischen auch eine Beta-Version im Internet ausgeliefert und ein endgültiges Release ist demnächst geplant. Gleichzeitig haben verschiedene Universitäten, Unternehmen und Fachleute bereits ihre Absicht ausgedrückt, die Java-Umgebung auf der Grundlage des offenen API von Sun zu duplizieren.

Ferner sind Gerüchte im Umlauf, daß mehrere andere Sprachen auf Java-Bytecodes herunterkompiliert werden sollen, um sie robuster zu machen und nach diesem allgemeingültigen Standard für den Austausch ausführbarer Inhalte im Internet auszulegen.

Starke Vision

Einer der Beweggründe für diese ungewöhnliche Entscheidung von Sun und der Hauptgrund für den enormen Erfolg von Java ist der Frust von praktisch einer ganzen Generation von Programmierern, die nach Möglichkeiten suchen, ihren Code mit anderen auszutauschen. Derzeit ist die Computerwissenschaft in unzählige winzige Bruchteile an Universitäten und in Unternehmen in aller Welt mit Hunderten von Sprachen gespalten. Alles ist selbstverständlich getrennt und teilt den Gesamtbereich in einsame Inselchen auf. Das ist die wohl schlimmste Art eines Turmes von Babylon. Mit Java besteht ein kleiner Hoffnungsschimmer, diesen Turm abzureißen. Da die Sprache einfach und nützlich zum Programmieren im Internet und das Internet das heute absolut »heißeste« Thema ist, dürfte Java in naher Zukunft ein großer Erfolg beschert sein.

Das verdient die Sprache aber auch. Sie ist das natürliche Ergebnis von Ideen, die seit Anfang der siebziger Jahre in der Smalltalk-Gruppe am Xerox PARC ausgebrütet werden. Smalltalk hat den ersten objektorientierten Bytecode-Interpreter entwickelt und viele der Konzepte von Java vorgegeben. Diese Bemühungen wurden aber über mehr als zwei Jahrzehnte nicht als Lösung für die allgemeinen Softwareprobleme erkannt. Heute sind diese Probleme so offensichtlich und das Internet schreit derart stark nach einer neuen Programmierart, daß Java auf einen fruchtbaren Boden fällt. Java dürfte darauf nicht nur als Pflänzchen heranwachsen, sondern sich wie ein Lauffeuer verbreiten. (Ist es da überraschend, daß Java anfangs intern »Green« und »OAK« (Englisch für »Eiche«) genannt wurde?)

Diese neue Vision dessen, wie die Software der Zukunft aussehen sollte, trifft auf das Internet mit einem Meer an Objekten, Klassen und offenen APIs. Traditionelle Anwendungen verschwinden zunehmend. Sie werden von eiffelturmartigen Verstrebungen abgelöst, in die viele Teile aus diesem Meer wie Legosteinchen eingefügt werden können. Benutzeroberflächen werden nach Herzenslust gemischt und kombiniert, aus Teilen zusammengestellt und nach Geschmack gestaltet - und das alles von den Benutzern. Auswahlmenüs werden mit dynamischen Listen aller für eine Funktion verfügbaren Optionen gefüllt - spontan, auf Wunsch und über das gesamte Internet.

In einer solchen Welt ist Softwareverbreitung kein Thema mehr. Software wird überall verfügbar, über eine Fülle neuer Mikroabrechnungsmodelle bezahlt und dürfte im Vergleich zu den heutigen Anwendungen nur Pfennige kosten. Gleichlaufend dazu werden Modelle zur Unterstützung von Unterhaltung, Kommerz und Sozialem - quasi als »Schmankerl« - im Cyberspace angeboten.

Das ist ein Traum, den viele von uns ein Leben lang geträumt haben. Ungeahnte Möglichkeiten werden wahr, jedoch muß uns der böige Wind des Wandels, der uns um die Ohren bläst, zum Handeln aufrütteln, weil es endlich eine Grundlage gibt, auf der wir unsere Träume ausleben können - Java.

Die virtuelle Java-Maschine

Um die Vision in etwas Brauchbares zu verwandeln, muß Java absolut verträglich sein. Die Sprache muß auf jedem Rechner und unter jedem Betriebssystem laufen - heute und in der Zukunft. Um dieses Maß an Portabilität zu erreichen, muß Java nicht nur über die Sprache selbst, sondern auch über die Umgebung, in der sie lebt, sehr präzise sein. Aus früheren Lektionen dieses Buches und Anhang B wissen Sie, daß die Java-Umgebung allgemeine Pakete mit Klassen und eine frei verfügbare Implementierung für diese Klassen umfaßt. Das beantwortet die Frage nach dem Nötigen, ist aber auch wichtig, um das Verhalten von Java zur Laufzeit zu spezifizieren.

Diese letztgenannte Anforderung hat bisher viele wohlgemeinte Versuche zunichte gemacht. Wer sein System auf Annahmen darüber stützt, was sich »hinter« dem Laufzeitsystem verbirgt, verliert. Wenn Sie auf irgendeine Weise von einem Rechner oder Betriebssystem abhängen, verlieren Sie. Java löst dieses Problem durch Erfinden eines abstrakten Rechners.

Diese »virtuelle« Maschine führt spezielle »Anweisungen« namens Bytecodes aus, bei denen es sich einfach um formatierte Bytes handelt, für die es eine genaue Spezifikation gibt. Die virtuelle Maschine ist auch für bestimmte grundlegende Fähigkeiten von Java zuständig, z. B. die Objekterstellung und die Müllbeseitigung (Papierkorb).

Um Bytecodes problemlos über das Internet zu schaufeln, brauchen Sie ein kugelfestes Sicherheitssystem. Außerdem müssen Sie wissen, wie es gepflegt wird und welches Format es voraussetzt, um Bytecodes zwischen virtuellen Maschinen auszutauschen.

Diese Anforderungen werden in der heutigen Lektion behandelt.

Damit verwische ich die Unterscheidung zwischen Laufzeit und der virtuellen Java-Maschine. In diesem Buch werden die Begriffe »Laufzeit« und »virtuelle Maschine« gleichbedeutend verwendet. Das läuft auf eine Umgebung hinaus, die geschaffen werden muß, um Java zu unterstützen. Ein Großteil der folgenden Beschreibung basiert auf den Alpha-Dokumenten »Virtual Machine Specifications« (und den Beta-Bytecodes). Wenn Sie also online tiefer in die Materie eintauchen, treffen Sie auf Ihnen bereits vertrauten Boden.

Übersicht

An dieser Stelle lohnt sich ein Auszug aus dem Einführungsteil der Dokumentation zur virtuellen Java-Maschine, weil er im Zusammenhang mit der oben beschriebenen Vision relevant ist:

Die Spezifikation der virtuellen Java-Maschine verfolgt einen Zweck, der mit Dokumenten anderer Sprachen und abstrakten Maschinen vergleichbar ist und sich doch unterscheidet. Sie dient zur Darstellung einer abstrakten logischen Maschine, frei von jeglicher Ablenkung durch irgendwelche Einzelheiten einer Implementierung. Sie geht nicht von einer bestimmten Implementierungstechnologie oder einem Host aus. Gleichzeitig bietet sie dem Leser ausreichend Informationen, um das abstrakte Design in verschiedenen Technologien zu implementieren.

Die Absicht des [...] Java-Projekts ist eine Sprache [...], die den Austausch »ausführbarer« Inhalte im Internet ermöglicht. Das Projektteam macht aus Java absichtlich keine herstellereigene Sprache und möchte nicht der ausschließliche Schirmherr von Java-Implementierungen sein. Vielmehr wird beabsichtigt, Dokumente wie dieses und Quellcode für unsere Implementierungen frei zur Verfügung zu stellen.

Diese Vision [...] kann nur erreicht werden, wenn der ausführbare Inhalt zuverlässig zwischen verschiedenen Java-Implementierungen ausgetauscht werden kann. Diese Absicht verbietet praktisch die absolut abstrakte Definition der virtuellen Java-Maschine. Relevante logische Elemente des Designs müssen so konkret sein, daß der Austausch eines kompilierten Java-Codes möglich ist. Das degradiert die Spezifikation der virtuellen Java-Maschine nicht zu einer Beschreibung der Java-Implementierung. Einzelne Designelemente, die beim Austausch ausführbarer Inhalte keine Rolle spielen, bleiben abstrakt. Es zwingt uns aber, zusätzlich zum abstrakten Maschinendesign ein konkretes Austauschformat für kompilierten Java-Code zu spezifizieren.

Die Spezifikation der virtuellen Java-Maschine setzt sich aus folgenden Teilen zusammen:

Diese Themen werden in den folgenden Abschnitten behandelt.

Trotz der relativ ausführlichen Spezifikation bleiben verschiedene Elemente des Designs (absichtlich) abstrakt, z. B.:

In diesen Bereichen kommt die Kreativität des Implementors der virtuellen Maschine voll zur Geltung.

Die Basisteile

Die virtuelle Java-Maschine kann in fünf Basisteile gegliedert werden:

Diese Teile können mit einem Interpreter, einem nativen Binärcode-Compiler oder gar einem Hardware-Chip implementiert werden. Auf jeden Fall müssen alle diese logischen abstrakten Komponenten der virtuellen Maschine in der einen oder anderen Form in jedem Java-System bereitgestellt werden.

Die von der virtuellen Java-Maschine benutzten Speicherbereiche müssen sich nicht zusammenhängend an einer bestimmten Stelle des Speichers befinden oder eine bestimmte Reihenfolge einhalten. Alle außer dem Methodenbereich müssen aber in der Lage sein, 32-Bit-Werte darzustellen (der Java-Stack ist z. B. 32 Bit groß).

Die virtuelle Maschine und der unterstützende Code werden meist Laufzeit-Umgebung genannt. Wenn in diesem Buch von Laufzeit die Rede ist, handelt es sich um Aktionen der virtuellen Maschine.

Java-Bytecodes

Die Bytecode-Anweisungen der virtuellen Java-Maschine sind optimiert und deshalb schlank und kompakt. Sie wurden für das Internet ausgelegt, deshalb wurden zwischen Geschwindigkeit und Platzbedarf Kompromisse gemacht. (Angesichts der Tatsache, daß sich die Internet-Bandbreite und die Geschwindigkeit von Massenspeichern schneller erhöhen als die CPU-Geschwindigkeit, scheint mir das ein vernünftiger Kompromiß.)

Wie bereits erwähnt, wird der Java-Quellcode in Bytecodes »kompiliert« und in einer .class-Datei gespeichert. Auf dem Java-System von Sun erfolgt das im javac-Werkzeug. Dabei handelt es sich nicht um einen herkömmlichen Compiler. javac übersetzt den Quellcode in Bytecode, so daß ein niedrigeres Format nicht direkt ausgeführt werden kann, sondern von jedem Rechner weitergehend interpretiert werden muß. Das genau bringt uns aber die Vorteile der totalen Portabilität.

Ich habe »kompiliert« im Zusammenhang mit javac absichtlich zwischen Anführungszeichen gesetzt, weil der »Just-in-Time«-Compiler, über den Sie bald mehr erfahren, eher das Verhalten eines echten Compilers aufweist. Die Verwendung des Begriffs »Compiler« für beide Java-Techniken ist mangels einer besseren Alternative nicht so gut gewählt, weil jede im Prinzip eine Hälfte dessen ausführen, was ein wirklicher Compiler allein macht.

Eine Bytecode-Anweisung besteht aus einem einzelnen Byte, einem Opcode, der zur Identifizierung der Anweisung und Null oder mehreren Operanden dient, die jeweils mehr als ein Byte lang sein können und die Parameter codieren, die der Opcode braucht.

Operanden, die mehr als ein Byte lang sind, werden nach den Bytes gespeichert, beginnend mit dem Byte der höheren Ordnung. Diese Operanden müssen vom Bytestrom zur Laufzeit zusammengesetzt werden. Ein 16-Bit-Parameter erscheint z. B. in einem Strom als zwei Bytes, so daß sein Wert first_byte * 256 + second_byte ist. Die Ausrichtung größerer Bytemengen ist nicht gewährleistet (außer, wenn sie in den speziellen Bytecodes lookupswitch und tableswitch stehen, die eigene Ausrichtungsregeln haben).

Bytecodes interpretieren Daten in den Laufzeit-Speicherbereichen als feste Typen: Primitivtypen, die aus mehreren Ganzzahlentypen mit Vorzeichen bestehen (8-Bit byte, 16-Bit short, 32-Bit int, 64-Bit long), einem vorzeichenlosen Ganzzahlentyp (16-Bit char) und zwei Gleitpunkttypen mit Vorzeichen (32-Bit float, 64-Bit double) sowie dem Typ »Referenz auf ein Objekt« (ein zeigerähnlicher 32-Bit-Typ). Einige spezielle Bytecodes (z. B. die dup-Anweisungen) behandeln Laufzeit-Speicherbereiche als Rohdaten ohne Berücksichtigung des Typs. Das ist aber eher die Ausnahme als die Regel.

Diese Primitivtypen werden nicht von Javas Laufzeit-Umgebung, sondern von javac unterschieden und verwaltet. Die Typen sind im Speicher nicht »gekennzeichnet« und können deshalb zur Laufzeit nicht unterschieden werden. Zum eindeutigen Hantieren der Primitivtypen sind verschiedene Bytecodes verfügbar. Der Compiler wählt anhand seiner Kenntnis über die in den verschiedenen Speicherbereichen vorhandenen Typen sorgfältig aus dieser Palette aus. Beim Hinzufügen von zwei Ganzzahlen erzeugt der Compiler beispielsweise einen iadd-Bytecode und für zwei Gleitpunkte wird fadd erzeugt (später mehr darüber).

Register

Die Register der virtuellen Java-Maschine sind mit den Registern eines »echten« Rechners identisch.

Register beinhalten den Zustand einer Maschine, beeinflussen ihren Betrieb und werden nach jeder Ausführung eines Bytecodes aktualisiert.

Im folgenden eine Aufstellung der Java-Register:

Die virtuelle Maschine definiert als Größe dieser Register 32 Bits.

Da die virtuelle Maschine primär auf Stacks basiert, nutzt sie zum Weitergeben oder Empfangen von Argumenten keine Register. Das ist Absicht und soll zur Einfachheit und Kompaktheit des Bytecodes beitragen. Übrigens: das pc-Register wird auch verwendet, wenn in der Laufzeit Ausnahmen abgearbeitet werden. Und schließlich sind catch-Klauseln mit den pc-Bereichen eines Methoden-Bytecodes verbunden.

Stacks

Die virtuelle Java-Maschine basiert auf Stacks. Der Rahmen eines Java-Stacks ist mit dem in herkömmlichen Programmiersprachen vergleichbar. Er enthält den Zustand eines Methodenaufrufs. Rahmen für verschachtelte Methodenaufrufe werden in diesem Rahmen gestapelt.

Ein Stack dient zur Bereitstellung von Parametern für Bytecodes und Methoden und zum Aufnehmen der Ergebnisse.

Jeder Stack enthält drei (möglicherweise leere) Datenmengen: die lokalen Variablen für den Methodenaufruf, die Ausführungsumgebung und den Operanden. Die Größe der ersten zwei Elemente steht zu Beginn eines Methodenaufrufs fest, während der Operand größenmäßig bei der Ausführung des Bytecodes der Methode variiert.

Lokale Variablen werden in einem Array mit 32-Bit-Zellen gespeichert und vom Register vars indiziert. Die meisten Typen belegen eine Zelle im Array; long und double belegen zwei.

long- und double-Werte, die über Index N gespeichert oder aktiviert werden, belegen die (32-Bit) Zellen N und N + 1. Diese 64-Bit-Werte sind deshalb nicht garantiert auf 64 Bit ausgerichtet. Die Entscheidung, diese Werte auf die zwei Zellen korrekt aufzuteilen, liegt beim Entwickler.

Die Ausführungsumgebung in einem Stack ist bei der Pflege des Stacks selbst hilfreich. Sie enthält einen Pointer auf den vorherigen Stack, einen auf die lokalen Variablen des Methodenaufrufs und je einen auf das momentane »Oben« und »Unten« des Stacks. In die Ausführungsumgebung können auch zusätzliche Debugging-Informationen gestellt werden.

Der Operanden-Stack (32-Bit, nach dem FIFO-Prinzip (First In, First Out)) wird zum Speichern der Parameter und Rückgabewerte der meisten Bytecode-Anweisungen verwendet. So erwartet der iadd-Bytecode beispielsweise zwei Ganzzahlen oben auf dem Stack.

Jeder primitive Datentyp hat eindeutige Anweisungen, die das Herausziehen, Verwenden und Zurückschieben der Operanden des jeweiligen Typs besorgen. Die long- und double-Operanden belegen z. B. zwei »Zellen« im Stack und die speziellen Bytecodes, die diese Operanden abarbeiten, berücksichtigen dies. Die Typen in einem Stack und deren Anweisungen müssen kompatibel sein (javac-Ausgaben gehorchen immer dieser Regel).

Das »Oben« im Operanden-Stack und das »Oben« des gesamten Java-Stacks sind fast immer identisch. Wenn ich nur Stack sage, meine ich beide.

Heaps

Ein Heap ist derjenige Teil des Speichers, dem neu erstellte Instanzen (Objekte) zugewiesen werden. In Java hat ein Heap meist eine große feste Größe, wenn das Java-Laufzeitsystem gestartet wird. Auf Systemen, die virtuellen Speicher unterstützen, kann er aber nach Bedarf fast unbegrenzt wachsen.

Da in Java Objekte automatisch der Müllbeseitigung unterliegen, muß der Programmierer den einem Objekt (das nicht mehr benutzt wird) zugeteilten Speicher nicht manuell freigeben (und kann das auch nicht).

Auf Java-Objekte wird indirekt zur Laufzeit über Handles - eine Art Pointer, der auf einen Heap zeigt - zugegriffen.

Da auf Objekte nie zugegriffen wird, können parallele Reinigungsprozeduren geschrieben werden, die unabhängig vom Programm arbeiten und Objekte im Heap nach eigenem Gutdünken beseitigen oder verschieben. Sie lernen alles über die Müllbeseitigung bzw. den Papierkorb später.

Der Methodenbereich

Wie die kompilierten Codebereiche konventioneller Programmiersprachen oder das TEXT-Segment in einem Unix-Prozeß speichert der Methodenbereich die Java-Bytecodes, die fast jede Methode im Java-System implementieren (wobei einige Methoden nativ sein können und damit z. B. in C implementiert werden). Im Methodenbereich werden auch die zum dynamischen Verknüpfen benötigten Symboltabellen und andere zusätzliche Informationen gespeichert, z. B. zum Debugging.

Da Bytecodes als Byteströme gespeichert werden, wird der Methodenbereich auf Bytegrenzen ausgerichtet (die anderen Bereiche werden auf 32-Bit-Wortgrenzen ausgerichtet).

Der Konstantenpool

In einem Heap hat jede Klasse einen »angehängten« Konstantenpool. Diese normalerweise von javac erzeugten Konstanten codieren alle Namen (von Variablen, Methoden usw.), die von einer Methode einer Klasse benutzt werden. Die Klasse erfaßt die Menge der Konstanten. Ein Versatz spezifiziert, wo in der Klassenbeschreibung das Konstanten-Array beginnt. Diese Konstanten werden mit speziell codierten Bytes typisiert und haben ein genau definiertes Format, wenn sie in der .class-Datei erscheinen.

Einschränkungen

Die virtuelle Maschine wirft in der derzeitigen Definition einige Einschränkungen auf:

Darüber hinaus nutzt die Sun-Implementierung der virtuellen Maschine sogenannte _quick-Bytecodes, die das System weiter einschränken. Vorzeichenlose 8-Bit-Versätze in Objekten können die Anzahl der Methoden einer Klasse auf 256 einschränken (was eventuell im endgültigen Release nicht mehr der Fall ist) und vorzeichenlose 8-Bit-Argumentzähler schränken die Größe der Argumentenliste auf 255 32-Bit-Wörter ein. (Das bedeutet, daß bei den meisten Typen bis zu 255 Argumente zulässig sind, für long oder double jedoch nur 127 benutzt werden können.)

Einzelheiten zu Bytecodes

Eine der Hauptaufgaben der virtuellen Maschine ist die schnelle effiziente Ausführung des Java-Bytecodes in Methoden. Im Gegensatz zu der gestrigen Diskussion über allgemeine Java-Programme ist das ein Fall, bei dem Geschwindigkeit von äußerster Wichtigkeit ist. Jedes Java-Programm leidet hier an einer langsamen Implementierung, so daß zur Laufzeit möglichst viele Tricks anzuwenden sind, damit der Bytecode schneller läuft.

Der Bytecode-Interpreter

Der Bytecode-Interpreter prüft jedes Opcode-Byte (Bytecode) im Bytecode-Strom einer Methode und führt pro Bytecode eine eindeutige Aktion aus. Das kann weitere Bytes für die Operanden des Bytecodes verbrauchen und sich auf den als nächstes zu prüfenden Bytecode auswirken. Das funktioniert wie die CPU eines Rechners, die den Speicher auf Anweisungen prüft und ausführt. Das ist die Software-CPU der virtuellen Java-Maschine.

Ein erster naiver Versuch, einen solchen Bytecode-Interpreter zu schreiben, ergibt etwas verheerend Langsames. Besonders schwierig ist die Optimierung der inneren Schleife. Viele kluge Leute zerbrechen sich seit über zwanzig Jahren darüber bereits den Kopf. Zum Glück haben einige davon Ergebnisse erzielt, die auf Java anwendbar sind.

Im Klartext heißt das, daß der Interpreter im derzeitigen Release von Java eine sehr schnelle innere Schleife hat. Sogar auf einem relativ langsamen Rechner kann dieser Interpreter mehr als 330.000 Bytecodes pro Sekunde ausführen! Für die meisten Java-Programme ist das ausreichend. Ist eine höhere Geschwindigkeit erforderlich, bietet sich die Verwendung von native-Methoden an (siehe gestrige Lektion).

Der »Just-in-Time«-Compiler

Vor etwa einem Jahrzehnt kam Peter Deutsch bei einem Versuch, die Ausführung von Smalltalk zu beschleunigen, hinter einen cleveren Trick. Er nannte das »dynamische Übersetzung« bei der Interpretation. Sun nennt das »Just-in-Time«-Kompilierung.

Der Trick liegt darin, daß ein z. B. in C geschriebener Interpreter ohnehin eine nützliche Folge von nativem Binärcode für jedes zu interpretierende Bytecode enthält: den Binärcode, den der Interpreter ausführt. Da der Interpreter bereits von C in den nativen Binärcode kompiliert wurde, gibt er eine Folge von nativen Codeanweisungen an die CPU des Rechners ab, auf dem er läuft. Durch Speichern einer Kopie der Binäranweisungen während des »Durchlaufs« kann der Interpreter ein laufendes Log mit Binärcode zusammenstellen.

Anhand dieses Logs können Optimierungen realisiert werden. Dadurch werden redundante oder unnötige Anweisungen vermieden, und das Ergebnis ist ein optimierter Binärcode, den ein guter Compiler genau so gut hätte produzieren können.

Bei der nächsten Ausführung einer Methode (auf genau die gleiche Weise) kann der Interpreter den gespeicherten nativen Binärcode ausführen. Da dies das Overhead der inneren Schleife und andere Redundanzen des Bytecodes einer Methode optimiert, kann die Geschwindigkeit um einen Faktor von 10 oder mehr gesteigert werden. In einem Versuch mit dieser Technologie hat Sun aufgezeigt, daß Java-Programme damit so schnell laufen wie kompilierte C-Programme.

Der java2c-Übersetzer

Ein anderer einfacher Trick, der greift, wenn man ein Programm mit einem guten portierbaren C-Compiler ausführt, ist die Übersetzung des Bytecodes in C und die anschließende Kompilation von C in den nativen Binärcode. Wartet man bis zur ersten Verwendung einer Methode oder Klasse und führt dies dann als »unsichtbare« Optimierung aus, können zusätzliche Verbesserungen der Geschwindigkeit erreicht werden.

Selbstverständlich ist man damit auf einen C-Compiler angewiesen. Wie Sie aber gestern erfahren haben, gibt es sehr gute günstige C-Compiler. Theoretisch kann Ihr Java-Code mit seinem eigenen C-Compiler auskommen oder wissen, wo er einen für das jeweilige Rechner- oder Betriebssystem im Internet bei Bedarf findet. (Da dies einige Regeln des normalen Java-Codeaustauschs im Internet verletzt, sollte man auf diesen Ansatz sparsam zurückgreifen.)

Verwenden Sie Java beispielsweise, um einen Server zu schreiben, der nur auf Ihrem Rechner lebt, könnten Sie java2c manuell ausführen und den Server komplett selbst in nativen Code übersetzen. Dabei wird die Java-Laufzeitumgebung in diesen Code eingebunden, so daß Ihr Server ein volles Java-Programm bleibt, dazu aber extrem schnell ist.

Ein von Sun durchgeführter Test mit dem java2c-Übersetzer hat gezeigt, daß die Geschwindigkeit eines kompilierten und optimierten C-Codes erreicht werden kann. Mehr kann man sich nicht erhoffen!

Leider gibt es im Beta-Release noch kein öffentlich verfügbares java2c-Werkzeug und die virtuelle Maschine von Sun führt keine »Just-in-Time«-Kompilation durch. Beides soll in einem späteren Release angeboten werden.

Die Bytecodes

Wir betrachten nun (eine fortschreitend weniger) detaillierte Beschreibung der Bytecode-Klassen.

Die Funktion jedes Bytecodes wird kurz beschrieben, und ein textliches »Bild« des Stacks vor und nach der Ausführung des Bytecodes wird aufgezeigt. Dieses Bild sieht in etwa so aus:

..., value1, value2 => ..., value 3

Das bedeutet, daß der Bytecode zwei Operanden - value1 und value2 - oben im Stack erwartet. Daraus wird value3 produziert und oben in den Stack gestellt. Jeder Stack sollte von rechts nach links gelesen werden, wobei der äußerste rechte Wert oben im Stack steht. Die drei Punkte (...) werden »als Rest des Stacks« gelesen, was für den aktuellen Bytecode uninteressant ist. Alle Operanden des Stacks haben eine Größe von 32 Bits.

Da die meisten Bytecodes ihre Argumente aus dem Stack nehmen und ihre Ergebnisse dorthin zurückstellen, wird im folgenden die Quelle bzw. das Ziel von Werten beschrieben, die sich nicht im Stack befinden. Die Beschreibung Load integer from local variable. bedeutet beispielsweise, daß die Ganzzahl in den Stack geladen wird und Integer add. erwartet, daß seine Ganzzahlen vom Stack entnommen und die Ergebnisse dorthin zurückgegeben werden.

Bytecodes, die sich nicht auf die Programmsteuerung auswirken, verschieben pc zum nächstfolgenden Bytecode. byte1, byte2 usw. bezieht sich auf das erste und zweite Byte usw., die dem Opcode-Byte folgen. Nach der Ausführung eines solchen Bytecodes schreitet pc automatisch über diese Operandenbytes, um den nächstfolgenden Bytecode zu starten.

Die folgenden Abschnitte haben den Stil eines Handbuchs, so daß jeder Bytecode und dessen Beschreibung getrennt (und meist redundant) aufgeführt wird. In späteren Abschnitten werde Teile übersichtlich zusammengefügt.

Konstanten in einen Stack schieben

bipush ... => ..., value

Schiebe eine Ganzzahl mit Vorzeichen von einem Byte. byte1 wird als 8-Bit-Wert mit Vorzeichen interpretiert. value wird auf eine Ganzzahl erweitert und in den Operandenstack zurückgeschoben.

sipush ... => ..., value

Schiebe eine Ganzzahl mit Vorzeichen von zwei Bytes. byte1 und byte2 werden zu einem 16-Bit-Wert mit Vorzeichen zusammengesetzt. value wird auf eine Ganzzahl erweitert und in den Operandenstack zurückgeschoben.

ldc1 ... => ..., item

Schiebe item vom Konstantenpool. byte1 wird als vorzeichenloser 8-Bit-Index für den Konstantenpool der aktuellen Klasse benutzt. item wird an diesem Index ermittelt und in den Stack zurückgeschoben.

ldc2 ... => ..., item

Schiebe item vom Konstantenpool. byte1 und byte2 werden zu einem vorzeichenlosen 16-Bit-Index für den Konstantenpool der aktuellen Klasse benutzt. item wird an diesem Index ermittelt und in den Stack zurückgeschoben.

ldc2w ... => ..., constant -word1, constant -word2

Schiebe long oder double vom Konstantenpool. byte1 und byte2 werden zu einem vorzeichenlosen 16-Bit-Index zusammengesetzt und in den Konstantenpool der aktuellen Klasse gestellt. Die aus zwei Wörtern bestehende Konstante wird am Index ermittelt und in den Stack zurückgeschoben.

aconst_null ... => ..., null

Schiebe das durch die Referenz bezeichnete null-Objekt in den Stack zurück.

iconst_m1 ... => ..., -1

Schiebe int -1 in den Stack zurück.

iconst_<I> ... => ..., <I>

Schiebe int <I> in den Stack. Insgesamt gibt es sechs solche Bytecodes, je einen für die Ganzzahlen 0-5: iconst_0, iconst_1, iconst_2, iconst_3, iconst_4 und iconst_5.

lconst_<L> ... => ..., <L> -word1, <L> -word2

Schiebe long <L> in den Stack. Insgesamt gibt es zwei solche Bytecodes, je einen für die Ganzzahlen 0 und 1: lconst_0 und lconst_1.

fconst_<F> ... => ..., <F>

Schiebe float <F> in den Stack. Insgesamt gibt es drei solche Bytecodes, je einen für die Ganzzahlen 0-2: fconst_0, fconst_1 und fconst_2.

dconst_<D> ... => ..., <D> -word1, <D> -word2

Schiebe double <D> in den Stack. Insgesamt gibt es zwei solche Bytecodes, je einen für die Ganzzahlen 0 und 1: dconst_0 und dconst_1.

Lokale Variablen in einen Stack laden

iload ... => ..., value

Lade int von der lokalen Variablen. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Ganzzahl enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben.

iload_<I> ... => ..., value

Lade int von der lokalen Variablen. Die lokale Variable <I> im aktuellen Java-Rahmen muß eine Ganzzahl enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: iload_0, iload_1, iload_2 und iload_3.

lload ... => ..., value -word1, value -word2

Lade long von der lokalen Variablen. Die lokalen Variablen byte1 und byte1+1 im aktuellen Java-Rahmen müssen zusammen eine lange Ganzzahl enthalten. Die in diesen Variablen enthaltenen Werte werden in den Operandenstack geschoben.

lload_<L> ... => ..., value -word1, value -word2

Lade long von der lokalen Variablen. Die lokalen Variablen <L> und <L>+1 im aktuellen Java-Rahmen müssen zusammen eine lange Ganzzahl enthalten. Der in diesen Variablen enthaltene Wert wird in den Operandenstack geschoben. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: lload_0, lload_1, lload_2 und lload_3.

fload ... => ..., value

Lade float von der lokalen Variablen. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Gleitpunktzahl mit einfacher Genauigkeit enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben.

fload_<F> ... => ..., value

Lade float von der lokalen Variablen. Die lokale Variable <F> im aktuellen Java-Rahmen muß eine Gleitpunktzahl mit einfacher Genauigkeit enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: fload_0, fload_1, fload_2 und fload_3.

dload ... => ..., value -word1, value -word2

Lade double von der lokalen Variablen. Die lokalen Variablen byte1 und byte1+1 im aktuellen Java-Rahmen müssen zusammen eine Gleitpunktzahl mit doppelter Genauigkeit enthalten. Der in diesen Variablen enthaltene Wert wird in den Operandenstack geschoben.

dload_<D> ... => ..., value -word1, value -word2

Lade double von der lokalen Variablen. Die lokalen Variablen <D> und <D>+1 im aktuellen Java-Rahmen müssen zusammen eine Gleitpunktzahl mit doppelter Genauigkeit enthalten. Der in diesen Variablen enthaltene Wert wird in den Operandenstack geschoben. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: dload_0, dload_1, dload_2 und dload_3.

aload ... => ..., value

Lade die Objektreferenz von der lokalen Variablen. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Rückgabeadresse oder Referenz auf ein Objekt oder Array enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben.

aload_<A> ... => ..., value

Lade die Objektreferenz von der lokalen Variablen. Die lokale Variable <A> im aktuellen Java-Rahmen muß eine Rückgabeadresse oder Referenz auf ein Objekt enthalten. Der Array-Wert dieser Variablen wird in den Operandenstack geschoben. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: aload_0, aload_1, aload_2 und aload_3.

Stackwerte in lokalen Variablen speichern

istore ..., value => ...

Speichere int in der lokalen Variablen. Der Wert muß eine Ganzzahl sein. Die lokale Variable byte1 im aktuellen Java-Rahmen wird auf value gesetzt.

istore_<I> ..., value => ...

Speichere int in der lokalen Variablen. Der Wert muß eine Ganzzahl sein. Die okale Variable <I> im aktuellen Java-Rahmen wird auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: istore_0, istore_1, istore_2 und istore_3.

lstore ..., value -word1, value -word2 => ...

Speichere long in der lokalen Variablen. Der Wert muß eine lange Ganzzahl sein. Die lokalen Variablen byte1 und byte1+1 im aktuellen Java-Rahmen werden auf value gesetzt.

lstore_<L> ..., value -word1, value -word2 => ...

Speichere long in der lokalen Variablen. Der Wert muß eine lange Ganzzahl sein. Die lokalen Variablen <L> und <L>+1 im aktuellen Java-Rahmen werden auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: lstore_0, lstore_1, lstore_2 und lstore_3.

fstore ..., value => ...

Speichere float in der lokalen Variablen. Der Wert muß eine Gleitpunktzahl mit einfacher Genauigkeit sein. Die lokalen Variablen byte1 und byte1+1 im aktuellen Java-Rahmen werden auf value gesetzt.

fstore_<F> ..., value => ...

Speichere float in der lokalen Variablen. Der Wert muß eine Gleitpunktzahl mit einfacher Genauigkeit sein. Die lokalen Variablen <F> und <F>+1 im aktuellen Java-Rahmen werden auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: rstore_0, fstore_1, fstore_2 und fstore_3.

dstore ..., value -word1, value -word2 => ...

Speichere double in der lokalen Variablen. Der Wert muß eine Gleitpunktzahl mit doppelter Genauigkeit sein. Die lokalen Variablen byte1 und byte1+1 im aktuellen Java-Rahmen werden auf value gesetzt.

dstore_<D> ..., value -word1, value -word2 => ...

Speichere double in der lokalen Variablen. Der Wert muß eine Gleitpunktzahl mit doppelter Genauigkeit sein. Die lokalen Variablen <D> und <D>+1 im aktuellen Java-Rahmen werden auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: dstore_0, dstore_1, dstore_2 und dstore_3.

astore ..., handle => ...

Speichere die Objektreferenz in der lokalen Variablen. handle muß eine Rückgabeadresse oder eine Referenz auf ein Objekt sein. Die lokale Variable byte1 im aktuellen Java-Rahmen wird auf value gesetzt.

astore_<A> ..., handle => ...

Speichere die Objektreferenz in der lokalen Variablen. handle muß eine Rückgabeadresse oder eine Referenz auf ein Objekt sein. Die lokale Variable <A> im aktuellen Java-Rahmen wird auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: astore_0, astore_1, astore_2 und astore_3.

iinc                   -no change-

Erhöhe die lokale Variable um die Konstante. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Ganzzahl enthalten. Ihr Wert wird um den Wert von byte2 erhöht, wobei byte2 als 8-Bit-Menge mit Vorzeichen behandelt wird.

Verwalten von Arrays

newarray ..., size => result

Zuweisung eines neuen Arrays, wobei size eine Ganzzahl sein muß. Sie stellt die Zahl der im neuen Array vorhandenen Elemente dar. byte1 ist ein interner Code, der den zuzuweisenden Array-Typ bezeichnet. Mögliche Werte für byte1 sind T_BOOLEAN(4), T_CHAR(5), T_FLOAT(6), T_DOUBLE(7), T_BYTE(8), T_SHORT(9), T_INT(10) und T_LONG(11).

Wir machen hier einen Versuch, einen neuen Array des bezeichneten Typs zuzuweisen, der Elemente gemäß size aufnehmen kann. Das ergibt result. Ist size kleiner als Null, wird NegativeArraySizeException ausgeworfen. Ist zur Zuweisung des Arrays nicht genügend Speicherplatz vorhanden, wird OutOfMemoryError ausgeworfen. Alle Elemente des Arrays werden auf ihre Standardwerte initialisiert.

anewarray ..., size => result

Zuweisung von neuen Array-Objekten. size muß eine Ganzzahl sein. Sie stellt die Zahl der im neuen Array vorhandenen Elemente dar. byte1 und byte2 werden benutzt, um einen Index für den Konstantenpool der aktuellen Klasse zu bilden. Das Element in diesem Index wird ermittelt und das Ergebnis muß eine Klasse sein.

Wir machen wiederum einen Versuch, einen neuen Array des bezeichneten Klassentyps zuzuweisen, der die durch size bezeichneten Elemente aufnehmen kann. Das ergibt result. Ist size kleiner als Null, wird NegativeArraySizeException ausgeworfen. Ist nicht genügend Speicherplatz vorhanden, wird OutOfMemoryError ausgeworfen. Alle Elemente des Arrays werden auf Null initialisiert.

anewarray dient zum Erstellen eines Objekt-Arrays mit einfacher Dimension. Die Anfrage new Thread[7] erzeugt z. B. folgenden Bytecode:

bipush 7


anewarray <Class "java.lang.Thread">

anewarray kann auch benutzt werden, um die äußerste Dimension eines multidimensionalen Arrays zu erstellen. Die Array-Deklaration new int[6][] erzeugt z. B.

bipush 6


anewarray <Class "[I">

(Weitere Informationen über Strings wie [I finden Sie im Abschnitt »Methodenunterschriften«.)

multianewarray ..., size1 size2...sizeN => result

Zuweisung eines neuen multidimensionalen Arrays. Jede size<I> muß eine Ganzzahl sein, die jeweils die Zahl der Elemente in einer Dimension des Arrays darstellt. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Elemente wird in diesem Index ermittelt und das Ergebnis muß eine Klasse einer oder mehrere Dimensionen des Arrays sein.

byte3 ist eine positive Ganzzahl, die die Zahl der zu erstellenden Dimensionen darstellt. Sie muß kleiner als oder gleich groß wie die Zahl der Dimensionen der Array-Klasse sein. byte3 ist auch die Zahl der aus dem Stack ausgeworfenen Elemente. Alle müssen Ganzzahlen von größer als oder gleich Null sein. Sie werden als Größe der Dimensionen herangezogen. Ein neuer Array des bezeichneten Klassentyps soll zugewiesen werden, der in der Lage ist, size<1> * size<2> * ... * <sizeN> Elemente aufzunehmen. Das ergibt result. Ist eines der size<I>-Argumente im Stack kleiner als Null, wird NegativeArraySizeException ausgeworfen. Ist nicht genügend Speicherplatz vorhanden, wird OutOfMemoryError ausgeworfen.

new int [6][3][] erzeugt folgenden Bytecode:

   bipush 6

   bipush 3

   multianewarray <Class "[[[I"> 2

Beim Erstellen von Arrays mit einfacher Dimension ist newarray oder anewarray mehr als ausreichend.

arraylength ..., array => ..., length

Hole die Länge des Arrays. array muß eine Referenz auf ein Array-Objekt sein. Die Länge des Arrays wird ermittelt und ersetzt array oben im Stack. Ist array Null, wird NullPointerException ausgeworfen.

iaload          ..., array, index => ..., value

laload          ..., array, index => ..., value-word1, value-word2

faload          ..., array, index => ..., value

daload          ..., array, index => ..., value-word1, value-word2

aaload          ..., array, index => ..., value

baload          ..., array, index => ..., value

caload          ..., array, index => ..., value

saload          ..., array, index => ..., value

Lade <type> vom Array. array muß ein Array <type>s sein. index muß eine Ganzzahl sein. Der <type>-Wert auf Position index in array wird geholt und oben in den Stack gestellt. Ist array Null, wird NullPointerException ausgeworfen. Liegt index nicht innerhalb der Grenzen von array, wird ArrayIndexOutOfBoundsException ausgeworfen. <type> ist nacheinander int, long, float, double, Objektreferenz, byte, char und short. <type>s long und double haben Werte mit zwei Wörtern.

iastore         ..., array, index, value => ...

lastore         ..., array, index, value-word1, value-word2 => ...

fastore         ..., array, index, value => ...

dastore         ..., array, index, value-word1, value-word2 => ...

aastore         ..., array, index, value => ...

bastore         ..., array, index, value => ...

castore         ..., array, index, value => ...

sastore         ..., array, index, value => ...

Speichere in <type>-Array. array muß ein Array vom <type>s sein. index muß eine Ganzzahl und value muß ein <type> sein. Der <type>-Wert wird in Position index in array gespeichert. Ist array Null, wird NullPointerException ausgeworfen. Liegt index nicht innerhalb der Grenzen des Arrays, wird ArrayIndexOutOfBoundsException ausgeworfen. <type> ist nacheinander int, long, float, double, Objektreferenz, byte, char und short. <type>s long und double haben Werte mit zwei Wörtern.

Stackoperationen

nop             -no change-

Mache nichts.

pop        ..., any => ...

Werfe das oberste Wort aus dem Stack aus.

pop2      ..., any2, any1 => ...

Werfe die obersten zwei Wörter aus dem Stack aus.

dup     ..., any => ..., any, any

Dupliziere das oberste Wort im Stack.

dup2    ..., any2, any1 => ..., any2, any1, any2,any1

Dupliziere die obersten zwei Wörter im Stack.

dup_x1    ..., any2, any1 => ..., any1, any2,any1

Dupliziere das oberste Wort im Stack und füge die Kopie zwei Wörter weiter unten im Stack ein.

dup2_x1   ..., any3, any2, any1 => ..., any2, any1, any3,any2,any1

Dupliziere die obersten zwei Wörter im Stack und füge die Kopien zwei Wörter weiter unten im Stack ein.

dup_x2   ..., any3, any2, any1 => ..., any1, any3,any2,any1

Dupliziere das oberste Wort im Stack und füge die Kopie drei Wörter weiter unten im Stack ein.

dup_x2   ..., any4, any3, any2, any1 => ..., any2, any1, any4,any3,any2,any1

Dupliziere die obersten zwei Wörter im Stack und füge die Kopien drei Wörter weiter unten im Stack ein.

swap   ..., any2, any1 => ..., any1, any2

Tausche die zwei obersten Elemente im Stack.

Arithmetische Operationen

iadd       ..., v1, v2 => ..., result

ladd       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

fadd       ..., v1, v2 => ..., result

dadd       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

v1 und v2 müssen <type>s sein. vs wird hinzugefügt und im Stack durch seine <type>-Summe ersetzt. <type> ist nacheinander int, long, float und double.

isub       ..., v1, v2 => ..., result

lsub       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

fsub       ..., v1, v2 => ..., result

dsub       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

v1 und v2 müssen <type>s sein. v2 wird von v1 abgezogen und beide vs werden im Stack durch ihren <type>-Unterschied ersetzt. <type> ist nacheinander int, long, float und double.

imul       ..., v1, v2 => ..., result

lmul       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

fmul       ..., v1, v2 => ..., result

dmul       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

v1 und v2 müssen <type>s sein. Beide vs werden im Stack durch ihr <type>-Produkt ersetzt. <type> ist nacheinander int, long, float und double.

idiv       ..., v1, v2 => ..., result

ldiv       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

fdiv       ..., v1, v2 => ..., result

ddiv       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

v1 und v2 müssen <type>s sein. v2 wird durch v1 dividiert und beide vs werden im Stack durch ihren <type>-Quotienten ersetzt. <type> ist nacheinander int, long, float und double.

irem       ..., v1, v2 => ..., result

lrem       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

frem       ..., v1, v2 => ..., result

drem       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

v1 und v2 müssen <type>s sein. v2 wird durch v1 dividiert und beide vs werden im Stack durch ihren <type>-Rest ersetzt. Ein Versuch, durch Null zu dividieren, führt zum Auswerfen von ArithmeticException. <type> ist nacheinander int, long, float und double.

ineg       ..., value => ..., result

lneg       ..., value-word1, value-word2 => ..., result-word1, result-word2

fneg       ..., value => ..., result

dneg       ..., value-word1, value-word2 => ..., result-word1, result-word2

value muß ein <type> sein. Er wird im Stack durch seine arithmetische Negation ersetzt. <type> ist nacheinander int, long, float und double.

Da Sie jetzt mit dem Aussehen von Bytecodes vertraut sind, werden die folgenden Zusammenfassungen nach und nach (aus Platzgründen) kürzer. Sie können weitere Einzelheiten aus der Spezifikation der virtuellen Maschine des letzten Java-Releases entnehmen.

Logische Operationen

ishl       ..., v1, v2 => ..., result

lshl       ..., v1-word1, v1-word2, v2 => ..., r-word1, r-word2

ishr       ..., v1, v2 => ..., result

lshr       ..., v1-word1, v1-word2, v2 => ..., r-word1, r-word2

iushr      ..., v1, v2 => ..., result

lushr      ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

Für die Typen int und long: arithmetische Verschiebung links, Verschiebung rechts und logische Verschiebung rechts.

iand       ..., v1, v2 => ..., result

land       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

ior        ..., v1, v2 => ..., result

lor        ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

ixor       ..., v1, v2 => ..., result

lxor       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., r-word1, r-word2

Für die Typen int und long: bitweises AND, OR und XOR.

Umwandlungsoperationen

i2l         ..., value => ..., result-word1, result-word2

i2f         ..., value => ..., result

i2d         ..., value => ..., result-word1, result-word2

l2i         ..., value-word1, value-word2 => ..., result

l2f         ..., value-word1, value-word2 => ..., result

l2d         ..., value-word1, value-word2 => ..., result-word1, result-word2

f2i         ..., value => ..., result

f2l         ..., value => ..., result-word1, result-word2

f2d         ..., value => ..., result-word1, result-word2

d2i         ..., value-word1, value-word2 => ..., result

d2l         ..., value-word1, value-word2 => ..., result-word1, result-word2

d2f         ..., value-word1, value-word2 => ..., result

int2byte    ..., value => ..., result

int2char    ..., value => ..., result

int2short   ..., value => ..., result

Diese Bytecodes konvertieren von value Typ <lhs> in Ergebnis Typ <rhs>. <lhs> und <rhs> können i, l, f und d sein, d. h. int, long, float und double. Die letzten drei Bytecodes haben selbsterklärende Typen.

Übergabe der Kontrolle

ifeq        ..., value => ...

ifne        ..., value => ...

iflt        ..., value => ...

ifgt        ..., value => ...

ifle        ..., value => ...

ifge        ..., value => ...

if_icmpeq   ..., value1, value2 => ...

if_icmpne   ..., value1, value2 => ...

if_icmplt   ..., value1, value2 => ...

if_icmpgt   ..., value1, value2 => ...

if_icmple   ..., value1, value2 => ...

if_icmpge   ..., value1, value2 => ...

ifnull      ..., value => ...

ifnonnull   ..., value => ...

Wenn Wert <rel> 0 im ersten Set des Bytecodes wahr ist, ist value1 <rel> value2 im zweiten Set wahr oder value ist im dritten Set Null (oder nicht Null). byte1 und byte2 werden benutzt, um einen 16-Bit-Versatz mit Vorzeichen zu bilden. Die Ausführung läuft von pc bis zu diesem Versatz. Andernfalls läuft die Ausführung bis zum Bytecode. <rel> ist eq, ne, lt, gt, le oder ge, d. h. gleich, nicht gleich, kleiner als, größer als, kleiner als oder gleich und größer als oder gleich.

lcmp        ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., result

fcmpl       ..., v1, v2 => ..., result

dcmpl       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., result

fcmpg       ..., v1, v2 => ..., result

dcmpg       ..., v1-word1, v1-word2, v2-word1, v2-word2 => ..., result

v1 und v2 müssen long, float oder double sein. Sie werden beide vom Stack ausgeworfen und verglichen. Ist v1 größer als v2, wird der ganzzahlige Wert 1 in den Stack zurückgeschoben. Ist v1 gleich v2, wird 0 in den Stack geschoben. Ist v1 kleiner als v2, wird -1 in den Stack geschoben. Ist im Fall von Gleitpunktzahlen entweder v1 oder v2 NaN, wird -1 für das erste und +1 für das zweite Bytecode-Paar in den Stack geschoben.

if_acmpeq   ..., value1, value2 => ...

if_acmpne   ..., value1, value2 => ...

Verzweige, falls Objektreferenzen gleich/nicht gleich sind. value1 und value2 müssen Referenzen auf Objekte sein. Sie werden vom Stack ausgeworfen. Ist value1 gleich/nicht gleich value2, werden byte1 und byte2 benutzt, um einen 16-Bit-Versatz mit Vorzeichen zu bilden. Die Ausführung läuft von pc bis zu diesem Versatz. Andernfalls läuft die Ausführung bis zum folgenden Bytecode.

goto        -no change-

goto_w      -no change-

Verzweige immer. byte1 und byte2 (sowie byte3 und byte4 bei goto_w) werden benutzt, um einen 16-(32)-Bit-Versatz zu bilden. Die Ausführung läuft von pc bis zu diesem Versatz.

jsr         ... => ..., return-address

jsr-w       ... => ..., return-address

Überspringe Subroutine. Die Adresse des unmittelbar nach jsr folgenden Bytecodes wird in den Stack geschoben. byte1 und byte2 (sowie byte3 und byte4 bei goto_w) werden benutzt, um einen 16(32)-Bit-Versatz zu bilden. Die Ausführung läuft von pc bis zu diesem Versatz.

ret         -no change-

ret2_w      -no change-

Gebe von Subroutine zurück. Die lokale Variable byte1 (und byte2 bei ret_w werden zu einem 16-Bit-Index zusammengesetzt) im aktuellen Java-Rahmen muß eine Rückgabeadresse enthalten. Der Inhalt dieser lokalen Variablen wird in pc geschrieben.

jsr schiebt die Adresse in den Stack und ret holt sie aus einer lokalen Variablen. Diese Asymmetrie ist Absicht. Die Bytecodes jsr und ret werden in der Implementierung des Schlüsselworts finally von Java benutzt.

Zurückgeben von Methoden

return     ... => [empty]

Gebe (void) von Methode aus. Alle Werte im Operandenstack werden verworfen. Der Interpreter gibt dann die Kontrolle an den Rufenden ab.

ireturn     ..., value => [empty]

lreturn     ..., value-word1, value-word2 => [empty]

freturn     ..., value => [empty]

dreturn     ..., value-word1, value-word2 => [empty]

areturn     ..., value => [empty]

Gebe <type> von Methode aus. value muß ein <type> sein. Der Wert wird in den Stack der vorherigen Ausführungsumgebung geschoben. Eventuelle andere Werte im Operandenstack werden verworfen. Der Interpreter gibt dann die Kontrolle an den Rufenden ab. <type> ist nacheinander int, long, float, double und Objektreferenz.

Wer ein Stackverhalten der »Return«-Bytecodes wie im C-Stack erwartet, ist wahrscheinlich verwirrt. Javas Operandenstack besteht aus einer Reihe unzusammenhängender Segmente, die jeweils einem Methodenaufruf entsprechen. Durch ein Return-Bytecode wird das Segment im Java-Operandenstack, das dem Rahmen des zurückgebenden Aufrufs entspricht, geleert, jedoch wirkt sich das nicht auf Elternaufrufe aus.

Tabellen-Jumping

tableswitch     ..., index => ...

tableswitch ist ein Bytecode mit variabler Länge. Unmittelbar nach dem tableswitch-Opcode werden Null bis drei 0-Bytes zum Auffüllen eingefügt, so daß das nächste Byte an der Adresse beginnt, die ein Vielfaches von Vier ist. Nach dem Auffüllen bestehen 4-Byte-Mengen mit Vorzeichen: default -offset, low, high und (high - low + 1) weitere 4-Byte-Versätze mit Vorzeichen. Diese Versätze werden als 0-basierte Sprungtabelle behandelt.

index muß eine Ganzzahl sein. Ist index kleiner als low oder größer als high, wird default -offset in pc eingefügt. Andernfalls wird das (index - low)-Element der Sprungtabelle herausgezogen und in pc eingefügt.

lookupswitch     ..., key => ...

lookupswitch ist ein Bytecode mit variabler Länge. Unmittelbar nach dem lookupswitch-Opcode werden Null bis drei 0-Bytes zum Auffüllen eingefügt, so daß das nächste Byte an der Adresse beginnt, die ein Vielfaches von Vier ist. Direkt nach dem Auffüllen besteht eine Reihe von 4-Byte-Paaren mit Vorzeichen. Das erste Paar ist speziell - es enthält default -offset und die Zahl der folgenden Paare. Jedes nachfolgende Paar besteht aus match und offset.

key muß im Stack eine Ganzzahl sein. Dieser Schlüssel wird mit allen match-Vorkommen verglichen. Entspricht er einem davon, wird der entsprechende offset in pc eingefügt. Stimmt key mit keinem match überein, wird default -offset in pc eingefügt.

Manipulieren von Objektfeldern

putfield     ..., handle, value => ...

putfield     ..., handle, value -word1, value word2 =>

Setze das Feld in ein Objekt. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Konstantenpool ist eine Feldreferenz auf einen Klassen- und einen Feldnamen. Das Element wird für einen Feldblock-Pointer ermittelt, der die Feldbreite und den Versatz (beides in Bytes) enthält.

Das Feld an diesem Versatz vom Anfang der Instanz, auf die handle zeigt, wird oben im Stack auf value gesetzt. Das erste Stackbild ist für 32-Bit- und das zweite für 64-Bit-Felder. Dieser Bytecode arbeitet beides ab. Ist handle Null, wird NullPointerException ausgeworfen. Ist das spezifizierte Feld static, wird IncompatibleClassChangeError ausgeworfen.

getfield     ..., handle, value => ...

getfield     ..., handle, value -word1, value word2 =>

Hole das Feld von einem Objekt. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Konstantenpool ist eine Feldreferenz auf einen Klassen- und einen Feldnamen. Das Element wird für einen Feldblock-Pointer ermittelt, der die Feldbreite und den Versatz (beides in Bytes) enthält.

handle muß eine Referenz auf ein Objekt sein. Der Wert am Versatz in dem Objekt, auf das handle zeigt, ersetzt handle oben im Stack. Das erste Stackbild ist für 32-Bit-, das zweite für 64-Bit-Felder. Dieser Bytecode arbeitet beides ab. Ist das spezifizierte Feld static, wird IncompatibleClassChangeError ausgeworfen.

putstatic     ..., handle, value => ...

putstatic     ..., handle, value -word1, value word2 =>

Setze statisches Feld in einer Klasse. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element des Konstantenpools ist eine Feldreferenz auf ein statisches Feld einer Klasse. Dieses Feld wird auf value oben im Stack gesetzt. Das erste Stackbild ist für 32-Bit-, das zweite für 64-Bit-Felder. Dieser Bytecode verarbeitet beides. Ist das spezifizierte Feld nicht static, wird IncompatibleClassChangeError ausgeworfen.

getstatic     ..., handle, value => ...

getstatic     ..., handle, value -word1, value word2 =>

Hole statisches Feld aus einer Klasse. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element des Konstantenpools ist eine Feldreferenz auf ein statisches Feld einer Klasse. Dieses Feld wird auf value oben im Stack gesetzt. Das erste Stackbild ist für 32-Bit-, das zweite für 64-Bit-Felder. Dieser Bytecode verarbeitet beides. Ist das spezifizierte Feld nicht static, wird IncompatibleClassChangeError ausgeworfen.

Aktivieren von Methoden

invokevirtual     ..., handle [arg1, arg2, ...]], ... => ...

Aktiviere Instanzmethode auf der Grundlage der Laufzeit. Der Operandenstack muß eine Referenz auf ein Objekt und einige Argumente enthalten. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index des Konstantenpools enthält die komplette Methodenunterschrift. Ein Pointer auf die Methodentabelle des Objekts wird von reference des Objekts abgerufen. Die Methodenunterschrift wird aus der Methodentabelle geholt. Sie entspricht garantiert einer der in der Tabelle stehenden Methodenunterschriften.

Das Ergebnis des Nachschlagens in der Methodentabelle ist ein Index der benannten Klasse, die benutzt wird, um die Methodentabelle des Objekt-Laufzeittyps zu holen, wobei ein Pointer auf den Methodenblock für die passende Methode gefunden wird. Der Methodenblock zeigt den Methodentyp (native, synchronized usw.) und die Zahl der Argumente (nargs) an.

Ist die Methode mit synchronized gekennzeichnet, wird der mit handle verbundene Überwacher aktiviert.

Die Basis des lokalen Variablen-Arrays für den neuen Java-Stackrahmen wird so gesetzt, daß sie im Stack auf handle zeigt, so daß handle und die bereitgestellten Argumente (arg1, arg2, ...) die ersten lokalen nargs-Variablen des neuen Rahmens sind. Die Gesamtzahl der von der Methode benutzten lokalen Variablen wird ermittelt. Die Ausführungsumgebung des neuen Rahmens wird verschoben, nachdem ausreichend Platz für die Lokalen geschaffen wurde. Die Basis des Operandenstacks für diese Methodenaktivierung wird auf das erste Wort nach der Ausführungsumgebung gesetzt. Schließlich fährt die Ausführung mit dem ersten Bytecode der passenden Methode fort.

Ist handle Null, wird NullPointerException ausgeworfen. Wird während der Methodenaktivierung ein Stacküberlauf erkannt, wird StackOverflowError ausgeworfen.

invokenonvirtual     ..., handle, [arg1, arg2, ...]] ... => ...

Aktiviere die Instanzmethode auf der Grundlage des Kompilierzeittyps. Der Operandenstack muß eine Referenz (handle) auf ein Objekt und eine Reihe von Argumenten enthalten. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element in diesem Index des Konstantenpools enthält die vollständige Methodenunterschrift und Klasse. Die Methodenunterschrift wird aus der Methodentabelle der angegebenen Klasse geholt. Sie entspricht garantiert einer der in der Tabelle stehenden Methodenunterschriften.

Das Ergebnis der Tabellensuche ist ein Methodenblock. Der Methodenblock bezeichnet den Methodentyp (native, synchronized usw.) und die Zahl der Argumente (nargs). (Die letzten drei Absätze sind mit dem vorherigen Bytecode identisch.)

invokestatic     ..., , [arg1, arg2, ...]] ... => ...

Aktiviere die (static) Klassenmethode. Der Operandenstack muß eine Reihe von Argumenten enthalten. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index des Konstantenpools enthält die vollständige Methodenunterschrift und Klasse. Die Methodenunterschrift wird aus der Methodentabelle der angegebenen Klasse geholt. Sie entspricht garantiert einer der in der Methodentabelle befindlichen Methodenunterschriften.

Das Ergebnis der Tabellensuche ist ein Methodenblock. Der Methodenblock bezeichnet den Methodentyp (native, synchronized usw.) und die Zahl der Argumente (nargs).

Ist die Methode mit synchronized gekennzeichnet, wird der mit der Klasse verbundene Überwacher gestartet. (Die letzten zwei Absätze sind mit denen in invokevirtual identisch, außer daß NullPointerException ausgeworfen werden kann.)

invokeinterface    ..., handle, [arg1, arg2, ...] => ...

Aktiviere die Schnittstellenmethode. Der Operandenstack muß eine Referenz (handle) auf ein Objekt und eine Reihe von Argumenten enthalten. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index des Konstantenpools enthält die vollständige Methodenunterschrift. Ein Pointer auf die Methodentabelle des Objekts wird von der Objektreferenz abgerufen. Die Methodenunterschrift wird aus der Methodentabelle geholt. Sie entspricht garantiert einer der in der Methodentabelle befindlichen Methodenunterschriften.

Das Ergebnis der Tabellensuche ist ein Methodenblock. Der Methodenblock bezeichnet den Methodentyp (native, synchronized usw.). Im Gegensatz zu den anderen invoke-Bytecodes wird jedoch die Zahl der verfügbaren Argumente (nargs) aus byte3 entnommen. byte4 ist für die künftige Verwendung reserviert. (Die letzten drei Absätze sind identisch mit denen in invokevirtual.)

Handhabung von Ausnahmen

athrow     ..., handle => [undefined]

Werfe die Ausnahme aus. handle muß ein Zeiger auf ein Ausnahmeobjekt sein. Diese Ausnahme, die eine Subklasse von Throwable sein muß, wird ausgeworfen. Der aktuelle Java-Stackrahmen wird nach der letzten catch-Klausel durchsucht, die die Ausnahme abarbeitet. Wird ein passender catch-Eintrag gefunden, wird pc auf die vom catch-Pointer bezeichnete Adresse zurückgesetzt und die Ausführung wird von da fortgesetzt.

Wird im aktuellen Stackrahmen keine entsprechende catch-Klausel gefunden, wird dieser Rahmen ausgeworfen und die Ausnahme wird erneut ausgegeben, wobei der Prozeß im Elternrahmen von neuem beginnt. Ist handle Null, wird NullPointerException ausgeworfen.

Verschiedene Objektoperationen

new     ... => ..., handle

Erstelle ein neues Objekt. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index sollte ein Klassenname sein, der in einen Klassenzeiger umgewandelt werden kann. Eine neue Instanz dieser Klasse wird erstellt und eine Referenz (handle) für die Instanz wird oben in den Stack gestellt.

checkcast     ..., handle => ..., [handle ...]

Prüfe den Typ einesObjekts. handle muß eine Referenz auf ein Objekt sein. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Die Zeichenkette in diesem Index des Konstantenpools muß ein Klassenname sein, der als Klassenzeiger verwendet werden kann.

checkcast ermittelt, ob handle in eine Referenz auf ein Objekt der betreffenden Klasse umgewandelt werden kann. (Eine Null-handle kann in jede Klasse konvertiert werden.) Kann handle gültig umgewandelt werden, fährt die Ausführung ab dem nächsten Bytecode fort und die Referenz für handle bleibt im Stack. Andernfalls wird ClassCastException ausgeworfen.

instanceof    ..., handle => ..., result

Ermittle den Typ eines Objekts. handle muß eine Referenz auf ein Objekt sein. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Die Zeichenkette in diesem Index des Konstantenpools muß ein Klassenname sein, der als Klassenzeiger verwendet werden kann.

Ist handle Null, ist das Ergebnis false. Andernfalls ermittelt instanceof, ob handle in eine Referenz auf ein Objekt der betreffenden Klasse umgewandelt werden kann. Das Ergebnis ist 1 (true) bzw. im negativen Fall 0 (false).

Überwachung

monitorenter     ..., handle => ...

Beginne Überwachung des Codebereichs. handle muß eine Referenz auf ein Objekt sein. Der Interpreter versucht über einen Sperrmechanismus, ausschließlichen Zugriff auf handle zu erhalten. Hat ein anderer Thread bereits das gleiche handle gesperrt, wartet der aktuelle Thread, bis handle gelöst wird. Hat der aktuelle Thread bereits handle gesperrt, fährt die Ausführung normal fort. Andernfalls erhält dieser Bytecode eine exklusive Sperre.

monitorexit     ..., handle => ...

Beende Überwachung des Codebereichs. handle muß eine Referenz auf ein Objekt sein. Die Sperre auf handle wird gelöst. Ist das die letzte Sperre dieses Threads auf handle (ein Thread kann ein handle mehrmals sperren), dürfen andere Threads, die auf handle warten, fortfahren. (Eine Null in einem Bytecode führt zur Ausgabe von NullPointerException.)

Debugging

breakpoint     -no change-

Rufe Breakpoint-Handler. Der Breakpoint-Bytecode wird zum Überschreiben eines Bytecodes benutzt, um die Kontrolle vorübergehend an den Debugger abzugeben, bevor der überschriebene Bytecode wirksam wird. Die Operanden (falls vorhanden) des Bytecodes werden nicht überschrieben. Der ursprüngliche Bytecode wird nach dem Entfernen des Breakpoints wieder hergestellt.

Die _quick-Bytecodes

Nachfolgend ein Auszug aus der Dokumentation der virtuellen Java-Maschine als Beispiel dafür, wie ein Bytecode-Interpreter beschleunigt werden kann:

Die Pseudo-Bytecodes mit Suffix _quick sind Varianten von Java-Standard-Bytecodes. Sie werden zur Laufzeit benutzt, um die Ausführungsgeschwindigkeit des Bytecode-Interpreters zu verbessern. Sie sind kein offizieller Bestandteil der virtuellen Maschinenspezifikation und außerhalb der Implementierung der virtuellen Java-Maschine nicht sichtbar. Innerhalb der Implementierung ermöglichen sie jedoch eine wirksame Optimierung.

javac erzeugt nur Bytecodes ohne _quick. Alle Bytecodes mit einer _quick-Variante verweisen auf den Konstantenpool. Wird die _quick-Optimierung eingeschaltet, ermitteln alle Bytecodes ohne _quick (mit einer _quick-Variante) das angegebene Element im Konstantenpool, signalisieren im Bedarfsfall einen in der Variante festgestellten Fehler, schalten sich in die _quick-Variante und führen dann die beabsichtigte Operation aus.

Das ist identisch mit den Aktionen von Bytecodes ohne _quick, mit Ausnahme des Schritts zum eigenen Überschreiben mit der _quick-Variante. Die _quick-Variante eines Bytecodes geht davon aus, daß das Element im Konstantenpool bereits ermittelt wurde und daß dadurch keine Fehler produziert wurden. Sie führt die beabsichtigte Operation auf dem ermittelten Element aus.

Das bedeutet, daß Ihre Bytecodes während der Interpretation automatisch laufend beschleunigt werden. Nachfolgend eine Aufstellung aller _quick-Varianten der derzeitigen Java-Laufzeitversion:

ldc1_quick

ldc2_quick

ldc2w_quick

anewarray_quick

multinewarray_quick

putfield_quick

putfield2_quick

getfield_quick

getfield2_quick

putstatic_quick

putstatic2_quick

getstatic_quick

getstatic2_quick



invokevirtual_quick

invokevirtualobject_quick

invokenonvirtual_quick

invokestatic_quick

invokeinterface_quick



new_quick

checkcast_quick

instanceof_quick

Sie können in früheren Abschnitten der heutigen Lektion nachschlagen, was diese Codes bewirken. Der Name des Original-Bytecodes ist mit Ausnahme von _quick identisch. Die Bytecodes putstatic, getstatic, putfield und getfield haben zwei _quick-Varianten, je eine für jedes Stackbild in ihren ursprünglichen Beschreibungen. invokevirtual hat zwei Varianten: eine für Objekte und eine für Arrays, um javala.Object schnell zu durchsuchen.

Beim Einlesen einer Klasse wird das Array constant_pool[] in der Größe nconstants erstellt und einem Feld der Klasse zugewiesen. constant_pool[0] wird so gesetzt, daß es auf ein dynamisch zugeteiltes Array zeigt, das die in constant_pool bereits ermittelten Felder bezeichnet. constant_pool[1] bis constant_pool[nconstants - 1] zeigen auf das dem Konstantenelement entsprechende Typenfeld. Wird ein Bytecode ausgeführt, der auf den Konstantenpool verweist, wird ein Index erzeugt und constant_pool[0] wird geprüft, ob der Index bereits ermittelt wurde. Falls ja, wird der Wert von constant_pool[index] ausgegeben. Andernfalls wird der Wert von constant_pool[index] als aktueller Pointer ermittelt und der in constant_pool[index] befindliche Wert wird überschrieben.

Das .class-Dateiformat

Ich führe hier nicht das gesamte .class-Dateiformat auf, sondern möchte nur einen Gesamteindruck vermitteln. (Sie können alles darüber in der Release-Dokumentation nachlesen.) Ich erwähne dieses Dateiformat hier, weil es einer der Bestandteile von Java ist, der sorgfältig spezifiziert werden muß, um die Kompatibilität aller Java-Implementierungen sicherzustellen.

Der Inhalt dieses Abschnitts stammt aus der Dokumentation von .class im neuesten Alpha-Release.

.class-Dateien werden benutzt, um die kompilierten Versionen von Java-Klassen und Java-Schnittstellen zu speichern. Kompatible Java-Interpreter müssen in der Lage sein, alle .class-Dateien, die der folgenden Spezifikation entsprechen, abzuarbeiten.

Eine .class-Datei besteht aus einem Strom von 8-Bit-Bytes. Alle 16- und 32-Bit-Mengen werden gebildet, indem zwei bzw. vier 8-Bit-Bytes eingelesen werden. (Zum Lesen und Schreiben von Klassendateien wird java.io.DataInput bzw. java.io.DataOutput benutzt.)

Das Klassendateiformat ist weiter unten als Reihe von C-ähnlichen Strukturen aufgeführt. Im Gegensatz zu C-struct gibt es hier aber kein Auffüllen oder Ausrichten zwischen den Teilen der Struktur. Jedes Feld der Struktur und jeder Array kann eine variable Größe haben. (Im Fall eines Arrays bestimmen einige Felder vor dem Array dessen Größe.) Die Typen u1, u2 und u4 stellen eine vorzeichenlose Ein-, Zwei- bzw. Vier-Byte-Menge dar.

Im .class-Format werden Attribute an verschiedenen Stellen verwendet. Alle Attribute haben folgendes Format:

GenericAttribute_info {

    u2 attribute_name;

    u4 attribute_length;

    u1 info[attribute_length];

}

attribute_name ist ein 16-Bit-Index des Konstantenpools der Klasse. Der Wert von constant_pool[attribute_name] ist eine Zeichenkette, die den Namen des Attributes enthält. Das Feld attribute_length bezeichnet die Länge der nachfolgenden Informationen in Bytes. Diese Länge beinhaltet nicht die vier Bytes, die zum Speichern von attribute_name und attribute_length benötigt werden. Im folgenden Text führe ich die Namen aller Attribute, die derzeit interpretiert werden können, auf, soweit ein Attribut nötig ist. Beim Lesen der Klassendateien werden die Informationen in Attributen, die nicht interpretieren können, übersprungen bzw. ignoriert.

Folgende Pseudostruktur ist eine Beschreibung des Formats einer Klassendatei auf der obersten Ebene:

ClassFile {

   u4  magic;

   u2  minor_version

   u2  major_version

   u2  constant_pool_count;

   cp_info         constant_pool[constant_pool_count - 1];

   u2  access_flags;

   u2  this_class;

   u2  super_class;

   u2  interfaces_count;

   u2  interfaces[interfaces_count];

   u2  fields_count;

   field_info      fields[fields_count];

   u2  methods_count;

   method_info     methods[methods_count];

   u2  attributes_count;

   attribute_info  attributes[attribute_count];

}

Hier wird eine der kleineren Strukturen benutzt:

method_info {

   u2  access_flags;

   u2  name_index;

   u2  signature_index;

   u2  attributes_count;

   attribute_info  attributes[attribute_count];

}

Hier ein Beispiel einer der letzten Strukturen in der .class-Dateibeschreibung:

Code_attribute {

   u2  attribute_name_index;

   u2  attribute_length;

   u1  max_stack;

   u1  max_locals;

   u2  code_length;

   u1  code[code_length];

   u2  exception_table_length;

   {  u2_   start_pc;

      u2_   end_pc;

      u2_   handler_pc;

      u2_   catch_type;

   }  exception_table[exception_table_length];

   u2  attributes_count;

   attribute_info  attributes[attribute_count];

}

Diese Beschreibung stellt keinen Anspruch auf Vollständigkeit, sondern soll Ihnen lediglich einen Eindruck der Strukturen von .class-Dateien vermitteln. Da der Compiler und Laufzeitquellen verfügbar sind, können Sie ohne umfassende Kenntnis der zugrundeliegenden Einzelheiten jederzeit .class-Dateien selbst verwenden.

Methodenunterschriften

Da Methodenunterschriften in .class-Dateien verwendet werden, ist das der richtige Zeitpunkt, sie ausführlich zu beschreiben. Erwähnt wurden Sie bereits in der gestrigen Lektion im Zusammenhang mit native-Methoden.

Eine Unterschrift ist eine Zeichenkette, die den Typ einer Methode, eines Feldes oder eines Arrays darstellt.

Eine Feldunterschrift steht für den Wert eines Methodenarguments oder den Wert einer Variablen und ist eine Reihe von Bytes in folgender Syntax:

<field signature> := <field_type>

   <field type>      := <base_type> | <object_type> | <array_type>

   <base_type>       := B | C | D | F | I | J | S | Z

   <object_type>     := L <full.ClassName> ;

   <array_type>      := [ <optional_size> <field_type>

   <optional_size>   := [0-9]*

Die Grundtypen haben folgende Bedeutung: B (byte), C (char), D (double), F (float), I (int), J (long), S (short) und Z (boolean).

Eine Unterschrift für einen Rückgabetyp stellt den Rückgabewert aus einer Methode dar und besteht aus einer Reihe von Bytes in folgender Syntax:

   <return signature>    := <field type>   V

Das Zeichen V (void) bedeutet, daß die Methode keinen Wert ausgibt. Andernfalls bezeichnet die Unterschrift den Typ des Rückgabewerts. Eine Argumentenunterschrift stellt ein Argument dar, das einer Methode übergeben wird:

   <argument signature>     := <field type>

Eine Methodenunterschrift stellt die Argumente dar, die die Methode erwartet, und den Wert, den sie ausgibt:

<method_signature>    := (<arguments signature>) <return signature>

   <arguments signature> := <argument signature>*

Wir wollen nun die neuen Regeln ausprobieren: Eine Methode namens complexMethod() in der Klasse my.package.name.ComplexClass hat drei Argumente -long, boolean und ein zweidimensionales short-Array - und gibt this aus. In diesem Fall lautet die Methodenunterschrift (JZ[[S)Lmy.package.name.ComplexClass.

Einer Methodenunterschrift wird meist der Name der Methode oder des Pakets (mit Unterstrichen anstelle von Punkten) und der Klassenname, gefolgt von einem Schrägstrich (/) sowie der Methodenname vorangestellt. Das ist die komplette Methodenunterschrift. (Sie haben mehrere davon gestern im Zusammenhang mit Stubs gesehen.) Die komplette Methodenunterschrift der Methode complexMethod() ist somit:

my_package_name_ComplexClass/complexMethod(JZ[[S)Lmy.package.name.ComplexClass;

Der Garbage-Collector

Vor Jahrzehnten haben Programmierer in der Lisp- und Smalltalk-Gemeinde realisiert, wie hilfreich es ist, die Freigabe zugewiesenen Speichers ignorieren zu können. Sie haben erkannt, daß die Zuweisung zwar eine grundlegende Notwendigkeit ist, die Freigabe desselben aber lediglich aufgrund der Faulheit des Systems dem Programmierer auferlegt wird. Sie haben erkannt, daß das System diese Aufgabe übernehmen sollte. In relativer Abgeschiedenheit entwickelten also ein paar Programmierer eine Reihe von Reinigungsprozeduren für diese Aufgabe und gestalteten ihre Pionierarbeit im Laufe der Jahre immer effizienter. Inzwischen ist sich die gesamte Programmierwelt über den Wert dieser automatisierten Technik einig. Java könnte sich zu einer der ersten weit verbreiteten Anwendungen dieser Technologie entwickeln.

Das Problem

Stellen Sie sich einmal vor, Sie sind Programmierer in einer C-ähnlichen Sprache (was nicht schwierig sein dürfte, da diese Sprachen heute vorherrschend sind). Jedesmal, wenn Sie etwas in einer solchen Sprache dynamisch erstellen, sind Sie für das Verfolgen des Objekts in Ihrem Programm während seiner gesamten Lebensdauer verantwortlich und Sie entscheiden, wann der sichere Zeitpunkt gekommen ist, es zu entfernen. Das kann eine schwierige (und zuweilen unmögliche) Aufgabe sein, weil sich eventuell in anderen Bibliotheken oder Methoden ein Pointer auf das Objekt befindet, was irgendwann kaum mehr nachvollziehbar ist. In diesem Fall entschließt man sich in der Regel, das Objekt überhaupt nie zu entfernen oder zumindest zu warten, bis jede Bibliothek und Methode, die das Objekt eventuell nutzt, ihrerseits zu einem Ende kommt.

Das ungute Gefühl beim Schreiben eines solchen Codes ist ganz natürlich - eine gesunde Reaktion auf einen unsicheren oder unzuverlässigen Aspekt des Programms. Ebenso natürlich ist, daß man sich dabei der Frage nicht erwehren kann, warum der Programmierer dafür sorgen soll.

Kürzlich durchgeführte Studien über Softwareentwicklung haben gezeigt, daß auf alle 55 Zeilen eines C-artigen Codes ein Fehler kommt. Das bedeutet im Vergleich, daß ein Rasierapparat etwa 80 und ein Fernseher 400 Fehler aufweist. Angesichts der zunehmenden Exponentialität von Computersoftware dürfte dieser Fehleranteil noch steigen.

Viele dieser Fehler sind auf die falsche Verwendung von Pointern und die frühzeitige Freigabe von im Speicher zugewiesenen Objekten zurückzuführen. Java greift beide Probleme auf. Erstens werden explizite Pointer in der Java-Sprache überhaupt nicht benutzt und zweitens gibt es in jedem Java-System einen Papierkorb, der Speicherzuweisungen freigibt.

Die Lösung

Nun stellen Sie sich ein Laufzeitsystem vor, das jedes von Ihnen erstellte Objekt verfolgt, feststellt, wann die letzte Referenz darauf verschwunden ist und das Objekt freigibt. Zu schön, um wahr zu sein? Und wie sollte so etwas funktionieren?

Bei einem relativ grobschlächtigen Ansatz, der in den Anfängen dieser Reinigungstechnik angewandt wurde, wird ein Referenzzähler an jedes Objekt angehängt. Beim Erstellen des Objekts wird der Zähler auf 1 gesetzt. Jedesmal, wenn eine Referenz auf das Objekt erfolgt, wird der Zähler erhöht, und jedesmal, wenn eine solche Referenz verschwindet, wird er gesenkt. Da alle derartigen Referenzen von der Sprache kontrolliert werden, kann der Compiler erkennen, wenn ein Objektreferenz erstellt oder vernichtet wird, und somit bei dieser Aufgabe hilfreich mitwirken. Das System behält eine Reihe von Wurzelobjekten, die als zu wichtig gelten, um beseitigt zu werden. Ein Beispiel solcher wichtigen Objekte ist die Klasse Object. Man muß also lediglich nach jeder Senkung testen, ob der Zähler 0 erreicht hat. In diesem Fall wird das Objekt beseitigt.

Bei diesem Ansatz kann die Freigabe von Objekten eigentlich nur korrekt erfolgen. Er ist so einfach, daß man sofort erkennen kann, ob und wie es funktioniert. Andererseits kommt eventuell der Verdacht auf, daß das nicht schnell genug ist, weil es ja so einfach ist. Falls Sie diesen Verdacht haben, liegen Sie richtig.

Denken Sie einmal an all die Stackrahmen, lokalen Variablen, Methodenargumente, Rückgabewerte und alles, was im Laufe eines Programmdaseins erzeugt wird. Bei diesen winzigen Nanoschritten in Ihrem Programm verlangsamt sich durch den Reinigungszähler die Programmausführung. Die ersten Versionen dieser Technik waren so langsam, daß man sie nie verwendet hat!

Zum Glück haben sich einige Leute verschiedene Tricks einfallen lassen, um diese Overhead-Probleme zu lösen. Ein Trick ist die Einführung spezieller »Übergangsbereiche« für Objekte, deren Referenzen nicht erfaßt werden müssen. In der optimalen Ausführung dieser Technik werden weniger als 3% der Gesamtzeit eines Programms beansprucht - ein bemerkenswertes Ergebnis.

Die Reinigung von Programmen wirft aber noch andere Probleme auf. Gibt man laufend Platz frei und beansprucht ihn wieder für etwas anderes im Programm, entstehen bald überall unzählige Fragmente und kleine Löcher, so daß für größere Objekte kein Platz frei ist. Da der Programmierer von der Last der manuellen Speicherfreigabe befreit ist, werden eventuell mehr Objekte als nötig erstellt. Die Verführung ist zumindest groß.

Ein anderer Aspekt ist die Ineffizienz in bezug auf Platz, nicht so sehr in bezug auf Zeit. Schließt sich der Kreis einer langen Kette von Objektreferenzen am Ausgangsobjekt, bleibt der Referenzzähler für ewig auf 1. Keines dieser Objekt wird jemals entfernt!

Das bedeutet, daß eine gute Reinigungsprozedur sporadisch im Speicher angesammelten Müll beseitigen muß.

Compaction ist ein Vorgang, bei dem ein Papierkorb zurückschreitet und den Speicher reorganisiert, indem die durch Fragmentierung entstandenen Löcher beseitigt werden. Bei diesem Prozeß werden die Objekte in einer neuen kompakten Gruppierung umpositioniert, so daß alle in einer Reihe angeordnet werden und die noch freien Bereiche im Speicher an einem Stück sind.
Die Beseitigung der nach der Referenzzählung noch übrigen Reste nennt man Marking und Sweeping. Bei diesem Verfahren werden zuerst alle Wurzelobjekte im System markiert. Dann werden die Objektreferenzen in diesen Objekten markiert usw. Sind keine Referenzen mehr zum Zurückverfolgen vorhanden, werden alle nicht markierten Objekte »weggefegt«, und das Ergebnis ist ein kompakter Speicher wie oben.

Der Vorteil dieser Verfahren ist die Beseitigung von Platzproblemen. Die Nachteile sind, daß die Reinigungsprozedur beim Zurückschreiten eine relativ lange Zeit benötigt, in der das Programm stillsteht, während alle Objekte markiert, beseitigt und umgestellt werden.

Andererseits kann die Müllbeseitigung stückchenweise ausgeführt werden, d. h. gleichlaufend mit der normalen Programmausführung. In den Algorithmen, die das ermöglichen, stecken Jahre anstrengender Arbeit.

Ein kleines Problem liegt noch im Zusammenhang mit Objektreferenzen vor. Verteilen sich diese »Pointer« nicht überall im Programm, nicht nur in Objekten? Und auch wenn sie nur in Objekten vorhanden sind, müssen sie nicht geändert werden, wenn das Objekt, auf das sie zeigen, entfernt oder verschoben wird? Die Antwort darauf ist ein klares Ja, und die Überwindung dieser letzten Probleme ist das, was eine wirklich effiziente Reinigungsprozedur auszeichnet.

Im Grunde gibt es nur zwei Alternativen. Bei der ersten wird der gesamte Speicher, in dem sich Objektreferenzen befinden, regelmäßig durchsucht. Werden Objektreferenzen aufgefunden, deren Objekte entfernt oder verschoben wurden, wird die alte Referenz geändert. Das betrifft aber nur die Pointer, die direkt auf Objekte zeigen. Durch Einführung verschiedener Arten von »Soft-Pointern« kann der Algorithmus stark verbessert werden. Erfahrungen haben gezeigt, daß dieser Ansatz auf modernen Rechnern ausreichend schnell ist.

Sie fragen sich vielleicht, wie mit dieser Technik Objektreferenzen identifiziert werden können. In den frühen Systemen wurden Referenzen mit einem »Pointer-Bit« speziell gekennzeichnet, so daß sie eindeutig aufgefunden werden konnten. Der moderne sogenannte »Garbage-Collector« geht einfach davon aus, daß alles, was danach aussieht, auch eine Objektreferenz ist, zumindest zum Zweck des Marking- und Sweeping-Vorgangs. Später beim Aktualisieren stellt er dann fest, was wirklich eine Objektreferenz ist.

Die zweite Alternative zur Handhabung von Objektreferenzen, die in Java derzeit angewandt wird, besteht zu hundert Prozent aus »Soft-Pointern«. Eine Objektreferenz ist im Grunde ein Handle, auch »OOP« genannt, der den wirklichen Pointer bezeichnet. Zur Abbildung dieser Handles in tatsächliche Objektreferenzen gibt es eine umfassende Objekttabelle. Dadurch wird zwar jede Objektreferenz mit zusätzlichem Overhead belastet (der teilweise durch Tricks wieder ausgeglichen werden kann, wie Sie sich vorstellen können), fordert aber keinen zu hohen Preis für diese unglaublich nützliche Vorgehensweise.

Die Reinigungsprozedur kann dabei z. B. je ein Objekt markieren, entfernen, verschieben oder prüfen. Jedes Objekt kann unabhängig aus dem laufenden Java-Programm herausgeschoben werden, indem lediglich die Einträge in der Objekttabelle geändert werden. Dadurch verläuft nicht nur die »Rückschrittphase« in winzigen Schrittchen, sondern die Reinigung kann während der Ausführung des Programms erfolgen. Das ist die Aufgabe des Garbage-Collectors in Java.

Sie müssen im Fall von kritischen Echtzeitprogrammen (die rechtmäßig native-Methoden beanspruchen, wie Sie gestern gelernt haben) bei der Reinigung sehr sorgfältig vorgehen. Andererseits: Wie oft schreibt man schon einen Java-Code zum Steuern eines Privatflugzeugs in Echtzeit?

Javas paralleler Papierkorb

Java wendet diese ausgefeilten Techniken an und bietet damit einen schnellen, effizienten und parallel ausführbaren »Garbage-Collector«. In einem separaten Thread reinigt er die gesamte Java-Umgebung (er ist sehr konservativ) leise im Hintergrund. Er ist sowohl in bezug auf Platz als auch Zeit äußerst effizient und schreitet stets nur winzige Zeiteinheiten zurück. Man merkt nicht, daß er überhaupt da ist.

Sie können die volle Marking- und Sweeping-Prozedur übrigens ausführen, indem Sie die Methode System.gc() aufrufen.

Die Sache mit der Sicherheit

Jede leistungsstarke flexible Technologie kann mißbraucht werden. Je stärker sich das Internet ausbreitet, um so mehr steigt das Potential von Mißbräuchen. Schon heute machen sich viele Leute Gedanken über die (mangelhafte) Sicherheit des Internets. Von allen Seiten sind Warnungen zu vernehmen, daß die Computerindustrie bzw. die Anbieter zu wenig tun, um das Internet sicherer zu machen.

Vorläufig - so scheint es - muß man sich um die Sicherheitsaspekte selbst kümmern. Java bietet dem Programmierer zum Glück viele Möglichkeiten, seine Programme (und die Systeme seiner Benutzer) sicher auszulegen.

Ein einfacher Grund, warum die Java-Sprache und -Umgebung als relativ sicher gilt, liegt in die Geschichte der Leute und des Unternehmens, die Java entwickelt haben. Zum Java-Team zählen viele erfahrene Programmierer, die auch maßgeblich an der Entwicklung ausgefeilter Techniken beteiligt waren. Seit meinem Gespräch mit Chuck McManis, einem der Sicherheitsgurus des Java-Teams, bin ich zuversichtlich, daß diese Fragen erst genommen und entsprechend behandelt werden.

Bei Sun Microsystems sind Netze seit über einem Jahrzehnt das zentrale Thema der gesamten Software des Unternehmens. Sun hat die Fachleute und das nötige Engagement, um Probleme dieser Art zu lösen. Vor diesem Hintergrund beschreibe ich das Java-Sicherheitsmodell im nächsten Abschnitt etwas ausführlicher.

Javas Sicherheitsmodell

Java schützt durch eine Reihe von Sperrmechanismen vor »böswilligem« Java-Code. Insgesamt wehren diese Mechanismen derartige Angriffe ab.

Selbstverständlich gibt es vor Ignoranz oder Sorglosigkeit keinen Schutz. Wenn Sie zu den Leuten gehören, die blind ausführbare Binärdateien in ihrem Internet-Browser herunterladen und ausführen, brauchen Sie nicht mehr weiterzulesen! Sie befinden sich bereits in einer größeren Gefahr, die Java je stellen könnte. Als Nutzer des Internets sollten Sie sich selbst über die möglichen Gefahren dieser neuen aufregenden Welt informieren. Insbesondere besteht durch das Herunterladen von »automatisch laufenden Makros« oder das Lesen von E-Mail mit »ausführbaren Anhängen« eine große Gefahr.

Java bringt hier kein weiteres Gefahrenpotential ein. Als ausführbarer und mobiler Code zur großangelegten Nutzung im Internet macht Java die Leute vielmehr über Gefahren, die längst vorhanden sind, aufmerksam. Java ist weit weniger gefährlich als alle üblichen Aktivitäten im Internet und kann im Lauf der Zeit noch sicherer ausgelegt werden, während diese potentiell gefährlichen Aktivitäten nie ausgeschlossen werden können. Seien Sie deshalb auf der Hut!

Als gute Faustregel im Internet gilt: Lade nie etwas auf deine Maschine herunter, was du ausführen willst (oder was automatisch ausgeführt wird), außer du kennst den Autor (oder das Unternehmen). Wenn Sie sich über Datenverlust oder Datenschutz keine Gedanken machen, können Sie im Internet machen, was Sie wollen.

Mit Java kann man sich hinsichtlich dieser Regel etwas entspannt zurücklehnen. Java-Applets können überall von jedem sicher ausgeführt werden.

Javas leistungsstarke Sicherheitsmechanismen greifen auf vier Ebenen der Systemarchitektur. Erstens ist die Java-Sprache an sich sicher, und der Java-Compiler gewährleistet, daß kein Quellcode diese Sicherheitsregeln verletzen kann. Zweitens wird der gesamte zur Laufzeit ausgeführte Bytecode auf Einhaltung dieser Sicherheitsregeln überprüft. Drittens stellt der Klassenlader sicher, daß durch Klassen beim Laden in das System keine Namens- oder Zugriffseinschränkungen verletzt werden. Viertens hindert die API-spezifische Sicherheit Applets an irgendeinem zerstörerischen Verhalten. Diese letzte Schicht hängt von der Sicherheit und Integrität der anderen drei Ebenen ab.

Wir sehen uns jetzt alle Sicherheitsebenen genauer an.

Die Sprache und der Compiler

Die Java-Sprache und ihr Compiler bilden die erste Abwehrlinie. Java wurde von Anfang an als sichere Sprache ausgelegt.

Die meisten anderen C-artigen Sprachen haben Einrichtungen zum Kontrollieren des Zugriffs auf »Objekte«, weisen aber auch die Schwäche auf, daß der Zugriff auf Objekte (oder Teile davon) - meist durch perverse Verwendung von Pointern - »geschmiedet« werden kann. Das bedeutet, daß ein System, das auf diese Sprachen aufbaut, zwei fatale Sicherheitsmängel aufweist. Zum einen kann sich kein Objekt selbst vor externen Eingriffen wie Änderung, Duplizierung usw. schützen. Zum anderen hat eine Sprache mit starken Pointern grundsätzlich schwerwiegende Fehler, die die Sicherheit untergraben. Solche Pointer-Fehler sind seit fast zehn Jahren im Internet die häufigsten Ursachen von Sicherheitsverletzungen.

Java vermeidet diese Bedrohungen mit einem Schlag. Die Sprache hat erstens überhaupt keine Pointer. Die Art von Pointern, die es gibt, sind Objektreferenzen, die aber sorgfältig kontrolliert werden können. Sie können nicht geschmiedet werden und sämtliche Umwandlungen werden vorab auf Zulässigkeit geprüft. Darüber hinaus gleichen starke neue Array-Einrichtungen in Java das Fehlen von Pointern nicht nur aus, sondern steigern die Sicherheit, indem Array-Grenzen strikt aufgezwungen werden. Dadurch kann der Programmierer Fehler (die böse Jungs für ihre schändlichen Untaten nutzen können) leichter erkennen und ausmerzen.

Die Sprachdefinition und die Compiler, die sie aufzwingen, bilden eine starke Schranke gegen böswillige Aktivitäten.

Da die interessantesten Softwareangebote im Internet bald fast nur noch Java-Programme sein dürften, gewährleisten diese Sprachdefinition und die Compiler eine solide sichere Basis für diese Software.

Überprüfen der Bytecodes

Sollte nun ein übel gesinnter Programmierer entschlossener vorgehen und den Java-Compiler für seine schändlichen Zwecke umschreiben, muß der Compiler auf Einhaltung aller Sicherheitsaspekte prüfen, weil zur Java-Laufzeit nicht erkannt werden kann, ob ein Bytecode von einem »vertrauenswürdigen« Compiler erzeugt wurde.

Vor der Ausführung wird jeder Bytecode einer rigorosen Testreihe unterzogen. Diese verschiedenen Tests stellen sicher, daß keine Pointer zurechtgebogen wurden, daß keine Zugriffsbeschränkungen übergangen werden, daß auf Objekte nicht als etwas anderes zugegriffen wird (InputStream sind immer Eingabeströme, nie etwas anderes), daß keine Methoden mit unzulässigen Argumenten aufgerufen werden und daß der Stack nicht überläuft.

Betrachten Sie einmal folgenden Java-Code:

public class VectorTest {

   public int  array[];

   public int  sum() {

      int[]  localArray = array;

      int    sum        = 0;

      for (int  i = localArray.length;  --i >= 0;  )

      sum += localArray[i];

      return sum;

   }

}

Die Bytecodes, die beim Kompilieren dieses Codes erzeugt werden, sehen etwa so aus:

       aload_0              Lade this

       getfield #10         Lade this.array

       astore_1             Speichere in localArray

       iconst_0             Lade 0

       istore_2             Speichere in sum

       aload_1              Lade localArray

       arraylength          Hole seine Länge

       istore_3             Speichere in i

   A:  iinc 3 -1            Subtrahiere 1 von i

       iload_3              Lade i

       iflt B               Beende Schleife, falls  < 0

       iload_2              Lade sum

       aload_1              Lade localArray

       iload_3              Lade i

       iaload               Lade localArray[i]

       iadd                 Addiere sum

       istore_2             Speichere in sum

       goto A               Mach's nochmal

   B:  iload_2              Lade sum

       ireturn              Gebe sie aus

Die Beispiele und Beschreibungen in diesem Abschnitt stammen aus einem sehr informativen Sicherheitsdokument, das im Alpha-Release von Java enthalten ist. Ich empfehle Ihnen, dieses Dokument auch in den neuen Versionen immer durchzulesen, um sich umfassend über die Java-Sicherheit zu informieren.

Typeninformationen und Anforderungen

Java-Bytecodes codieren mehr Typeninformationen als der Interpreter benötigt. Trotzdem wird z. B. aload immer benutzt, um eine Objektreferenz zu laden, und iload wird benutzt, um eine Ganzzahl zu laden. Einige Bytecodes (z. B. getfield) beinhalten eine symbolische Tabellenreferenz. Diese Symboltabelle enthält noch mehr Typeninformationen. Dadurch wird zur Laufzeit sichergestellt, daß Java-Objekte und -Daten nicht illegal manipuliert werden.

Vor und nach der Ausführung eines Bytecodes hat jeder Stackbereich und jede lokale Variable einen bestimmten Typ. Diese Sammlung von Typeninformationen (über alle Stackbereiche und lokalen Variablen) nennt man Typenzustand der Ausführungsumgebung. Eine wichtige Anforderung des Java-Typenzustands ist, daß sie statistisch ermittelbar ist, d. h. vor der Ausführung eines Programmcodes. Daraus folgt, daß jeder Bytecode, der zur Laufzeit gelesen wird, diese induktive Eigenschaft aufweisen muß.

Mit Bytecodes ohne Verzweigungen und ab einem bestimmten Anfang ist der Zustand jedes Stackbereichs immer bekannt. Im folgenden Beispiel wird mit einem leeren Stack begonnen:

iload_1 Lade ganzzahlige Variable. Der Typenzustand des Stacks ist I.

iconst 5 Lade ganzzahlige Konstante. Der Typenzustand des Stacks ist II.

iadd Füge zwei Ganzzahlen ein und produziere eine Ganzzahl. Der Typenzustand des Stacks ist I.

Smalltalk- und PostScript-Bytecodes unterliegen nicht dieser Einschränkung. Ihr dynamischeres Typenverhalten bietet mehr Flexibilität, jedoch wurde bei Java mehr Wert auf Sicherheit gelegt. Java muß immer alle Typen kennen.

Zur Java-Laufzeit wird noch eine weitere Anforderung gestellt: Wenn Bytecodes mehr als einen Pfad verfolgen können, um zu einem bestimmten Punkt zu gelangen, muß die Reise auf allen Pfaden mit genau dem gleichen Typenzustand erfolgen. Das ist eine strikte Anforderung, die beispielsweise impliziert, daß Compiler keine Bytecodes erzeugen können, die alle Elemente eines Arrays in den Stack laden.

Der Verifier

Bytecodes werden auf Einhaltung aller genannten Anforderungen geprüft. Anhand der zusätzlichen Typeninformationen in einer .class-Datei wird eine weitere Kontrolle zur Laufzeit von einem Mechanismus namens Verifier durchgeführt. Er prüft alle Bytecodes nacheinander, baut dabei den vollen Typenzustand auf und kontrolliert die Typen aller Parameter, Argumente und Ergebnisse. Der Verifier fungiert somit als Torhüter für Ihre Laufzeitumgebung und gewährt nur den Bytecodes Einlaß, die sich ordnungsgemäß ausweisen können.

Der Verifier ist ein sehr wichtiger Teil der Java-Sicherheit und hängt von der korrekten Implementierung des Laufzeitsystems ab (keine absichtlichen oder versehentlichen Fehler). Bei Drucklegung wurden Java-Laufzeiten nur von Sun produziert, die alle sicher sind. Künftig muß man allerdings aufpassen, da Systeme von anderen angeboten werden, über deren Sicherheitsregeln nicht viel bekannt ist.

Bytecodes, die den Verifier passiert haben, verursachen folgendes garantiert nicht: Über- oder Unterläufe des Operandenstacks, falsche Verwendung von Parametern, Argumenten oder Rückgabetypen, ungültige Konvertierung von Daten in andere Typen (z. B. von einer Ganzzahl in einen Pointer) und illegale Zugriffe auf Objektfelder (d. h. der Verifier prüft auf Einhaltung der Regeln in bezug auf public, private, package und protected).

Als zusätzlicher Bonus läuft der Interpreter schneller, weil er davon ausgehen kann, daß diese Faktoren zutreffen. Alle erforderlichen Sicherheitskontrollen werden im voraus durchgeführt, so daß er mit Vollgas loslegen kann.

Da Sie sich also darauf verlassen können, daß eine private-Variable wirklich privat ist und kein Bytecode irgendwelche Zaubereien mit Umwandlungen ausführen kann, um Informationen herauszuziehen (etwa eine Kreditkartennummer), treten viele der in anderen Umgebungen potentiell vorhandenen Probleme erst gar nicht in Erscheinung.

Der Klassenlader

Der Klassenlader ist ein weiterer Torhüter, aber auf einer höheren Ebene. Wird eine neue Klasse in das System geladen, muß sie aus einem bestimmten »Reich« stammen. Im derzeitigen Release gibt es drei mögliche Gefilde: der lokale Rechner, das durch Firewall geschützte lokale Netz und das Internet. Diese drei Reiche werden vom Klassenlader unterschiedlich behandelt.

In Wirklichkeit kann es so viele Reiche geben, wie Ihr Sicherheitsbedürfnis (oder Ihre Paranoia) verlangen. Das ist möglich, weil sich der Klassenlader unter Ihrer Kontrolle befindet. Als Programmierer können Sie in Ihrem Klassenlader eigene Sicherheitsaspekte implementieren. Als Benutzer können Sie Ihren javakundigen Browser oder das Java-System anweisen, welche (der drei) Sicherheitsbereiche jetzt oder künftig anzuwenden sind. Als Systemverwalter können Sie anhand der Sicherheitsfunktionen von Java vom Benutzer verlangen, daß er nicht nach der Methode »Bitte, fühlt euch alle frei, auf meinem System zu machen, was ihr wollt« zu arbeiten.

Vor allen Dingen läßt der Klassenlader niemals zu, daß eine Klasse durch eine aus einem niedrigeren Bereich ersetzt wird. Alle E/A-Primitiven des Dateisystems (über die Sie sich besonders viel Gedanken machen müssen), sind in einer lokalen Java-Klasse definiert. Das bedeutet, daß alle im lokalen Rechnerbereich hausen. Somit kann keine Klasse von der Außenwelt (über das vermeintlich so sichere LAN oder das Internet) diese Klassen ersetzen und Unheil stiften. Ferner können Klassen aus einem niedrigeren Bereich keine Methoden von höheren Klassen aufrufen, es sei denn, diese Methoden wurden explizit als public deklariert. Das bedeutet, daß Klassen von anderswo als vom lokalen Rechner diese Methoden nicht einmal sehen, geschweige denn aufrufen können.

Darüber hinaus wird jedes neu über das Netz geladene Applet in einen separaten paketähnlichen Namensbereich gestellt. Das bedeutet, daß Applets sogar voreinander geschützt sind! Kein Applet kann auf die Methoden (oder Variablen) eines anderen Applets ohne dessen ausdrückliche Mitarbeit zugreifen. Außerdem können Applets innerhalb und außerhalb der Firewall unterschiedlich behandelt werden.

Eigentlich ist die Sache komplizierter. Im derzeitigen Release befinden sich Applets der gleichen Quelle in einem »Namespace«-Paket. Diese Quelle ist meist ein Host (Domainname) im Internet. Je nach dem, wo sich die Quelle befindet (außerhalb oder innerhalb der Firewall), sind eventuell weitere Einschränkungen anwendbar (oder es greifen überhaupt keine). Dieses Modell wird in künftigen Java-Releases höchstwahrscheinlich erweitert.

Der Klassenlader teilt im Prinzip die Welt der Java-Klassen in kleine geschützte Gruppen auf, von deren Sicherheit Sie immer ausgehen können. Diese Art der Zuverlässigkeit ist bei sicheren Programmen unabdingbar.

So verläuft also das Leben einer Methode: Sie beginnt als Quellcode auf einem Rechner, wird (möglicherweise auf einem anderen Rechner) in Bytecode kompiliert und bereist (als .class-Datei) die Internet-Welt. Wird ein Applet in einem javakundigen Browser ausgeführt (oder als Klasse heruntergeladen und manuell in java ausgeführt), werden die Methoden-Bytecodes aus der .class-Datei herausgezogen und vom Verifier geprüft. Wurden sie als sicher eingestuft, kann sie der Interpreter ausführen.

Auf jeder Ebene werden die Sicherheitsvorkehrungen verstärkt. Die letzte Sicherheitsebene ist die Java-Klassenbibliothek, in der mehrere Klassen und APIs weitere Sicherheitsschranken auferlegen.

Der Sicherheitsmanager

SecurityManager ist eine abstract-Klasse, um die das Java-System kürzlich erweitert wurde, damit alle Entscheidungen hinsichtlich der Sicherheitspolitik an einer Stelle im System erfaßt werden. Sie haben im vorherigen Abschnitt erfahren, daß Sie Ihren eigenen Klassenlader erstellen können. In den meisten Fällen ist das aber nicht nötig, weil Sie mit einer Subklasse von SecurityManager die gleiche Wirkung erzielen. Eine Instanz einer Subklasse von SecurityManager wird immer als momentaner Sicherheitsmanager installiert. Er hat die komplette Kontrolle über alle »gefährlichen« Methoden. Er berücksichtigt die vorher beschriebenen Bereiche. Jeder dieser Bereiche kann getrennt konfiguriert werden, um die (vom Programmierer gewünschte) Wirkung zu erzielen. Für Systemverwalter und Benutzer bietet der Sicherheitsmanager verschiedene Funktionen zum individuellen Einrichten der Sicherheitsmechanismen.

Um welche »gefährlichen« Methoden handelt es sich, die da geschützt werden müssen?

Dateiein- und -ausgaben gehören selbstverständlich dazu. Applets können von Natur aus Dateien nur mit ausdrücklicher Genehmigung des Benutzers öffnen, lesen und beschreiben - und auch dann nur in bestimmten Verzeichnissen. (Manche Benutzer haben keine Ahnung davon, aber wozu sind Systemverwalter schließlich da!)

Ferner zählen Methoden dazu, die erstellt werden sowie ein- und ausgehende Netzverbindungen nutzen können.

Schließlich zählen jene Methoden zu dieser Sorte, die einem Thread den Zugriff, die Kontrolle und das Recht der Manipulation anderer Threads gewähren.

In bezug auf Datei- und Netzzugriff kann der Benutzer eines javakundigen Browsers zwischen drei Schutzbereichen (und einem Unterbereich) wählen:

Für Dateizugriffe ist der source-Bereich unbedeutend, deshalb kommen dafür nur die anderen drei Schutzbereiche in Frage. (Als Programmierer haben Sie selbstverständlich vollen Zugang zum Sicherheitsmanager und können ihre eigenen Kriterien nach Herzenslust festlegen.)

Hinsichtlich Netzzugriff wären mehr Schutzbereiche wünschenswert. Eventuell möchten Sie verschiedene Gruppen vertrauenswürdiger Domänen (Unternehmen) mit jeweils zusätzlichen Privilegien für das Laden Ihres Applets durch eine solche Gruppe einrichten. Manchen Gruppen kann man mehr trauen als anderen. Vielleicht möchten Sie Gruppen sogar gewähren, sich zu vergrößern, indem Gruppenmitglieder die Aufnahme neuer Mitglieder empfehlen (etwa durch ein Java-Zulassungssiegel?).

Jedenfalls sind die Möglichkeiten schier endlos, so lange man den Schöpfer eines Applets mit Sicherheit identifizieren kann.

Vielleicht glauben Sie, dieses Problem sei durch die Kennzeichnung von Klassen mit ihrem Ursprung bereits erledigt. Warum also sollte das nicht genügen?

Weil wir im Grunde ein Applet mit seinem ursprünglichen Schöpfer permanent kennzeichnen wollen. Egal, wie weit es gereist ist, sollte ein Browser die Integrität und Authentizität des Applet-Schöpfers immer feststellen. Allein die Tatsache, daß Sie eine Firma oder Einzelperson nicht kennen, muß nicht heißen, daß Sie mißtrauisch sein müssen. Es soll lediglich heißen, daß man eben nie wissen kann und deshalb nichtausgewiesenen Applets mißtrauen sollte.

Sun plant die unwiderrufbare Kennzeichnung von Applets durch eine digitale Unterschrift des Autors. Außerdem habe ich in der Dokumentation über Sicherheit folgenden Kommentar gefunden: »... um einen Mechanismus zu realisieren, durch den asymmetrische Schlüssel und verschlüsselte Nachrichten mit äußerster Sicherheit angehängt werden können, um Fragmente zu codieren, die nicht nur identifizieren, woher der Code kommt, sondern auch seine Integrität sicherstellen. Dieser Mechanismus wird in künftigen Releases implementiert.« Stöbern Sie durch die Dokumentation, vielleicht finden Sie noch mehr Interessantes für eine sichere Zukunft im Internet!

Ein letztes Wort zum Thema Sicherheit: Trotz bester Bemühungen des Java-Teams muß immer ein Kompromiß zwischen sinnvoller Funktionalität und absoluter Sicherheit gefunden werden. Java-Applets können beispielsweise Fenster erstellen. Das ist eine sehr nützliche Eigenschaft, die von einem bösartigen Applet aber schamlos ausgenutzt werden kann, z. B. durch Veranlassung des Benutzers auf die eine oder andere Art, sein Paßwort einzutippen.

Flexibilität und Sicherheit können nicht gleichzeitig maximiert werden. Bisher haben sich die Leute im Internet mehr um Flexibilität gekümmert und mit einem Mindestmaß an Sicherheit gelebt. Hoffen wir, daß Java zur Verbesserung dieses Zustands beiträgt.

Zusammenfassung

Heute haben Sie tiefe Einblicke in die große Vision über Java gewonnen und verschiedene Aspekte über die vielversprechende Zukunft von Java erfahren.

Sie haben einen Blick hinter die Kulissen - in die virtuelle Java-Maschine - geworfen. Sie haben gelernt, mit dem Bytecode-Interpreter, dem Garbage-Collector, dem Klassenlader, dem Verifier, dem Sicherheitsmanager und den starken Sicherheitsfunktionen von Java umzugehen.

Sie wissen jetzt fast alles, um eine eigene Java-Laufzeitumgebung zu schreiben. Zum Glück ist das aber nicht nötig. Laden Sie die neueste Version von Java oder verwenden Sie einen javakundigen Browser, um alle Vorteile von Java sofort zu genießen.

Ich hoffe, daß Java auf Sie genauso wirkt wie auf mich - es öffnet neue Horizonte am Programmierhimmel.


Fragen und Antworten

F: Mir ist noch nicht ganz klar, wie die Java-Sprache und der Compiler das Internet angeblich sicherer machen. Können die Sicherheitsschranken von Java nicht genauso durchbrochen werden wie alle anderen?

A: Ja, das stimmt, aber vergessen Sie nicht den wichtigen Punkt dabei: Die Verwendung einer sicheren Sprache und eines sicheren Compilers hat eine Langzeitwirkung, d. h. das Internet wird sicherer, je mehr Java-Code geschrieben wird. Die überwältigende Mehrheit dieses Java-Codes wird von »ehrlichen« Java-Programmierern geschrieben, die sichere Bytecodes produzieren. Das Internet wird also im Lauf der Zeit mit zunehmender Bevölkerung von Java-Programmen sicherer.

F: Sie haben zwar gesagt, daß ich mich nicht um die Programmreinigung kümmern muß, was aber, wenn ich das will?

A: Aha, Sie planen also eine Java-Anwendung, die Flugzeuge steuert. Super! Für diese besonderen Fälle können Sie zur Java-Laufzeit beim Start java -noasyncgc angeben, so daß der Garbage-Collector ausgeschaltet wird. Er funktioniert dann nur noch auf ausdrückliche Anfrage (z. B. durch System.gc()) oder wenn kein Speicherplatz mehr verfügbar ist. Vergessen Sie dabei aber eines nicht: Ist der Garbage-Collector ausgeschaltet, haben Ihre Objekte ein sehr langes Leben. Falls Sie wirklich an einer kritischen Echtzeitanwendung arbeiten, achten Sie darauf, Objekte möglichst häufig wiederzuverwenden und nicht zu viele zu erstellen!

F: Das gefällt mir! Kann ich mit dem Garbage-Collector noch andere Dinge anstellen?

A: Sie können den sofortigen Aufruf der finalize()-Methoden von kürzlich entfernten Objekten über System.runFinalization() erzwingen. Sie können das machen, wenn Sie nach Ressourcen fragen, die Ihren Vermutungen nach noch durch Objekte, die verschwunden aber nicht vergessen sind (d. h. auf finalize() warten), gebunden sind. Das ist noch exotischer als das Ausschalten des Garbage-Collectors. Ich erwähne das nur der Vollständigkeit halber, kann das aber nicht empfehlen.

F: Was ist das letzte Wort über Java?

A: Java kann enorm viel geben - mir jedenfalls. Ich hoffe, daß Sie die gleiche Erfahrung machen. Die Zukunft des Internets ist voller ungeahnter Möglichkeiten. Der Weg dorthin ist steinig, aber mit Java haben Sie einen guten Reisebegleiter.


Copyright ©1996 Markt&Technik
Buch- und Software- Verlag GmbH
Alle Rechte vorbehalten. All rights reserved.

Schreiben Sie uns!

Previous Page TOC Index Next Page See Page