Previous Page TOC Index Next Page See Page

21

Hinter der Bühne




von
Charles L. Perkins

In der letzten Lektion befassen wir uns heute 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 Garbage-Collector 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 (kompakt, einfach, sicher und stabil) und ihre flexible netzorientierte Umgebung sollen 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 Dollar 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 Sprache Java. Die rechtlichen Einschränkungen sind zwar immer noch relativ locker, wurden im Vergleich zu den ersten Releases aber straffer angezogen. Das hat sich mit der letzten Version jedoch nicht verschärft.

Was ein wirklich offener Standard sein will, muß von mindestens einer ausgezeichneten, kostenlos verfügbaren »Demo-Implementierung« unterstützt werden. SUN hat eine Alpha-, eine Beta-, mehrere 1.0-Versionen und jetzt auch Version 1.1 von Java im Rahmen des kostenlosen Java Development Kit (JDK) ausgeliefert. Ferner hat SUN ausführliche Spezifikationen der Sprache (Java Language Specification) und der virtuellen Java-Maschine (Java Virtual Machine Specification) in einer Buchreihe (Addison-Wesley) veröffentlicht. Außerdem hat SUN eine Reihe Kompatibilitätstests implementiert, die andere virtuelle Maschinenimplementierungen bestehen müssen, um das »Gütesiegel« der vollen Java-Übereinstimmung zu verdienen. Mit dieser Reihe soll sichergestellt werden, daß Java-Bytecodes auf allen Maschinen gleichermaßen laufen.

Gleichzeitig haben verschiedene Universitäten, Unternehmen und Privatleute bereits ihre Absicht ausgedrückt, die Java-Umgebung auf der Grundlage des offenen API von SUN zu unterstützen. Inzwischen gibt es schon mehrere nicht von SUN entwickelte Versionen der virtuellen Java-Maschine. Fast alle Hersteller von Produkten für die PC-, Mac- und UNIX-Plattformen haben angekündigt, übereinstimmende virtuelle Java-Maschinen in die künftigen Versionen ihrer Betriebssysteme zu integrieren. Vor diesem Hintergrund kann man davon ausgehen, daß Java die vorrangige Sprache für jede Art von Software – nicht nur Internet-Software – werden könnte.

Mehrere Universitäten und Unternehmen planen die Schaffung von Entwicklungsumgebungen und Sprachcompilern, die auf Java-Bytecodes aufsetzen. Heute können (außer Java) bereits mehrere Sprachen in Java-Bytecodes kompiliert und somit über das Internet übertragen werden. Java-Bytecodes gelten inzwischen als robuster verbreiteter Standard zum Austausch ausführbarer Inhalte im Internet, und sie mausern sich zum universellen Bytecode. Durch Verwendung dieser neuen universellen Bytecodes ist es heute schon möglich, beispielsweise eine in LISP mit einer in Smalltalk geschriebenen Klasse dynamisch zu verknüpfen. In Kürze dürfte es auch möglich sein, ausgefeilte sprachunabhängige Entwicklungsumgebungen zu schaffen, auf denen Klassen während der Entwicklung gemischt werden können und vielleicht sogar jede Methode in einer anderen Sprache geschrieben werden kann.

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 vorrangige 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 Java viele Konzepte vorgegeben. Diese Bemühungen wurden aber über mehr als zwei Jahrzehnte nicht als Lösung für allgemeine Software-Probleme 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. (Interessant, daß Java anfangs intern »Green« und »OAK« (Englisch für »Grün« bzw. »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 selbst. Auswahlmenüs werden mit dynamischen Listen aller für eine Funktion verfügbaren Optionen gefüllt – spontan, auf Wunsch und über das gesamte Internet.


java.beans, eine neue Komponente von Version 1.1, in Verbindung mit Remote Methode Invocation und der Serialisation, sind bereits erste Schritte in diese Richtung. Die interessante Neugründung Marimba, die von vier der ursprünglichen Schöpfer Javas gegründet wurde, unternimmt weitere Schritte im Bereich der asynchronen Echtzeitnutzung der Bildschirmbereiche, wobei der Unterschied zwischen Anwendung, Applet, Browser, Desktop und Bildschirmschoner zunehmend zu einer phantastischen neuen Welt verschmilzt, die ganz auf den Benutzer ausgerichtet ist.

In einer solchen Welt ist Software-Verbreitung kein Thema mehr. Software wird überall verfügbar, über eine Fülle neuer virtueller Abrechnungsmodelle 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 (Garbage-Collector).

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 dem von Tim Lindholm, Frank Yellin und Kathy Walrath verfaßten frühen Dokument Virtual Machine Specifications. Ich danke ihnen für die Überlassung ihrer ausgezeichneten Dokumentation. Wenn Sie online tiefer in die Materie eintauchen, treffen Sie auf Ihnen bereits vertrauten Boden.


Darüber hinaus wurde die folgende Beschreibung auf die neu veröffentlichte Spezifikation – The Java Virtual Machine Specification – von Lindholm und Yellin (Addison-Wesley) auf den neuesten Stand gebracht, weil dieses Dokument derzeit das letzte Wort zu diesem Thema ist.

Übersicht

Ich zitiere hier aus Einführung der Dokumentation der virtuellen Java-Maschine, weil dieser Text für die vorher erwähnte Vision relevant ist:

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 Entwicklers 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. Sie verlangen nicht einmal einen angrenzenden Speicherbereich.

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 von Java-Code.


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 Back-End-Compilers für einen herkömmlichen Compiler 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ührt, was ein herkömmlicher Compiler allein macht.

Eine Bytecode-Anweisung besteht aus einem ein Byte großen Opcode, der zur Identifizierung der Anweisung und keines oder mehrerer 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 der Byteordnung 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. Der Instruktionsdatenstrom von Bytecode ist nur nach Bytes ausgerichtet, und 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).


Wort verkörpert auf abstrakte Weise die »natürliche« Größe eines Speicherwortes einer Maschine. Auf einer »32-Bit-Maschine« ist es fast immer 32 Bit (4 Byte) lang.

Objektreferenz (objectref) ist ein von der Implementierung abhängiger zeigerähnlicher Typ, den man sich als »Referenz auf ein Objekt« vorstellen kann.

Rückgabeadresse (returnAddress) fungiert als Index in den Bytecodes einer Methode, der bei Rückgabe von einem Subroutinenaufruf benutzt wird.

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), ein vorzeichenloser Ganzzahlentyp (16-Bit char) und zwei Gleitpunkttypen mit Vorzeichen (32-Bit float und 64-Bit double nach IEEE) sowie der Typ »Objektreferenz« (objectref, ein zeigerähnlicher 32-Bit-Typ) und der aus einem Wort bestehende Pseudotyp returnAddress. 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.


Die Begriffe »32-Bit« und »aus einem Wort bestehend« im vorherigen Absatz beziehen sich auf völlig unterschiedliche Dinge. Wird eine exakte Bitlänge angegeben, z.B. »32-Bit int«, bezieht sie sich auf den Wertbereich, der für den betreffenden Typ zulässig ist. Die Bezeichnung »aus einem Wort bestehend« bedeutet lediglich, daß der Typ (als Speicheranforderung) in ein Wort passen muß. Diese Spezifikation setzt voraus, daß ein Wort groß genug ist, um ein byte, short, int, char, float, objectref, returnAddress oder einen nativen Zeiger zu speichern, und daß zwei Wörter groß genug sein müssen, um ein long oder double aufzunehmen.

