Über unsMediaKontaktImpressum
Dr. Jürgen Lampe 17. November 2015

Java Performance-Tuning

Die technische Entwicklung macht die zielgerichtete Leistungssteigerung von Software immer komplexer, aber auch immer wichtiger. Da die bisher gebräuchlichen Ansätze in dieser Situation an Wirkung verlieren, wird die Aufmerksamkeit auf weniger beachtete Aspekte gelenkt: Algorithmen und Datenstrukturen. Als natürliche Konsequenz ergibt sich die Notwendigkeit, Performance-Tuning als integralen Bestandteil der Software-Entwicklung zu begreifen.

Der Begriff Performance-Tuning ist wenig prägnant. Daher lässt es sich nicht vermeiden, zunächst eine Abgrenzung des Gegenstands zu versuchen: Im allgemeinen Sprachgebrauch bedeutet Tuning die abschließende Feinanpassung auf ganz konkrete Anforderungen. Ein bekanntes Beispiel dafür sind Autos, wo beim Tuning unter Inkaufnahme von Verschlechterungen in anderen Bereichen (z. B. Alltagstauglichkeit) die Anpassung an spezielle Wünsche (Höchstgeschwindigkeit, Straßenlage, Prestige) erfolgt. Typischerweise sind Mittel und Ergebnisse individuell. Im Gegensatz dazu wird Performance-Tuning in der Softwareproduktion häufig als Mittel angesehen, vereinbarte Leistungsziele überhaupt zu erreichen. Das wird dann bisweilen auch Performance-Engineering genannt. Doch unabhängig von der Bezeichnung setzt die Auseinandersetzung mit Fragen der Performance bisweilen erst ein, wenn es Probleme gibt. Dabei ist ein nicht erreichtes Performance-Ziel genau so ein Fehler, wie jede andere nicht erfüllte funktionale Anforderung. Und allen Fehlern ist gemeinsam, dass sie kostspieliger zu beheben sind, wenn ihre Ursachen in frühen Phasen der Entwicklung (z. B. Design) liegen.

Zwar gelingt es auch bei grundlegenden Fehlern des Öfteren, ohne teure Umbauten eine akzeptable Lösung zu finden, aber solche Konstruktionen sind wegen ihrer Abhängigkeit von ganz spezifischen Umständen häufig fragil. In der Vergangenheit war das meist kein allzu ernsthaftes Problem, weil der stetige Leistungszuwachs der Hardware und im Fall Java auch die Verbesserungen der virtuellen Maschine (JVM) geholfen haben, solche Risiken zu überdecken. Doch die Zeiten, in denen das Warten auf die nächste CPU-Generation bei Performance-Problemen eine Lösungsoption war, sind zumindest vorläufig vorbei.

Die Schwierigkeiten, die der weiteren Steigerung der skalaren Operationsgeschwindigkeit entgegenstehen, haben dazu geführt, dass die Varianz der Prozessor- und Rechnerstrukturen enorm zugenommen hat. Gleichzeitig sind die JVM um Techniken erweitert worden, die ihre Leistung verbessern, aber dabei auf vielfältige Weise mit den unterschiedlichen Prozessorfeatures (Cache, Pipeline, Prefetch usw.) interagieren. Herkömmliches Performance-Tuning, also die Harmonisierung von JVM, Betriebssystem und Hardware, die Welt der Schalter und Systemeinstellungen, wird schwieriger und riskanter. Die mittlerweile erreichte Komplexität der Hard- und Softwarestrukturen führt im Detail zu hochgradig nichtdeterministischem Verhalten. Mit Mitteln des Java-Quellcodes kann da fast nichts direkt beeinflusst werden.

Perfomance by Design

Die schiere Unmöglichkeit des Fein-Tunings von Java-Programmen heißt natürlich nicht, dass es keine Möglichkeiten gibt, die resultierende Performance durch den Quellcode zu beeinflussen. Der angemessene Begriff für die Methodik, dies zu erreichen, wäre das bereits erwähnte Performance-Engineering. Um Missverständnisse zu vermeiden, wird im Folgenden besser von Performance by Design gesprochen.

