Stream-API in Java SE 8

Im ersten Teil dieser Serie über Neuheiten in Java SE 8 wurden Lambda Ausdrücke sowie Default-Methoden in Interfaces vorgestellt. Im zweiten Teil wird der ehemalige Neofoniker und jetzige Senior Softwareentwickler bei idealo, Martin Gerlach, eine weitere Neuerung in Java 8 vorstellen: die Stream API. Mit ihr sollen Zugriffe auf Daten vereinfacht werden.

Mit Hilfe der Lambda Ausdrücken und den Default-Methoden ist es leicht möglich Operationen auf (potentiell unendlich langen) Datenströmen (Streams) im funktionalen Stil auszuführen. Ein Beispiel dafür sind z.B. Map/Reduce-Operationen auf Collections. Auch andere, in der Analyse großer Datenmengen übliche Funktionen, lassen sich damit durchführen. Zudem besteht die Möglichkeit, bestimmte Operationen auf einfache Weise automatisch parallel durch mehrere Threads auszuführen zu lassen.

Streams im Überblick und erste Beispiele

Die Package-Dokumentation von java.util.stream gibt einen ausführlichen Überblick über die Stream-API. Darüber hinaus wurden mittles Default-Methoden diverse Interfaces des Collection-Frameworks sowie vereinzelte weitere Interfaces und Klassen um Streaming-Features erweitert, ein Beispiel ist die Methode java.io.BufferedReader.lines(). Einen Überblick über die Erweiterungen des JDK hinsichtlich Streams und Lambdas findet man hier. Eine vollständige Behandlung des Themas würde hier den Rahmen sprengen. Dieser Artikel gibt daher lediglich einige Beispiele.

Ausgangspunkt sind die neuen Default-Methoden java.util.Collection.stream() sowie die Variante für parallele Verarbeitung, .parallelStream(), mittels der man von jeder Implementierung von Collection<E> einen Stream<E> über die Elemente der Collection erhält. Durch Aufruf dieser Methoden findet zunächst noch kein Verarbeitungsschritt statt, sondern es werden mittels verschiedener “Fluent API Calls” die durchzuführenden Operationen als Sequenz von intermediären (“intermediate”) Operationen und maximal einer abschließenden (“terminal”) Operation definiert. z.B.:

List<Point> points; // java.awt.Point(int x, int y)


// Map-Reduce


int sumYForPositiveX = points.stream()


.filter(p -> p.x > 0) // intermediate


.mapToInt(p -> p.y) // intermediate


.reduce(0, Integer::sum); // = .sum() // terminal


// GroupBy-(Map)-Reduce


Map<Integer, Integer> sumYbySignumX = points.stream()


.collect(Collectors.groupingBy( // terminal


p -> Integer.signum(p.x),


Collectors.summingInt(p -> p.y)));


// Map-Collect


List<Double> distancesFromOrigin = points.stream()


.mapToDouble(p -> p.distance(0.0, 0.0)) // i.


.collect(Collectors.toCollection(ArrayList::new)); // t.

International Iteration bei Java 8

Im Unterschied zum Interface Iterator<E>, mittels dem der Programmierer die Iteration über Iterable<E>-Implementierungen (z.B. auch Implementierungen von Collection<E>) von außen (“External Iteration”) steuern kann (bzw. muss!), liegt der Fokus von Stream<E> auf der Durchführung bestimmter Operationen auf der zugrundeliegenden Datenstruktur. Implementierungen von Stream<E> sowie der Varianten für primitive Typen (IntStreamLongStreamDoubleStream und diverse auf primitive Typen zugeschnittene Methoden) stellen für die zahlreichen Methoden dieses Interfaces Implementierungen bereit, die sich intern um die Ausführung dieser Operationen kümmern und u.a. auch das Iterieren über die Elemente der zu analysierenden Datenstruktur übernehmen. Das nennt man “Internal Iteration”. So wird es dem Entwickler ermöglicht, durch Angabe der verschiedenen Operationen mittels der Streaming-API auszudrücken, was mit seiner Datenstruktur gemacht werden soll und es der im JDK enthaltenen Implementierung der API zu überlassen, wie dies geschieht.

Brückenfunktionalitäten zwischen den beiden Welten bieten BaseStream.iterator() sowie Iterator().forEachRemaining() und Iterable.forEach(). Einen Iterator als Stream-Quelle zu benutzen ist etwas komplizierter (siehe Stackoverflow zum erstenzweiten und dritten).

Eigenschaften von Streams

Streams sind:

Persistenzlos – Streams speichern keine Daten, sondern dienen dazu, Daten von einer Quelle (Datenstruktur, I/O-Ressource, …) durch eine Abfolge (“Pipeline”) von Operationen zu verarbeiten und abschließend zu aggregieren, einer Datensenke zuzuführen, o.ä.

Funktional – Streams verändern ihre Datenquelle nicht, Stream-Operationen erzeugen vielmehr neue Streams mit veränderten Eigenschaften. filter() löscht z.B. keine Elemente aus einer zugrundeliegenden Collection, sondern erzeugt einen neuen Stream, der bestimmte Elemente der Collection auslässt.

Lazy – Intermediäre Operationen sind immer lazy, d.h. sie werden nicht bei ihrem Aufruf, sondern erst bei Aufruf einer terminalen Operation ausgeführt. Dies hat den Vorteil, dass Abfolgen von Operationen, wie z.B. im Falle von “Finde den ersten String mit drei aufeinanderfolgenden Vokalen” (.stream().filter(s -> contains3ConsecutiveVowels(s)).findFirst()), optimiert werden können. In diesem Beispiel muss eben nicht (immer) jeder String untersucht werden.

Möglicherweise unendlich – Streams über Collections sind endlich, aber Streams müssen nicht endlich sein. Durch “abkürzende” (short-circuiting) Operationen (s.u.) wie limit() oder findFirst() sind endliche Berechnungen über unendliche Streams möglich. Es lässt sich auch eine langlaufende terminale Operation vorstellen, die durch zustandslose intermediäre Operationen wie etwa map() und filter() verarbeitete Elemente eines unendlich langen (Input-)Streams fortlaufend per Seiteneffekt (s.u.) in eine Datensenke schreibt.

Consumable – Wie im Falle eines Iterators können die Elemente eines Streams nur einmal durchlaufen werden d.h., es kann nur max. eine terminale Operation ausgeführt werden, die dazu führt, dass vorgelagerte intermediäre Operationen (in optimierter Art und Weise) ablaufen. Streams können also vor Aufruf der ersten terminalen Operation nicht zwischengespeichert werden, um weitere terminale Operationen auszuführen:

Stream<Integer> xStream = points.stream().map(p -> p.x); // ok


List<Integer> x1 = xStream.collect(Collectors.toList()); // ok


List<Integer> x2 = xStream.collect(Collectors.toList()); // Boom!


// IllegalStateException – … stream has already been operated upon …

Erlaubt ist hingegen das Speichern der Streamdefinition als Lambda, welches dann mehrfach ausgeführt wird:

Supplier s = () -> points.stream().map(p -> p.x);


List<Integer> x1 = s.get().collect(Collectors.toList()); // ok


List<Integer> x2 = s.get().collect(Collectors.toList()); // ok!

Sequentiell oder Parallel – Streams können “parallel sein”, was dazu führt, dass viele (aber nicht alle) Operationen parallel ablaufen. Zum Einsatz kommt dabei der common ForkJoinPool der JVM. Außer über die in der JavaDoc beschriebenen System-Properties kann man leider keinen Einfluss auf den verwendeten Pool nehmen. Weiterhin muss man damit rechnen, dass bestimmte Operationen die Ausführung durch Synchronisationsmechanismen sogar verlangsamen, so wird z.B. sorted() immer nur single-threaded ausgeführt und vorher wie hinterher mittels Barriers synchronisiert. Ist das Sortieren die einzige oder die mit Abstand umfassendste Aufgabe des Streams vor der terminalen Operation, so macht die Verwendung eines parallelen Streams hier keinen Sinn.

Java-8-Stream-APIs erzeugen mit wenigen Ausnahmen wie Collection.parallelStream() per default sequentielle Streams, welche mittels BaseStream.parallel() in parallele umgewandelt werden können. BaseStream.sequential() erklärt umgekehrt einen Stream für sequentiell.

Geordnet oder Ungeordnet – Streams über geordnete Collections wie etwa Listen oder sortierte Sets sind geordnet, Streams über HashSets sind es z.B. nicht. Operationen wie sorted() wandeln einen nicht geordneten Stream in einen geordneten um, und die Operation unordered() ermöglicht es den Stream für nachfolgende Operationen als nicht geordnet zu betrachten, was die parallele Ausführung von Operationen wie distinct() oder gruppierte Reduktion (grouped reduction, “group by”) i.d.R. etwas effizienter macht.

Für identisch geordnete Streams muss die Ausführung einer Kette von Operationen für jeden Stream dasselbe Ergebnis liefern, für ungeordnete Streams muss dies nicht der Fall sein, z. B. muss IntStream.range(0, 3).unordered().map(x -> x*2).collect(Collectors.toList()) bei wiederholter Ausführung nicht unbedingt immer [0, 2, 4] erzeugen, sondern es wäre für jede Ausführung jede Permutation der resultierenden Listenelemente erlaubt. Diese Ordnung zu garantieren, ist für sequentielle Streams fast immer viel einfacher als für parallele!

Eigenschaften von Stream-Operationen

Intermediär oder Terminal – Auf einem Stream können beliebig viele intermediäre Operationen zum Einsatz kommen und maximal eine terminale Operation. Intermediäre Operationen wie map(), filter(), distinct(), sorted() usw. geben daher wieder einen Stream zurück, auf dem dann durch Aufruf der Fluent-API-Methoden weitere Operationen ausgeführt werden können. Terminale Operationen wie reduce()collect(), findFirst(), findAny() usw. konsumieren ihren Stream indem sie alle intermediären Operationen ausführen und dann ein endgültiges Ergebnis (keinen Stream) liefern.

Short-circuiting– Stream-Operationen sind abkürzend, wenn sie einen Stream “verkürzen”. Sie können intermediär sein wie skip() und limit() oder terminal wie findFirst() und findAny().

Stateless oder Stateful – Manche intermediären Stream-Operationen müssen einen internen Zustand halten, um ihr Ergebnis zu berechnen. Beispiele dafür sind limit(), skip(), distinct(), sorted(). Manche dieser Operationen, z.B. distinct() und sorted(), die den ganzen Stream sehen müssen, um ein korrektes Ergebnis zu liefern, funktionieren daher generell nicht besonders gut auf parallelen Streams (s.o.). limit() und skip() sind nur dann problematisch, wenn ein geordneter Stream parallel bearbeitet werden soll, da es dann wichtig ist, welche Elemente einbezogen bzw. übersprungen werden.

Seiteneffekte – Prinizipiell kann jede Stream-Operation Seiteneffekte erzeugen, indem es außerhalb ihrer Lambda-Scopes liegende Objekte verändert, was allerdings generell nicht zu empfehlen ist. z. B.:

List<String> stringList = new ArrayList<>();

points.stream().forEach(p -> results.add(p.toString())); // ARGH!

Dies ist ein vermeidbarer Seiteneffekt. Anstelle von forEach(Consumer) (was im übrigen auch für geordnete Streams die Reihenfolge im Gegensatz zu forEachOrdered(Consumer) nicht einhalten muss!) sollte hier besser collect(Collector) verwendet werden:

List<String> stringList = points.stream()


.map(Object::toString) // or: p -> p.toString()


.collect(Collectors.toList());

Ein nicht vermeidbarer Seiteneffekt ist z.B. die Ausgabe auf der Console, bzw. generell das Schreiben in Datensenken. Außerdem gibt es in der Kategorie noch die intermediäre Operation peek(Consumer):

List<Point> positiveX = points.stream()


.peek(System.out::println) // all


.filter(p -> p.x > 0)


.peek(p -> System.out.println(„** “ + p)) // only filtered


.collect(Collectors.toList());

Das peek()-Beispiel zeigt übrigens auch sehr schön, in welcher Reihenfolge die Operationen tatsächlich ausgeführt werden, man achte dazu auf die Position der mit “**” beginnenden Ausgaben, der aus der Liste [ Point(1,1), Point(1,-1), Point(-1,-1), Point(-1,1) ] herausgefilterten Punkte mit positiver X-Koordinate:

java.awt.Point[x=1,y=1]


** java.awt.Point[x=1,y=1]


java.awt.Point[x=1,y=-1]


** java.awt.Point[x=1,y=-1]


java.awt.Point[x=-1,y=-1]


java.awt.Point[x=-1,y=1]

Wie man sieht, werden in einem sequentiellen Stream zunächst für jedes Listen-Element alle relevanten Stream-Operationen ausgeführt bevor mit dem nächsten Listen-Element fortgefahren wird. Weiterhin finden die Ausgaben nur statt, wenn eine terminale Operation wie hier collect() in der Pipeline vorhanden ist.

Reduce vs. Collect oder Immutability vs. Mutability

Die Operation collect() im vorigen Beispiel ist eigentlich wenig “funktional”, da sie eine durch Collectors.toList() intern) initial erzeugte Liste durch die Operation verändert, nämlich mit Werten befüllt.

Im Gegensatz dazu geht reduce() von nicht veränderlichen (immutable) Objekten aus, die schrittweise von der eigentlichen Reduktionsfunktion (z. B. Aufsummierung, Konkatenierung) zu neuen nicht veränderlichen Objekten und schließlich zum Ergebnis zusammengefügt werden.

Beispiele von String-Repräsentationen

Folgende Beispiele zum Zusammenfügen von String-Repräsentationen (Strings sind in Java unveränderliche Objekte) der Elemente der Punkteliste aus den vorangegangen Beispielen verdeutlichen den Unterschied:

1. String-Konkatenation

String pointsStr = points.stream().reduce(„“, // Initial value


(String s, Point p) -> s + “ “ + p, // Accumulator


(String s1, String s2) -> s1 + s2)); // Combiner

Die Kombinationsfunktion (Combiner) benötigt man nur für parallele Ausführung, da hier die Reduktion zunächst auf Teillisten erfolgt und die Teilergebnisse anschließend kombiniert werden müssen.

Natürlich weiß schon ein Java-Anfänger, dass String-Konkatenation keine besonders performante Operation ist. Der Compiler optimiert bei langen “+”-Ketten zwar durch interne Benutzung von StringBuilder, aber in diesem Fall ist das nur innerhalb der Lambdas für die Einzelteile möglich, so dass hier im Endeffekt anstelle vieler String-Objekte viele StringBuilder-Objekte erzeugt und wieder verworfen werden. Besser wäre vielleicht dieser Ansatz:

2. Reduce mit StringBuilder

String pointsStr = points.stream().reduce(new StringBuilder(),


StringBuilder::append, // (sb, point) -> sb.append(point)


StringBuilder::append) // (sb, sb) -> sb.append(sb)


.toString(); // make String from StringBuilder

Nun werden dem initialen Wert, einem neuen StringBuilder-Objekt mittels append() die String-Repräsentationen der Listen-Elemente der Reihe nach hinzugefügt. Das funktioniert allerdings nur, weil append() eine “Funktion” ist. Sie gibt nämlich als Ergebnis den (veränderten) StringBuilder wieder zurück. Außerdem funktioniert dieser Ansatz nur sequentiell. Denn wenn derselbe(!) initiale Wert in parallel ausgeführten Verarbeitungsschritten (points.parallelStream().reduce(…)) durch append() verändert wird, führt das mindestens dazu, dass höchstwahrscheinlich die Ordnung der Liste nicht eingehalten wird, aber in der Regel endet dieser Versuch mit einer Exception, da StringBuilder-Objekte nicht threadsafe sind.

Für solche Fälle gibt es in Java 8 daher collect() – auch “mutable reduction” genannt:

3. Collect mit StringBuilder

String pointsStr = points.parallelStream()


.collect(StringBuilder::new,


StringBuilder::append, StringBuilder::append)


.toString();

Der Unterschied zu reduce() ist, dass hier als Startwert gar kein Datenwert, sondern eine Funktion (Lambda Ausdruck oder Methodenreferenz) erwartet wird, die für jeden parallelen Verarbeitungsteil das eigentliche (veränderliche) Startobjekt erzeugt (Supplier). Hier startet also jeder parallele Teil mit einem eigenen StringBuilder. Des Weiteren sind Akkumulator und Combiner hier keine BiFunctions, sondern BiConsumers, die keine Rückgabewerte erwarten bzw. Rückgabewerte, wie etwa von append(), verwerfen.

Die Klasse Collectors stellt viele nützliche Collector-Implementierungen zur Benutzung mit der Stream-Methode collect(Collector) bereit. Collector-Objekte fassen die o.g. Akkumulations- und Kombinationslogik zusammen.

Als einführende und weiterführende Literatur zu Streams seien als zwei von vielen diese Quellen genannt:

  • Kurz und bündig: Die Package-Beschreibung von java.util.stream.

  • Ausführlich: Richard Warburton, “Java 8 Lambdas – Functional Programming for the Masses”, O’Reilly

Des Weiteren danke ich Angelika Langer und Klaus Kreft für einen inspirierenden Lambda- und Streams-Hackathon am 6.11.2014 in Berlin, aus dem das eine oder andere Beispiel in veränderter Form für diesen Artikel verwendet wurde.

Die neue Date/Time-API in Java 8

Entwicklern, die zuvor bereits mit der Bibliothek Joda-Time gearbeitet haben, werden die neuen APIs im Package java.time und dessen Subpackages bekannt vorkommen. In der Tat ist der Hauptentwickler von Joda-Time, Stephen Colebourne, einer der Specification Leads des JSR-310.

Wie Oracle selbst beschreibt, bestehen die wesentlichen Schwachpunkte der bisherigen API, hauptsächlich DateCalendar und SimpleDateFormat, insbesondere darin, dass Objekte dieser Klassen nicht threadsafe sind (Objekte von Date und Calendar sind mutable aber nicht synchronisiert und SimpleDateFormat ist intern unnötig zustandsbehaftet), sowie in einigen nicht-intuitiven Design-Entscheidungen, die noch auf frühe Java-Tage zurück gehen. Zum Beispiel repräsentiert der Wert 0 für das Jahresfeld in der Date-Klasse das Jahr 1900 und die Monate sind nicht von 1 bis 12 nummeriert, sondern von 0 bis 11. Zwar wurde mit der Calendar-Klasse in Java 1.1 eine etwas flexiblere, für Internationalisierung geeignetere Lösung (inkl. Zeitzonen und nicht-gregorianischen Kalendern) eingeführt und die Date-Klasse im Prinzip zum Wrapper, um die Unix-Epoche (Anzahl der Millisekunden seit dem 1.1.1970 0:00 UTC) degradiert, aber auch Calendar ist nicht threadsafe und mitunter nicht sehr intuitiv zu benutzen.

Die Klassen des Packages java.time folgen dagegen den Prinzipien Immutability (“Value Classes”) und Domain Driven Design, insb. der Trennung von ortsabhängigen Datumswerten und ortsunabhängigen Zeitbegriffen, also genau von “Date” und “Time”.

Weiterhin wurde auf eine lesbare, intuitiv verständliche API geachtet, wie folgendes Beispiel von Datumsarithmetik über den Beginn der mitteleuropäischen Sommerzeit hinweg zeigt:

LocalDateTime savingTest =


LocalDateTime.of(2014, Month.MARCH, 28, 23, 30);


savingTest.plusHours(48); // „2014-03-30T23:30“


savingTest.plusDays(2); // „2014-03-30T23:30“




ZonedDateTime atZone = savingTest.atZone(ZoneId.of(„Europe/Berlin“));


atZone.plusHours(48); // „2014-03-31T00:30+02:00[Europe/Berlin]“


atZone.plusDays(2); // „2014-03-30T23:30+02:00[Europe/Berlin]“

Abgesehen davon, dass die “plus”-Operationen hier jeweils neue Objekte erzeugen (im Kommentar jeweils die durch .toString() generierte String-Repräsentation der neuen Objekte), ist deren Semantik auch sehr verständlich:

Liegt keine Information zur Zeitzone vor (LocalDateTime), so entsprechen 48 Stunden immer zwei Tagen.

In Zeitzonen mit Sommerzeit (ZonedDateTime) ist dies um die Umstellungszeitpunkte herum aber nicht immer der Fall. Hier geht man von der intuitiven Interpretation der addierten Zeiträume aus:

  • Für “48 Stunden” zählt man die Uhr 48 Stunden vorwärts und dabei wird zur Umstellung Ende März für die MESZ dann eine Stunde übersprungen, so dass man bis zum 31.3. 0:30 zählt.

  • Addiert man zu einem beliebigen Zeitpunkt jedoch “2 Tage”, so geht man von der selben Uhrzeit “übermorgen” aus, also 30.3. 23:30. Dies sind im angegebenen Beispiel jedoch nur 47 Stunden vom Ausganszeitpunkt (“atZone”) gesehen.

Analog:

LOG.info(atZone); // „2014-03-28T23:30+01:00[Europe/Berlin]“


Period twoDays = Period.ofDays(2); // „P2D“


Period.between( // „P2D“


atZone.toLocalDate(), atZone.plusDays(2).toLocalDate());


Duration fortyEightHours = Duration.ofDays(2); // „PT48H“


Duration.between(atZone, atZone.plusDays(2)); // „PT47H“

Period (“P”, Datumsunterschied in Tagen) und Duration (“Time Period” bzw. “PT”, Zeitunterschied mit Nanosekunden-Präzision) sind dabei die beiden zentralen Klassen für Zeiträume, mit gemeinsamem Interface TemporalAmount.

Zusätzlich werden auch andere “Chronologies” als das ISO-8601-System wie “Japanese” oder “ThaiBuddhist” unterstützt. Die entsprechenden Klassen befinden sich im Package java.util.chrono.

Den alten APIs wurden Konvertierungsfunktionen hinzugefügt, z. B.:

static GregorianCalendar GregorianCalendar.from(ZonedDateTime)


static Date Date.from(Instant)


ZonedDateTime GregorianCalendar.toZonedDateTime()


Instant Calendar.toInstant()


Instant Date.toInstant()

Instant-Objekte repräsentieren dabei – ähnlich wie Date, nur eben immutable – Zeitpunkte der Unix-Epoche mit Nanosekunden-Präzision. Intern werden diese Zeitpunkte durch je einen long-Wert für die Sekunden der Epoche sowie einen int-Wert für den Nanosekundenbruchteil (0 … 999.999.999) repräsentiert.

Weitere Java 8-Neuerungen

Alle Neuerungen des JDK 8 sind unter What’s New in JDK 8 nachzulesen, hier eine kleine Auswahl:

Hinsichtlich Concurrency wurde neben der Unterstützung paralleler Streams (siehe Teil 2), das Collections-Framework massiv um Interfaces, Klassen und Methoden erweitert. Siehe Package java.util.concurrent (mit Subpackages) sowie z. B. ConcurrentHashMap als Beispiel für eine so erweiterte Klasse (über 30 neue Methoden). Außerdem gibt es mit java.util.concurrent.atomic.LongAccumulatorLongAdderDoubleAccumulator, sowie DoubleAdder durchsatzoptimierte neue Werkzeuge zum parallelen aktualisieren (speziell addieren bzw. aggregieren) von Zahlenwerten durch zahlreiche Threads.

In der HotSpot JVM wurde der PermGen Space durch den sog. “Metaspace” (native memory) ersetzt, das Compilerflag -XX:PermGenSpace ist damit unnötig und wirkungslos.

Es ist nun möglich vom Compiler Informationen zur Reflection für Parameternamen (javac -parameters) im Bytecode hinterlegen zu lassen. Einige Frameworks, wie z.B. Spring, werden davon profitieren, denn bisher war hierfür das Kompilieren im Debug-Modus nötig.

Repeatable Annotations können nun ohne ihre Container-Annotation geschrieben werden (z.B. mehrere @Schedule Annotationen in Spring Batch oder mehrere @CompoundIndex Annotationen in Spring Data), allerdings handelt es sich hier nur um vereinfachte Syntax und keine Änderung im Bytecode, d.h. die Reflection erfolgt nach wie vor über die weiterhin benötigte Container-Annotation.

Einige Funktionen für vorzeichenlose Ganzzahlarithmetik und Parsing für Long und Integer wurden hinzugefügt. Hierbei handelt es sich nicht um neue Typen! Zum Beispiel:

// This raised and still raises a NumberFormatException


long s = Long.parseLong(„ffffffffffffffff“, 16); // Exception !!!


// New in Java 8


long u = Long.parseUnsignedLong(„ffffffffffffffff“, 16); // Now OK!


LOG.info(Long.toString(u)); // „-1“ … oops?! See below!


LOG.info(Long.toUnsignedString(u, 16)); // „ffffffffffffffff“ … ah!

Hier ist zu beachten, dass die Variable u intern tatsächlich den Wert -1 hat. Dies ist die vorzeichenbehaftete Interpretion des Bitmusters aus 64 Einsen bzw. 16 mal “f” im Hexadezimalsystem im Wertebereich [ -263, 263 – 1 ] (64 Bit), also genau im Wertebereich von Java long Werten. Im Wertebereich [ 0, 264 – 1 ] (vorzeichenlose 64 Bit), würde das Bitmuster tatsächlich als 264 – 1 interpretiert werden.

Im JDK8 gibt es nun (endlich!) einen Base64 Encoder und Decoder. JavaFX und die Security-Packages wurden erweitert und an diversen Stellen wurden Performance-Verbesserungen erzielt. Es gibt außerdem 3 “Compact Profiles” (Java-Untermengen) für die Unterstützung entsprechender Devices und Appliances, sowie eine auf der JVM basierende und dadurch mit Java interaktionsfähige Javascript Engine namens Nashorn (“jjs”).

Die Version 8 der Sprache Java und des JDK bringt umfassende Neuerungen. Zentrale Punkte sind die Unterstützung von funktionalen Elementen wie Lambda-Ausdrücken und Streams. Um die bestehenden APIs, insb. das Collection-Framework, hiermit “aufzurüsten”, wurden Default- und Static-Methoden auf Interfaces eingeführt. Die neue Date/Time-API, inspiriert von der Bibliothek Joda-Time, folgt ebenfalls wichtigen in der funktionalen Programmierung üblichen Design-Prinzipien wie z.B. Immutability (Value Classes). Multicore-Computing wird durch parallele Streams und Concurrency-Erweiterungen unterstützt.

Alles in allem werden dem Entwickler damit mächtige und einigermaßen moderne Werkzeuge in die Hand gegeben.

Schwachpuntke Java 8

Trotzdem gibt es – gerade hinsichtlich Lambdas und Streams – noch einige Schwachstellen:

Zum einen führt Javas Typsicherheit dazu, dass die Zuweisung eines Lambda-Ausdrucks an Variablen und Parameter nur über funktionale Interfaces möglich ist, die u.U. inkompatibel zu anderen funktionalen Interfaces sind, für die der Lambda-Ausdruck aber ebenso eingesetzt werden kann.

Zum anderen muss Code, welcher so typisierte Lambdas (etwa als Function<T, R> oder IntFunction<T>) entgegen nimmt, beim Aufruf der Funktion den Methodennamen des funktionalen Interfaces benutzen, da die Java-Syntax (noch) keine reinen Funktionsaufrufe kennt, z. B.:

IntFunction<String> f = String::length; // or: f = s -> s.length();


int len = f(myString); // That would be nice but does not compile


int len = f.apply(myString); // Need to name the method :/

Es kann nicht f(myString) aufgerufen werden, sondern es muss f.apply(myString) verwendet werden, da die abstrakte Methode von IntFunction<T> eben int apply(T) ist.

Zu guter Letzt sollte man einmal darauf achten, wieviele (nämlich die meisten) der neuen Interfaces, Klassen und Methoden zur Verwendung von Lambdas und Streams nur der Unterstützung der primitiven Java-Typen int, long und double dient (boolean, byte, short und float wurden berechtigterweise gleich außen vor gelassen). Siehe auch das vorige Beispiel, hier wurde IntFunction<String> statt Function<String, Integer> verwendet, da Java Generic Type Parameter keine primitiven Typen unterstützen und man hier aber eben auf jeden Fall int (und nicht Integer) erhalten möchte.

Performance ist hierbei mittlerweile kaum noch ein Argument, da Autoboxing-/-unboxing auf modernen Rechnerarchitekturen kaum noch einen Unterschied zur reinen Verwendung primitiver Typen machen dürfte.

Nur über “null”-Werte müsste man sich noch etwas Gedanken machen, dann stünde einer Abschaffung von primitiven Typen nicht mehr viel im Wege (s. z. B. folgenden etwas älteren Artikel: “For the Java Development Kit (JDK) 10 or after, a fundamental change is being discussed: making the Java language Object Oriented. This might see the introduction of a unified type system that turns everything into objects and means no more primitives.”)

Vorteile von Java 8 für Unternehmen

Die oben genannten Neuerungen und die sich daraus ergebenden Perspektiven überwiegen die genannten Schwachstellen bei weitem.

Für Unternehmen mit eigener Softwareentwicklung lohnt sich ein schrittweiser Umstieg auf Java 8 aus mehreren Gründen:

Die neuen Sprach-Features schließen eine Lücke zu den sich insbesondere in der Verarbeitung großer Datenmengen und der Datenanalyse (“Big Data”) immer weiter verbreitenden JVM-Sprachen mit funktionalen Features, die nicht an einen abwärtskompatiblen Standard gebunden sind (Clojure, Scala, usw.) sowie zu JavaScript, welches sich auch serverseitig immer stärkerer Beliebtheit erfreut.

Für erfahrene Java-Entwickler und Anwendungs-Architekten sind die Neuerungen leicht zu erlernen und mit etwas Grundwissen über funktionale Prinzipien ebenso leicht im Alltag anzuwenden. Dabei geht es nicht nur um lokale Optimierungen von Code-Stellen zwecks Lesbarkeit, sondern, mit zunehmender Routine, nach und nach auch um die Weiterentwicklung ganzer Anwendungsarchitekturen im Hinblick auf Multicore- und Cluster-Computing, Performance, Skalierbarkeit und Elastizität. Funktionale Prinzipien wie Immutability, die daraus unmittelbar folgende Threadsafety, sowie Streaming usw., erleichtern zusammen mit Techniken der reaktiven oder ereignisorientierten Programmierung sowie neuartigen Virtualisierungslösungen (Infrastructure as Code, as a Service, …) die Erstellung effizienter, skalierbarer, und wartbarer Systeme.

 

Überblick über alle Neuerungen im JDK 8: What’s New in JDK 8

 

Veröffentlicht am 24. November 2014, aktualisiert am 18. Oktober 2020