Diese Primitivtypen werden nicht von Javas Laufzeit-Umgebung, sondern vom Java-Compiler unterschieden und verwaltet. Die Typen sind im Speicher nicht »gekennzeichnet« und können deshalb zur Laufzeit nicht unterschieden werden. Zum eindeutigen Hantieren der primitiven Typen 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. Diese zwei Bytecodes können genau die gleiche Operation ausführen, aber jede beschreibt den von ihr benötigten Parametertyp implizit (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 ein Wort.


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. Es ermöglicht auch eine effiziente Implementierung auf register-schwachen Architekturen, was die meisten heutigen Rechner leider sind. Wären die CPUs da draußen in der weiten Welt etwas ausgeklügelter, müßte man diesen Aspekt von Java vielleicht erneut überprüfen. Andererseits sind Einfachheit und Kompaktheit immer gute Gründe!


In der Java Virtual Machine Specification wird nur pc als einziges Register beschrieben. Die übrigen Register gelten deshalb als nicht spezifizierte interne Implementierungsdetails, die hier nur der Vollständigkeit halber aufgeführt werden.

Übrigens: Das pc-Register wird auch verwendet, wenn zur 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 (Stapel) 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 die Operanden. Die Größe der ersten zwei Elemente steht zu Beginn eines Methodenaufrufs fest, während die Größe der Operanden bei der Ausführung des Bytecodes der Methode variieren kann.

Lokale Variablen werden in einem Array mit jeweils einem Wort großen 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 ein Wort großen Zellen N und N + 1. Diese 64-Bit-Werte sind deshalb nicht garantiert auf zwei Wörter ausgerichtet. Die Entscheidung, diese Werte korrekt auf die zwei Zellen 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 Operandenstack (ein Wort groß, 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. Er wirft sie aus, fügt sie zusammen und schiebt die sich daraus ergebende Summe zurück in den Stapel.

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 Operandenstack 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.

Dem Heap wird in Java zu Beginn der Laufzeit oft ein großer Speicher mit fester Größe zugewiesen. Auf Systemen, die virtuellen Speicher unterstützen, kann der Heap aber nach Bedarf fast unbegrenzt »wachsen«.

Da in Java Objekte automatisch der Müllbeseitigung (Garbage-Collector) 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 objectref – eine Art Pointer, der auf einen Heap zeigt – zugegriffen.

Da auf Objekte nie direkt 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 und den Garbage-Collector 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.

Der Konstantenpool

In einem Heap hat jede Klasse einen »angehängten« Konstantenpool. Diese anfänglich 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 Konstantenarray beginnt. Diese Konstanten werden mit speziell codierten Bytes typisiert und haben ein genau definiertes Format, wenn sie in der class-Datei erscheinen. Später in der heutigen Lektion beschäftigen wir uns mit diesem Dateiformat. Alle Einzelheiten darüber sind übrigens umfassend in der Java Virtual Machine Specification definiert.

Einzelheiten zu Bytecodes

Eine der Hauptaufgaben der virtuellen Java-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. Als einziges weiteres Ziel (bzw. Einschränkung) muß der Java-Programmierer nicht in der Lage sein, diese Tricks im Verhalten seiner Programme zu sehen. Der Entwickler eines Java-Programms muß ganz schön clever sein, um diese beiden Ziele zu erreichen.

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 590.000 Bytecodes pro Sekunde ausführen! Das ist doch recht gut, wenn man bedenkt, daß die CPU eines solchen Rechners unter dem Einsatz von Hardware nur etwa dreißigmal besser ist.

Dieser Interpreter ist für die meisten Java-Programme ausreichend. Ist eine höhere Geschwindigkeit erforderlich, bietet sich die Verwendung von nativen Methoden an (siehe gestrige Lektion). Was aber, wenn ein cleverer Entwickler Besseres erreichen will?

»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 »Durchgangs« kann der Interpreter ein laufendes Log mit Binärcode zusammenstellen. Ebenso mühelos kann er ein Log mit Bytecodes führen, die er ausgeführt hat, um eine ganze Methode zu interpretieren. (Eigentlich nutzen die Just-in-Time-Compiler vieler der heutigen Java-fähigen Browser einen schnellen Compiler, der Bytecode in nativen Code übersetzt, um diesen Schritt auszuführen, was zwar weniger clever, aber immerhin schnell ist).

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.


Davon stammt das Wort »Compiler« im Begriff »Just-in-Time-Compiler«. Im Wirklichkeit handelt es sich lediglich um einen herkömmlichen Compiler – dem Teil, der den Code erzeugt. Mit »herkömmlich« meine ich hier javac.

Jetzt kommt die Stelle, an der der Trick greift. 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 bis 15 gesteigert werden. In einem Versuch mit dieser Technologie hat SUN aufgezeigt, daß Java-Programme damit so schnell laufen wie kompilierte C-Programme.


Der Einschub im letzten Absatz war nötig, denn wenn sich etwas an der Eingabe in die Methode unterscheidet, sind ein anderer Pfad durch den Interpreter und erneutes Loggen nötig. (Heute gibt es ausgeklügelte Technologien, um dies und andere Schwierigkeiten zu lösen.) Der Cache des nativen Codes einer Methode muß bei jeder Änderung der Methode als ungültig deklariert werden, und der Interpreter muß jedesmal, wenn eine Methode erstmals abläuft, vorab einen kleinen Beitrag leisten. Dieser kleine Verwaltungsaufwand wird aber durch die beträchtlichen Steigerungen der Geschwindigkeit mehr als ausgeglichen.

Der java2c-Compiler

Ein weiterer 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, ohne daß der Java-Programmierer etwas davon wissen muß.

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 nur sparsam zurückgreifen.)

Verwenden Sie Java beispielsweise, um einen Server zu schreiben, der nur auf Ihrem Rechner lebt, könnten Sie java2c (oder einen Java-Compiler, der direkt im nativen Java-Code arbeitet) manuell ausführen und den Server komplett selbst in nativen Code übersetzen. Auf diese Weise können Sie die Flexibilität von Java beim Schreiben und Pflegen des Servers (und die Möglichkeit der dynamischen Verknüpfung mit neuem Java-Code »im Flug«) voll nutzen. 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 auch in der neuen Version 1.1 noch kein öffentlich verfügbares java2c-Werkzeug. Sein Schicksal scheint es zu sein, experimentell zu bleiben. Außerhalb von SUN sind verschiedene Versionen von Java-Compilern für die direkte Übersetzung in nativen Code in Arbeit. Außerdem führen die meisten heute erhältlichen Java-fähigen Browser diese Übersetzung als Teil ihrer Umsetzung der Just-in-Time-Kompilation von Bytecodes wirksam aus.

Die Bytecodes

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

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

..., value1, value2 => ..., value3

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 wird von rechts nach links gelesen, 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 einem Wort.

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 Flußkontrolle auswirken, verschieben pc zum nächstfolgenden Bytecode. Diejenigen, die sich auf pc auswirken, teilen das explizit mit. 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.

Bei der Beschreibung einiger Bytecodes werden verschiedene Fehler oder Ausnahmen, die diese Bytecodes auswerfen können, ausdrücklich erwähnt. Darüber hinaus ist es jedem Bytecode zu jeder Zeit (implizit) gestattet, eine beliebige Unterklasse von VirtualMachineError auszugeben. OutOfMemoryError wird beispielsweise oft von Bytecodes ausgegeben, die Arrays und Instanzen erzeugen, während StackOverflowError meist bei Bytecodes vorkommt, die Methoden aufrufen.


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 werden Teile übersichtlich zusammengefügt. Ich zeige zuerst jeweils die Textform auf, weil die meisten Online-Dokumente auch so aufgebaut sind.

Konstanten auf 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.

ldc           ... => ..., 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.

ldc_w         ... => ..., 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.

ldc2_w        ... => ..., item.word1, item.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 ... => ..., objectref

Lade die Objektreferenz von der lokalen Variablen. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Referenz auf ein Objekt enthalten. Der Wert dieser Variablen wird in den Operandenstack geschoben. aload kann nicht zum Laden einer Rückgabeadresse (returnAddress) benutzt werden. Diese Asymmetrie zu astore ist Absicht.

aload_<A> ... => ..., objectref

Lade die Objektreferenz von der lokalen Variablen. Die lokale Variable <A> im aktuellen Java-Rahmen muß eine Referenz auf ein Objekt enthalten. Der 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. aload_<A> kann nicht zum Laden einer Rückgabeadresse (returnAddress) benutzt werden. Diese Asymmetrie zu astore_<A> ist Absicht.

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 lokale 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 lokale Variable byte1 im aktuellen Java-Rahmen wird auf value gesetzt.

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

Speichere float in der lokalen Variablen. Der Wert muß eine Gleitpunktzahl mit einfacher Genauigkeit sein. Die lokale Variable <F> im aktuellen Java-Rahmen wird auf value gesetzt. Insgesamt gibt es vier solche Bytecodes, je einen für die Ganzzahlen 0-3: fstore_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 ..., objectref – oder – address => ...

Speichere die Objektreferenz in der lokalen Variablen. objectref – or – address muß eine Rückgabeadresse (returnAddress) oder eine Referenz auf ein Objekt sein. Die lokale Variable byte1 im aktuellen Java-Rahmen wird auf value gesetzt. astore speichert eine Rückgabeadresse (returnAddress). Diese Asymmetrie ist Absicht.

astore_<A> ..., objectref – oder – address => ...

Speichere die Objektreferenz in der lokalen Variablen. objectref – or – address muß eine Rückgabeadresse (returnAddress) 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. astore_<A> speichert im Fall der Implementierung von finally eine Rückgabeadresse (returnAddress). astore_<A> kann nicht zum Laden einer Rückgabeadresse (returnAddress) benutzt werden. Diese Asymmetrie ist Absicht.

iinc                   -keine Änderung-

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.

Arrayoperationen

newarray        ..., count => arrayref

Zuweisung eines neuen Arrays, wobei count eine Ganzzahl sein muß. Sie stellt die Zahl der im neuen Array vorhandenen Elemente dar. byte1 ist ein interner Code, der den zuzuweisenden Arraytyp 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, ein neues Array des bezeichneten Typs zuzuweisen, der Elemente gemäß count aufnehmen kann. Das ergibt arrayref. Alle Elemente des Arrays werden auf ihre Standardwerte initialisiert. Ist count kleiner als Null, wird eine NegativeArraySizeException ausgeworfen.

anewarray       ..., count => arrayref

Zuweisung von neuen Arrayobjekten. count 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. Das Ergebnis muß eine Klasse, ein Array oder eine Schnittstelle sein.

Wir machen wiederum einen Versuch, ein neues Array des bezeichneten Klassentyps zuzuweisen, der die durch count bezeichneten Elemente aufnehmen kann. Das ergibt arrayref. Alle Elemente des Arrays werden auf Null initialisiert. Ist count kleiner als Null, wird NegativeArraySizeException ausgeworfen.


anewarray dient zum Erstellen eines Objektarrays mit einfacher Dimension. Die Anfrage new Thread[7] erzeugt z.B. folgende Bytecodes:

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 »Methodensignaturen«.)
multianewarray  ..., count1, [count2, ..., countN] => arrayref

