Artikel

 
Juli 2005 | Artikel

Mit dem Tiger in der Falle

(Link zum Artikel: http://www.it-republik.de/jaxenter/artikel/0740)

Java 5 Pitfalls: Fallstricke und Best Practices bei der Entwicklung mit Java 5

Text: von Josef Adersberger
Der Tiger ist nun fast ein Jahr alt - erste Pilotprojekte sind gestartet und diverse Schulungen und Beratungsleistungen verkauft. Der hinzugekommene syntaktische Zucker schmeckt vielen Entwicklern. Spätestens mit der für 2006 geplanten J2EE-Version 5.0 werden die in J2SE 5.0 veröffentlichten Sprachfeatures zum Massengut. Höchste Zeit, Erfahrungen zu den entdeckten Fallstricken bei der Entwicklung mit dem Tiger-Release zusammenzufassen. Der Artikel setzt grundlegende Kenntnisse der in Java 5 eingeführten Sprachkonstrukte voraus.

Sun hat sich bei der Entwicklung von Java 5zwei oberste Ziele gesetzt: Erstens soll die Entwicklung mit Java einfacher werden und zweitens soll dabei die Abwärtskompatibilität gewahrt bleiben. Um mit Java 1.x kompatibel zu bleiben, wurde in den Java-Compiler eine Art Präprozessor integriert, der die neuen Sprachkonstrukte aus Java 5 erkennt und in herkömmlichen Java-Code übersetzt. So wird aus der schönen neuen Foreach-Schleife wieder die gute alte For-Schleife mit Iterator oder Schleifenzähler. Die Laufzeitumgebung (JVM) kennt weiterhin nur den klassischen Bytecode ohne Generics, Autoboxing et al. Das ist der Grund für viele der Fallstricke in Java 5, die im folgenden Artikel, strukturiert nach dem Sprachkonstrukt, beschrieben werden.

Autoboxing: Der Programmierer muss wissen, was er tut
Die transparente Umwandlung von primitiven Datentypen zu ihrer Objektrepräsentation und zurück erspart hässliche Methodenkaskaden beim Zusammenspiel von algebraischen Operationen mit generischen Konstrukten wie dem Collections-Framework oder die Verwendung von unnötig mächtigen Klassen wie BigDecimal.
Das Boxing, die Umwandlung eines Datentyps zu seiner Objektrepräsentation, kostet Rechenzeit und Hauptspeicherplatz: Ein neues Objekt muss erzeugt und befüllt werden. Da dies mit Autoboxing transparent erfolgt, besteht die Gefahr, dass der Entwickler nach einiger Zeit, mit zunehmender Selbstverständlichkeit des Autoboxing, das Bewusstsein für das mögliche Performanceproblem verliert. Im ersten Codefragment in Listing 1 ist klar ersichtlich, dass hier viele Objekte erzeugt werden. Das zweite Codefragment verschleiert dies jedoch durch die Verwendung des Autoboxing. Die ersten beiden Codefragmente in Listing 1 sind um ca. den Faktor zehn langsamer als das dritte Codefragment, das eine mögliche Alternative darstellt. Schlimmer als der Performanceverlust durch die Objekterzeugung ist die erhöhte Beschäftigung des Garbage Collector - besonders bei Server-Anwendungen. Es kann hier sogar vorkommen, dass der Garbage Collector bei den vielen neu erzeugten Objekten mit dem Aufräumen nicht mehr nachkommt und das gesamte System blockiert, um in Ruhe arbeiten zu können.

Listing 1
Autoboxing und Performance
  1. final long LOOPS = 10000l;
  2. //klassisches Fehlermuster
  3. List integerList = new ArrayList(LOOPS);
  4. for (int i = 0; i < LOOPS; i++){
  5. integerList.add( new Integer(i) );
  6. }
  7. //Java 5.0-Fehlermuster
  8. List<Integer> integerList = new ArrayList<Integer>(LOOPS);
  9. for (int i = 0; i < LOOPS; i++){
  10. integerList.add(i); //Autoboxing
  11. }
  12. //performantere Version
  13. int[] intArr = new int[LOOPS];
  14. for (int i = 0; i < LOOPS; i++){
  15. intArr[i] = i;
  16. }
Wie bei so vielen Fehlermustern greift auch hier kein Schwarz-Weiß-Denken. Oft entscheidet der Zusammenhang, ob ein Fehlermuster wirklich einen Fehler darstellt. So kann es sein, dass, bezogen auf das Beispiel, dringend eine List benötigt wird und kein Array. Wandelt man später den Array in eine List um, so kommt es eben dann zu einer vielfachen Objekterzeugung - eine List kann nun mal nur Objekte enthalten, außer man nutzt spezielle Implementierungen [1] [2] [3]. Folgendes deutet aber auf einen Flaschenhals im Code hin:
  • Hohe Anzahl der Schleifendurchläufe im gesamten Programmlauf (auch 100 Iterationen pro Schleife ist kritisch, wenn im Aufrufweg drei solcher Schleifen geschachtelt sind).
  • kurze Lebensdauer der aus dem Boxing entstandenen Objekte. Dies deutet darauf hin, dass die Objektrepräsentation unter Umständen nicht wirklich benötigt wird, da sofort wieder eine Umwandlung zurückstattfindet.
  • mehrfaches Boxing mit gleichen Werten. Da alle Objektrepräsentationen (Float, Integer, Double etc.) unveränderbar (immutable) sind, können Referenzen auf sie geteilt werden, ohne Seiteneffekte fürchten zu müssen. Wandelt man hingegen in einer Schleife immer wieder denselben primitiven Datenwert in ein Objekt um, so entstehen hier unnötig viele Instanzen.
Den letzten Punkt hat auch Sun erkannt und das Integer Caching in den Boxing-Mechanismus integriert. In der Annahme, dass sich die Verwendung von Ganzzahlen in einer Glockenkurve um die 0 verteilt, werden alle Integer-Objekte im Intervall von -127 bis +128 bereits zum Ladezeitpunkt instanziiert und für den weiteren Gebrauch vorgehalten. Wird ein Boxing für ein int in diesem Wertebereich durchgeführt, so erhält man immer die gleiche Referenz auf ein Integer-Objekt. Dadurch funktioniert es in diesem Wertebereich auf einmal, dass Integer-Instanzen mit demselben Wert auch dieselbe Referenz besitzen und somit der ==-Operator implizit auch die Wertgleichheit ermittelt (Listing 2). Ähnlich dem bereits in Java 1.x bekannten Fehlermuster Vergleich von Strings per == statt per equals() entsteht hier Fehlerpotenzial: der DAP (Dümmste Anzunehmende Programmierer) sieht, dass es in vielen Fällen funktioniert und nimmt an, dass dies immer so ist. Dadurch können schwer ortbare Fehlerquellen entstehen - es sei denn, man sucht gezielt nach diesem Fehlermuster.

Listing 2
Integer Caching
  1. Integer intInner1 = 123;
  2. Integer intInner2 = 123;
  3. Integer intOuter1 = 500;
  4. Integer intOuter2 = 500;
  5. //Referenzvergleich im Integer-Caching-Bereich
  6. System.out.println( intInner1 == intInner2 ); //true
  7. //Referenzvergleich außerhalb des Integer-Caching-Bereichs
  8. System.out.println( intOuter1 == intOuter2 ); //false
Eine weitere Eigenheit des Java 5 Autoboxing, die den Entwickler zumindest nerven kann, ist, dass der Autoboxing-Mechanismus kein automatisches Upcasting kennt. So ist man es bei den primitiven Datentypen gewohnt, dass in der Hierarchie double - float - long - int eine Zuweisung ohne expliziten Cast möglich ist (wenngleich auch über die mathematische Korrektheit hiervon diskutiert werden kann). Bei einer Zuweisung, bei der der Autoboxing-Mechanismus mit ins Spiel kommt, ist dies nicht mehr der Fall (Listing 3): einem Long kann man nur einen primitiven Datentyp long zuordnen, nicht aber, wie man es durchaus erwarten könnte, den Datentyp int. Auch die Zuweisung von Arrays des primitiven Datentyps und Arrays des Objekt-Wrapper (Listing 3) ist nicht möglich - obwohl man es durchaus erwarten könnte. Aus Performance-Gründen ist es wohl besser so.

Listing 3
Typkompatibilität beim Autoboxing
  1. //Gültige Zuweisung bei primitiven Datentypen
  2. float fa = 123; //int zu float
  3. long la = 123; //int zu long
  4. //Ungültige Zuweisungen beim Autoboxing
  5. Float fb = 123; //int zu Float (Compiler-Error)
  6. Long lb = 123; //int zu Long (Compiler-Error)
  7. //Gültige Zuweisungen beim Autoboxing
  8. Float fc = 123f; //float zu Float
  9. Long lc = 123l; //long zu Long
  10. //Array-Typen werden nicht automatisch umgewandelt
  11. Integer[] iarr = new int[] {1,2,3,4}; //Compiler-Error
Für unvermutet auftretende Fehler beim Auto-Unboxing, der Umwandlung des Wrapper-Objekts zu einem primitiven Datentypen, sorgt das Fehlermuster der sicheren NullPointerException:
  1. Integer i = null;
  2. //...
  3. int i2 = i; //NullPointerException!
Wird ein Unboxing eines Wrapper-Objekts durchgeführt, das auf null gesetzt wurde, so tritt eine NullPointerException auf. Nicht wenige Entwickler würden hier erwarten (und nehmen dies auch bei der Programmierung an), dass im Beispiel der int-Wert auf null gesetzt wird oder der Compiler schon Alarm schlägt. Ist dieses Verhalten auch bekannt, so kann es leicht passieren, dass man in Methoden seine Vorbedingungen unvollständig prüft und einen Integer-Parameter mit null als Wert, der später einem int zugewiesen wird, akzeptiert. Der Quellcode einer solchen Methode sieht sehr harmlos aus und das Fehlerpotenzial wird schnell übersehen.
Diese Eigenheit ist freilich eine Grauzone in der Theorie des Autoboxing, die z.B. C# auch erst ab Version 2.0 mit den Nullable Types in den Griff bekommt. Gleichzeitig sind alle anderen beschriebenen Fallstricke des Java 5 Autoboxing in C# nicht zu finden. Zusammenfassend zu den Fallstricken des Autoboxing lässt sich sagen, dass die beschriebenen Fehlermuster recht einfach durch die automatisierte, statische Codeanalyse gefunden werden können. Es ist zu hoffen, dass die hierfür gängigen Werkzeuge wie FindBugs [4] diese Fehlermuster bald erkennen werden.
Generics: Sackgassen und Irrwege
Die Generics sind in Java 5 die große Ausnahme gegenüber der neuen Leichtigkeit des Seins. Mittlerweile ist viel und gute Literatur vorhanden, um sich in das Thema der Generics einzuarbeiten [5] [6] [7]. In der Praxis ergeben sich aber trotzdem immer wieder Probleme durch die Komplexität dieses Sprachmittels. Wie der folgende Abschnitt zeigt, wird durch die Generics der Code nicht unbedingt typsicherer, generischer und leichter verständlich.
Die Daseinsberechtigung der Generics ist die erhöhte Typsicherheit. Diese kann aber leicht durch die Raw Types ausgehebelt werden, die aus Kompatibilitätsgründen eingeführt wurden. Raw Types sind generische Klassen, die ohne Typ-Parameter verwendet werden, also an keinen Typen gebunden werden. Da die Laufzeitumgebung keine Generics kennt, können durch Zuweisung an seinen Raw Type einem streng typisierten Objekt ungültige Typen untergeschoben werden (Listing 4, Abb. 1). Die Java-Sprachspezifikation [8] erlaubt es, dass zukünftige Java-Versionen die Raw Types nicht mehr unterstützen.

Listing 4
Raw Types hebeln die Typsicherheit aus
  1. //Raw Type von List wird verwendet
  2. List wines = new ArrayList();
  3. wines.add( new Wine("Cabernet Sauvignon") );
  4. wines.add( new Beer("Chiemseer Braustoff") ); //erlaubt aber falsch
  5. //Zuweisung eines Raw Type zu einem generischen Typen
  6. //Erlaubt, aber Compiler-Warnung ?unchecked assignment?
  7. List<Wine> wines2 = wines;
  8. Wine wine1 = wines2.get(0);
  9. Wine wine2 = wines2.get(1); //ClassCastException!

Listing 5
Typ-Kontrollen an den Grenzen
  1. public List<Wine> deliverWine(List<Wine> wines){
  2. //Vorbedingung prüfen
  3. Assert.checkCollection(wines, Wine.class);
  4. //...
  5. //Liste dynamisch typsicher machen
  6. return Collections.checkedList(wines, Wine.class);
  7. }
  8. //Legacy Code -----------------------
  9. List wines = new ArrayList();
  10. wines.add(new Wine("Grauburgunder"));
  11. List delivered = deliverWine(wines);
  12. //Hier ClassCastException, obwohl Raw Type (vgl. Listing 4)
  13. delivered.add( new Beer("Wolpertinger Urstoff") );

Darum sollte in Java 5-Code auf die Verwendung von Raw Types verzichtet werden - generische Klassen sollten immer parametrisiert verwendet werden. Bei der Anbindung von Java 1.x-Code kommt man aber unter Umständen nicht um den Kontakt mit Raw Types herum. Hier muss man sich abschotten. Listing 5 zeigt, wie man sich in seiner heilen, typsicheren Welt von der bösen untypisierten Welt schützen kann (die verwendete Assert-Klasse befindet sich auf der Heft-CD). Wird eine Methode von fremdem Code aufgerufen (z.B. an den Außenschnittstellen eines Systems), dann sollten zumindest alle Collections überprüft werden, ob sie die korrekten Typen enthalten (Assert.checkCollection()). In die andere Richtung sollten zumindest alle Collections dynamisch typsicher gemacht werden, wenn sie an Legacy-Code herausgereicht werden. Die Collections-Klasse bietet hierfür die statischen Methoden checkedCollection(), checkedList() usw. Diese stülpen einen Wrapper um den jeweiligen Collection-Typ. Dieser wirft eine ClassCastException, sobald in eine Collection Elemente des falschen Typs eingefügt werden. Damit tritt die Ausnahme an der Stelle ihrer Ursache auf, dem Einfügen, und nicht erst, wenn das Element herausgenommen wird.
Ein Punkt, der Entwickler in Selbstzweifel treiben kann, ist die Typkompatibilität bei den Generics. Schon bei einem einfachen Beispiel gerät der Gedankengang ins Stocken:
  1. List<Beer> beers = new ArrayList<Beer>();
  2. List<AlcoholicDrink> alcoDrinks;
  3. alcoDrinks = beers; //Compiler-Error: Incompatible types

Wieso kann ich einer Ansammlung an alkoholischen Getränken keine Ansammlung an Bieren zuordnen - die Listenelemente sind doch zueinander typkompatibel? Der Grund hierfür liegt daran, dass sonst folgende Zeilen möglich wären:
  1. alcoDrinks.add( new Wine("Cabernet Sauvignon") );
  2. Beer beer = beers.get(0); //ClassCastException

Dadurch wären wieder Typ-Inkonsistenzen bzw. ClassCastExceptions möglich, obwohl durchgängig Generics verwendet wurden. Noch schlimmer wird die Logik zur Typ-Kompatibilität bei der Verwendung von Bounds bei der Typ-Parametrisierung:
  1. List<? extends AlcoholicDrink> liAlco;
  2. liAlco.add( new Wine("Grauburgunder") ); //Compiler-Fehler: Incompatible types
  3. liAlco.add( new AlcoholicDrink() ); //Compiler-Error
  4. liAlco.add( new Drink() ); //Compiler-Error
  5. liAlco.add( new Object() ); //Compiler-Error

Parametrisiert man einen Typen anhand von extends-Bound, so kann man einer Variable nichts mehr zuweisen, die in dem Typen parametrisiert ist (wie es der Parameter der add()-Methode ist). Der Grund dafür ist, dass sonst die Typsicherheit nicht mehr gewährleistet werden könnte. Die Begründung dafür liefert die Überlegung, welche konkreten Typen dem geboundeten Typen zugewiesen werden können: Einer List von ? extends AlcoholicDrink kann sowohl eine List von Wine als auch eine List von Beer zugeordnet werden:
  1. List<Wine> liWine;
  2. List<Beer> liBeer;
  3. List<? extends AlcoholicDrink> liAlco = liWine;
  4. List<? extends AlcoholicDrink> liAlco = liBeer;
  5. liAlco.add( ??? );

Eine Bierliste kann aber nur Sorten oder eventuelle Unterarten, wie z.B. Pils oder Weißbier enthalten; eine Weinliste nur Weine und ihre Unterarten. Somit gibt es keine Schnittmenge an gültigen Getränken, die in beide Listen passen würden und somit auch keinen geeigneten Paramter-Typen für die add()-Methode. Auch die super-Bound verhält sich in dieser Situation wenig intuitiv:
  1. List<? super AlcoholicDrink> liAlco;
  2. liAlco.add( new Object() ); //Compiler-Error: Incompatible types
  3. liAlco.add( new Drink() ); //Compiler-Error
  4. liAlco.add( new AlcoholicDrink() );
  5. liAlco.add( new Wine("Cabernet Sauvignon") );
  6. liAlco.add( new Beer("Chiemseer Braustoff") );

Hier können nur alle Subtypen der Bound dem Parameter zugewiesen werden - was man eigentlich bei super nicht erwartet. Auf die Spur führt hier dieselbe Überlegung wie oben: Einer List von ? super AlcoholicDrink kann eine List von Object, Drink oder AlcoholicDrink zugewiesen werden. Also akzeptieren alle diese Listen mindestens einen AlcoholicDrink oder einen seiner Derivate wie z.B. Beer. Auch in die andere Richtung, dem Auslesen von parametrisierten Werten, gibt es Regeln zu beachten:
  1. List<? extends AlcoholicDrink> liAlco;
  2. Drink drink = liAlco.get(0);
  3. AlcoholicDrink alcoDrink = liAlco.get(0);
  4. Beer beer = liAlco.get(0); //Compiler-Error: Incompatible types
  5. List<? super AlcoholicDrink> liAlco;
  6. AlcoholicDrink alcoDrink = liAlco.get(0) ; //Compiler-Error: Incompatible types
  7. Drink drink = liAlco.get(0); //Compiler-Error
  8. Object o = liAlco.get(0) ;

Aus einer List< ? extends AlcoholicDrink> kann man sich alle Supertypen von AlcoholicDrink herausholen. Aus einer List< ? super AlcoholicDrink> kann man nur Objects herausholen. Abpictureung 2 zeigt zusammenfassend die Typ-Kompatibilität bei der Verwendung von Bounds - beim Schreiben (Zuweisung an) und dem Lesen (Zuweisung von). Die kompatiblen Typen sind dabei in Wildcard-Syntax ausgedrückt. Die aufgezeigte Semantik gilt selbstverständlich nicht nur für Collections, sondern bei jeder Verwendung von Bounds bei der Typ-Parametrisierung.
Statische Importe
Der Import von statischen Elementen spart Tipparbeit. Typische Hilfsfunktionen (Math.sqrt(), LogManager.getLogManager()) und allgemeingültige Variablen (Answers.TO_THE_GREAT_QUESTION_OF_LIFE) können so referenziert werden, als seien sie der importierenden Klasse eigen. Die lästige Qualifikation der Container-Klasse entfällt. Es gibt dabei aber eine Fehlerquelle zu beachten: die Überlagerung von statischen Importen mit Methoden oder Feldern der Klasse. Hierfür ist nämlich spezifiziert, dass ein statischer Import nie eine interne Definition der Klasse überschreibt (Listing 6). Der Compiler meldet aber keinen Fehler. Dadurch kann es, besonders bei Wartungsarbeiten an einer Klasse, unklar werden, welche Methode gerade benutzt wird und falsche Annahmen getroffen werden.

Listing 6
Statische Importe
  1. import static java.lang.Math.sqrt;
  2. public class MyClass {
  3. public double sqrt(double x){ return x*x; }
  4. public void foo(){
  5. double res = sqrt(5d); //benutzt MyClass.sqrt()
  6. }
  7. }
Zusammenfassung
Java hat seine Unschuld verloren. Es muss noch viel Arbeit in Form von Java 5-Coding-Styles geleistet werden, um die neue Komplexität (durch Generics) und Fehlerpotenziale (Autoboxing, statische Importe) in den Griff zu bekommen. Nichtsdestoweniger bieten die neuen Sprachmittel in Java 5 viele Möglichkeiten, die Java-Programmierung ausdrucksstärker und einfacher zu machen.
Zu guter Letzt geht noch ein Dank an Prof. Dr. Siedersleben, den Studenten und Dozenten der Fachhochschule Rosenheim, deren Seminar J2SE 5.0 [9] die Grundlage für diesen Artikel geliefert hat.
Josef Adersberger (Josef.Adersberger@QAware.de) ist Leiter des Bereichs Improvement & Innovation bei QAware und Dozent an der Fachhochschule Rosenheim. Sein Hauptinteresse gilt der Softwarequalitätssicherung, der technologieübergreifenden Softwarearchitektur und Softwareproduktionsumgebungen.
Links & Literatur


Anzeige

Kommentare

Gravatar Johannes aka !mpact 14.01.2008
um 16:46 Uhr
Sehr interessanter Artikel!

Allerdings möchte ich noch eine kleine Anmerkung zu dem Code-Listing 1 machen. Die bei der performanteren Variante auftretende Leistungssteigerung, ist nicht dem Autoboxing zuzuschreiben. Ein Array mit int Werten ist natürlich IMMER performanter als eine ArrayList oder das generische Pendant, allein schon aus dem Grund das die Liste vermutlich als LinkedList oder etwas ähnliches implementiert ist. D.h. mehr als eine Zuweisung gemacht werden muss (beim Hinzufügen eines Wertes). Vielleicht sollte man eher Integer[] und int[] vegleichen, um wirklich den Leistungsunterschied, den Autoboxing ausmacht, zu vergleichen.
#zitieren

Anzeige

zurück zum Seitenanfang
X