Aller au contenu principal

POO en Java · L2 · Section 4/7

Collections et Streams

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#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 ; utiliser CompletableFuture et 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 dominantStructurePourquoi
Parcours séquentiel, accès par indiceArrayListContiguïté mémoire : excellentes performances de cache
Ajout/retrait aux extrémitésArrayDequeFile ou pile en tableau circulaire
Recherche par cléHashMapAccès quasi constant, sans ordre
Ordre d'insertion préservéLinkedHashMapTable de hachage plus liste chaînée
Clés triées, parcours ordonnéTreeMapArbre équilibré, opérations en O(log n)
Accès concurrent en lecture/écritureConcurrentHashMapVerrouillage 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 :

javajava

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.
  • map transforme un-à-un, flatMap transforme un-plusieurs puis aplatit.
  • Préférez toList() (Java 16+) à collect(toList()), et Collectors.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 :

javajava

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

  1. Pipeline de statistiques sur des logs : partez d'un Stream<String> de lignes niveau;service;latenceMs, produisez la latence moyenne par service, triée par latence décroissante. Vérification observable : sur dix lignes dont trois pour le service auth (latences 100, 200, 300), la sortie commence par auth=200.0.
  2. 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 :

javajava

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.

Quelle affirmation sur les Streams est exacte ?
Quelle affirmation sur les Streams est exacte ?