Zuweisung eines neuen multidimensionalen Arrays. Jedes count<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 Element wird in diesem Index ermittelt, und das Ergebnis muß eine Arrayklasse mit einer oder mehreren Dimensionen 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 Arrayklasse 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 neues Array des bezeichneten Klassentyps soll zugewiesen werden, das in der Lage ist, count1 * count2 * ... * countN Elemente aufzunehmen. Das ergibt arrayref. Die Komponenten des ersten Arrays werden mit Unterarrays vom Typ des zweiten Arrays usw. initialisiert (außer dem letzten, falls vorhanden, das mit den Vorgabewerten der Elemente initialisiert wird). Ist auf die Basisklasse des neuen Arrays kein zulässiger Zugriff möglich, wird ein IllegalAccessError erzeugt. Ist eines der count<I>-Argumente im Stack kleiner als Null, wird eine NegativeArraySizeException erzeugt.


new int [6][3][] erzeugt folgende Bytecodes:

bipush 6
bipush 3
multianewarray <Class "[[[I"> 2,

wobei nur zwei der drei Dimensionen des Arrays erzeugt werden. Das ist zulässig – die letzte Dimension des neuen Arrays wird einfach nicht initialisiert.

Beim Erstellen von Arrays mit einer Dimension ist newarray oder anewarray meist effizienter.
arraylength     ..., arrayref => ..., length

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

iaload          ..., arrayref, index => ..., value

laload ..., arrayref, index => ..., value.word1, value.word2
faload ..., arrayref, index => ..., value
daload ..., arrayref, index => ..., value.word1, value.word2
aaload ..., arrayref, index => ..., value
baload ..., arrayref, index => ..., value
caload ..., arrayref, index => ..., value
saload ..., arrayref, index => ..., value

Lade <type> vom Array. arrayref muß ein Array <type>s sein. index muß eine Ganzzahl sein. Der <type>-Wert auf Position index im Array wird geholt und oben in den Stack gestellt. Ist arrayref Null, wird NullPointerException erzeugt. Liegt index nicht innerhalb der Grenzen des Arrays, wird ArrayIndexOutOfBoundsException erzeugt. <type> ist nacheinander int, long, float, double, objectref, byte (oder boolean), char und short. <type>s long und double haben Werte mit zwei Wörtern, wie in den vorherigen Bytecodes load.

iastore         ..., arrayref, index, value => ...

lastore ..., arrayref, index, value.word1, value.word2 => ...
fastore ..., arrayref, index, value => ...
dastore ..., arrayref, index, value.word1, value.word2 => ...
aastore ..., arrayref, index, value => ...
bastore ..., arrayref, index, value => ...
castore ..., arrayref, index, value => ...
sastore ..., arrayref, index, value => ...