Gewöhnlich werden bei der Spezifikation neuer Software Leistungskennwerte als nichtfunktionale Anforderungen geführt und oft nur recht vage formuliert. Angesichts der technischen Grenzen wird es jedoch zunehmend wichtiger, Leistungsziele von Anfang an in das Design einzubeziehen. Dazu müssen bereits in der Konzeptionsphase Ideen für das Leistungsmodell entwickelt und wichtige Einflussfaktoren quantifiziert werden. Wenn der Aufwand für das Erreichen vorgegebener Leistungsziele hoch ist, müssen Entwurfsentscheidungen diesen Gesichtspunkt berücksichtigen. Leistung ist ein Kostenfaktor, der in Konkurrenz zu etablierten Zielen wie Robustheit, Flexibilität und Wartbarkeit tritt.

Um auf das Beispiel Auto zurückzukommen, so reicht die Agenda schon lange weit über die Forderung hinaus, dass es lediglich fahren muss. Neben den Vorgaben für Höchstgeschwindigkeit und Beschleunigung wird inzwischen wohl keine Entwicklung mehr gestartet, ohne auch solche für den Kraftstoffverbrauch, die Verschleißfestigkeit und die Produktionskosten zu stellen. Ganz so weit ist die Softwareentwicklung in Bezug auf die Leistung noch nicht.

Algorithmus

Ein entscheidender Schritt bei der Entwicklung einer leistungsfähigen Anwendung ist die Auswahl des richtigen Verfahrens. Das ist nicht einfach und erfordert als typische Ingenieurarbeit viele Abwägungen und daraus resultierende Kompromisse. Die erste und wichtigste Aufgabe in dieser Phase besteht darin, die vorgelegten Anforderungen zu prüfen und zu verstehen. Ohne möglichst vollständiges Verständnis ist es unmöglich zu erkennen, an welchen Punkten die höchsten Belastungen zu erwarten sind und welche Auswirkungen unterschiedliche Realisierungsvarianten auf die zu erwartenden Leistungsdaten haben können. Bereits zu diesem Zeitpunkt müssen möglichst zuverlässige Aussagen über das geforderte Nutzungsprofil vorliegen. Dazu gehören auch Charakteristika der zu behandelnden Daten, wenn diese auf die Performance Einfluss haben können, was bei hinreichendem Umfang eigentlich immer gilt. Beispielsweise haben Konto- oder Artikelnummern eine ganz andere Struktur als Namen oder beschreibende Bezeichnungen, was die Effizienz von Such- oder Sortierverfahren direkt beeinflussen kann. Zu erwartende Ausführungsfrequenzen sind auch deshalb wichtig, weil viele besonders schnelle Algorithmen einen hohen Initialisierungsaufwand haben, sodass ihr Vorteil erst bei großer Anzahl spürbar wird. Eine Liste mit linearer Suche kann bei sehr wenigen Einträgen schneller sein als eine Hashtabelle, insbesondere dann, wenn die benötigten Hashwerte aufwändig zu berechnen sind.

Wenn es um die zentralen Teile des Verfahrens und wirklich große Mengen geht, sollte man nicht übersehen, dass keine automatische Optimierung und keine JVM in der Lage sind, die prinzipielle Komplexität eines Algorithmus etwa von O(n2) auf O(log(n)) zu reduzieren. Darüber hinaus gibt es einen zweiten Gesichtspunkt, der sehr viel schwieriger zu bewerten ist, weil bislang kein angemessenes Modell dafür existiert. Gemeint ist die Datenlokalität. Alle theoretischen Betrachtungen setzen einen bezüglich der Zugriffszeiten homogenen Speicher voraus. Das ist in modernen Computerarchitekturen mit mehreren Cache-Schichten nicht gegeben. Die Zugriffszeiten sind um Größenordnungen länger, wenn ein Wert nicht aus dem Cache geholt werden kann. Was sich im Cache befindet, hängt jedoch von der Vorgeschichte und parallel laufenden Prozessen ab und ist somit kaum steuerbar. Die einzig mögliche Aussage lautet daher, dass bei Verfahren, die über einige Zeit auf verhältnismäßig kleinen Datenmengen operieren, die Chance besteht, dass diese Daten nach einer Anlaufphase weitgehend im Cache stehen und so schneller verarbeitet werden können. Allerdings kann bei parallelen Ausführungspfaden (Threads) durch konkurrierende Zugriffe ein gegenteiliger Effekt auftreten. Trotz dieser Unwägbarkeiten sollte der Aspekt der Datenlokalität bei der Verfahrensauswahl bedacht werden.

