Home ORDIX AG             Dienstleistung             Trainingsshop    Kunden / Referenzen Aktuelles    Kontakt
Home  Pfeil  ORDIX News  Pfeil  2/2009  Pfeil  Java/JEE
suche: 
Dieser Artikel richtet sich an Java-Entwickler, die sich für Tipps und Anregungen bei der                Ausnahmebehandlung interessieren.

Glossar

J2SE-API
Java 2 Standard Edition - Application Programming Interface. Beschreibung der Programmierschnittstellen der Pakete, die mit der Java Plattform ausgeliefert werden.
JUnit
Ein Open Source Framework zur Durchführung von automatisierten Tests von Java-Programmen.
JVM
Java Virtual Machine. Programm, in dem Java-Programme ausgeführt werden. Eine JVM ist vom Betriebssystem abhängig, während das Java-Programm selbst unabhängig vom Betriebssystem ist.
Log4j
Ein Open Source Framework der Apache Software Foundation zum Protokollieren von Anwendungsmeldungen in Java.
Stacktrace
Ein Stacktrace steht zur Verfügung, wenn es zu einem Ausnahmezustand kommt. Mit seiner Hilfe kann die Aufrufkaskade rekonstruiert werden.
Best Practice Titelbild



Java Best Practice (Teil IV)

Ausnahmen sind die Regel


Für den Umgang mit Fehlern und Ausnahmesituationen bietet Java auf Sprachebene ein durchaus komfortables und ausgefeiltes Konzept in Form des Exception Handling. Es ist die Aufgabe des Entwicklers, dieses Konzept zielführend einzusetzen. Nachfolgende Tipps und Anregungen können dabei sicherlich helfen.

Abb. 1: Exception-Klassenhierarchie. checked exceptions sind vom (Sub-) Typ Exception, unchecked exceptions sind vom (Sub-)Typ Error und RuntimeException.
Abb. 1: Exception-Klassenhierarchie. checked exceptions sind vom (Sub-) Typ Exception, unchecked exceptions sind vom (Sub-)Typ Error und RuntimeException.
 

Tipp 1: RuntimeExceptions sind zwingend zu vermeiden. Robuster Code muss so geschrieben werden, dass Ausnahmen vom Typ RuntimeException nicht mehr auftreten.

Tipp 2: Ausnahmen vom Typ Error kann der Entwickler ignorieren. Eine sinnvolle Behandlung ist kaum möglich.

Tipp 3: Checked Exceptions müssen nicht vermieden, sondern behandelt werden!

Tipp 4: Ist ein Ausnahmezustand in einer Methode nicht zu beheben, ist eine Eskalation angesagt. Angeforderte Ressourcen müssen unbedingt wieder freigegeben werden.

Tipp 5: Das Auftreten von Ausnahmen in kritischen Programmbereichen sollte mit JUnit getestet werden.

Tipp 6: Auch wenn ein Problem nicht lösbar ist, darf es nicht verschwiegen werden.

Tipp 7: Alle Exceptions, die nicht angemessen programmtechnisch behandelt werden können, sollten protokolliert werden.

Tipp 8: java.text.MessageFormat (aus der J2SE-API) unterstützt hervorragend die einheitliche Verwendung und dynamische Erzeugung von Fehlertexten.

Tipp 9: log oder throw! Entweder den Fehler (maximal einmal) protokollieren oder weiterreichen.

Tipp 10: Bei der Erzeugung neuer Exceptions möglichst immer den Konstruktor mit beiden Parametern verwenden, also Exception(String message, Throwable cause).

Tipp 11: Die Reduzierung auf Exception ist unbedingt zu vermeiden. Verwenden Sie stattdessen für den Ausnahmefall spezifische und geeignete Exception-Typen.

Tipp 12: Keine leeren Catch-Blöcke und kein return im Finally-Block! Unterdrücken und ignorieren Sie Ausnahmen nie. Sie sind für immer verloren.

Abb. 2: Tipps zum Exception Handling.
 
@org.junit.Test(expected=IndexOutOfBoundsException.class)
public void testListIdx() {
	List list = new ArrayList(); 
	list.get(0);
}
Abb.3: Mit JUnit lässt sich problemlos das erwartete Auftreten von Ausnahmen testen. Der Test schlägt fehl, wenn die Liste nicht leer ist.
 
catch (ClassNotFoundException e) {
	LOG.error("Blah", e);
	throw e;
}

catch (ClassNotFoundException e) {
	LOG.error("Blah", e);
	throw new MyServiceException("Blah", e);
}

catch (ClassNotFoundException e) {
	e.printStackTrace();
	throw new MyServiceException("Blah");
}
Abb. 4: Negativbeispiel. Abfangen der Ausnahme, Logging bzw. Stacktrace-Ausgabe und anschließendes Weiterleiten mit throw.
 