Speichere in <type>-Array. arrayref muß ein Array vom <type>s sein. index muß eine Ganzzahl und value muß ein <type> sein. Der <type>-Wert wird in Position index des Arrays gespeichert. Ist arrayref Null, wird NullPointerException erzeugt. Liegt index nicht innerhalb der Grenzen des Arrays, wird ArrayIndexOutOfBoundsException erzeugt. <type> ist nacheinander int, long, float, double, objectref, byte (oder boolean), char und short. <type>s long und double haben Werte mit zwei Wörtern wie in den vorherigen Bytecodes store. Der Versuch, mit aastore einen Wert von objectref in einer Arraykomponente zu speichern, der von der Zuweisung her nicht mir ihr kompatibel ist, führt zu ArrayStoreException.

Stackoperationen

nop             -no change-

Mache nichts.

pop        ..., word => ...

Werfe das oberste Wort aus dem Stack aus.

pop2      ..., word2, word1 => ...

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

dup     ..., word => ..., word, word

Dupliziere das oberste Wort im Stack.

dup_x1    ..., word2, word1 => ..., word1, word2,word1

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   ..., word3, word2, word1 => ..., word1, word3,word2,word1

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

dup2   ..., word2, word1 => ..., word2, word1, word2,word1

Dupliziere die obersten zwei Wörter im Stack.

dup_x2   ..., word4, word3, word2, word1 => ..., word2, word1, word4,word3,word2,word1

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

swap   ..., word2, word1 => ..., word1, word2

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. Der Versuch, durch Null-Ergebnisse zu dividieren, führt zu einer ArithmeticException. <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. Der Versuch, durch Null-Ergebnisse zu dividieren, führt zu einer 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 Virtual Machine Specification (Lindholm und Yellin, Addison-Wesley) 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 UND, ODER 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

i2b ..., value => ..., result
i2c ..., value => ..., result
i2s ..., value => ..., result

Diese Bytecodes konvertieren einen Wert vom Typ <lhs> in ein Ergebnis vom Typ <rhs>. <lhs> kann i, l, f und d sein, und <rhs> kann i, l, f, d, b, c und s sein, d.h. int, long, float, double, byte, char und short.

Ü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 => ...

Verzweigung auf Ganzzahlen. Wenn Wert <rel> 0 im ersten Set des Bytecodes wahr ist, ist value1 <rel> value2 im zweiten Set wahr. byte1 und byte2 werden benutzt, um einen 16-Bit-Versatz mit Vorzeichen zu bilden. Die Ausführung fährt an diesem Versatz von der Adresse dieses Bytecodes fort. Andernfalls fährt die Ausführung am nächsten Bytecode fort. <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.

ifnull      ..., objectref => ...

ifnonnull ..., objectref => ...
if_acmpeq ..., objectref1, objectref2 => ...
if_acmpne ..., objectref1, objectref2 => ...

Verzweigung auf Objektreferenzen (objectref). Ist objectref im ersten Bytecode-Set »Null/nicht Null« oder ist objectref1 im zweiten Set »gleich/nicht gleich« objectref2, werden byte1 und byte2 benutzt, um einen 16-Bit-Versatz mit Vorzeichen zu bilden. Die Ausführung fährt an diesem Versatz ab der Adresse dieses Bytecodes fort. Andernfalls wird die Ausführung ab dem nächsten Bytecode fortgesetzt.

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.

goto        -keine Änderung-

goto_w -keine Änderung-

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 fährt von diesem Versatz ab der Adresse dieses Bytecodes fort.

jsr         ... => ..., address

jsr_w ... => ..., address

Überspringe Subroutine. Die Adresse (address) des unmittelbar nach jsr folgenden Bytecodes wird als Rückgabeadresse (returnAddress) in den Stack geschoben. byte1 und byte2 (sowie byte3 und byte4 bei jsr_w) werden benutzt, um einen 16-(32)-Bit-Versatz zu bilden. Die Ausführung fährt an diesem Versatz von der Adresse dieses Bytecodes fort.

ret         -keine Änderung-

Gebe von Subroutine zurück. Die lokale Variable byte1 im aktuellen Java-Rahmen muß eine Rückgabeadresse (returnAddress) enthalten. Der Inhalt dieser lokalen Variablen wird in pc geschrieben. Die Ausführung fährt von dort fort.


jsr schiebt die Rückgabeadresse (returnAddress) 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 Java-Schlüsselwortes finally benutzt. ret ist nicht zu verwechseln mit return, den am Anfang des nächsten Abschnitts beschriebenen Bytecode.

Zurückgeben von Methoden

return     ... => [empty]

Gebe (void) von Methode zurück. Alle Werte im Operandenstack werden verworfen. Der Interpreter gibt dann die Kontrolle an den Rufenden der Methode zurück, wobei aus dem Rahmen des Rufenden der aktuelle Java-Rahmen wird.

ireturn     ..., value => [empty]

lreturn ..., value.word1, value.word2 => [empty]
freturn ..., value => [empty]
dreturn ..., value.word1, value.word2 => [empty]
areturn ..., objectref => [empty]

Gebe <type> (oder die Objektreferenz) von der Methode aus. value muß ein <type> sein. Der Wert (bzw. objectref) 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 der Methode zurück, wobei aus dem Rahmen des Rufenden der aktuelle Java-Rahmen wird. <type> ist nacheinander int, long, float und double.


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 table-switch-Opcode werden Null bis drei 0-Bytes zum Auffüllen eingefügt, so daß das nächste Byte am Versatz dieser Methode beginnt, die ein Vielfaches von vier ist. Nach dem Auffüllen bestehen 4-Byte-Mengen mit Vorzeichen: default, low, high und weitere (high – low + 1) 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 zur Adresse dieses Bytecodes hinzugefügt. Andernfalls wird das Element (index – low) der Sprungtabelle herausgezogen und zur Adresse dieses Bytecodes hinzugefügt.

lookupswitch     ..., key => ...

lookupswitch ist ein Bytecode mit variabler Länge. Unmittelbar nach dem lookup-switch-Opcode werden Null bis drei 0-Bytes zum Auffüllen eingefügt, so daß das nächste Byte am Versatz dieser Methode 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 den default-Versatz und die Zahl der folgenden Paare. Jedes nachfolgende Paar besteht aus einem match und einem offset.

key muß im Stack eine Ganzzahl sein. Dieser Schlüssel wird mit allen match-Vorkommen verglichen. Entspricht er einem davon, wird der entsprechende Versatz zur Adresse dieses Bytecodes hinzugefügt. Stimmt key mit keinem match überein, wird der default-Versatz zur Adresse dieses Bytecodes hinzugefügt. In beiden Fällen fährt die Ausführung an dieser neuen Adresse fort.


Die Bytecodes tableswitch und lookupswitch werden benutzt, um in Java die switch-Anweisung zu implementieren.

Manipulieren von Objektfeldern

getfield      ..., objectref => ..., value

getfield ..., objectref => ..., value.word1, value.word2
putfield ..., objectref, value => ...
putfield ..., objectref, value.word1, value.word2 => ...

Hole das Feld vom (oder setze es im) 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 aufgelöst, um die Breite und den Versatz (beides in Bytes) zu ermitteln.

Bei getfield ersetzt der Wert des Feldes an diesem Versatz vom Anfang der Instanz, auf die objectref zeigt, die Objektreferenz (objectref) oben im Stack. Bei setfield wird dieses Feld auf den oben im Stack befindlichen Wert gesetzt. Diese Bytecodes handhaben Feldgrößen von einem und zwei Wörtern. Ist das spezifizierte Feld statisch (static), wird IncompatibleClassChangeError erzeugt. Ist objectref Null, wird NullPointerException erzeugt.

