Tuto Java Stream API : Arrêtez de coder comme en 1998 Tuto Java Stream API : Arrêtez de coder comme en 1998

Tuto Java Stream API : Arrêtez de coder comme en 1998

// 👉 Exemples complets disponibles sur mon dépôt GitHub : // github.com/jbwittner/tuto_java

On est en 2026. Java 8 est sorti il y a plus de dix ans (oui, ça fait mal).

À l’époque, c’était la révolution : on arrêtait enfin d’écrire du code impératif verbeux pour embrasser le déclaratif. On passait du rôle de “Chef de chantier” (qui micro-manage chaque itération i++) à celui d‘“Architecte” (qui dessine le plan et laisse le runtime se débrouiller).

Mais soyons honnêtes : la plupart des devs utilisent les Streams comme des boucles for déguisées, sans comprendre la mécanique sous le capot.

Et c’est là que les problèmes de perf et de mémoire commencent.

Aujourd’hui, on fait le tour complet. On part du code legacy qui pique les yeux pour arriver aux entrailles de la JVM et aux nouveautés de Java 24.

Le Pain Point : La “Spaghetti Loop”

Avant les Streams, filtrer une liste d’utilisateurs actifs de plus de 18 ans ressemblait à ça.

Choisissez votre poison :

Option A : La boucle indexée (Le dinosaure)

// Le bon vieux monde des années 90
List<User> activeAdults = new ArrayList<>();
// Gestion manuelle de l'index (risque d'IndexOutOfBounds)
for (int i = 0; i < users.size(); i++) {
User u = users.get(i);
if (u != null) { // Defensive coding
if (u.isActive()) {
if (u.getAge() >= 18) {
activeAdults.add(u); // Effet de bord (Side-effect)
}
}
}
}
return activeAdults;

Option B : La boucle for-each (Le standard)

// Toujours l'ancien monde, mais avec un peu plus de style
List<User> activeAdults = new ArrayList<>();
// Cache un itérateur sous le capot
for (User u : users) {
// La logique métier est noyée dans la structure de contrôle
if (u != null && u.isActive() && u.getAge() >= 18) {
activeAdults.add(u);
}
}
return activeAdults;

Le verdict : C’est lisible ? Bof. C’est surtout impératif. On mélange l’itération, le filtrage et l’accumulation. Si on ajoute deux autres conditions, ça devient un sapin de Noël d’accolades.

La Révélation : Architecture & Loop Fusion

L’API Stream a débarqué avec une promesse : arrêter de dire à la machine comment faire, et commencer à lui dire ce qu’on veut faire.

Mais attention, avant de voir la version élégante que tout le monde connaît, regardons ce qui se passe réellement sous le capot.

Un Stream attend des objets implémentant des interfaces.

Étape 1 : La Version “Brute” (Sans Lambdas)

Si Java 8 n’avait pas introduit les Lambdas en même temps que les Streams, on aurait dû écrire ça. Attention les yeux, c’est la même logique que la boucle for, mais avec encore plus de bruit :

// Ce que le compilateur voit (Classes Anonymes)
List<User> activeAdults = users.stream()
.filter(new Predicate<User>() { // On instancie une classe anonyme
@Override
public boolean test(User u) {
return u.isActive();
}
})
.filter(new Predicate<User>() { // Encore une...
@Override
public boolean test(User u) {
return u.getAge() >= 18;
}
})
.collect(Collectors.toList());

Le constat : C’est horrible. On a remplacé 10 lignes de boucles par 15 lignes de “boilerplate” (code inutile).

Intermède : Zoom sur la Révolution Lambda

C’est ici qu’il faut s’arrêter une seconde. Pourquoi le code ci-dessus est-il si lourd ?

Parce que Java, historiquement, ne savait manipuler que des Objets.

On ne pouvait pas passer une “fonction” en paramètre d’une méthode. On était obligé de passer un objet qui contenait la fonction (comme notre new Predicate ci-dessus).

Java 8 a introduit les Lambdas pour régler ce problème sans casser le langage.

Une Lambda, c’est quoi ? C’est une manière ultra-concise d’implémenter une Interface Fonctionnelle (une interface qui n’a qu’une seule méthode abstraite).

Regardez la transformation anatomique :

1. Le code verbeux (Classe Anonyme) :

new Predicate<User>() {
public boolean test(User u) {
return u.isActive();
}
}

2. On retire le décor (Le compilateur devine le type) : Le compilateur sait que filter attend un Predicate<User>. Il sait aussi que Predicate n’a qu’une méthode test qui prend un User. Donc, on peut virer new Predicate..., public boolean test... et même le type (User u).

3. L’essence pure (La Lambda) : Il ne reste que deux choses : le paramètre et l’action. Syntaxe : (Paramètres) -> { Action }

u -> u.isActive()

C’est tout. On est passé du “Comment” (je crée une classe, j’instancie, je surcharge…) au “Quoi” (je prends u, je vérifie s’il est actif). C’est ce qu’on appelle le Behavior Parameterization : on passe un comportement en paramètre, comme si c’était une simple variable.

Étape 2 : Le Résultat Final (L’application des Lambdas)

Maintenant qu’on a compris que u -> u.isActive() est juste un raccourci pour créer un objet Predicate, le code devient limpide :

// Le monde moderne (Clean & Déclaratif)
List<User> activeAdults = users.stream()
.filter(u -> u.isActive()) // Le compilateur génère le Predicate
.filter(u -> u.getAge() >= 18) // Code métier pur
.toList(); // (Java 16+)

Voici visuellement comment le flux de données traverse ces filtres :

flowchart LR Input[Source: Users] --> Filter1{Actif ?} Filter1 -- Oui --> Filter2{Age >= 18 ?} Filter1 -- Non --> Poubelle1[🗑️ Ignoré] Filter2 -- Oui --> Output[✅ Collecté] Filter2 -- Non --> Poubelle2[🗑️ Ignoré]

Comment ça marche vraiment ? (La Loop Fusion)

C’est ici que ça devient technique. Quand vous écrivez .filter(...).map(...), il ne se passe rien.

Vous construisez juste une liste chaînée d’opérations (un graphe) en mémoire.

Le génie des Streams, c’est qu’ils ne parcourent pas la collection plusieurs fois.

Contrairement à une approche naïve où filter créerait une liste temporaire pour la passer à map, le Stream utilise une architecture de Sink (récepteurs).

Le flux réel (en une seule passe) :

flowchart LR Source[Spliterator] -->|1. Pull| Stage1{Filter} Stage1 -- "Oui (2. Transform)" --> Stage2[Map] Stage1 -- "Non (Rejet)" --> Trash[🗑️] Stage2 -->|3. Push| Sink[Collect]

Décryptage de la mécanique :

  1. Le Spliterator (La Source) : Il “tire” (pull) les éléments un par un, à la demande.
  2. Les Stages (Le Pipeline) : L’élément traverse les opérations comme une bille dans un toboggan.
    • Si le Filter dit “Non”, l’élément est immédiatement abandonné (pas de mémoire gâchée).
    • Si le Filter dit “Oui”, il passe directement au Map sans être stocké.
  3. Le Sink (Le Terminal) : C’est le réceptacle final. Dès qu’un élément sort du Map, il est “poussé” (push) dans la liste finale.

C’est la Loop Fusion.

Pas de liste intermédiaire, pas de gaspillage.

C’est pour ça que les Streams bien écrits peuvent rivaliser avec des boucles manuelles.

La Boîte à Outils Complète (Cheat Sheet)

Pour passer de “Bricoleur” à “Artisan”, voici ce que vous avez vraiment à disposition, incluant les dernières nouveautés Java 16 à 24.

Les Opérations Intermédiaires (Lazy)

  • filter(Predicate) : Le videur de boîte de nuit
// Input: [1, 2, 3, 4, 5, 6]
// Garde les nombres pairs
stream.filter(n -> n % 2 == 0);
// Output: [2, 4, 6]
  • map(Function) : Transformation 1 pour 1
// Input: [User(name="Bob"), User(name="Alice")]
// Extrait le nom de chaque utilisateur
// 1. Version Lambda classique :
stream.map(u -> u.getName());
// 2. Version "Method Reference" (Plus concise, fait la même chose) :
stream.map(User::getName);
// Output: ["Bob", "Alice"]
  • flatMap(Function) : Transformation 1 pour N (Aplatissement)
// Input: [Order(items=["Ecran", "Clavier"]), Order(items=["Souris"])]
// Transforme chaque commande en un flux d'articles, puis "aplatit" le tout
stream.flatMap(order -> order.getItems().stream());
// Output: ["Ecran", "Clavier", "Souris"]
  • mapMulti(BiConsumer) (Java 16+) : L’alternative performante à flatMap Évite de créer un Stream temporaire pour chaque élément (soulage le Garbage Collector) (un autre article sera dédié à ça)
// Input: [1, 2, 3]
stream.mapMulti((number, buffer) -> {
buffer.accept(number); // On garde le nombre
buffer.accept(number * 10); // On ajoute son multiple
});
// Output: [1, 10, 2, 20, 3, 30]
  • gather(Gatherer) (Java 24+) : La révolution pour les opérations intermédiaires complexes (un autre article sera dédié à ça)
// Input: [1, 2, 3, 4, 5, 6]
// Exemple : Grouper par fenêtre de 3 éléments
stream.gather(Gatherers.windowFixed(3));
// Output: [[1, 2, 3], [4, 5, 6]]
  • distinct() : Supprime les doublons (utilise `equals()).
// Input: [1, 2, 2, 3, 1]
stream.distinct();
// Output: [1, 2, 3]
  • sorted() : Trie le flux (Stateful : charge tout en mémoire !⚠️ ).
// Input: ["Zorro", "Barman", "Alice"]
stream.sorted();
// Output: ["Alice", "Barman", "Zorro"]
  • peek(Consumer) : Espionne le flux sans le modifier (Debug).
// Input: ["one", "two"]
// Affiche "Processing: one", "Processing: two" dans la console
stream.peek(u -> System.out.println("Processing: " + u));
// Output: ["one", "two"] (Le flux est inchangé)

Les Opérations Terminales (Eager)

Elles déclenchent le traitement et ferment le Stream.

⚠️ ATTENTION : LE STREAM EST À USAGE UNIQUE !

Une fois consommé, il est “fermé”. Tenter de le réutiliser lance une IllegalStateException.

  • Accumulation : convertit le flux en une collection ou une structure de données
// Input: [User(id=1, city="Paris"), User(id=2, city="Lyon"), User(id=3, city="Paris")]
// 1. Vers une Liste (Java 16+ : LA méthode moderne)
// Attention : Renvoie une liste IMMUABLE (UnmodifiableList)
stream.toList();
// L'ancienne méthode (Java 8)
// Plus verbeuse, mais permet souvent d'obtenir une liste mutable (ArrayList)
stream.collect(Collectors.toList());
// 2. Map (Clé=ID, Valeur=Nom)
stream.collect(Collectors.toMap(User::getId, User::getName));
// Output: {1="Bob", 2="Alice", ...}
// 3. Grouping By (Le "GROUP BY" SQL)
stream.collect(Collectors.groupingBy(User::getCity));
// Output: {"Paris"=[User1, User3], "Lyon"=[User2]}
  • Réduction (reduce) : Le moteur sous le capot :
// Input: [1, 2, 3, 4]
// Somme (Identité = 0)
stream.reduce(0, Integer::sum);
// Calcul: 0 + 1 -> 1 + 2 -> 3 + 3 -> 6 + 4 -> 10
// Output: 10
// Max (Identité = 0)
stream.reduce(0, (max, i) -> i > max ? i : max);
// Output: 4
  • Vérification (Retourne un booléen) : Idéal pour valider des conditions. C’est du “Short-Circuiting” : ça s’arrête dès que la réponse est trouvée.
// Input: [18, 25, 12, 40]
// Est-ce que quelqu'un est mineur ?
boolean hasMinor = stream.anyMatch(age -> age < 18);
// Output: true (S'arrête dès qu'il voit 12)
// Est-ce que tout le monde est majeur ?
boolean allMajors = stream.allMatch(age -> age >= 18);
// Output: false
// Est-ce qu'il n'y a aucun centenaire ?
boolean noCentenarian = stream.noneMatch(age -> age >= 100);
// Output: true
  • Recherche : Retourne un Optional (on en parlera plus tard)
// Input: ["Alice", "Bob", "Charlie"]
// Trouve le premier (Ordre déterministe)
stream.filter(s -> s.startsWith("B")).findFirst();
// Output: Optional["Bob"]
// Trouve n'importe lequel (Optimisé pour le Parallèle)
// En séquentiel, c'est souvent le premier aussi, mais ne pariez pas dessus.
stream.parallel().filter(s -> s.length() > 3).findAny();
// Output: Optional["Alice"] (ou "Charlie", ou "Bob"...)

Cas d’Usage Avancés (Snippets de Senior)

C’est bien beau les exemples simples, mais en prod, la donnée est rarement aussi propre. Voici des patterns plus musclés.

  • Le GroupingBy “Inception” (Collecteurs en cascade) : On ne veut pas juste grouper, on veut filtrer et transformer le résultat à l’intérieur du groupe.
// Objectif : Map<Ville, Liste<Noms des majeurs>>
Map<String, List<String>> namesByCity = users.stream()
.collect(Collectors.groupingBy(
User::getCity, // Clé du map (Ville)
Collectors.filtering( // Downstream 1 : Filtre avant d'accumuler
u -> u.getAge() >= 18,
Collectors.mapping( // Downstream 2 : Transforme User -> String (Nom)
User::getName,
Collectors.toList() // Downstream 3 : Accumule en Liste
)
)
));
// Output: { "Paris"=["Bob, Marc"], "Lyon"=["Alice"] }
  • Le Collector “Teeing” (Java 12+) : Faire deux calculs différents en une seule passe sur le stream (ex: Stats complètes).
// Exemple : Calculer la moyenne d'âge des actifs vs inactifs en une passe
final Map<Boolean, Double> averageAgeByActiveStatus = users.stream()
.collect(Collectors.teeing(
// Branche 1 : Moyenne des actifs
Collectors.filtering(User::isActive,
Collectors.averagingInt(User::getAge)),
// Branche 2 : Moyenne des inactifs
Collectors.filtering(u -> !u.isActive(),
Collectors.averagingInt(User::getAge)),
// Fusion : On met tout dans une Map
(activeAvg, inactiveAvg) -> Map.of(
true, activeAvg,
false, inactiveAvg
)
));
// Output: { true=34.5, false=22.0 }
  • Analyse de Contenu (FlatMap & IntStream) : Aller chercher des données imbriquées, comme les lettres composant un mot.
// Objectif : Obtenir une liste de tous les caractères uniques dans les noms des utilisateurs actifs
final List<Character> uniqueCharsInActiveUserNames = users.stream()
.filter(User::isActive)
.map(User::getName)
// chars() renvoie un IntStream, on doit le mapper en Character
.flatMap(name -> name.chars().mapToObj(c -> (char) c))
.distinct()
.toList();
// Output: ['A', 'l', 'i', 'c', 'e', 'B', 'o', 'b', ...]

Le Piège du parallelStream

C’est le bouton magique qui attire tous les juniors. “J’ai 12 cœurs, je mets .parallel(), ça va aller 12 fois plus vite !”

Spoiler : Non.

Pourquoi ça plante ?

  1. Coût de coordination : Découper et recoller les morceaux coûte cher. Si le traitement par élément est trop rapide (ex: addition), la gestion des threads prend plus de temps que le calcul.
  2. Mauvaise structure de données : Le parallèle sur une LinkedList est une catastrophe (division en O(N)). Sur une ArrayList, c’est OK (O(1)).
  3. Famine du Pool (Starvation) : Par défaut, tous les streams partagent le même ForkJoinPool. Si vous faites une requête SQL bloquante dans un stream parallèle, vous bloquez toute l’appli.

Règle d’or : Jamais d’I/O dans un stream parallèle par défaut.

Nuance Critique : Source I/O vs Opération I/O

Il ne faut pas confondre lire une source en streaming et faire des I/O dans le pipeline.

  • Files.lines(path) : C’est OK. Le flux lit le fichier ligne par ligne de manière paresseuse (Lazy). C’est efficace et le blocage est géré à la source.
  • stream.parallel().map(this::callDatabase) : C’est INTERDIT. Là, vous demandez aux threads de calcul (les “ouvriers” du ForkJoinPool) d’attendre une réponse réseau. Vous bloquez des ressources CPU pour rien, et vous risquez de geler tout le pool commun de l’application.

Gestion de la Mémoire : Éviter le OOM

On entend souvent : “Les Streams, c’est lazy, donc ça consomme peu de mémoire.” C’est vrai… sauf quand c’est faux.

Stateless vs Stateful :

  • Stateless (filter, map) : ✅ Ami de la RAM. Traite élément par élément.
  • Stateful (sorted, distinct) : ⚠️ Danger. Pour trier, il faut charger tous les éléments en mémoire tampon. Files.lines(...).sorted() sur un fichier de 10 Go = Crash JVM.

Le piège de la Matérialisation (ETL)

// ❌ Mauvaise pratique : On charge tout en RAM
List<Data> hugeList = source.stream().map(...).toList();
// ✅ Bonne pratique : Streaming pur
source.stream().map(...).forEach(this::processOneByOne);

Le “Reality Check” : Quand le Stream devient toxique

On adore les Streams, mais parfois, à vouloir faire le malin, on écrit des horreurs. Si vous avez besoin de manipuler des indices, de comparer des éléments adjacents (i et i+1), ou de modifier des variables externes, arrêtez tout.

Le Stream n’est pas fait pour ça.

Regardez cet exemple de recherche de coordonnées dans une matrice :

// Le challenge : Trouver les coordonnées (x, y) du premier élément négatif d'une matrice
// ❌ Version Stream : "J'ai lu un livre sur la programmation fonctionnelle"
// Illisible, boxing inutile, overhead massif
Optional<int[]> coordinates = IntStream.range(0, matrix.length).boxed()
.flatMap(r -> IntStream.range(0, matrix[r].length)
.filter(c -> matrix[r][c] < 0)
.mapToObj(c -> new int[]{r, c}))
.findFirst();
// 👉 Pourquoi tant de code pour rien ?
// 1. IntStream.range() : Crée un flux d'index (0, 1, 2...) primitifs 'int'.
// 2. .boxed() : Convertit les 'int' en objets 'Integer'. C'est OBLIGATOIRE ici
// car flatMap attend des objets pour renvoyer un Stream<int[]>.
// Coût : Performance et RAM gaspillées pour rien.
// ✅ Version Boucle : "Je veux juste que ça marche"
// Simple, performant, zéro allocation mémoire superflue
for (int r = 0; r < matrix.length; r++) {
for (int c = 0; c < matrix[r].length; c++) {
if (matrix[r][c] < 0) {
return new int[]{r, c};
}
}
}

Morale : Ne soyez pas un fanatique. Si une boucle for est plus claire et plus performante, utilisez une boucle for. Votre équipe vous remerciera lors de la prochaine code review.

Conclusion : Vers une maîtrise éclairée

L’API Stream Java, c’est tout simplement un outil formidable. Franchement, c’est une petite merveille qui a complètement changé notre façon de voir le code.

C’est élégant, ça respire, et ça fait du bien à nos yeux fatigués par des années de boucles imbriquées. C’est le genre d’outil qui vous rappelle pourquoi vous avez aimé la programmation au départ : la satisfaction de construire quelque chose de propre et d’efficace.

Ce qu’il faut retenir de ce voyage :

  1. La lisibilité est reine. Si votre Stream fait 40 lignes avec des peek partout, vous avez raté le coche. Découpez, extrayez, respirez. Une boucle for bien nommée vaut mieux qu’un reduce incompréhensible.

  2. La performance est contextuelle. Le parallélisme n’est pas magique et a un coût (overhead). Une boucle primitive sur un petit tableau sera toujours plus rapide. Ne sacrifiez pas la simplicité pour une hypothétique optimisation sans mesure préalable.

  3. L’évolution est constante. De collect(toList()) à stream.toList(), en passant par les puissants Gatherers de Java 24, l’API continue de s’affiner. Restez curieux et mettez à jour vos pratiques.

Au final, être un développeur Senior, ce n’est pas connaître toutes les méthodes de l’API par cœur. C’est savoir regarder une boucle for imbriquée et se dire “Il y a une meilleure façon de faire”, tout en sachant regarder un Stream complexe et admettre “Là, une boucle serait plus claire”.

Le code moderne n’est pas celui qui utilise le plus de features. C’est celui qui choisit la bonne abstraction pour le bon problème, et qui laisse le collègue qui passera après vous avec le sourire (plutôt qu’avec une envie de meurtre).

Allez, git push et on part en pause café.

PS : Pour des exemples complets, n’hésitez pas à jeter un œil à mon dépôt GitHub : github.com/jbwittner/tuto_java


← Retour au blog