The Dark Side of Java 8

Es gibt unzählige Vorteile des Releases von Java 8. Jedoch sind unter Java 8 auch einige Bugs vorhanden. Der folgende Blogpost von Jean-Claude Brantschen geht auf eine Anzahl an Snippets in Java 8 ein.

An den Voxxed Days 2019 in Zürich hat mich die Session von Grzegorz Piwowarek ziemlich beeindruckt. Sie trägt den Namen “The Dark Side of Java 8”. Darin stellt Grzegorz eine Reihe Java 8 Snippets vor, die sich (unter Java 8) nicht genau so verhalten, wie wir das erwarten würden. Von schlechter Performance bis zu falschen Resultaten ist alles möglich. Ich möchte hier eine Auswahl der Snippets präsentieren. Die ganze Session von Grzegorz ist am Blog Ende als Youtube Video verlinkt.

Beispiel 1 – Conditional Stream Termination

Wir starten mit einer Methode, die Dates sortiert zurückgibt. Es sind jeweils die ersten Tage des Monates von 2018.

 
private static List getDates() {
  List res = new ArrayList<>();
  for (int i = 1; i <= 12; i++) {
    res.add(LocalDate.of(2018, i, 1));
  }
  return res;
}

Wir möchten nun alle Dates vor dem 1. Juli 2018 ausgeben.

 
LocalDate endDate = LocalDate.of(2018, 7, 1);
getDates() 
    .stream() 
    .filter(date -> date.isBefore(endDate)) 
    .forEach(System.out::println);

Das Resultat sieht aus wie erwartet:

2018-01-01
2018-02-01
2018-03-01
2018-04-01
2018-05-01
2018-06-01

Dennoch ist das Verhalten nicht ganz so wie erwartet:

  • Java 8 Streams sind ja “lazy” (werden lazy ausgewertet).
  • Da die Daten sortiert sind, kann die Stream Verarbeitung abgebrochen werden, sobald ein Datum nicht mehr auf das Filterkriterium passt.
  • Nachprüfen können wir das, indem wir einen peek() Aufruf in die Stream Chain einhängen.
 
getDates() 
    .stream() 
    .peek(date -> System.out.println("... evaluates " + date)) 
    .filter(date -> date.isBefore(endDate)) 
    .forEach(System.out::println);

... evaluates 2018-01-01
2018-01-01
... evaluates 2018-02-01
2018-02-01
... evaluates 2018-03-01
2018-03-01
... evaluates 2018-04-01
2018-04-01
... evaluates 2018-05-01
2018-05-01
... evaluates 2018-06-01
2018-06-01
... evaluates 2018-07-01
... evaluates 2018-08-01
... evaluates 2018-09-01
... evaluates 2018-10-01
... evaluates 2018-11-01
... evaluates 2018-12-01

Die Ausgabe zeigt, dass der ganze Stream “eager” ausgewertet wird und dass nicht abgebrochen wird. Warum? Java bzw. der Stream weiss nicht, dass die Input Daten sortiert sind … und deshalb müssen alle Elemente des Streams ausgewertet werden.

Die Lösung für das Problem wurde in Java 9 mit der neuen Stream Methode takeWhile() eingeführt.

 
LocalDate endDate = LocalDate.of(2018, 7, 1);
getDates() 
    .stream() 
    .peek(date -> System.out.println("... evaluates " + date)) 
    .takeWhile(date -> date.isBefore(endDate)) 
    .forEach(System.out::println);

.. evaluates 2018-01-01
2018-01-01
... evaluates 2018-02-01
2018-02-01
... evaluates 2018-03-01
2018-03-01
... evaluates 2018-04-01
2018-04-01
... evaluates 2018-05-01
2018-05-01
... evaluates 2018-06-01
2018-06-01
... evaluates 2018-07-01

Der Stream wird nur solange ausgewertet, wie das Prädikat in takeWhile() erfüllt ist (d.h. true zurückgibt). Genau das Verhalten, das wir ursprünglich erwartet hätten.

Beispiel 2 - Nested Streams und flatMap()

Wir starten mit einer Klasse User, die für einen User den Namen und eine Liste von Adressen verwaltet.

 
static class User {
  private String name;
  private List addresses;
  
  public User(String name, String...addresses) {
    this.name = name;
    this.addresses = new ArrayList<>();
    this.addresses.addAll(Arrays.asList(addresses));
  }

  public String getName() {
    return name;
  }

  public List getAddresses() {
    return addresses;
  }
}

Zusätzlich haben wir noch eine Methode getUsers(), die uns mit Testdaten beliefert.

 
static List getUsers() { 
  List res = new ArrayList<>();
  res.add(new User("user1", "a1","a2","a3","a4","a5"));
  res.add(new User("user2", "b1","b2","b3","b4","b5"));
  return res;
}

Was uns interessiert ist die erste Adresse des ersten Users. Da wir mehrere User haben, die jeweils mehrere Adressen haben, haben wir hier geschachtelte Listen. Diese werden in der Stream Verarbeitung zu geschachtelten Streams, die mittels flatMap() wieder flach gemacht werden. Zur Illustration haben wir ein peek() eingefügt um zu schauen, welche Elemente der Stream wirklich auswertet.

Erfassen von Code-Blöcken:
 
Optional firstAddress = getUsers()
.stream()
.flatMap(u -> u.getAddresses().stream())
.peek(System.out::println)
.findAny();

System.out.println("first address: " + firstAddress.get());

Wir lassen das mal unter Java 8 (und 9) laufen und erhalten folgende Ausgabe:

a1
a2
a3
a4
a5
first address: a1

Das Resultat ist wie erwartet a1. Überraschend ist allerdings, dass alle Element des inneren Stream “eager” ausgewertet werden, obwohl die Stream Operation nach dem ersten Element gestoppt werden könnte. Das heisst ganz klar: nested Streams sind unter Java 8 (und 9) nicht lazy! Dies ist ein Bug.

Besonders tragisch wird es, wenn der zugrundeliegende Stream unendlich ist (d.h. die Element werden nach Bedarf on-the-fly generiert). Auch dies ist ein Bug.

Beide Bugs sind in Java 10 gefixt. Die Ausgabe unter Java 10 sieht dann so aus:

a1
first address: a1

Beispiel 3 - Nested streams mit flatMap und takeWhile

Wir starten mit einer Methode, die die Zahlen 1 bis 7 als nested List of Strings zurückgibt.

 
List> list = List.of( 
      List.of("1", "2"), 
      List.of("3", "4", "5", "6", "7"));

Mittels Stream und flatMap() können wir die Zahlen ausgeben.

 
list.stream() 
  .flatMap(lst -> lst.stream()) 
  .forEach(System.out::println);

und erhalten wie erwartet folgende Ausgabe.

1
2
3
4
5
6
7

Wir können den flatMap() Code noch etwas kompakter schreiben mittels Methoden Referenz und erhalten immer noch die gleiche Ausgabe.

 
list.stream() 
  .flatMap(Collection::stream) 
  .forEach(System.out::println);

Nun machen wir ein zweites Beispiel. Wir suchen die Zahlen, die kleiner sind als 4.

Als Input haben wir einen flachen Stream mit den Zahlen 1 bis 7. Auf diesem Stream wenden wir ein takeWhile() an.

 
Stream.of("1", "2", "3", "4", "5", "6", "7") 
  .takeWhile(i -> !i.equals("4")) 
  .forEach(System.out::println); 

Und fast schon überraschend erhaltend wir das, was wir erwartet hätten.

1
2
3

Spannend wird es jetzt, wenn wir die beiden Beispiele kombinieren.

 
list.stream() 
  .flatMap(Collection::stream) 
  .takeWhile(i -> !i.equals("4")) 
  .forEach(System.out::println);

Unter Java 8 (und 9) erhalten wir folgende Ausgabe.

1
2
3
5
6
7

Nicht wirklich was wir erwartet hätten. Dies ist wiederum ein Bug, der in Java 10 behoben wurde. Unter Java 10 ist die Ausgabe dann wie erwartet:

1
2
3

Die Moral der Geschichte

Java 8 ist ein gigantisches Release. Es bringt Java mittels Stream und Lambdas in die Welt der funktionalen Programmierung. Es ist auch klar, dass sich bei einem solchen grossen Release Bugs einschleichen. Oracle hat seine Java Release Zyklen drastisch geändert. Statt nur alle 3 bis x Jahre ein grosses Release, gibt es jetzt alle 6 Monate kleine Releases, die nur eine überschaubare Anzahl neuer Features beinhalten. Was aber von Oracle nicht so öffentlich kommuniziert wird, ist der Fakt, dass unter der Haube eine ganze Reihe von Bugfixes gemacht werden, die man dann gratis dazu bekommt. Die obigen Beispiele haben dies gezeigt. Also ein Grund mehr, regelmässig auf einen neuen Java Release zu wechseln.

 

Referenzen:

  • Voxxed Days Zürich 2019 - https://voxxeddays.com/zurich/
  • Video -
  • Oracle Releas Zyklen - https://www.puzzle.ch/de/blog/articles/2019/03/18/java-8-ist-eol-was-nun
  • Bug: flatMap() mit nested Streams - https://bugs.openjdk.java.net/browse/JDK-8075939.
  • Bug: flatMap() mit nested Streams und infinite Streams: https://bugs.openjdk.java.net/browse/JDK-8189234
  • Fix für die flatMap() Bugs - http://hg.openjdk.java.net/jdk/jdk10/rev/fca88bbbafb9
Kommentare sind geschlossen.