getstatic     ..., => ..., value_

getstatic ..., => ..., value.word1, value.word2
putstatic ..., value => ...
putstatic ..., value.word1, value.word2 => ...

Hole das statische Feld von (oder setze es in) der Klasse. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Konstantenpool ist eine Feldreferenz auf ein statisches Feld einer Klasse.

Bei getstatic wird der Wert dieses Feldes oben im Stack eingefügt. Bei setstatic wird dieses Feld auf den oben im Stack befindlichen Wert gesetzt. Diese Bytecodes handhaben Feldgrößen von einem und zwei Wörtern. Ist das spezifizierte Feld nicht statisch (static), wird IncompatibleClassChangeError erzeugt.


Alle vier dieser »Accessor«-Bytecodes (und die unten beschriebenen invoke-Bytecodes) müssen spezielle Laufzeitkontrollen ausführen, wenn das betreffende Feld (bzw. die Methode) mit einem geschützten (protected) Zugriff versehen ist, weil dieser Zugriffsmodus eine besondere Beziehung zwischen der Klasse der aktuellen Methode und dem Objekt, auf das zugegriffen wird, voraussetzt. Weitere Einzelheiten finden Sie in der Java Language Specification.

Aktivieren von Methoden

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 den vollständigen Methoden-Descriptor und die Klasse. Der Methoden-Descriptor wird aus der Methodentabelle der angegebenen Klasse geholt. Er entspricht garantiert einem der in der Methodentabelle befindlichen Methoden-Descriptoren.

Das Ergebnis der Tabellensuche ist ein Methodenblock. Der Methodenblock bezeichnet den Methodentyp (native, synchronized usw.) und die im Operandenstack erwartete Zahl der Argumente (nargs). Ist die Methode mit synchronized gekennzeichnet, erfolgt der Eintritt in den entsprechenden Überwacher (monitor) der Klasse.

Die Basis des lokalen Variablenarrays für einen neuen Java-Stackrahmen wird so gesetzt, daß sie auf eine Kopie von arg1 zeigt, was aus den gelieferten Argumenten (arg1, arg2, ...) die ersten lokalen Variablen (nargs) des neuen Rahmens macht. Die Gesamtzahl der lokalen Variablen und anderer von der Methode benutzter Daten wird ermittelt. Dann wird die Basis des Operandenstacks für diesen Methodenaufruf auf das erste Wort nach dem allen gesetzt. Schließlich wird pc auf den ersten Bytecode der passenden Methode (oder über eine von der Implementierung abhängige Abstimmung im Falle von native) gesetzt, wo auch die Ausführung beginnt.

Ist die angegebene Methode nicht statisch, wird IncompatibleClassChangeError erzeugt.

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

Aktiviere die Instanzmethode auf der Grundlage des Laufzeittyps. 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 den kompletten Methoden-Descriptor. 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 Tabelle stehenden Methoden-Descriptoren.

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 objectref verbundene Überwacher (monitor) aktiviert.

Die Basis des lokalen Variablenarrays für den neuen Java-Stackrahmen wird so gesetzt, daß sie auf eine Kopie von objectref zeigt, so daß objectref und die gelieferten Argumente (arg1, arg2, ...) die ersten lokalen Variablen (nargs) des neuen Rahmens sind. Die Gesamtzahl der lokalen Variablen und anderer von der Methode benutzter Daten wird ermittelt. Die Basis des Operandenstacks für diesen Methodenaufruf wird auf das erste Wort nach dem allen gesetzt. Schließlich wird pc auf den ersten Bytecode der passenden Methode (oder über eine von der Implementierung abhängige Abstimmung im Falle von native) gesetzt, wo auch die Ausführung beginnt.

Ist die angegebene Methode statisch, wird IncompatibleClassChangeError erzeugt. Ist sie abstrakt, wird AbstractMethodError erzeugt. Ist objectref Null, erfolgt eine NullPointerException.

Behandlung von Ausnahmen

invokespecial     ..., objectref, [arg1, [arg2, ...]] => ...

Aktiviere die Instanzmethode private, super oder <init> mit besonderer Handhabung. Dieser Bytecode wird benutzt, um private, Oberklassen- und Initialisierungsinstanzmethoden auf besondere Art zu aktivieren. Bis zur JDK-Version 1.0.2 wurde dafür invokevirtual (invokeonvirtual) benutzt. Die zwei Operationen sind auch jetzt noch identisch, außer wenn das Flag ACC_SUPER für die aktuelle Klasse gesetzt wird. In diesem Fall wird dynamisch von der Oberklasse über die Kette zu den Elternklassen nach einer besseren Entsprechung des Methoden-Descriptors gesucht, dann wird diese Methode aufgerufen. Dadurch wird super aus der Java Language Specification nicht nur korrekt, sondern auch sinnvoll implementiert.

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

Aktiviere die Schnittstellenmethode. Dieser Bytecode wird benutzt, um Instanzmethoden für Schnittstellen auf spezielle Art zu aktivieren. Er ist abgesehen von den folgenden Unterschieden mit invokevirtual identisch. Die Zahl der verfügbaren Argumente (nargs) muß mit byte3 (aus historischen Gründen darin gespeichert) übereinstimmen. byte4 muß Null sein (ist für interne (_quick) Verwendung reserviert). Die Methodensignatur wird zwar ermittelt, stimmt aber nicht garantiert mit einem Tabelleneintrag überein. Die Methodentabelle muß durchsucht werden. Wird keine passende Methode gefunden, folgt ein IncompatibleClassChangeError. Schließlich wird zusätzlich zu den übrigen Fehlern, die bei invokevirtual auftreten können, IllegalAccessorError erzeugt, falls die passende Methode nicht öffentlich (public) ist.

Verschiedene Objektoperationen

new               ... => ..., objectref

Erstelle ein neues Objekt. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index muß eine Klasse (nicht ein Array oder eine Schnittstelle) ermitteln. Eine neue Instanz dieser Klasse wird erstellt, und eine Referenz (objectref) für die Instanz wird oben in den Stack gestellt. Die Instanz ist erst zur vollen Nutzung bereit, wenn ihre <init>-Methode aufgerufen wird. Ist die ermittelte Klasse abstrakt, erfolgt InstantiationError. Kann die aktuelle Klasse nicht auf die ermittelte Klasse zugreifen, wird IllegalAccessError erzeugt.

checkcast         ..., objectref => ..., objectref

Prüfe den Typ eines Objekts. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index muß ein Array, eine Klasse oder eine Schnittstelle ermitteln.

checkcast ermittelt, ob objectref in das betreffende Array bzw. die Klasse oder Schnittstelle umgewandelt werden kann. (objectref mit dem Wert Null kann in alles konvertiert werden.) Kann objectref gültig umgewandelt werden, fährt die Ausführung ab dem nächsten Bytecode fort, und objectref bleibt im Stack. Andernfalls wird ClassCastException erzeugt.

instanceof        ..., objectref => ..., result

Ermittle den Typ eines Objekts. byte1 und byte2 werden benutzt, um einen Index im Konstantenpool der aktuellen Klasse zu bilden. Das Element im Index muß ein Array, eine Klasse oder eine Schnittstelle ermitteln.