public void foo() throws Exception {...}

public void foo() throws MyException,
	AnotherException, SomeOtherException,
	YetAnotherException {...}

try {
	foo();
} catch (Exception e) {
	LOG.error("foo failed", e);
}
Abb. 5: Die Deklaration und Behandlung von Ausnahmen sollte bezüglich des Exception-Typs möglichst detailliert sein. Exception allein ist unzureichend.
 
catch (NotSuchMethodException e) {
	;
}

catch (NotSuchMethodException e) {
	return null;
}

try {
	blah();
} finally {
	cleanUp();
	return null ;
}
Abb. 6: Leere catch-Blöcke oder lediglich ein return sowie return im finally-Block führen zum Verlust der Ausnahme.

Keine Regel ohne Ausnahmen

Die Aufgabe der Entwicklung ist es, Daten und Prozesse entsprechend der Spezifikation zu verarbeiten. Hierzu werden Werkzeuge gesucht und Software-Lösungen realisiert. Doch mit der Umsetzung ist die Arbeit noch nicht beendet!

Was ist, wenn die Ausgangsdaten in der Datenbank nicht gefunden werden, die Datei nicht geparst werden kann, bei der Berechnung durch Null dividiert wird, der Nutzer über die GUI eine negative Zahl angegeben hat oder in verteilten Systemen benötigte Sub-/Fremdsysteme nicht zur Verfügung stehen? Ausnahmen dieser Art sind unvermeidbar, sie sind die Regel - keine Regel ohne Ausnahmen.

Wesentlicher Vorteil der Ausnahmebehandlung durch Exception Handling ist zunächst einmal die syntaktische Trennung der Ausnahmen von der regulären Datenverarbeitung. Bei der Verwendung von Exceptions wird der Programmfluss nicht durch Abfrage des Rückgabestatus unterbrochen. Ein spezieller Programmbereich überwacht potentielle Ausnahmen und ruft gegebenenfalls einen Programmcode zur Behandlung auf.

Typische Unsicherheiten

Bei Java-Entwicklungen tauchen häufig folgende typische Unsicherheiten auf:

Wer kümmert sich eigentlich um die Ausnahmen? Was ist der Unterschied zwischen checked und unchecked exceptions und was hat es mit dieser Unterscheidung auf sich? Gibt es nicht doch bessere Ansätze als einfach try/catch/printStackTrace()? Warum stürzt das Programm ohne Fehlermeldung ab? Existiert da tatsächlich irgendwo ein leerer catch-Block? Wie ich den Fehler beheben kann, weiß ich nicht. Aber ein System.err.println(...)ist schnell eingetippt. Besser wäre aber ein standardisiertes Protokollieren (Logging). Schön wäre auch zu wissen (testen), ob eine Ausnahme wie erwartet ausgelöst wird.

Ausnahme ist nicht gleich Ausnahme

Ausnahmen sind nichts anderes als Objekte aus der Exception-Klassenhierachie (siehe Abbildung 1). Alle Ausnahmen sind mindestens von Throwable abgeleitet, d. h. sie können prinzipiell erst einmal ausgelöst werden. Bei den Exception-Klassen sind generell zwei verschiedene Typen zu unterscheiden: geprüfte Ausnahmen (checked exceptions) und nicht geprüfte Ausnahmen (unchecked exceptions).

Die beiden Begriffe checked bzw. unchecked kategorisieren Ausnahmen, die entweder im Code geprüft oder deklariert (checked) bzw. nicht geprüft werden müssen (unchecked).

Bei den checked exceptions überwacht der Compiler die festgelegten Regeln: wo und wann können sie auftreten bzw. müssen sie behandelt werden. Die unchecked exceptions können zu jedem Zeitpunkt von der JVM oder auch explizit durch einen Code ausgelöst werden. Sie führen ohne Behandlung immer zum Abbruch der Ausführung bzw. des laufenden Threads. Die Unterscheidung zwischen checked und unchecked ergibt sich ganz einfach aus der Klassenhierarchie (siehe Abbildung 1): Zu den unchecked exceptions gehören alle Error- und RuntimeException-Klassen, zu den checked exceptions alle anderen Klassen

RuntimeException

Ausnahmen vom Typ RuntimeException sind unchecked und signalisieren - einfach gesagt - technische Fehler im Code (Programmier- und/oder Denkfehler), die der Compiler nicht aufdecken kann. So führt z. B. eine ganzzahlige Division durch Null zu einer ArithmeticException und ein ungültiger Indexwert beim Zugriff auf Array-Elemente zu einer ArrayIndexOutOfBoundsException(siehe Tipp 1 in Abbildung 2).