Schließlich gehören zur Bewertung eines Algorithmus auch seine Implementierungskosten. Wenn der erwartete Leistungsabstand zwischen den in Betracht gezogenen Varianten gering ist, sollte man besser das günstiger zu implementierende Verfahren auswählen, weil es gewöhnlich auch besser wartbar sein wird.

Zielgerichtet optimieren

In der Praxis stößt man häufig auf die Situation, dass für den überwiegenden Teil der Verarbeitungsfälle ein relativ schnelles und einfaches Verfahren existiert. Der Aufwand, d. h. letztlich die Leistungseinbuße, wird durch den Rest verursacht, der eine Sonderbehandlung erfordert. Diese Erfahrung wird manchmal und nicht ganz korrekt 80/20-Regel genannt [1]. Wenn die Verarbeitungsgeschwindigkeit zu den wesentlichen Zielen gehört, sollte man in solch einem Fall von vornherein verschiedene Ausführungszweige vorsehen. Auf den ersten Blick mag das wie eine unnötige Verkomplizierung erscheinen. In Wirklichkeit vereinfacht aber diese Trennung die Entwicklung. Denn die konkurrierenden Ziele Geschwindigkeit, Code-Qualität und vollständige Funktionalität können getrennt angegangen werden. Im Hauptzweig entsteht vorrangig performanter Code, im Nebenzweig vor allem gut verständlicher Code. Das funktioniert natürlich nur, wenn eine entsprechende Auftrennung leicht möglich ist. Die Methode, kritische Funktionen auf eigens dazu angelegten Wegen (vgl. Autobahnen oder spezielle Nervenbahnen vom Gehirn zu Händen und Kehlkopf beim Menschen) ausführen zu lassen, ist in der materiellen Welt weit verbreitet, wird bei Software aber noch viel zu selten bewusst eingesetzt.

Die Identifikation der zu erwartenden Hotspots, d. h. der für die Leistung des Gesamtsystems kritischen Teile, und die daraus folgenden Optimierungen sind ein integraler Bestandteil aller Entwicklungsschritte. Das setzt allerdings die Definition verlässlicher Anforderungsprofile voraus. In der Folge erhält man eine Lösung, die nicht mehr nach dem Motto "One size fits all" allein die funktionalen Anforderungen erfüllt, sondern für das spezielle Profil optimiert ist. Das impliziert logischerweise, dass größere Veränderungen am Leistungsprofil in der gleichen Weise eine Software-Anpassung notwendig machen können wie modifizierte funktionale Anforderungen.

Implementierung von Datenstrukturen

Nachdem die bisherigen Überlegungen eher abstrakt waren und ohne Einschränkungen auf andere Programmiersprachen übertragen werden können, sollen in diesem Abschnitt einige Fragen diskutiert werden, die stärker an spezielle Java-Eigenschaften gebunden sind.

Eine dieser typischen Java-Eigenheiten ist, dass, mit Ausnahme der einfachen Typen, Speicherplatz grundsätzlich im Heap allokiert wird. Daher korrelieren Speicherverbrauch und Leistung häufig deutlich. Ein anderer Punkt mit Einfluss auf die Perfomance ist der Umgang mit Zeichenketten (Strings). Durch Java ist die Verwendung von UTF und Internationalisierung popularisiert worden, ohne dass der dahinter stehende Aufwand deutlich sichtbar wurde.

Beide Aspekte werden als Mittel zum Performance-Tuning viel zu wenig beachtet. Wie die Daten durch Java-Typen dargestellt werden, hat erhebliche Auswirkungen auf die Verarbeitungsgeschwindigkeit. Die wichtigsten Faktoren dabei sind:

  • Allokation und Initialisierung
  • Verwaltungsaufwand
  • Speicherbereinigung (Garbage-Collection)
  • Einfluss auf die Cache-Trefferrate (Hit-Rate)

Performance-Betrachtungen auf der Basis der Objektstrukturen haben überdies einen ganz praktischen Vorteil: Es gibt eine solide Basis für Messungen. Die Anzahl der erzeugten Objekte und der damit verbundene Speicherverbrauch einschließlich seines zeitlichen Verlaufs können recht zuverlässig und reproduzierbar gemessen werden. Ebenso leicht ist es, mit Hilfe von Profilern die Hotspots der Objekterzeugung zu identifizieren. Die Ergebnisse sind dabei relativ unabhängig von einer konkreten technischen Plattform. Für den großen Teil der datenlastigen Anwendungen sind erfahrungsgemäß durch Anpassungen im Bereich der Datenorganisation spürbare Geschwindigkeitsgewinne und oft ein deutlich reduzierter Speicherverbrauch erreichbar. Ein Nachteil sei auch nicht verschwiegen: ein Performance-Gewinn lässt sich in der Regel erst im (produktionsnahen) Betrieb erkennen. Weil die wesentlichen Effekte indirekt wirken, ist ein realistisches Benchmarking schwer machbar.

Objektanzahl

Im Laufe der Jahre ist es den JVM-Entwicklern gelungen, die Kosten der Objekterzeugung deutlich zu senken. Außerdem liegt es teilweise in der Hand des Entwicklers – und ist damit relativ klar erkennbar –, welchen Aufwand die Initialisierung verursacht. Weniger sichtbar ist, dass jedes Objekt Meta- oder Verwaltungsdaten mit sich führt, die unter anderem vom Garbage-Collector (GC) oder bei der Typ-Prüfung (instanceof) gebraucht werden. Die Anzahl der dafür benötigten Bytes variiert zwischen den verschiedenen JVM-Implementierungen, ist aber unabhängig von der Größe der Objekte. Das bedeutet, dass bei gleicher Nettogröße viele kleine Objekte (mit ihren individuellen Metadaten) in der Summe mehr Heap-Speicher belegen als ein großes Objekt (mit einem Satz Metadaten). Außerdem kann es durch Alignment auf Wort- oder Doppelwortgrenzen zu weiteren Verlusten pro Objekt kommen. Der größere Speicherverbrauch erhöht die Aktivität des GC. Gleichzeitig steigen die Kosten pro GC-Lauf, da jedes einzelne Objekt auf Lebendigkeit geprüft werden muss.

Neben der Größe der Objekte hat deren Lebensdauer Auswirkungen auf die Kosten der Speicherverwaltung. Unkritisch sind Objekte, die sehr lange oder sehr kurz leben. Kostspielig wird es, wenn Objekte einige GC-Läufe überleben und bei Reorganisierungen des Heap (Kompaktieren) umkopiert werden müssen. Die Wiederverwendung von Objekten kann deshalb bisweilen kontraproduktiv sein.

Wie eingangs bereits erwähnt wurde, wirkt sich Datenlokalität fast immer positiv auf die Geschwindigkeit aus, weil dadurch die Wahrscheinlichkeit steigt, dass die nachfolgend benötigten Daten bereits mit den vorherigen in eine Cache-Line geladen wurden. Besonders bei häufig durchlaufenen Schleifen ist das positiv bemerkbar. Wenn die Daten dann noch in konstanten Abständen vorliegen, wie das bei Arrays der Fall ist, können die in der Hardware implementierten Prefetch- und Preload-Techniken ihr Potential voll entfalten.

Beispiel 1 soll das Besprochene illustrieren. Die Aufgabe besteht darin, für eine große Menge von Punkten mit ganzzahligen Koordinaten, den maximalen x-Wert zu ermitteln. Als mögliche Datenstrukturen werden drei Varianten betrachtet:

  • Ein Feld von Punkt-Objekten
  • Zwei Felder, die jeweils die x- und y-Werte enthalten
  • Ein Feld doppelter Länge, das abwechselnd x- und y-Werte enthält.

Ein beispielhafter Vergleich der Ausführungszeiten (ohne JVM-Optimierungen) ergab wie erwartet, dass die zweite Variante (0,6 ms) die schnellste ist, deutlich abgeschlagen die erste ((3,1 ms). Der relevante Unterschied zwischen der zweiten und der dritten Variante (0,8 ms) ist hauptsächlich dadurch zu erklären, dass sich durch den verdoppelten Abstand zwischen den Werten die Cache-Hit-Rate halbiert. Der letztere Effekt verschwindet sofort, wenn statt nach dem Maximum einer Koordinate nach dem Maximum der Summe der beiden Werte gesucht wird.

Beispiel 1: Datenstrukturen für zweidimensionale Punkte

  private static final int ANZAHL= 1000000; 
//  Variante 1: Array von Punkten (java.awt.Point)
  private static Point[] points= new Point[ANZAHL];
  static int maxX1(Point[] points) {
    int result= Integer.MIN_VALUE;
    for (Point point : points) {
      if (point.x > result) {
          result= point.x;
      }
    }
    return result;
  }
// Variante 2: Zwei Arrays von x- und y-Werten  
  private static int[] pointsX= new int[ANZAHL];
  private static int[] pointsY= new int[ANZAHL];
  static int maxX2(int[] coordX) {
    int result= Integer.MIN_VALUE;
    for (int i= 0; i < coordX.length; i++) {
      if (coordX[i] > result) {
        result= coordX[i];
     }
    }
    return result;
  }
// Variante 3: Ein Array, abwechselnd x- und y-Werte  
  private static int[] pointsXY= new int[2*ANZAHL];
  static int maxX3(int[] coord) {
    int result= Integer.MIN_VALUE;
    for (int i= 0; i < coord.length; i+= 2) {
      if (coord[i] > result) {
        result= coord[i];
     }
    }
    return result;
  }

Die Dauer des Schleifendurchlaufs ist allerdings nur ein Effekt der verschiedenen Implementierungen. Wenn diese Datenstruktur sehr häufig angelegt und verarbeitet wird, weil es beispielsweise um die fortlaufende Auswertung von Messreihen geht, haben die unterschiedlichen Objektzahlen erfahrungsgemäß einen spürbaren Einfluss auf die Gesamtgeschwindigkeit.

Der Overhead für die Metainformationen variiert zwischen den Plattformen, aber 16 Byte pro Objekt ist ein realistischer Schätzwert. Im Beispiel, bei dem es um zwei int-Werte (je vier Byte) geht, benötigt die Variante 1 mit den Point-Objekten rund dreimal soviel Speicherplatz.

Kleine Objekte in großer Zahl sind überproportional teuer. Wenn weniger Initialisierungs- und Verwaltungsoperationen auszuführen sind und weniger Speicherplatz belegt wird, stehen Ressourcen wie Cache und Prozessorzyklen anderen Codeabschnitten zur Verfügung, die auf diese Weise indirekt beschleunigt werden.

Zeichenketten (Strings)

Eine häufige Quelle für Leistungsengpässe sind Zeichenketten. Java hat die Verwendung für Programmierer sehr bequem gemacht. Das verleitet jedoch zu einem leichtfertigen Umgang, weil der dahinterstehende Aufwand verborgen bleibt. Das grundlegende und nicht zu beseitigende Dilemma mit Zeichenketten besteht darin, dass sie wie der Name bereits sagt, aus Ketten von Zeichen bestehen und damit meist mehr Bytes benötigen als der Prozessor in einem Zyklus verarbeiten oder über den Bus transportieren kann.

Letzteres ist besonders kritisch, da die Busoperationen schon lange nicht mehr mit den Prozessorgeschwindigkeiten mithalten können. Dieses Problem wird noch dadurch verschärft, dass viele wichtige Operationen die sequentielle Durchmusterung erfordern: Verkettung, (exakter) Vergleich, Hashcode, Suche nach Zeichen usw. Ihre Kosten steigen mit der Kettenlänge. Außerdem wird natürlich für jede Zeichenkette Speicherplatz belegt. Diese String-Objekte sind zwar unveränderbar, aber nur der Compiler vermeidet bei Literalen innerhalb einer Klasse unnötige Kopien. Generell erzeugt z. B. jeder Aufruf von new Integer(i).toString(); ein neues Objekt, unabhängig davon, ob der Wert von i gleich bleibt oder nicht. (In diesem speziellen Fall bringt auch Integer.valueOf(i).toString(); keine Verbesserung.) Praktikable Lösungsansätze für das Problem sehr großer Zahlen von identischen Strings hängen sehr stark von den jeweiligen Umständen ab. Einige Denkanstöße:

  • Wenn im obigen Beispiel die möglichen Werte von i in einem festen Intervall liegen, könnte man einen Cache anlegen und obigen Code durch iToStringCache[i] ersetzen.
  • Wenn die Anzahl der erzeugenden Objekte beschränkt ist, kann jedes Objekt sein Ergebnis cachen. Das setzt jedoch voraus, dass ein hoher Anteil der so vorgehaltenen Strings wirklich mehrfach gebraucht wird.
  • Wenn vor allem die Anzahl der erzeugten String-Objekte und weniger die Laufzeit das Problem verursacht, könnte man jeden neuerzeugten String zuerst in einer Map bzw. einem Set suchen und durch den dort eventuell schon vorhandenen ersetzen. Das vermeidet zwar nicht die Erzeugung, kann aber verhindern, dass mehr als ein identischer Wert im Heap lebendig bleibt.

Beispiel 2: Ein Konstruktor aus java.lang.String

  public String(char[] paramArrayOfChar) {
    int i = paramArrayOfChar.length;
    this.offset = 0;
    this.count = i;
    this.value = Arrays.copyOf(paramArrayOfChar, i);
  }

Das Erzeugen von Zeichenketten zur Laufzeit ist auch deshalb eine teure Operation, weil in den meisten Fällen zunächst ein char-Feld als Puffer angelegt wird, dessen Inhalt dann in das String-Objekt kopiert wird. Mit Ausnahmen in Bezug auf Teilstrings funktionieren alle public String-Konstruktoren wie der in Beispiel 2 gezeigte. Das lässt den Schluss zu, dass es ganz allgemein eine gute Idee ist, möglichst wenige String-Objekte zu erzeugen. Wege dazu gibt es verschiedene. Zwei in der Praxis bewährte sollen im Folgenden skizziert werden:

Eine lange bekannte Perfomance-Falle sind Anweisungen wie log.debug(“a“+name+“=“+wert);, weil die Zeichenkette immer erzeugt wird, auch wenn sie gar nicht ins Log geschrieben wird. Der gängige Weg, dieses Problem zu umgehen, besteht darin, das Ganze mittels einer Abfrage log.isDebugEnabled() zu kapseln. Seit Java 8 gibt es eine elegantere Möglichkeit, diese Situation zu entschärfen: Statt des Strings kann an die Log-Methoden auch ein java.util.Supplier<String>, beispielweise ein Lambda-Ausdruck, übergeben werden. Somit ist eine Lazy-Evaluation realisierbar, die den String erst dann erzeugt, wenn er gebraucht wird. Diese Strategie ist nicht nur im Zusammenhang mit dem Logging nützlich, da an vielen Stellen Strings zusammengebaut werden, die nur in einem Teil der Fälle benötigt werden. Natürlich lässt sich dieses Vorgehen auch für andere große oder aufwändig zu erzeugende Objekte nutzen.

Eine andere Quelle überflüssiger String-Objekte sind ungünstige Methoden-Signaturen. Es gibt in den Systembibliotheken unzählige Methoden mit String-Parametern, bei denen der Typ java.lang.CharSequence völlig ausreichend wäre. Dieses schlechte Vorbild hat die Programmierpraxis geprägt. Hinzu kommt, dass bestimmte Funktionen nur für Strings definiert sind. Um beispielsweise den Inhalt eines StringBuilders mit den Standardmethoden in einen Zahlwert zu konvertieren, muss daraus zuvor ein String erzeugt werden. Durch das Vermeiden solcher unnötiger Konvertierungen lassen sich bei textbezogenen Anwendungen erhebliche Leistungsverbesserungen erreichen. Ein Beispiel für den Effekt dieser Optimierung ist das Javolution-XML-Prozessing [2]. Gleichzeitig wird damit demonstriert, welches Potential bei der Definition der XML-Standard-API verschenkt wurde. Denn um die beeindruckende Effizienz zu erreichen, wurde eine analoge API definiert, in der konsequent String durch CharSequence ersetzt wurde. Um diese Bibliothek zu verwenden, ist neben dem Austausch der Package-Namen lediglich erneutes Compilieren erforderlich.

In der JVM sind einige Heuristiken implementiert, um den Aufwand von String-Operationen zu reduzieren. Grundsätzlich lösen lässt sich das Problem auf der Ebene der JVM jedoch nicht.

Assoziationen

Es gibt weitere Strukturen, mit denen bislang kein Computer wirklich effizient umgehen kann. Dazu gehören assoziative Collections, deren bekanntester Vertreter in Java die Map ist. Solche Abbildungen ordnen einem Wert (Schlüssel, Argument) einen anderen Wert (Funktionswert) zu. Technisch ist das nicht ohne eine Art von Berechnung realisierbar. Im primitivsten Fall ist das eine einfache Suche. In der Regel werden effizientere Strukturen wie Hashtabellen oder Bäume benutzt. Allen gemeinsam ist der Umstand, dass kein unmittelbarer Zugriff möglich ist. Im Gegensatz dazu erlaubt ein Array oder eine Liste einen wesentlich schnelleren Zugriff, weil aus dem angegebenen Index mit ganz wenigen einfachen Operationen die Speicheradresse bestimmt werden kann.

Manchmal lassen sich Maps mit Hilfe einer Technik ersetzen, die an die Normalisierung in relationalen Datenbanken erinnert. Die Schlüssel werden dabei in einer Liste registriert und im Weiteren durch ihren Index ersetzt. Das Prinzip wird in Beispiel 3 veranschaulicht, das sich an die in Java übliche Implementierung von Mehrsprachigkeit anlehnt. Statt die Schlüssel-Strings direkt als Parameter zu verwenden, werden diese durch einfache int-Werte ersetzt. Die Zuordnung, d. h. die Ermittlung der Indexwerte, übernimmt eine Liste "Key-Registry", die jeden Schlüssel genau einmal enthält, und beim Laden der jeweiligen Klasse abgefragt wird. Bei allen Verwendungen kann dann, angenommen die hypothetische FastMessages-Klasse existiert, ohne irgendein Suchverfahren direkt auf die Werte zugegriffen werden. Voraussetzung für dieses Verfahren ist lediglich, dass die Schlüssel statisch sind. Beim Einlesen der Werte, z. B. aus einer Properties-Datei, wird die gleiche Registry benutzt. Abhängig von den konkreten Umständen erfordert die Verwaltung etwas Sorgfalt im Umgang mit Indexwerten für die keine Abbildung definiert ist. Um beispielsweise Werte für nicht vorhandene Schlüssel durch "!" + key + "!" ersetzen zu können, muss die Registry um eine Methode zur inversen Zuordnung ergänzt werden.

Beispiel 3: Mapping mit registrierten Schlüsseln

 // Key-Registry
    static List<String> keyList= new ArrayList<String>();
    
    public static int registerKey(String key) {
        int result= keyList.indexOf(key);
        if (result < 0) {
            result= keyList.size();
            keyList.add(key);
        }
        return result;
    }
// ----------------------------------------------------
// Anwendungsfragment  
      private static final String KEY1= "example.key1";
    private static final int IDX_OF_KEY1= registerKey(KEY1);
 …
// Übliche Verwendung mit String-Parameter:  
    txt= Messages.getString(KEY1);
// Schnelle Alternative mit int-Parameter:  
txt= FastMessages.get(IDX_OF_KEY1);
// oder evtl. sogar nur (ggf. auf Exception achten):
txt= currentMessages[IDX_OF_KEY1];
}