Ist objectref Null, ist das Ergebnis 0 (false). Andernfalls ermittelt instanceof, ob objectref gültig in das betreffende Array bzw. die Klasse oder Schnittstelle umgewandelt werden kann. Das Ergebnis ist 1 (true) bzw. im negativen Fall 0 (false).


Die Zulässigkeit einer Umwandlung richtet sich normalerweise einfach danach, ob objectref eine Instanz der ermittelten Klasse oder einer ihrer Unterklassen bzw. einer Instanz einer Klasse ist, die die ermittelte Schnittstelle implementiert. Komplikationen ergeben sich erst, wenn auf Arrays getestet wird. Einzelheiten hierzu finden Sie in der Java Language Specification.

Überwachungsoperationen

monitorenter     ..., objectref => ...

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

monitorexit     ..., objectref => ...

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

Breite Bytecodes

wide              <wie modifizierter Bytecode>

Führe den Bytecode mit einem größeren Index aus. byte1 muß der <Opcode> eines anderen Bytecodes sein, der auf eine lokale Variable im aktuellen Java-Rahmen zugreift. <Opcode> kann entweder iinc sein oder von iload, lload, fload, dload, aload, istore, lstore, fstore, dstore, astore und ret stammen. Im Falle von iinc werden byte4 und byte5 benutzt, um eine neue Konstante zu bilden, die zur lokalen Variablen hinzugefügt wird. Bei allen Fällen von <Opcode>s werden byte2 und byte3 benutzt, um den neuen (breiteren) Index in den lokalen Variablen zu bilden. Bei allen übrigen Fällen verhält sich der Bytecode genauso wie bei der nicht modifizierten Form.

Obwohl der Bytecode »modifiziert« ist, heißt das nicht, daß der ursprüngliche Bytecode noch irgendwo vorhanden ist. Der breite (wide) Bytecode nimmt den <Opcode> des Original-Bytecodes als Operand und bildet aus ihm einen brandneuen Bytecode, der als eigenständige Einheit behandelt werden muß. (Beispielsweise können Sie nicht in die Mitte des wide-Bytecodes verzweigen, um den Original-Bytecode auszuführen.)

Reservierte Bytecodes

breakpoint        -keine Änderung-

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

impdep1           [undefined]

impdep2 [undefined]

Das sind von der Implementierung abhängige Traps, die normalerweise eine »Hintertür« bilden, um spezifisches Verhalten in einer virtuellen Maschinenimplementierung auszulösen oder mit Just-in-Time-Compilern bzw. ausgeklügelten Debuggern zu interagieren.


Keiner dieser reservierten Bytecodes darf in class-Dateien erscheinen. Sie wurden lediglich dazu reserviert, das harmonische Interagieren verschiedener Werkzeuge, die möglicherweise nach dem Laden und Ausführen von class-Dateien ausgeführt werden, zu unterstützen.

Die quick-Bytecodes

Die nächsten drei Absätze stammen aus der Dokumentation der virtuellen Java-Maschine als Beispiel dafür, wie ein Bytecode-Interpreter beschleunigt werden kann:

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

ldc_quick

ldc_w_quick
ldc2_w_quick

anewarray_quick
multinewarray_quick

getfield_quick
getfield_quick_w
getfield2_quick
putfield_quick
putfield_quick_w
putfield2_quick
getstatic_quick
getstatic2_quick
putstatic_quick
putstatic2_quick

invokestatic_quick
invokevirtual_quick
invokevirtual_quick_w
invokevirtualobject_quick
invokenonvirtual_quick
invokesuper_quick
invokeinterface_quick

new_quick
checkcast_quick
instanceof_quick

Sie können in den früheren Abschnitten der heutigen Lektion nachschlagen, was diese Codes bewirken. Vielfach finden Sie den Namen des Original-Bytecodes, auf dem eine quick-Variante basiert, indem Sie quick (oder quick_w) aus dem Namen entfernen. Ausnahmen bilden invokevirtualobject_quick, eine spezielle Variante von invokevirtual, um Array- bzw. Objektmethoden zu beschleunigen, invokeon-virtual_quick und invokesuper_quick – Varianten von invokespecial, wobei die letzte das neue Verhalten für Superklassen korrekt abarbeitet, sowie getfield2_quick, setfield2_quick, getstatic2_quick und setstatic2_quick – die zwei Wörter großen Varianten der jeweiligen Original-Bytecodes. quick-Bytecodes, die mit w enden, sind ihren Original-Bytecodes vom Verhalten her am ähnlichsten, während die Varianten ohne w auf kleineren Indizes basieren und dadurch schneller abgearbeitet werden.


Verschiedene Werkzeuge (wie Debugger und Just-in-Time-Compiler) müssen spezielle Bytecodes wie die quick-Varianten »erkennen« oder zumindest ignorieren können. Einige API-Neuerungen in der Java-Version 1.1 deuten darauf hin, daß diese Interaktionsschicht künftig unterstützt wird. Was die quick-Optimierung anbelangt, wäre etwas zu SUNs ungewöhnlicher Handhabung des Konstantenpools zu erwähnen:

Beim Einlesen einer Klasse wird das Array constant_pool[] in der Größe nconstants erstellt und einem Feld der Klasse zugewiesen. con-stant_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] zurückgegeben. Andernfalls wird der Wert von constant_pool[index] als aktueller Pointer ermittelt und der in constant_pool[index] befindliche Wert ü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 Java Virtual Machine Specification nachlesen.) Ich erwähne dieses Dateiformat hier, weil es einer der Bestandteile von Java ist, die sorgfältig spezifiziert werden müssen, um die Kompatibilität aller Java-Implementierungen sicherzustellen.

Der Inhalt dieses Abschnitts stammt aus einer frühen Version der Dokumentation von class.

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 in Java aus einem Stream von 8-Bit-Worten. Alle 16- und 32-Bit-Mengen werden gebildet, indem zwei bzw. vier 8-Bit-Worte eingelesen werden. (Zum Lesen und Schreiben von Klassendateien werden java.io.DataInput, java.io.DataInputStream, java.io.DataOutput und java.io.DataOutputStream benutzt.)

Das class-Dateiformat ist weiter unten als Reihe von C-ähnlichen Strukturen aufgeführt. Im Gegensatz zu struct in C gibt es hier aber kein Auffüllen oder Ausrichten zwischen den Teilen der Struktur. Jedes Feld der Struktur und jedes 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:

attribute_info {

u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}

attribute_name_index ist ein 16-Bit-Index des Konstantenpools der Klasse. Der Wert von constant_pool[attribute_name_index] ist eine Zeichenkette, die den Namen des Attributs enthält. Das Feld attribute_length bezeichnet die Länge der nächsten Informationen in Bytes. Diese Länge beinhaltet nicht die 6 Byte, die zum Speichern von attribute_name_index und attribute_length benötigt werden.

Bestimmte Attribute sind als Teil der class-Dateispezifikation vordefiniert: Sourcefile spezifiziert den Namen der java-Datei, die diese class-Datei produziert; ConstantValue enthält die konstanten (statischen) Initialisierungswerte; Code enthält Informationen, die zur Ausführung einer Methode benötigt werden, und die Bytecodes der Methode; Exceptions listen die geprüften Ausnahmen einer Methode auf; LineNumberTable und LocalVariableTable unterstützen gemeinsam Debugger auf Quellebene, um Ausgaben in von Menschen lesbarer Form auszugeben. Die Attribute Code, ConstantValue und Exceptions müssen von allen Lesern der class-Datei erkannt werden. Der Rest ist optional. Künftig werden eventuell weitere zwingende und wahlweise Attribute hinzugefügt. Alle class-Dateileser müssen die Informationen in Attributen, die sie nicht interpretieren können, überspringen bzw. ignorieren.

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 eine der kleineren Strukturen:

method_info {

u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attribute_count];
}

Und schließlich ein Beispiel einer der letzten Strukturen in der class-Dateibeschreibung:

Code_attribute {

u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 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 Compiler und Laufzeitquellen verfügbar sind, können Sie ohne umfassende Kenntnis der zugrundeliegenden Einzelheiten jederzeit class-Dateien selbst verwenden.

Einschränkungen

Die virtuelle Java-Maschine und die Spezifikationen des class-Dateiformats werfen in der derzeitigen Definition einige Einschränkungen auf:

Darüber hinaus kann ein gültiger Java-Methoden-Descriptor (wird im nächsten Abschnitt beschrieben) nicht mehr als 255 Wörter an Methodenargumenten anfordern. Das bedeutet, daß bei den meisten (ein Wort großen) Typen bis zu 255 Argumente zulässig sind, für long und double jedoch nur 127 benutzt werden können.


Aufgrund eines verborgenen this-Parameters, der von der virtuellen Maschine übergeben wird, reduziert sich diese Einschränkung (auf 255 Wörter) bei Instanzmethoden auf 254 (bei statischen Methoden sind es 255 Wörter).

Methoden-Descriptoren

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


Ein Descriptor ist eine Zeichenkette, die den Typ einer Methode, eines Feldes, eines Rückgabewertes oder eines Parameters darstellt.

Ein Feld-Descriptor steht für den Typ einer Klasse oder Instanzvariablen und ist eine Reihe von Zeichen in folgender Syntax:

<field descriptor> := <field_type>

<field type> := <base_type> | <object_type> | <array_type>
<base_type> := B | C | D | F | I | J | S | Z
<object_type> := L <full/package/name/ClassName> ;
<array_type> := [ <component_type>
<component_type> := <field_type>

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

Die Rückgabe-Descriptor steht für den Rückgabewert aus einer Methode und ist eine Reihe von Zeichen in folgender Syntax:

<return descriptor>    := <field type> | V

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

<parameter descriptor> := <field type>

Ein Methoden-Descriptor stellt die Parameter dar, die die Methode erwartet, und den Wert, den sie ausgibt (<x>* bedeutet 0 oder mehr <x>s):

<method_descriptor>    := (<parameter_descriptor>*) <return descriptor>

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 zurück. In diesem Fall lautet der Methoden-Descriptor (JZ[[S)Lmy/package/name/ComplexClass.

Einem Methoden-Descriptor 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 der komplette Methoden-Descriptor. (Sie haben mehrere davon gestern in Zusammenhang mit Stubs gesehen.) Der komplette Methoden-Descriptor 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 (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 an das Ende ihres Daseins gelangt.

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.

Vor einiger Zeit durchgeführte Studien über Software-Entwicklung 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 Ordnungshüter, den Garbage-Collector, 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 eine 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 er funktioniert. Andererseits kommt möglicherweise der Verdacht auf, daß das nicht schnell ablaufen kann, weil es ja so einfach ist. Falls Sie diesen Verdacht haben, liegen Sie richtig.

Denken Sie einmal an all die Stackframes, 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 Aufräumzä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.

Das Aufräumen 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 möglicherweise mehr Objekte als nötig erstellt. Die Verführung ist zumindest groß.

Ein wichtiger 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 Objekte wird jemals entfernt!

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


Compaction ist ein Vorgang, bei dem eine Aufräumprozedur (der Garbage-Collector) 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. – das Ganze rekursiv. Sind keine Referenzen mehr zum Zurückverfolgen vorhanden, werden alle nicht markierten Objekte »weggefegt«, und das Ergebnis ist ein kompakter Speicher wie zuvor.


Der Vorteil dieser Verfahren ist die Beseitigung von Platzproblemen. Die Nachteile sind, daß die Aufräumprozedur beim Zurückschreiten relativ viel 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, falls 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 Aufräumprozedur – einen guten Garbage-Collector – 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 sogenannte »konservative« 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«. Wir haben es also mit einer flotteren Ausgabe des konservativen Garbage-Collectors zu tun. Eine Objektreferenz (objectref) ist im Grunde ein indirekter Index, auch »OOP« genannt, der zum wirklichen Pointer verweist. Zur Umwandlung dieser OOP-Indizes in echte 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 Linienflugzeugs 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 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.

Falls Sie demnächst eine volle Aufräumprozedur nach dem Marking-und-Sweeping-Verfahren umsetzen wollen, können Sie das ganz einfach, indem Sie die Methode System.gc() aufrufen. Das empfiehlt sich, wenn Sie gerade einen größeren Teil des Heap-Speichers freigegeben haben und den zwischengelagerten Müll beseitigen möchten.

Im Idealfall merken Sie nie etwas davon, daß der Garbage-Collector im Hintergrund wuselt. Schließlich haben Generationen von Programmierern schlaflose Nächte verbracht, um Ihnen diese Annehmlichkeiten zu ermöglichen.

Die Sache mit der Sicherheit

Da wir schon beim Thema »schlaflose Nächte« sind, komme ich gleich auf einen Java-Aspekt, der dieselben bei vielen Leuten verursacht. Der von vielen gehegte Verdacht, daß Java-Programme überall im Internet nur darauf lauern, auf den Benutzersystemen randalieren zu können, ist tatsächlich eines der größten Hindernisse, die eingangs genannte Vision vom grenzenlosen Code-Austausch zu verwirklichen.

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 Internet. Von allen Seiten sind Warnungen zu vernehmen, daß die Computerindustrie bzw. die Anbieter zu wenig tun, um das Internet sicherer zu machen. Andererseits sind ganze Industrien im Aufblühen, die sich nur damit beschäftigen, der virtuellen Gemeinde mehr Sicherheiten zu bieten.

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 gelten, liegt in der Geschichte der Leute und des Unternehmens, die Java entwikkelt 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 ernst 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, weil das Probleme sind, die vor allem Netze betreffen und damit künftig vorrangige Bedeutung haben. So entschied man sich beispielsweise Ende 1995, Whitfield Diffie, den Mann, der die zugrundeliegenden Konzepte vieler interessanten Formen der modernen Verschlüsselung entwickelt hat, den gesamten Netzsicherheitsbereich zu übertragen.

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, als Java sie je erzeugen könnte. Als Nutzer des Internet 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 Laufe 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. (Diese Schicht bildet eine Abwehr dagegen, daß ein Compiler einen Code erzeugt, der die Sicherheitsregeln verletzt.) 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 – »gefälscht« 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 Gefahren mit einem Schlag. Die Sprache hat erstens überhaupt keine Pointer. Die Art von Pointern, die es gibt, sind Objektreferenzen, die sorgfältig kontrolliert werden können. Sie können nicht gefälscht 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 Arraygrenzen 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 Java-Programmierer mit »schlechten Absichten«.

Da die interessantesten Software-Angebote 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 Absichten umschreiben, stößt sein Programm zur Laufzeit auf die Hürde, daß generell auf Einhaltung aller Sicherheitsanforderungen geprüft wird, 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 ist immer ein Eingabedatenstrom, 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 so aus:

aload_0      Load this

getfield #10 Load this.array
astore_1 Store in localArray
iconst_0 Load 0
istore_2 Store in sum
aload_1 Load localArray
arraylength Gets its length
istore_3 Store in i
A: iinc 3 -1 Subtract 1 from i
iload_3 Load i
iflt B Exit loop if < 0
iload_2 Load sum
aload_1 Load localArray
iload_3 Load i
iaload Load localArray[i]
iadd Add sum
istore_2 Store in sum
goto A Do it again
B: iload_2 Load sum
ireturn Return it


Die ausgezeichneten Beispiele und Beschreibungen in diesem Teil des Buches stammen aus einem sehr informativen Java-Sicherheitsdokument von Frank Yellin. Die neueste Version dieses Dokuments mit dem Titel Low Level Security in Java finden Sie unter http://java.sun.com/sfaq/verifier.html.

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ß er statistisch ermittelt werden kann, d.h. vor der Ausführung eines Programmcodes. Daraus folgt, daß jeder Bytecode, der zur Laufzeit gelesen wird, diese induktive Eigenschaft aufweisen muß: Während vor der Ausführung des Bytecodes nur der Typenzustand bekannt ist, muß der Typenzustand danach voll ermittelt werden.

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:


Smalltalk- und PostScript-Bytecodes unterliegen nicht dieser Einschränkung. Ihr dynamischeres Typenverhalten bietet mehr Flexibilität, jedoch wurde bei Java mehr Wert auf Sicherheit der Ausführungsumgebung 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. (Da sich der Typenzustand des Stacks bei jedem Durchgang durch eine solche Schleife ändert, hätte der Anfang der Schleife – der »gleiche Punkte« in mehreren Pfaden – mehr als einen Typenzustand, was nicht zulässig ist.)

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. SUN hat Validierungsfolgen für Laufzeiten, Compiler usw. implementiert, um deren Sicherheit und Richtigkeit sicherzustellen. Sie wurden aber noch nicht auf alle vorhandenen Implementierungen angewandt. Vorläufig ist also Vorsicht geboten, denn ihre Laufzeit ist die Grundlage, auf der die übrigen Java-Sicherheitsmechanismen ruhen.

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. Außerdem können Objektreferenzen als Fähigkeiten behandelt werden, weil sie nicht gefälscht werden können. Diese Fähigkeiten erlauben beispielsweise ausgeklügelte Sicherheitsmodelle für Dateiein- und -ausgaben und Authentifikation, die auf Java aufsetzen.


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. Diese Garantien vereinfachen auch die Aufstellung von Schranken gegen zerstörerische Applets. Da sich das Java-System über bösartige Bytecodes keine Sorgen machen muß, kann es zuverlässige weitere Sicherheitsmechanismen auf höheren Ebenen einrichten.

Der Class Loader

Der Class Loader ist ein weiterer Torhüter, aber auf einer höheren Ebene. Wird eine neue Klasse in das System geladen, muß sie aus einem bestimmten Bereich stammen. Im derzeitigen Release gibt es drei mögliche Gefilde: den lokalen 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 Bereiche geben, wie Ihr Sicherheitsbedürfnis (oder Ihre Paranoia) verlangt. Das ist möglich, weil sich der Class Loader unter Ihrer Kontrolle befindet. Als Programmierer können Sie in Ihrem Class Loader eigene Sicherheitsaspekte implementieren. Als Benutzer können Sie Ihren Java-fähigen 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 I/O-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 Applet ohne dessen ausdrückliche Mitarbeit zugreifen. Außerdem können Applets innerhalb und außerhalb von Firewalls 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 Javafähigen Browser ausgeführt (oder als Klasse heruntergeladen und manuell mit 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 (oder ein Codegenerator kann nativen Binärcode daraus erzeugen und diesen Code direkt in einem »Just-in-Time«-Compiler ausführen lassen).

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 abstrakte 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 vorher erfahren, daß Sie Ihren eigenen Class Loader erstellen können. In den meisten Fällen ist das aber nicht nötig, weil Sie mit einer Unterklasse von SecurityManager die gleiche Wirkung erzielen. Eine Instanz einer Unterklasse 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. In den meisten Java-fähigen Browsern können Applets 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 ein- und ausgehende Netzverbindungen erstellen und 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 Java-fähigen 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 des Netzzugriffs 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 es Gruppen sogar gestatten, sich zu vergrößern, indem Gruppenmitglieder die Aufnahme neuer Mitglieder empfehlen (etwa durch ein Java-Zulassungssiegel?). Die neuen Java-Sicherheitsklassen in Version 1.1 ermöglichen das Erstellen solcher verfeinerten Sicherheitsebenen.

Jedenfalls sind die Möglichkeiten schier endlos, solange 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. Die Java-Laufzeit unternimmt eine Menge dahingehend, daß diese Ursprungsinformationen nie verlorengehen. Jede ausführende Methode kann dynamisch durch diese Informationen an einer beliebigen Stelle der Aufrufkette eingeschränkt werden. 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 ein Applet gereist ist, sollte ein Browser die Integrität und Authentizität des Applets immer feststellen. Allein die Tatsache, daß Sie eine Firma oder Einzelperson nicht kennen, muß aber nicht heißen, daß Sie mißtrauisch sein müssen. Es soll lediglich heißen, daß man eben nie wissen kann und deshalb nicht ausgewiesenen 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.« Ein Anfang dieses neuen Paradigmas ist mit den neuen Sicherheitsklassen und Sicherheitswerkzeugen des JDK 1.1 bereits gemacht worden. Die globale Architektur für eine übergreifende Kennzeichnung und Identifizierung existiert im Internet noch nicht. Sehen Sie sich aber diese Art von Merkmalen in jedem neuen Java-Release an. Sie werden künftig im Internet eine entscheidende Rolle spielen!

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. (Alle modernen Java-Releases haben andererseits ein spezielles Banner in den von einem Applet ausgegebenen Fenster, um dieses Problem zu beseitigen.)

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 Class Loader, 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 Java-fähigen Browser, um alle Vorteile von Java sofort zu genießen.

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 das Aufräumen in einem Programm kümmern muß, was aber, wenn ich das will (oder muß)?

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. Das kann recht nützlich sein, wenn Sie mehrere Threads haben, die sich gegenseitig in Unordnung bringen, und Sie beim Testen der Threads den gc-Thread aus dem Weg schaffen wollen. 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 Internet ist voller ungeahnter Möglichkeiten. Der Weg dorthin ist steinig, aber mit Java haben Sie einen guten Reisebegleiter.


(c) 1997 SAMS
Ein Imprint des Markt&Technik Buch- und Software- Verlag GmbH
Elektronische Fassung des Titels: Java in 21 Tagen, ISBN: 3-8272-2009-2

Previous Page Page Top TOC Index Next Page See Page