Der Index des Arrays und die Objektreferenz zur Vermeidung der NullPointerException sind genauso zu prüfen, wie die Gültigkeit arithmetischer Operationen.

Error-Klassen

Zu den unchecked exceptions gehören auch die Error-(Sub)-Klassen. Diese gelten in der Regel als nicht behebbar und sollten folglich im Code nicht abgefangen werden. Es ist kaum möglich, aus dem Programm heraus zum Beispiel LinkageError (die Klasse kann zur Laufzeit nicht eingebunden werden) oder OutOfMemoryError sinnvoll zu behandeln. Hier muss die Ursache analysiert und "von außen" behoben werden (siehe Tipp 2 in Abbildung 2).

Checked Exceptions

Checked exceptions sind dagegen Ausnahmen, die durch den Programmfluss bzw. der Programmumgebung und nicht durch einen fehlerhaften Code verursacht werden.

Hierunter fallen z. B. Klassen, die nicht gefunden werden (ClassNotFoundException), Ein- und Ausgabefehler (IOException) oder auch fachliche- und applikationsspezifische Ausnahmen, für die i. d. R. eigene Exception-Klassen existieren. Diese Fehler müssen in einem robusten Programm unbedingt abgefangen werden. Außerdem müssen - je nach Aufgabe - Strategien implementiert werden, wie diesen Fehlern (situationsabhängig) zu begegnen ist. Auf Basis der vorliegenden Fehlerinformationen sollte es möglich sein, sinnvoll reagieren zu können, z. B. in Form von Alternativen, Wiederherstellungs-Mechanismen, Verbindungsauf-/abbau etc. Ziel dabei ist es, das Programm in einem stabilen Zustand zu halten, gegebenenfalls unter akzeptablen Einschränkungen (siehe Tipp 3 in Abbildung 2).

Eine ParseException, die beispielsweise bei der Umwandlung einer Zeichenkette in ein Datum auftritt, zeigt an, dass die Zeichenkette nicht das korrekte Format hat. Bei einer Benutzerinteraktion könnte die Behandlung so aussehen: Anzeige eines entsprechenden Hinweises mit der Aufforderung zur Neueingabe im passenden Format

Ohne Benutzerinteraktion ist die Situation schon schwieriger. Wird ein ungültiges Datumsformat an die Methode übergeben, so kann die Methode diese Ausnahme nicht beheben. Sie hat jetzt nur die Möglichkeit, die ParseException an den Aufrufer weiterzuleiten (siehe Tipp 4 in Abbildung 2).

Um sicher zu stellen, dass angeforderte Ressourcen wieder freigegeben werden, eignet sich der Finally-Block.

Testen

Das korrekte Auslösen von Exceptions lässt sich sehr leicht mit JUnit-Tests überprüfen. Entweder mit der Methode fail() oder ab JUnit 4.x noch einfacher mit dem Attribut expected=Throwable1[,...ThrowableN] (siehe Abbildung 3).

Insbesondere kritische Programmbereiche, z. B. der Persistenz-Layer von JEE-Anwendungen, erfordern Robustheit und Zuverlässigkeit. Hier sollte unbedingt das Verhalten bei Störungen während Transaktionen und das Funktionieren des Rollbacks getestet werden (siehe Tipp 5 in Abbildung 2).

Interessant und hilfreich ist auch der Einsatz von Assertions (ab Java 1.4), um die Stabilität weiter zu erhöhen und mögliche Fehler gänzlich zu vermeiden. Über dem Befehl assert Expression1 [: Expression2] sind Zusicherungen auf der Compiler-Ebene überprüfbar, z. B. ob einer Methode ungeeignete bzw. verbotene Parameterwerte übergeben wurden. Liefert der Ausdruck false, erzeugt die JVM ein AssertionError.

Ausgabe, Logging und Fehlermeldungen

Problemlösungen beim Auftreten einer Ausnahme sind selten. In vielen Fällen werden die Ausnahmen daher nur in Form einer allerletzten Eskalation behandelt: Die Ausnahme wird protokolliert (siehe Tipp 6 in Abbildung 2).

Logging, z. B. in eine Log-Datei oder eine SQL-Datenbank, ist eine große Hilfe während der Entwicklungszeit oder auch später im produktiven Einsatz. Log4j ermöglicht über eine Steuerdatei das selektive Ein- und Ausschalten von Log-Meldungen. Ausgaben über System.out... oder System.err... im catch-Block sind unbrauchbar. Die Konsole ist nicht immer sichtbar, die Meldungen sind statisch und zur Laufzeit nicht konfigurierbar. Für die Ausgabe sollte immer und nur ein standardisierter Logging-Mechanismus benutzt werden (siehe Tipp 7 in Abbildung 2)!

Wichtig hierbei ist ein festgelegtes und einheitliches Format der Log-Einträge, damit die Ausgaben später mit Tools auswertbar sind. Der Einsatz von Exception-ID's vereinfacht in verteilten, mehrschichtigen Multithreading-Systemen die Zuordnung und Identifizierung von Fehlern (siehe Tipp 8 in Abbildung 2).

Catch-Block mit Fehlerausgabe und Weiterleiten mit throw

Die Varianten der Fehlerbehandlung in Abbildung 4 sind Negativbeispiele. Durch solche Catch-Blöcke könnte es sehr leicht passieren, dass die Exception weiter oben in der Aufrufhierarchie erneut protokolliert wird.

Mit der Kombination aus Fehlerausgabe und Weiterleiten (throw) entstehen sehr oft doppelte Log-Einträge bzw. Stacktrace-Ausgaben. Die Fehleranalyse und Zuordnung des Stacktraces wird dadurch unnötig erschwert. Redundante Log-Einträge kann niemand gebrauchen (siehe Tipp 9 in Abbildung 2).

Auch die Umwandlung der abgefangenen ClassNotFoundException in eine MyServiceException ist kritisch. Die Verwendung eines neuen Exception-Typs ist nur dann sinnvoll, wenn der Aufrufer dadurch weitere Informationen oder Reaktionsmöglichkeiten erhält. Das heißt, die Klasse MyServiceException müsste mindestens zusätzliche Schnittstellen haben. Ansonsten verschwendet allein die Objekterzeugung nur Ressourcen und es entsteht kein Mehrwert, es sei denn, die Umwandlung ist technisch bedingt, wie z. B. in JEE-Anwendungen, wo bestimmte Exception-Typen nicht beim Client ankommen dürfen oder sollen (siehe Tipp 10 in Abbildung 2).

Catch und Throws mit der Klasse Exception

Die Basisklasse java.lang.Exception ist mit Vorsicht zu benutzen. Exception ist einfach zu unspezifisch und viel zu generisch. Insbesondere der (bewusste) Einsatz von checked exceptions wird hierdurch vereitelt, denn auch RuntimeException ist als Unterklasse typkompatibel und somit auch eine Exception. Durch ein einfaches throws Exception (siehe Abbildung 5) bekommt der Aufrufer lediglich die Information, dass irgendetwas in der Methode schief gehen kann. Existieren mehrere potentielle Fehlerquellen/-ursachen, auf die ein Client unterschiedlich reagieren kann, sollten die zugehörigen Exception-Typen auch entsprechend deklariert sein. Ansonsten geht dem Aufrufer ein wichtiger Detaillierungsgrad bezüglich der Ursache und der Reaktion verloren.

Ähnlich problematisch wie mit throws Exception verhält es sich auch mit catch(Exception). Sollte die aufgerufene Methode foo() später zusätzliche Exceptions deklarieren, ist der Code compiler-technisch weiterhin korrekt. Alle weiteren Exception-Typen werden ungemerkt mit abgefangen. Die neue Exception geht quasi verloren und ist damit praktisch wertlos (siehe Tipp 11 in Abbildung 2).

Ausnahmeverlust

Ausnahmen können leider sehr mühelos verloren gehen. Der Sourcecode in Abbildung 6 ist compilierbar und zeigt den worst case der Ausnahmebehandlung. Die aufgetretene NotSuchMethodException geht für immer und ewig verloren, inklusive dem Stacktrace. Die Idee von Aufräumaktionen (z. B. Ressourcenfreigabe) in cleanUp() innerhalb des Finally-Blocks ist ein guter Ansatz. Aber auch hier ist Vorsicht geboten: return im Finally-Block unterdrückt die Ausnahme, sie wird nicht nach außen geliefert. Und sollte cleanUp() selbst eine Exception werfen, geht die NotSuchMethodException ebenfalls verloren.

Die Folgen sind gravierend. Es kommt zu nicht reproduzierbaren Programmabstürzen, ohne die geringste Chance der Fehleranalyse. Wenn eine Behandlung nicht möglich ist, muss mindestens ein e.printStackTrace() herhalten -besser natürlich ein Log-Eintrag (siehe Tipp 12 in Abbildung 2)!

Fazit

Exception Handling ist eine interessante und anspruchsvolle Aufgabe. Wichtig ist das Bewusstsein, dass Fehler und Ausnahmen auftreten werden. Der korrekte und pflichtbewusste Umgang mit diesen Situationen zählt ebenso zu den Aufgaben des Entwicklers wie etwa eine korrekte Programmlogik und Datenverarbeitung.

  Teil I: Java-Objekte in der Identitätskrise
Teil II: Konfiguration der IDE
Teil III: Typisch Ant
Teil IV: Exception Handling
Teil V: Software-Engineering


Ingo Vogt (info@ordix.de).