Über unsMediaKontaktImpressum
Elisabeth Schulz 16. März 2021

Java und die Fußangeln in der Programmiersprache

Ein einfaches Java-Programm zu lesen ist meist kein Hexenwerk – die Sprache ist meist sehr explizit und hat zumindest an der Oberfläche wenig Überraschungen. Alles, was geschehen soll, steht auch gut sichtbar aufgeschrieben. Aber trotz all dieser Versuche, Komplexität zu vermeiden, gibt es auch in einer Sprache, die sehr auf explizite, einfach zu verstehende Programme ausgelegt ist, einige Überraschungen, die teilweise zur stunden- oder tagelangem Debugging führen können. In diesem Artikel möchte ich einige dieser Fußangeln vorstellen und erklären, wie man vermeiden kann, allzu lange in ihnen gefangen zu bleiben. Dieser Artikel richtet sich naturgemäß vor allem an Java-Entwickler:innen, die bereits Grundwissen der Sprache besitzen.

Viele dieser Fußangeln sind "corner cases", die in der Sprachdefinition "so festgelegt" sind, aber in den meisten Tutorials nicht thematisiert werden. Sie betreffen meistens die anfangs extrem kleine, aber mit zunehmender Entwicklung der Sprache stetig weiter aufklaffende Schere zwischen der Java-Sprache und dem zugrundeliegenden Bytecode, können aber im einzelnen auch einfach Konsequenzen aus logischen, aber hier und dort auch unglücklichen Default-Entscheidungen sein.

Solche Probleme zu finden ist meistens eine langwierige und mühselige Aufgabe. Zum Werkzeugkasten hier gehören drei unabdingbare Tools:

  • Ein Debugger (in den meisten modernen IDEs bereits mitgeliefert), um das Laufzeitverhalten des Codes zu analysieren und die tatsächlichen Objektstrukturen sichtbar zu machen – inklusive der eventuell gar nicht im Quelltext vorhandenen Klassen.
  • Ein Bytecode Disassembler, der die tatsächliche Struktur von Klassen sichtbar macht. Hier reicht meist sogar das in der Java-Distribution enthaltene javap.
  • Dokumentation für alle Frameworks, die nochmals massiv in die Struktur des Bytecode eingreifen, wie z. B. Spring AOP, um dort eventuell noch vorgenommene Anpassungen zu verstehen und adäquat mitzubehandeln.

Grade javap ist hierbei essentiell. Das Tool ermöglicht uns, das zu sehen, was die JVM auch sieht – sozusagen den Maschinencode der JVM. Was hier steht, ist letzten Endes die Wahrheit.

Um eine Klasse zu debuggen, ist es oft sinnvoll, sie zu disassemblieren. Der einfachste Weg, das zu tun, ist javap -v -c -private /pfad/zur/Datei.class. Mit den dann vorhandenen Informationen kann man genauer erkennen, was exakt vorgeht.

Unerwartete Referenzen

Das Observer-Pattern hat zwar sicherlich seine höchste Ausprägung in Swing gefunden, aber auch heute noch wird es an vielen Stellen verwendet, wenn Code entkoppelt werden soll und verschiedene Domänen am besten über Nachrichten miteinander kommunizieren. Es ist auch für unerfahrene Entwickler:innen immer wieder eine Quelle für Memory Leaks.

Listing 1:

interface Listener {
  void onEvent(Object e);
}

class Observable {
  Collection<Listener> listeners = new ArrayList<>();
  void addListener(Listener l) { // ...
  }
}

class Observed {
  Object heavyweight;

  void lookAt(Observable o) {
    o.addListener(new  Listener {

      @Override
      public void onEvent(Object e) {
        // ...
      }
    });
  }
}

Diese Implementierung scheint einfach, hat aber mehrere entscheidende Haken. Auf den vielleicht einfachsten möchte ich hier eingehen: Die Implementierung des Listeners führt dazu, dass das ganze Observed-Objekt am Leben erhalten wird. Der Schwanz wackelt mit dem Hund und der Listener erhält die ganze Instanz am Leben.

Dieses Problem ist auf zwei Arten lösbar: Man kann entweder in Observable Vorsorge treffen oder sich im Aufrufer gezielt dagegen schützen.

Die erste Option ist natürlich vorteilhaft, aber nur dann möglich, wenn die Observable-Klasse auch in der Codebasis verfügbar ist. Die zweite Methode erfordert etwas Disziplin in der Observed-Klasse: Sie darf nicht einfach eine anonyme oder innere Klasse definieren, die dann als Listener registriert wird – hierbei kommt es zu der unsichtbaren Referenz zum this der Observed-Klasse.

Stattdessen kann man eine static "inner class" definieren, oder (seit Java 8) eine Lambda-Funktion, wenn der Listener wie hier nur eine Methode hat. Im Falle einer statischen Klasse wird die Referenz zur enthaltenden Instanz garantiert nicht generiert. Das Lambda kann schon per Definition nicht auf "non-static"-Felder der Klasse zugreifen und ist damit automatisch einer static class gleichwertig.

Was aber, wenn wir selbst die Rolle des Observable-Objektes einnehmen? Wie können wir das Pattern sicher implementieren? Das folgende Beispiel löst sowohl das Memory Leak als auch ein oft lange verborgenes Problem, wenn Listener im Zuge eines Events neu registriert werden sollen:

Listing 2:

import java.lang.ref.WeakReference;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

class Observable {
  private final List<WeakReference<Listener>> listeners = new CopyOnWriteArrayList<>();

  void emitEvent(String eventId) {
    for (var listenerRef : listeners) {
      var listener = listenerRef.get();
      if (listener != null) {
        listener.onEvent(eventId);
      }
    }
  }
   
  void addListener(Listener l) {
    listeners.add(new WeakReference<>(l));
  }
}

Hier sehen wir zwei Maßnahmen gegen häufig gemachte Fehler: Zum einen verwendet dieser Code einen CopyOnWriteArrayList, eine spezielle Listen-Implementierung, die für jede Modifikation eine Kopie anlegt. Dadurch kann, selbst während ein Event gerade abgearbeitet wird, neue Registrierung von Listenern erlaubt werden. Zum anderen wird durch die WeakReference sichergestellt, dass die Listener nicht länger am Leben bleiben als ihre "Wirte". Auf diese Semantik sollte man allerdings in der Dokumentation hinweisen, da ansonsten eventuell Listener sterben, die eigentlich hätten überleben sollen. Alternativ kann natürlich auch diese Kenntnis erzwungen werden, indem von Anfang an nur eine WeakReference als Parameter akzeptiert wird.

Die Krux mit der Initialisierung

Die meisten Java-Entwickler:innen werden schon einmal mit dem Singleton-Pattern gekämpft haben. Es erscheint oft sinnvoll, manche Daten nur einmal vorzuhalten. Gerade Konstanten sind hier beliebt. Diese Daten müssen dann teilweise von anderen Objekten beobachtet werden und können aufeinander verweisen.

Listing 3:

class A {
     static final Integer CONST1 = B.CONST2;
     static final Integer CONST2 = 111;
}

class B {
     static final Integer CONST1 = A.CONST2;
     static final Integer CONST2 = 999;
}

Hier zeigt sich eine besonders böse Fußangel – denn die Abhängigkeit hier ist nicht wirklich zyklisch (jede Konstante könnte sich sauber auflösen). Trotzdem erhalten wir für B.CONST1 den Wert null, oder für A.CONST1 den Wert null. Beides könnte passieren.

Wie ist das möglich? Zuerst einmal muss man hierfür wissen, dass in Java ein "lazy classloading" praktiziert wird: Jede Klasse wird erst dann in die VM geladen, wenn sie auch gebraucht wird. Das führt dazu, dass wenn auf eine neue Klasse verwiesen wird, erst einmal diese Klasse geladen und initialisiert wird. Dazu zählt auch, dass die statischen Felder der Klasse ihre Werte erhalten.

Wenn nun die beiden Klassen geladen werden, beginnt zuerst die Klasse zu initialisieren, die im Bytecode zuerst referenziert wird. Wenn wir beispielhaft mit A beginnen zu initialisieren, dann wird begonnen, ihre Felder mit Werten zu versehen. Wir versuchen also, in Deklarationsreihenfolge die Felder zu initialisieren, und beginnen mit CONST1, die ihren Wert von B.CONST2 bezieht. Um diesen Wert aber zu erhalten, muss erst die Klasse B geladen werden. Die Klasse B wiederum versucht, sich zu initialisieren (Stack-Semantik, Last-In, First-Out) und für ihr CONST1-Feld einen Wert zu beziehen. Die Klasse A ist aber noch nicht initialisiert. Das Feld hat noch einen impliziten null-Wert. Also wird die Konstante auf diesen Wert initialisiert, obwohl der Wert im nächsten Schritt durch die 999 ersetzt würde, die danach an das Feld CONST2 zugewiesen wird. Im Pseudocode also:

Listing 4:

implicitly A.CONST1 = null
implicitly A.CONST2 = null
A.CONST1 = {
  implicitly B.CONST1 = null
  implicitly B.CONST2 = null
  B.CONST1 = A.CONST2 // ( = null)
  B.CONST2 = 999
 
} B.CONST2 // ( = 999)
A.CONST2 = 111

Solche Ketten können natürlich über mehrere Stufen gehen und es daher immer schwieriger machen, den Zyklus zu finden.

Interessanterweise ist dieses Verhalten auch noch ein wenig inkonsistent – primitive Konstanten (also int, float, …) sind hiervon ausgenommen. Wenn statt des Integer-Wrappers einfache int-Werte verwendet würden, dann würde alles wie erwartet aufgelöst. Der Grund dafür ist, dass der Compiler solche Konstanten intern vorhält und dann an der Benutzungsstelle direkt ersetzt.

Wieder einmal sieht man hier die generelle Empfehlung bestätigt, static soweit wie möglich zu vermeiden, vor allem, wenn es um Objekte geht. Neben dem Potential für Memory Leaks ist auch das Classloading eine Quelle von Überraschungen. Davon abgesehen können bei schwergewichtigen Initialisierungen auch unerwartete, kaum zu verstehende "Hänger" erzeugt werden, und eine Exception, die einen Initializer verlässt, ist ebenfalls fatal.

Shared Data

Java ist nicht mehr absolut auf dem Stand der Zeit, was parallele und asynchrone Entwicklung angeht. Das ist leider unbestreitbar und kann an folgendem Beispiel sichtbar gemacht werden.

Listing 5:

public class Hazardous {
  static final int updateTimer = 100;
  static int count;

  public static void main(String[] args) throws Exception {
    new Thread(() -> {
      int temp = 0;
      while (true) {
        if (temp != count) {
          System.out.println("Detected: " + temp + " to " + count);
          temp = count;
        }
      }
    }).start();

    for (int i = 0; i < 5; i++) {
      count++;
      System.out.println("Set: " + count);
      Thread.sleep(updateTimer);
    }

    Thread.sleep(updateTimer);
    System.exit(0);
  }
}

Was dieses Programm tut, ist nicht definiert. Es kann im Wechsel "Set"- und "Detected"-Zeilen ausgeben, es kann auch etwas anderes tun. Bei den meisten modernen X86_64-Prozessoren auf Microsoft Windows ergibt sich folgender Output:

Listing 6:

Set: 1
Detected: 0 to 1
Set: 2
Set: 3
Set: 4
Set: 5

Der Grund dafür ist, dass Java an dieser Stelle eine wenig intuitive Semantik hat: Writes von einem Thread müssen nicht unbedingt für andere Threads sichtbar gemacht werden, wenn sich nicht eine happens-before-Abhängigkeit zwischen den Aktionen gibt. Es ist also komplett möglich, dass die Variable im main-Thread mehrfach geändert wird, aber nie publiziert und sichtbar gemacht wird.

Die Gründe dafür liegen in der Performance – Caching und die oftmals hierarchischen Speicherstrukturen moderner Computer machen selbst Hauptspeicher-Zugriffe zu langsam für die CPU. Jeden Zugriff aus dem Hauptspeicher zu lesen würde bedeuten, dass die CPU den größten Teil der Zeit darauf verwendet, auf den Hauptspeicher zu warten.

Die genaue Spezifikation [2] ist eher komplex und schwer zu verstehen. Im Grunde ergeben sich aber zwei grundlegende Möglichkeiten, sicherzustellen, dass Änderungen an geteilten Variablen auch sichtbar werden: volatile und synchronized.

Die Mutex-Eigenschaft von synchronized ist wahrscheinlich relativ bekannt – aber ein synchronized-Block stellt (vereinfacht gesagt) auch sicher, dass alle Schreibzugriffe in ihm für andere Threads veröffentlicht werden, bevor er beendet ist. Dies ist zwar relativ schwergewichtig, aber in der Lage, auch komplizierte Lese- und Schreibzugriffe zu kombinieren.

Es gibt aber noch eine andere Möglichkeit, die etwas leichtgewichtiger ist: volatile. Dieses selten benutzte Keyword kann Felder einer Klasse modifizieren. Bei einem solchen Feld muss die CPU die Caches umgehen und direkt aus und in den Hauptspeicher kommunizieren.

Generell ist aber auch mit einer volatile-Variable nur schwierig umzugehen, da schon ein einfacher Ausdruck wie i++ eigentlich aus zwei Operationen besteht: einer Lese- und einer Schreiboperation. Wenn mehrere Threads konkurrieren, können sich selbst bei volatile noch Probleme ergeben. Daher ist es vorteilhaft, stattdessen bereits gebaute Klassen der Standardbibliothek (wie z. B. aus java.lang.atomic.* und java.util.concurrent.*) zu nutzen. Diese ermöglichen es, besser verständliche und benutzerfreundliche Abstraktionen zu benutzen, anstatt sie (wahrscheinlich mit subtilen Bugs) neu zu erfinden.

Neu entstehende Klassen

Java bietet mit der Lambda-Syntax eine einfache Möglichkeit, in funktionalen Patterns zu entwickeln, wie in den meisten zeitgemäßen Sprachen üblich. Die dazu nötige Rüstzeug fußt aber nach wie vor auf dem objektorientierten Grundgerüst, das die JVM zur Verfügung stellt.

Die erfahreneren Kolleg:innen erinnern sich sich wahrscheinlich noch an die Syntax, die vor der Einführung der Lambda-Syntax für solche Fälle verwendet wurde: anonyme Klassen. Wir sehen es sehr deutlich, wenn wir beide gegenüberstellen:

Listing 7:

Stream<String> oldStyle(Stream<String> aStream) {
  return aStream.map(new Function<String, String>() {
    @Override
    public String apply(String s) {
      return s + s;
    }
  });
}

Stream<String> lambdaStyle(Stream<String> aStream) {
  return aStream.map(s -> s + s);
}

Der Lambda-Stil scheint deutlich weniger Overhead zu erzeugen, es wird nicht etwa erst eine neue Klasse geladen, eine "Platzhalter"-Instanz erzeugt und dann schließlich übergeben. Aber im Hintergrund gelten immer noch die gleichen Spielregeln der JVM: Alles ist ein Objekt. An diesem Prinzip wird mit der neuen Syntax nichts geändert – allerdings mit einem kleinen Twist: Die Klasse, die das Lambda repräsentiert, wird nicht mehr von der:dem Entwickler:in geschrieben und vom Compiler übersetzt, sondern wird mittels eines Bootstrap-Prozesses zur Laufzeit generiert.

Was in der kompilierten Klasse allerdings vorgehalten wird, ist ein wenig Infrastruktur, um den Prozess der Generierung dieser neuen Klasse zu ermöglichen und unterstützen. Diese Infrastruktur sorgt dafür, dass der Körper des Lambdas zu einer statischen Methode der "Wirtsklasse" und mittels der LambdaMetafactory eine Implementierung des Lambda-Interface erzeugt wird, die effizient auf diese Methode weiterleitet. Alle Parameter, die das Lambda per Closure bekommt, werden in dieser erzeugten Instanz gespeichert.

Das klingt erst einmal unintuitiv, hat aber in der Performance echte Vorteile. Aber es bedeutet auch, dass diese neu generierten Klassen sich fast genauso verhalten wie Klassen, die vom Compiler generiert werden. Das heißt, dass möglicherweise auch Effekte, wie wir sie im Abschnitt "Unerwartete Referenzen" gesehen haben, auch hier zum Tragen kommen. Die generierte Klasse kann eine Referenz zur "äußeren" Klasse beinhalten – hat sie aber nicht notwendigerweise.

Die Entscheidung, ob die Klasse diese Referenz erhält, wird je nachdem gefällt, ob im Lambda Daten des umgebenden Objektes referenziert werden. Die Regel hierbei ist, dass für jedes Lambda eine Implementierung generiert wird. Nicht zu jedem Aufruf des Lambdas oder jeder Parameter-Kombination. Also wird alles von der Umgebung in die Closure übernommen, was erreichbar ist.

Inwiefern ist dies, abgesehen von der Scope-Erweiterung, eine Fußangel? Vor allem beim Debugging ist es oftmals sehr problematisch, da diese generierten Klassen nicht unbedingt so "aussehen", wie es der Entwickler erwartet. Insbesondere wenn Lambdas andere Lambdas erzeugen, wird es zu einem Suchspiel, wo genau eine Referenz in der Closure gefangen wurde. Ein Stacktrace wird nicht nur unübersichtlich, die Lambda-Proxies geben oftmals auch in der Feldansicht nichts besonders Wertvolles von sich preis, da alle Semantik verloren geht. Die gefangenen Felder heißen einfach "arg0, …". Kaum jemand dürfte sich die "guten alten Zeiten" mit Ein-Buchstaben-Variablen ernsthaft zurückwünschen.

Eine recht einfache Lösung ist es hier, soweit wie sinnvoll möglich, auf Inline-Lambdas zu verzichten und stattdessen eine (statische) Methodenreferenz zu nutzen, sobald ein Lambda nicht mehr trivial ist. Dadurch landet man direkt wieder im "normalen" Kontext des Objektes und kann in den dort geltenden Bedingungen weitersuchen.

Zum Abschluss

Natürlich gibt es noch einiges mehr zu entdecken, wie zum Beispiel die generierten "forwarder"-Methoden, die generische Interfaces ermöglichen, den bizarren Lebenszyklus von Objekten, und die Tatsache, dass die throws-Deklaration schon seit Langem Makulatur ist. Einiges davon habe ich in einem GitHub-Repository zusammengetragen [2].

Trotz aller (teils berechtigten) Kritik ist Java nach wie vor eine lebendige und aktive Sprache, die sich ständig weiterentwickelt, um im Marktplatz weiterhin Relevanz (wenn auch nicht länger Dominanz) zu zeigen. Die sich dadurch zwangsläufig ergebenden überraschenden Effekte sind ein Preis, der dabei unweigerlich auftritt. Die einzige Methode, diesen Preis nicht zu zahlen, wäre, die Entwicklung der Sprache zu stoppen, was niemand ernsthaft will. Daher: Es lohnt sich wohl, die Fallen zu kennen – und sie in Kauf zu nehmen.

Autorin

Elisabeth Schulz

Elisabeth Schulz hat ihre lebenslange Leidenschaft für Computer kurzerhand zum Beruf gemacht und arbeitet bei der codecentric AG in Kundenprojekten.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben