allgemein
objekt-orientierter entwurf
programmierung
qualitätssicherung / testen

bugTeaser  #005: Die entflohene Referenz  
Schwierigkeitsgrad  schwierig 
Lösung veröffentlicht am   12.04.05 

Erklärung

Schaut man sich den Code des vorliegenden bugTeasers an, so scheint er auf den ersten Blick durchaus in Ordnung. Allenfalls die Verwendung einer anonymen Klasse zur Implementierung des Listeners ist vielleicht etwas ungewöhnlich; schliesslich hätte die ModelChangeLogger-Klasse auch ohne implementiert werden können:


public class ModelChangeLogger 
       implements IModelChangeListener {

  public ModelChangeLogger(Model model) {
    model.addListener(this);
  }

  public void onChange(Event e) {
    log(e+"");
  }

  public void log(String msg) {
    // Implementation nicht von Belang
    ... 
  }

}


In dieser Variante implentiert die Klasse die nötige Listener-Schnittstelle einfach direkt. Auch diese Lösung entält aber den gleichen schwerwiegenden Fehler, der die ModelChangeLogger-Klasse zu einem Paradebeispiel einer brüchigen Implementierung macht, die nur darauf wartet, uns bei der besten oder vielmehr schlechtesten Gelegenheit um die Ohren zu fliegen.

Wo liegt denn nun der Fehler, und wann kommt es zu Problemen? Fangen wir mit den Problemen an. Um es kurz zu machen: die Implementierung von ModelChangeLogger ist nicht thread-safe, da es zu sog. Race Conditions kommen kann. D.h. der Zustand von bestimmten Datenwerten hängt von der Ausführungsreihenfolge der ausgeführten Threads ab, die konkurrierend auf diese Daten lesend und oder schreibend zugreifen. Diese Sorte Fehler ist besonders unangenehm, weil sie meist nur unter Last und oft sogar nur auf Multiprozessorsystemen auftritt und nur sehr schwierig zu entdecken ist.

Dem aufmerksamen Leser wird aufgefallen sein, dass es sich bei ModelChangeLogger um eine konkrete Klasse handelt, die instantiiert und verwendet werden kann. Und wenn die Klasse stets nur so verwendet werden würde, gäbe es auch keine Probleme. Leider hat der Designer der Klasse vergessen, diesen Sachverhalt explizit zu machen und zu verhindern, dass die Klasse auch als Basisklasse verwendet werden kann; damit hat er Tür und Tor für die oben genannten Probleme geöffnet.

Um zu verstehen, wo der Hund begraben liegt, müssen wir uns ansehen, wie in Java die Instantiierung von Objekten im Zusammenhang mit Vererbung abläuft: Java garantiert, dass der Konstruktor einer Klasse immer aufgerufen wird, wenn eine Instanz dieser Klasse erzeugt wird. Weiter ist garantiert, dass der Konstruktor auch dann aufgerufen wird, wenn eine Instanz einer abgeleiteten Klasse erzeugt wird. Für das Einhalten dieser zweiten Garantie ist es nötig, dass jeder Konstruktor den Konstruktor seiner Basisklasse entweder implizit oder explizit aufruft. Da z.B. die ModelChangeLogger-Klasse direkt von der Java-Klasse Object abgeleitet ist, und der Konstruktor von ModelChangeLogger keinen expliziten Basiskonstruktoraufruf enthält, fügt der Compiler einen impliziten super() Aufruf ein. Konstruktorenaufrufe werden also grundsätzlich verkettet; jedesmal, wenn ein Objekt erzeugt wird, dann werden die Konstruktoren in einer Kaskade von der abgeleiteten Klasse zur Basisklasse bis hinauf zur Wurzelklasse Object aufgerufen. Die Ausführung der Konstruktoren erfolgt dabei in umgekehrter Reihenfolge, d.h. von oben nach unten, so dass stets zuerst der Konstruktor von Object ausgeführt wird, und dann die Konstruktoren der Subklasse in der Klassenhierarchie hinunter bis zur ursprünglich instantiierten Klasse. Das hat eine wichtige Konsequenz: Wenn ein Konstruktor ausgeführt wird, dann kann er sich darauf verlassen, dass die Felder der Basisklassen bereits korrekt initialisiert sind. Beim eben Gesagten handelt es sich um Grundlagenwissen, dass jedem Java-Programmierer bestens bekannt sein dürfte.

Ein zweiter Aspekt der Konstruktion von Java-Objekten wird dagegen häufig vergessen. Es geht dabei um den Aufbau der Methodentabellen im Zusammenhang mit Vererbung. In Java können grundsätzlich alle nicht finalen Methoden in abgeleiteten Klassen überschrieben werden, sofern von der Klasse selber überhaupt Ableitungen zulässig sind. D.h. alle Methoden von als nicht final deklarierten Klassen, die nicht ihrerseits final oder private deklariert sind, können in Ableitungen überschrieben werden. Anders als in C++ oder ObjectPascal, wo mit virtual festgelgt werden muss, dass Methoden in abgeleiteten Klassen überschrieben werden können, ist in Java das Überschreiben also grundsätzlich möglich und muss bei Bedarf explizit unterbunden werden. Auch dies dürfte für einen Java-Entwickler keine Überraschung sein.

Was aber viele nicht wissen oder schlicht für nicht bedenkenswert halten, ist dies: die Methodentabellen einer Klasse, sind bereits vor der Initialisierung eines Objektes, d.h. vor dem oben beschriebenen Aufruf der Konstruktorenkette, vollständig angelegt. D.h. dass das Method-Dispatching bereits während der Initialisierung - also schon bei der Ausführung der Konstruktoren - auf der Basis der tatsächlichen Klasse des zu initialisierenden Objekts erfolgt. Genau darum existiert für die Programmierung mit Java eine wichtige Faustregel:

Rufen Sie keine nicht-finalen Methoden aus einem Konstruktor auf.

Der Grund dafür ist einfach: Da die Ausführung der Konstruktoren von oben nach unten erfolgt und das Method-Dispatching die tatsächliche Klasse bereits korrekt berücksichtigt, kann es passieren, dass die überschriebene Version einer Methode einer abgeleiteten Klasse aufgrufen wird, und zwar bevor der auf dieser Klasse definierte Konstruktor ausgeführt worden ist. Das heisst dann, dass der Aufruf der überschriebenen Methode auf einem Objekt in einem noch nicht gültigen Zustand erfolgt. Im folgenden Beispiel führt dieser Umstand dann zu einer NullPointerException:

public class ClassA {
  
  public ClassA() {
    meanAndDangerous();
  }

  protected void meanAndDangerous() {
    
  }
  
}

public class ClassB extends ClassA {
  
  private String fSomeField = "initialized";
  private int fLength; 
  
  public ClassB() {
    super();
    
  }

  protected void meanAndDangerous() {
    fLength = fSomeField.length();
  }
  
  public String toString() {
    return fSomeField + fLength;
  }
  
  public static void main(String[] args) {
   ClassA obj = new ClassB();
   System.out.println(obj);
  }
  
    
}



Der Aufruf der Methode meanAndDangerous() erfolgt zu einem Zeitpunkt, zu dem die auf Klasse B definierten Felder noch nicht initialisiert worden sind.

Mit diesen Informationen dürfte es nun ein Leichtes sein, den Fehler im vorliegenden bugTeaser zu entdecken, da es sich im Prinzip um genau das gleiche Problem handelt: Durch die Registrierung als Listener im Konstruktor des ModelChangeLoggers geben wir eine Referenz auf this nach aussen, die in einer Multi-Threading-Umgebung dazu führen kann, dass noch während der Ausführung des Konstruktors eine Listener-Benachrichtigung eintrifft und die log() Methode ausgeführt wird. Das heisst dieser Code verletzt die oben angeführte Faustregel, da es aufgrund der entflohenen this-Referenz geschehen kann, dass die nicht als final deklarierte log() Methode aufgerufen wird, bevor die Konstruktion vollständig abgeschlossen ist. Das führt dann im folgenden Beispiel einer von ModelChangeLogger abgeleiteten Klasse zu einer gefährlichen Race-Condition, die die Stabilität der Anwendung ernsthaft gefährdet:


public class BrokenModelChangeLogger 
       extends ModelChangeLogger {

  private List fMessages = new ArrayList();

  public BrokenModelChangeLogger(Model model) {
    super(model);
  }

  public void log(String msg) {
    super.log(msg);
    fMessages.add(msg); // Potential risk!!!
  }

}


Wird die Klasse ModelChangeLogger oder zumindest die Methode log() final deklariert, können wir dieses Problem einfach verhindern.

Abschliessend noch eine Anmerkung zur Listener-Registrierung an sich: Es fällt auf, dass die Klasse ModelChangeLogger keine Möglichkeit der Listener-Deregistrierung vorsieht. Ob es sich hierbei um ein Versehen oder um Absicht handelt, wissen wir nicht. In jedem Fall liegt hier eine weitere mögliche Quelle für Probleme im Zusammenhang mit sog. Vagabunden (engl. Loiterers), der Java-Variante der MemoryLeaks. Doch das ist eine andere Geschichte und soll ein andermal erzählt werden.


Über diesen bugTeaser diskutieren.

Druckbare Version