CodeGym /Cours /JAVA 25 SELF /Spliterator et flux parallèles

Spliterator et flux parallèles

JAVA 25 SELF
Niveau 33 , Leçon 3
Disponible

1. Introduction à Spliterator

Si vous pensiez que les collections en Java ne s’itèrent qu’avec un Iterator, vous aviez tout à fait raison jusqu’à Java 8. Mais avec l’arrivée de Stream API et la mode du parallélisme, un nouveau héros est apparu — le Spliterator.

Spliterator est une interface qui permet non seulement d’itérer sur les éléments d’une collection, mais aussi de découper la source de données en parties pour un traitement parallèle. Le nom est la fusion des mots split et iterator.

Imaginez un grand gâteau. Un Iterator classique le coupe en petites parts et les mange dans l’ordre. Un Spliterator peut couper le gâteau en deux, donner une moitié à un ami — et vous commencez à manger tous les deux en même temps. Beaucoup d’amis — on découpe encore !

Interface Spliterator — méthodes principales

public interface Spliterator<T> {
    boolean tryAdvance(java.util.function.Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
    // ... quelques autres méthodes, mais celles-ci sont les plus importantes
}
  • tryAdvance — effectue une action sur l’élément suivant (analogue de next() + action).
  • trySplit — tente de diviser la source en deux parties et de retourner un nouveau Spliterator pour la partie « détachée ».
  • estimateSize — estime combien d’éléments restent.
  • characteristics — renvoie un masque binaire de caractéristiques (ordre, unicité, immutabilité, etc.).

2. Utilisation de Spliterator : itération manuelle et découpage

Obtenir un Spliterator à partir d’une collection

Toute collection implémentant Collection peut fournir son Spliterator :

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();

Itération manuelle des éléments

Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("Nom: " + name))) {
    // Tout se fait à l'intérieur de tryAdvance
}

Découpage de la collection

Le plus intéressant — la méthode trySplit() :

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

System.out.println("Première partie:");
spliterator1.forEachRemaining(System.out::println);

System.out.println("Deuxième partie:");
if (spliterator2 != null) {
    spliterator2.forEachRemaining(System.out::println);
}

Ce qui se passe : le Spliterator va tenter de diviser la collection en deux parties (pas toujours exactement en deux — cela dépend de l’implémentation). Vous pouvez maintenant traiter les deux parties indépendamment — même dans des threads différents !

3. Flux parallèles : pourquoi et comment ça fonctionne

Un flux parallèle (parallelStream()) est un flux qui traite les éléments non pas l’un après l’autre, mais simultanément dans plusieurs threads. Particulièrement utile pour de grands volumes de données et des processeurs multicoeurs.

import java.util.List;

List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
// Flux séquentiel:
names.stream().forEach(System.out::println);
// Flux parallèle:
names.parallelStream().forEach(System.out::println);

Quel est l’intérêt ?
Dans un flux séquentiel, les éléments sont traités dans un seul thread. Dans un flux parallèle — la source est découpée en parties (à l’aide d’un Spliterator), et chaque partie est traitée dans un thread distinct.

Comment cela fonctionne en interne ?

  1. Spliterator découpe la collection en parties — généralement en fonction du nombre de cœurs disponibles (ou un peu plus).
  2. Chaque partie est traitée dans son propre thread — un ForkJoinPool commun est utilisé.
  3. Les résultats sont rassemblés — combinés en une collection finale ou une valeur.

Schéma de fonctionnement d’un flux parallèle

flowchart LR
    A[Collection] --> B{Spliterator}
    B --> C1[Partie 1] --> D1[Fil 1]
    B --> C2[Partie 2] --> D2[Fil 2]
    B --> C3[Partie 3] --> D3[Fil 3]
    D1 & D2 & D3 --> E[Assemblage du résultat]

4. Avantages et limites des flux parallèles

Avantages

  • Accélération du traitement de grandes collections : pour des calculs lourds, un flux parallèle accélère nettement l’exécution.
  • Simplicité : pas besoin d’écrire du code multithread manuellement — remplacez stream() par parallelStream().

Limites et pièges

  • Pas toujours plus rapide : pour de petites collections, les surcoûts peuvent annuler le gain.
  • L’ordre n’est pas garanti : avec forEach/map/filter l’ordre peut différer. Si l’ordre est nécessaire — utilisez forEachOrdered.
  • Problèmes de sécurité des threads : les opérations avec effets de bord (modification de collections/variables externes) entraînent des data races.
  • Toutes les opérations ne s’y prêtent pas : des calculs dépendants (par exemple, une accumulation strictement séquentielle) peuvent ne pas fonctionner comme prévu.

Quand utiliser les flux parallèles ?

  • Grandes collections (dizaines de milliers d’éléments et plus).
  • Opérations lourdes sur chaque élément.
  • L’ordre strict n’est pas crucial.
  • Pas d’effets de bord (fonctions pures).

Quand NE PAS les utiliser ?

  • Peu d’éléments.
  • Le code modifie des variables ou collections externes.
  • Il est important de conserver l’ordre de traitement.
  • La source de données se partitionne mal (par exemple, LinkedList).

5. Exemples pratiques

Exemple 1 : comparaison des temps d’exécution

import java.util.*;
import java.util.stream.*;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.range(0, 10_000_000)
                                         .boxed()
                                         .collect(Collectors.toList());

        long start = System.currentTimeMillis();
        long count = numbers.stream()
                .filter(n -> isPrime(n))
                .count();
        long time = System.currentTimeMillis() - start;
        System.out.println("Flux séquentiel: " + time + " ms, nombres premiers trouvés: " + count);

        start = System.currentTimeMillis();
        count = numbers.parallelStream()
                .filter(n -> isPrime(n))
                .count();
        time = System.currentTimeMillis() - start;
        System.out.println("Flux parallèle: " + time + " ms, nombres premiers trouvés: " + count);
    }

    // Vérification la plus simple d'un nombre premier (pour l'exemple)
    public static boolean isPrime(int n) {
        if (n < 2) return false;
        for (int i = 2, sqrt = (int)Math.sqrt(n); i <= sqrt; i++)
            if (n % i == 0) return false;
        return true;
    }
}

Résultat : sur de grands volumes de données, le flux parallèle est souvent plus rapide (surtout sur des processeurs multicoeurs). Sur de petits volumes — la différence peut être nulle ou la variante parallèle peut être plus lente.

Exemple 2 : problème d’ordre

import java.util.List;

List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
System.out.println("Flux séquentiel:");
names.stream().forEach(System.out::println);

System.out.println("Flux parallèle:");
names.parallelStream().forEach(System.out::println);

System.out.println("Flux parallèle avec forEachOrdered:");
names.parallelStream().forEachOrdered(System.out::println);

Conclusion : dans un flux séquentiel et avec forEachOrdered, l’ordre est conservé, tandis que dans un flux parallèle sans celui-ci — non.

Exemple 3 : danger des effets de bord

import java.util.*;
import java.util.stream.*;

List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();

// DANGEREUX ! À ne pas faire !
numbers.parallelStream().forEach(n -> results.add(n * n));

System.out.println("Taille de la liste: " + results.size());

Que peut-il se passer ? La taille de la liste peut être inférieure à celle attendue, et parfois une ConcurrentModificationException peut survenir. La raison — ArrayList n’est pas thread-safe, et un flux parallèle lance plusieurs threads simultanément.

6. Spliterator : particularités et caractéristiques

Caractéristiques de Spliterator

Spliterator décrit ses propriétés via un masque de bits :

  • ORDERED — les éléments ont un ordre défini (par exemple, pour une liste).
  • DISTINCT — tous les éléments sont uniques (par exemple, pour un ensemble).
  • SORTED — les éléments sont triés.
  • SIZED — la taille est connue.
  • IMMUTABLE — la collection est immuable.
  • CONCURRENT — la collection est thread-safe.
  • SUBSIZED — tous les spliterators après trySplit() connaissent aussi leur taille.
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));

À quoi cela sert-il ? Stream API et les flux parallèles utilisent ces indicateurs pour des optimisations. Par exemple, si la source est immuable et triée, on peut la découper et rassembler le résultat de manière plus sûre et plus efficace.

7. Quand et comment utiliser Spliterator directement ?

Au quotidien, il est rare d’écrire ses propres Spliterator : les collections standard implémentent déjà tout. Mais si vous créez votre propre source de données ou souhaitez contrôler finement l’itération/le découpage, Spliterator sera utile.

Exemple : itération manuelle avec tryAdvance

import java.util.List;
import java.util.Spliterator;

List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("Premier élément: " + name));
spliterator.forEachRemaining(name -> System.out.println("Le reste: " + name));

Exemple : découpage de la collection

Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();

if (spliterator2 != null) {
    spliterator2.forEachRemaining(name -> System.out.println("Partie 2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("Partie 1: " + name));

8. Erreurs courantes avec Spliterator et les flux parallèles

Erreur n°1: utiliser des flux parallèles pour de petites collections. Au lieu d’une accélération, vous obtiendrez un ralentissement — les surcoûts de découpage et de planification dépassent le gain.

Erreur n°2: s’attendre à conserver l’ordre des éléments. Les flux parallèles ne garantissent pas l’ordre. S’il est important — utilisez forEachOrdered, mais une partie de l’efficacité parallèle sera perdue.

Erreur n°3: effets de bord dans les expressions lambda. À l’intérieur d’un flux parallèle, il n’est pas sûr de modifier des variables/collections externes — vous obtiendrez des data races et des bugs difficiles à traquer.

Erreur n°4: utilisation de collections non thread-safe dans un flux parallèle. Ajouter dans un ArrayList classique depuis plusieurs threads — c’est le chemin direct vers des erreurs comme ConcurrentModificationException.

Erreur n°5: attendre une accélération instantanée. Les flux parallèles ne sont pas une baguette magique. Profilez : si les données sont faibles ou l’opération est légère — le flux séquentiel est plus rapide.

Erreur n°6: flux parallèles avec des sources qui se découpent mal. Par exemple, LinkedList se découpe souvent de manière inefficace — le parallélisme peut seulement ralentir l’exécution.

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION