Contenu

Quelques propriétés magiques des Streams en Java 🧙

Introduction

L’arrivée des streams (et des lambdas) a été une petite révolution dans l’écosystème Java. Cette première arrivée de concepts de programmation fonctionnelle a apporté un peu de fraicheur dans l’écriture du code. Avec les streams, on a pu simplifier l’écriture de boucles fastidieuses, comme ici :

1
2
3
4
5
myList.stream()
    .map(Builder::toDto)
    .filter(name -> !name.equalsIgnoreCase("Kevin"))
    .sorted()
    .collect(Collectors.toList());

Mais derrière cette apparente simplicité, se cachent des propriétés intéressantes, que l’on va étudier ici.

Structure d’un stream

Un stream est composé d’une succession d’opérations dites intermédiaires (filter(), map(),…) et d’une opération terminale (forEach(), collect()). Les premières renvoyant systématiquement un stream, permettant ainsi de les enchaîner les une à la suite de l’autre, alors que l’opération terminale renvoie un autre type (elle peut aussi renvoyer void).

/stream-under-the-hood/interVsTerminal.png

Sous le capot, chaque opération crée un nouveau stream. Ces streams seront exécutés à la suite de l’autre lorsque l’opération terminale est exécutée. Ainsi si l’opération terminale retourne un résultat (par exemple collect(Collector.toList()) et que son résultat n’est pas utilisé, alors aucune des opérations du stream ne sera exécutée. Ainsi, le stream gère la lazyness.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Aucune utilisation de ce stream
List.of(
		new Person("Kevin", "Jean"),
		new Person("Paul", "Jean"),
    	new Person("Pierre", "Jean")
		).stream()
			.map(Person::name)
			.filter(name -> !name.equalsIgnoreCase("Kevin"))
			.sorted()
			.peek(Log::info)
			.collect(Collectors.toList());

Comme le fait remarquer votre IDE, le résultat de ce stream est ignoré. Ainsi l’opération terminale n’est pas appelée, donc aucune opération intermédiaire n’est exécutée : vous n’aurez aucun log d’affiché.

Il est intéressant de noter qu’une fois une opération terminale appliquée, il est impossible de réutiliser le stream. Il faut donc faire attention aux méthodes renvoyant directement un stream, car mal utilisées, elles peuvent provoquer un IllegalStateException1.

1
2
3
4
5
6
7
Stream<String> superStream = recupererResultatSousFormeDeStream();

//OK
List<String> maListeDeString = superStream.collect(Collectors.toList());

//KO car une opération terminale a déja été appliquée (IllegalStateException)
List<String> maListeDeString = superStream.collect(Collectors.toList());

Ordre du traitement

Ordonné ou non

L’ordre de traitement d’un stream va dépendre de plusieurs paramètres. En premier lieu, il est défini par l’objet d’entrée : une List par exemple est par définition une collection ordonnée, ainsi son traitement, sauf utilisation d’opérateurs particuliers, respectera son ordre. Par contre, un Set, étant par définition non ordonné, sera traité sans ordre particulier.

Pour prendre un exemple : si l’on a une collection de [1,2,3], le résultat de map(x -> x*2) sera forcément [2,4,6] si on l’applique sur une List, alors que [6,2,4] sera une possibilité en sortie d’un Set (de même que n’importe quelle autre combinaison).

Certaines opérations particulières vont forcer la conservation d’un ordre (comme sorted()) alors que certaines opérations terminales ignorent complètement l’ordre établi, comme forEach() (il existe un forEachOrdered() d’ailleurs). Par ailleurs, en partant d’une collection ordonnée, il est possible d’appeler la méthode unordered(), qui va explicitement stipuler que l’ordre importe peu.

Sur des stream séquentiels, la différence de performance est minime que le traitement soit ordonné ou non.

Stream séquentiel ou parallèle

Plutôt que d’utiliser la méthode stream() sur une collection, il est aussi possible d’appeler parallelStream(). Derrière, au lieu d’utiliser un seul cœur du processeur pour effectuer les opérations intermédiaires du stream, chacune va être exécutée en parallèle sur chacun des cœurs de votre processeur.

/stream-under-the-hood/SequentialVsParallelStream.png

Avec ce type de stream, le fait de forcer un ordre de traitement (avec par exemple ordered()) va bien évidemment impacter les performances. En effet, pour cela, la JVM va devoir synchroniser les différentes opérations, pour qu’elles s’attendent entre elles afin de converser l’ordre des éléments.

Il est tentant de vouloir utiliser des stream parallèles dans tous les cas où cela semble pertinent (si on ne fixe pas d’ordre d’exécution). Cependant, il faut noter que pour coordonner tous les threads (même si le traitement n’est pas ordonné), l’empreinte mémoire va fortement augmenter par rapport à un traitement séquentiel.

Sous le capot

Variables internes

Un stream est représenté en interne sous forme de linkedlist contenant chacune des étapes du stream Chaque étape va contenir les informations suivantes, récupérées de l’étape d’avant. Ces informations étant initialisées par la structure d’entrée2 :

Flag Explication
SIZED La taille du stream est connue.
DISTINCT Les éléments du stream sont distincts, d’après Object.equals() pour les streams d’objets, ou d’après == pour les streams de types primitifs.
SORTED Les éléments du stream sont triés dans l’ordre naturel3.
ORDERED L’ordre des éléments a de l’importance.

Chaque opération intermédiaire a un effet connu sur les flags du stream; une opération peut ajouter, effacer ou conserver un flag. Par exemple :

  • filter() va conserver SORTED et DISTINCT, mais effacer la valeur de SIZED.
  • map() va effacer la valeur de SORTED et DISTINCT, mais conserver la valeur de SIZED.
  • sorted()va conserver SIZED et DISTINCT, mais effacer la valeur de SORTED.

Ces flags vont être utilisés en interne pour optimiser les stream. Ainsi si on a :

1
2
3
4
5
TreeSet<String> ts = ...
String[] sortedAWords = ts.stream()
                          .filter(s > s.startsWith("a"))
                          .sorted()
                          .toArray();

Les flags incluent ici SORTED car la structure d’entrée est un TreeSet. Comme on l’a vu, la méthode filter() conserve le flag SORTED; on arrive donc à l’étape sorted() avec un flag SORTED déjà existant. En temps normal, l’appel à sorted() devrait rajouter une étape au stream, mais comme on sait que les éléments sont déjà triés (grâce au flag), la méthode sorted() ne fait rien et se contente de renvoyer la précédente opération (le code du filter()) 🤩. Dans le même ordre d’idée, si on sait que les éléments sont déjà DISTINCT, l’ajout de distinct() n’aura aucun effet.

Exécution d’un stream

La question des opérations

Lorsque l’on appelle une opération terminale en utilisant le résultat de cette dernière (souvenez-vous de cette histoire de lazyness), la JVM va déterminer comment exécuter concrètement notre stream. Pour cela, elle va examiner les opérations intermédiaires à exécuter :

  • Les opérations dites stateless (filter(), map(), flatMap()) peuvent être exécutées sur chaque élément indépendamment des autres.
  • Les opérations stateful (sorted(), limit(), distinct()) doivent avoir connaissance de l’ensemble des éléments du stream.

Ainsi si le stream est exécuté séquentiellement, ou en parallèle, mais uniquement avec des opérations stateless, alors il sera joué en une seule fois. Sinon, il sera exécuté en plusieurs fois, en séparant les exécutions au niveau des opérations stateful !

Dans le même ordre d’idée, les opérations terminales peuvent être de deux types :

  • non-short-circuiting (reduce(), collect(), forEach()) qui permet de traiter tous les éléments d’un coup.
  • short-circuiting (allMatch(), findFirst()), ce qui va traiter chaque élément l’un à la suite de l’autre.

Détail de l’exécution

Pour fixer les idées et utiliser les concepts sur les opérations, considérons le stream suivant :

1
2
3
4
5
6
7
8
var result = List.of(0,1,2,3)
   .stream()
   .map(i->i*3)
   .filter(i->i%2==1)
   .sorted()
   .collect(Collectors.toList());

LOGGER.info(result);
  1. Le map() étant stateless, il traite chaque élément qui arrive et renvoie donc le résultat de la multiplication à l’étape suivante.
  2. Le filter() étant lui aussi stateless, il traite chaque élément, mais ne renvoie pas systématiquement chaque élément à l’étape suivante : seuls les nombres impairs sont passés à l’étape suivante.
  3. Le sorted() est stateful, il doit donc attendre que tous les éléments soient présents. Il met donc en buffer tout ce que lui renvoie le filter() et quand il a reçu tous les éléments, il les trie.
  4. Enfin l’opération terminale est non-short-circuiting, donc elle va traiter tous les éléments d’un coup et en faire une nouvelle liste.

Conclusion

Dans cet article on aura donc vu quelques propriétés intéressantes des stream en Java. Sous couvert d’une étonnante simplicité d’utilisation, il s’agit en fait d’une structure finalement assez complexe et conçue pour pouvoir être la plus efficace possible. Je n’ai couvert ici qu’une petite partie de leur fonctionnement : il faudrait creuser le parallélisme et descendre au niveau bytecode. Pour aller plus loin, je vous conseille fortement la lecture de cette série d’articles en anglais de Brian Goetz (architecte du langage), qui couvre tout ce qu’il y a savoir sur le sujet.


  1. C’est stipulé dans la JavaDoc. ↩︎

  2. Brian Goetz en parle très bien ici. ↩︎

  3. Il s’agit de l’ordre de tri par défaut d’un objet en Java (l’ordre alphabétique pour String par exemple). Il peut être redéfini en implémentant l’interface Compararable↩︎