4 Laufzeitverbesserungen

4.1 Natives

Nach der Untersuchung des wirklichen Laufzeitverhaltens von Java Programmen und JVM liegt es nahe, Konzepte der Laufzeitverbesserung zu behandeln.

In Benchmark (vgl. Abschnitt 3.3) wird System.arraycopy() verwendet, eine für das Kopieren von Arrays optimierte Routine der JVM.

Was für das Kopieren von Arrays schon vorgesehen ist, kann auch für andere Probleme selbst implementiert werden. Java sieht native Methoden vor. D.h. es lassen sich aus dem Java-Code heraus direkt C (C++)-Routinen aufrufen. JavaSoft hat für diesen Fall entsprechende Tools und C-Include-Files vorgesehen. Sie sind Teil der Standard-Distribution und ermöglichen die Kommunikation zwischen C- und Java-Ebene. C++-Routinen können ebenfalls über diese Schnittstelle eingebunden werden [Foot96].

Abbildung 4.1: Abstrakte Ebenen von Java: Java Bytecode

Durch diese änderung auf Ebene des Java-Bytecodes (vgl. Abbildung 4.1) geht zwar die Systemunabhängigkeit verloren, es kann aber bei rechenintensiven Beispielen deutliche Geschwindigkeitsvorteile bringen. Außerdem besteht die Möglichkeit, zusätzlich zu den systemabhängigen Methoden langsamere äquivalente in Java zu schreiben, die ausgeführt werden, wenn die dynamisch ladbare Bibliothek nicht zur Verfügung steht (vgl. das nächste Beispiel), z.B. auf einer anderen Hardwareplattform.

Die folgende Klasse UseNative stellt die Einbindung von native Methoden vor. Die Methoden displayHelloWorld(), add_ab1() und add_ab2() sind außerhalb des Java Codes implementiert. Verwendet werden können sie aber genauso wie normale Methoden:

UseNative use = new UseNative(); 
use.displayHelloWorld();

Beispielsweise gibt use.displayHelloWorld() Hello World an der Standardausgabe aus.

Die Methode displayHelloWorldFallBackCode() erfüllt die gleiche Aufgabe, prüft aber vorher ob der Ladebefehl für die Shared Library funktioniert hat und führt andernfalls eine entsprechende Java Routine aus.

Auch Parameter können der native Methode mitübergeben werden. Die Funtionen

use.add_ab1(use.a, use.b);
use.add_ab2(use.b);

geben beide den Additionswert der Variablen a und b zurück. Im ersten Fall werden beide als Paramter an die native Routine übergeben, im zweiten nur b. Auf a greift die C-Routine über die von der JDK vorgesehene Schnittstelle zu.

Im Folgenden wird der Beispiel-Code für die Klass UseNative gegeben:

class UseNative {

  private static boolean nativeCode;

  public native void displayHelloWorld();
  public native int add_ab1(int a, int b);
  public native int add_ab2(int b);

  public void displayHelloWorldFallBackCode() {
    if (nativeCode)
      displayHelloWorld();
    else
      System.out.println("Hello World");
  }

  static {
    try {
      System.loadLibrary("hello");
      nativeCode = true;
    } 
    catch (UnsatisfiedLinkError e) {
      System.out.println("Library not found."); 
      nativeCode = false;
    }
    catch (Exception e) {
      System.out.println("Cannot load Library.");
      nativeCode = false;
    }
  }

  public int a;
  public int b;
}

Der dazugehörende C Code der Datei MyUseNative.c sieht so aus:

#include <StubPreamble.h>
#include "UseNative.h"
#include <stdio.h>

/* Ausgabe an stdout */

void UseNative_displayHelloWorld(struct HUseNative *h) {
  printf("Hello World!\n");
  return;
}

/* Addition der beiden Parameter */

long UseNative_add_ab1(struct HUseNative *h, long x, long y) {
  return x + y;
}

/* Addition der int-Variable a des Java 
 * Objekts mit dem übergebenen Paramter
 */

long UseNative_add_ab2(struct HUseNative *h, long y) {
  return unhand(h)->a + y;
}

Um dem Java Programm die C Funktionen zur Verfügung zu stellen, sind nur drei Arbeitschritte nötig:

  1. Übersetzen des Java Codes:
    javac UseNative.java
    
  2. Erzeugen der C-Header und Source Dateien, die die Klasse UseNative beschreiben und eine Schnittstelle zwischen Java und C zur Verfügung stellen:
    javah UseNative
    javah -stubs UseNative
    
  3. Übersetzen des eigenen C-Codes und Binden zu einer Library
    Unter Solaris geht dies zum Beispiel in einer Befehlszeile:
    gcc -G MyUseNative.c UseNative.c 
        -o libhello.so 
        -I/export/opt/java/include
        -I/export/opt/java/include/solaris 
        -I/export/opt/GNU/lib/g++-include
    

Nicht nur die Verwendung von Code in C oder C++ zum Geschwindigkeitsgewinn ist für Softwareentwickler interessant. Auch die Wiederverwendbarkeit von altem Code ist so möglich.

Mit der Beta-Release der JDK 1.1 hat JavaSoft ein neue genau spezifizierte Schnittstelle für native Methoden eingeführt, das Java Native Method Interface [Java96b].

4.2 JIT/ Ahead-of-Time-(Re)Compiler

Die Laufzeiteffizienz kann ebenfalls auf der abstrakten Ebene der JVM gesteigert werden (vgl Abbildung 4.2).

Abbildung 4.2: Abstrakte Ebenen von Java: Java Virtual Machine (JVM)

Abbildung 4.3: Interpretation vs JITC

Ein Konzept sind sogenannte Just In Time-Übersetzer, die parallel zur Ausführung oder dem Laden der Klasse ausgeführt werden. Sie generieren Native-Code on-the-fly \footnote{ d.h. während der Ausführung, direkt bei Bedarf }, der schneller ausgeführt werden kann. Der JIT-Code kann dann auch plattformspezifische Hardwarevoraussetzungen, wie einen Coprozessor oder ähnliches, nutzen. Die Abbildung 4.3 verdeutlicht den Unterschied zwischen Interpretation und Just in Time-Übersetzung.

Die JDK von Sun sieht für die Einbindung eines JIT-Compilers die Klasse java.lang.Compiler vor [Java96a]. Sie ist sowohl zuständig für das Feststellen ob eine entsprechende Bibliothek mit JIT-Compiler existiert, als auch für die Initialisierung; beides geschieht im static-initializer-Block beim Laden der Klasse:

try {
  String library = System.getProperty("java.compiler");
  if (library != null) {
    System.loadLibrary(library);
    initialize();
  }
} catch Throwable e) { }

Mit der System-Routine getProperty() wird die Umgebungsvariable JAVA_COMPILER gelesen und die dort optional angegebene Bibliothek dynamisch geladen.

Außerdem enthält die Klasse verschiedene Methoden mit denen Applikationen Informationen an den Compiler senden und empfangen können:

public static void disable();
public static void enable();
public static boolean compileClass(Class clazz);
public static boolean compileClasses(String string);
public static Object command(Object any);

Die genaue Beschreibung dieser Methoden und weiterer Strukturen findet sich in [Java96a].

Vor oder parallel zur Ausführung einer Klasse wird ihr Bytecode in maschinenabhängigen Code übersetzt, der dann nicht mehr interpretiert werden muß, sondern wie ein normales Programm ausgeführt werden kann. Besonders dann, wenn weiterübersetzter Code mehrfach verwendet wird, kann die JIT-Übersetzung die Ausführungszeit reduzieren.

Die oben getesteten JVM von Symantec, Asymetrix, Microsoft und auch die frei erhältliche JVM kaffe verfügen über eine JIT-Übersetzereinheit. In der Windows-Version des Navigators setzt Netscape derzeit noch die JIT-JVM von Borland ein.

Das Übersetzen des Bytecodes in Maschinenbefehle ermöglicht auch die Ausnutzung spezifischer Hardware. Der erzeugte Code kann wesentlich weiter optimiert werden als dies für die Stack-Maschine der JVM möglich ist.

Besonders bei rechenintensiven Programmen sparen JIT-Compiler Zeit und hier ist dann auch mehr Aufwand bei der Optimierung durch kürzere Ausführzeiten leicht aufzuwiegen. Derzeit sind die Produkte auf dem Markt sicher noch nicht am Ende ihrer Entwicklungsmöglichkeiten angelangt, schon der Performanceunterschied der einzelnen JIT-JVM deutet darauf hin.

Was ein JIT-Übersetzer on-the-fly durchführt (vgl. markierter Ablaufpfad in Abbildung 4.3), kann auch vorher von einem Ahead-of-Time-Compiler oder Recompiler berechnet und in einem fat Classfile abgelegt werden [Java96a]. Fat Classfiles enthalten neben dem normalen Java-Bytecode plattformabhängigen Maschinencode für die gesamte Klasse oder nur für bestimmte Methoden. Es ist auch möglich den Code von mehreren Maschinenarchitekturen hier abzulegen. Auch in diesem Fall wird der Zugriff über die Klasse java.lang.Compiler geregelt. Die dynamisch ladbare Bibliothek muß dann Routinen zum Zugriff auf den entsprechenden übersetzten Code besitzen.

4.3 Andere Hardware

Just in Time-Übersetzer, Natives und Fat Classfiles sind natürlich abhängig von der zugrundeliegen Hardware und Software. Aber sie verfolgen Konzepte, die systemunabhängig sind.

Eine andere Herangehensweise an das Problem Performancesteigerung ist der Austausch der Hardware selbst (vgl. Abbildung 4.4).

Abbildung 4.4: Abstrakte Ebenen von Java: Hardware, Betriebssystem

4.3.1 Teilauslagerung

Teile einer JVM lassen sich relativ einfach auf andere Hardwaresysteme oder Subsysteme ausgliedern. Dazu sind nur ähnlich der in Abschnitt 4.1 besprochen native Methoden die von der JVM verwendeten Routinen auf dem Hostsystem durch effizientere (z.B. einen Coprozessor benutzende) zu ersetzen.

Beispielsweise ließen sich die Klassen-Methoden für trigonometrische Funktionen in java.lang.Math so mit wenig Aufwand durch leistungsfähigere äquivalente ersetzen.

4.3.2 Vollimplementierung

Aber auch die vollständige Implementierung der JVM ist denkbar. Die JVM stellt eine sehr kleine, robuste Menge von Befehlen auf einer sehr einfachen abstrakten Maschinenstruktur zur Verfügung

Die Konzepte (vgl. Kapitel 2) lassen sich auf bestehende Hardware abbilden. Und durch Optimierung und Ausnutzung besonderer Hardwarebedingungen läßt sich die Performance deutlich steigern.

Weiter Überlegungen zur Portierung der Runtime der JDK 1.0.2 findet man in [Günt97].

----------------------------------------------------------------
[home] [TOC] [prev] [next] [guestbook] [contact]          (c) SM