POO en Java · L2 · Section 4/7
Collections et Streams
Progression
#Collections, Streams et parallélisme
Ce chapitre est ancré dans des scénarios concrets : analyser des logs, agréger des statistiques de commandes, traiter un pipeline d'événements. Avant de sortir les Streams, nous choisissons les structures de base ; ensuite nous décortiquons l'API Stream, sa paresse, ses collecteurs ; enfin nous abordons la concurrence moderne, jusqu'aux virtual threads.
Prérequis : chapitre Interfaces/génériques (bornes, records) ; structures de données élémentaires (liste, table associative, file).
Objectifs : choisir une collection selon les motifs d'accès ; composer des transformations paresseuses et choisir les collecteurs ; savoir quand
parallel()aide ou nuit ; utiliserCompletableFutureet les virtual threads à bon escient.
#1. Choisir la bonne structure
Le JDK offre trois familles pour les usages courants. Le critère de choix n'est pas la mode mais le motif d'accès dominant :
| Besoin dominant | Structure | Pourquoi |
|---|---|---|
| Parcours séquentiel, accès par indice | ArrayList | Contiguïté mémoire : excellentes performances de cache |
| Ajout/retrait aux extrémités | ArrayDeque | File ou pile en tableau circulaire |
| Recherche par clé | HashMap | Accès quasi constant, sans ordre |
| Ordre d'insertion préservé | LinkedHashMap | Table de hachage plus liste chaînée |
| Clés triées, parcours ordonné | TreeMap | Arbre équilibré, opérations en O(log n) |
| Accès concurrent en lecture/écriture | ConcurrentHashMap | Verrouillage par segment, pas d'itérateur faiblement cohérent verrouillant |
Deux pièges classiques : LinkedList est presque toujours battue par ArrayList (chaque élément est un objet séparé, mauvais pour le cache), et Collections.synchronizedList verrouille l'intégralité de la liste à chaque opération, là où CopyOnWriteArrayList paie une copie complète à l'écriture mais rend la lecture gratuite (pertinent quand les écouteurs d'événements sont parcourus souvent et modifiés rarement).
#2. L'API Stream : composition et paresse
Un Stream décrit quoi calculer, pas quand le calculer. Rien ne s'exécute avant une opération terminale (collect, forEach, count, toList). Cette paresse autorise la fusion des étapes : un filter suivi d'un map ne parcourt la source qu'une fois, en une seule passe.
Top 10 des produits par quantité vendue, sur des commandes représentées par des records :
1import java.util.Comparator;2import java.util.List;3import java.util.Map;4import static java.util.stream.Collectors.*;5 6var topProduits = commandes.stream()7 .flatMap(c -> c.lignes().stream()) // Commande -> Ligne8 .collect(groupingBy(Ligne::produit, summingInt(Ligne::quantite)))9 .entrySet().stream()10 .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())11 .limit(10)12 .toList();Lecture de haut en bas : aplatir les lignes, agréger par produit, trier par quantité décroissante, tronquer. Chaque étape est testable et la chaîne se lit comme la question posée.
Règles d'hygiène :
- Un stream se consomme une fois ; appelez
stream()à nouveau pour repartir de la source. maptransforme un-à-un,flatMaptransforme un-plusieurs puis aplatit.- Préférez
toList()(Java 16+) àcollect(toList()), etCollectors.toUnmodifiableList()si l'immuabilité de sortie est requise. - Ne mettez pas d'effet de bord dans
filter/map: la source peut être parcourue plusieurs fois ou zéro fois.
#3. Quand parallel() aide, et quand il nuit
parallel() découpe la source et distribue le travail sur le pool commun (ForkJoinPool). Il est pertinent quand la source est grande et que chaque élément coûte cher en calcul (transformation, distance, parsing). Il nuit presque toujours quand les éléments sont traités en quelques nanosecondes, quand la source est un LinkedList ou un itérateur coûteux à découper, ou quand l'opération terminale est un collect avec accumulateur non thread-safe.
Test de bon sens : mesurez. Le même pipeline avec et sans parallel() sur un jeu de données représentatif tranche en quelques minutes, les chiffres absolus variant selon la machine.
#4. Concurrence structurée et virtual threads
Au-delà des Streams, Java 21 apporte les virtual threads (JEP 444) : des threads portés par le runtime, peu coûteux à créer par milliers, qui libèrent le thread porteur pendant les attentes d'E/S. Le cas d'usage type : appeler plusieurs services distants puis agréger.
Style un thread par tâche, lisible et séquentiel en apparence :
1import java.time.Duration;2import java.util.List;3 4try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {5 List<java.util.concurrent.Future<String>> resultats = services.stream()6 .map(service -> executor.submit(() -> service.interroger()))7 .toList();8 for (var futur : resultats) {9 System.out.println(futur.get()); // agrégation dans l'ordre de soumission10 }11}Chaque interroger bloque son virtual thread ; le runtime exécute les autres pendant l'attente. Avec des threads plateforme classiques, quelques centaines d'appels simultanés satureraient la mémoire ; ici le plafond se compte en centaines de milliers.
Pour les pipelines de callbacks existants, CompletableFuture reste l'outil : thenApply, thenCombine et allOf composent des étapes asynchrones sans bloquer. La concurrence structurée (API en preview dans les JDK récents) ajoute la garantie qu'un parent ne se termine pas avant ses enfants et qu'une annulation se propage : suivez son intégration dans les prochaines versions LTS.
#5. Atelier
- Pipeline de statistiques sur des logs : partez d'un
Stream<String>de lignesniveau;service;latenceMs, produisez la latence moyenne par service, triée par latence décroissante. Vérification observable : sur dix lignes dont trois pour le serviceauth(latences 100, 200, 300), la sortie commence parauth=200.0. - Transformez un traitement d'appels réseau séquentiels (boucle sur dix services, chaque appel dormant 100 ms) en version virtual threads : le temps total doit passer d'environ une seconde à environ 100 ms.
Éléments de correction pour l'atelier 1 :
1import java.util.Map;2import java.util.stream.Collectors;3import static java.util.stream.Collectors.*;4 5Map<String, Double> latenceMoyenneParService =6 lignes.stream()7 .map(ligne -> ligne.split(";"))8 .collect(groupingBy(champs -> champs[1],9 averagingDouble(champs -> Double.parseDouble(champs[2]))));L'ordre décroissant s'obtient en re-streamant la Map et en triant par valeur, comme dans le top produits.