Ganz nebenbei sieht man an diesem Beispiel, wie das Bemühen um Effizienz die Lesbarkeit des Codes durch die zusätzliche Indirektion verschlechtern kann. Wenn die Namen sorgfältig gewählt werden und man eine spürbare Verbesserung erreicht, sollte das – wie hier – vertretbar sein.

Durch wachsende Komplexität weisen Hard- und Softwaresysteme Eigenschaften von Organismen auf

Notwendige Nachbemerkung

Jede Bemühung, die Performance zu verbessern, braucht ein solides Fundament. Es bedarf einer klaren Definition messbarer Ziele. Je nachdem, ob kurze Antwortzeiten, maximaler Durchsatz oder beschränkte Nutzung bestimmter Ressourcen gefordert sind, werden sich unterschiedliche Vorgehensweisen anbieten. In jedem Fall ist es unumgänglich, immer wieder zu messen, um Engpässe und andere kritische Abschnitte erkennen zu können. Tatsächlich besteht nur an solchen Stellen die Chance, den geleisteten Tuning-Aufwand durch entsprechende Ergebnisse gerechtfertigt zu sehen.

Es wird allerdings immer schwieriger, verlässliche Messdaten mit Hilfe von Benchmarks oder ähnlich überschaubaren Testsätzen zu gewinnen. Durch ihre wachsende Komplexität weisen Hard- und Softwaresysteme inzwischen Eigenschaften von Organismen auf. Es ist daher nicht abwegig, sich beim Perfomance-Tuning wie ein Arzt zu fühlen, der durch eine Kombination von Medikamenten einen gewünschten Effekt hervorrufen will, wohl wissend, dass es zu Neben- und unerwarteten Wechselwirkungen kommen kann. Dazu braucht es Augenmaß und Erfahrung.

Autor

Dr. Jürgen Lampe

Dr. Jürgen Lampe befasst sich seit mehr als 15 Jahren mit Design und Implementierung von Java-Anwendungen im Bankenumfeld.
>> Weiterlesen
botMessage_toctoc_comments_9210