Introduits avec Java 8, les Streams Java révolutionnent le traitement des données en offrant une approche fonctionnelle élégante. Cette API permet de manipuler les collections de manière déclarative grâce à des opérations en chaîne, simplifiant ainsi le code tout en améliorant sa lisibilité.
Structure des opérations
L'API Stream dans Java fonctionne selon un modèle en trois phases distinctes :
✏️ Création du Stream
Plusieurs façons de créer un Stream :
// À partir d'une Collection
List<String> liste = Arrays.asList("a", "b", "c");
Stream<String> stream = liste.stream();
// Création directe
Stream<String> stream = Stream.of("a", "b", "c");
// À partir d'un tableau
String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
Création d'un Stream
🔎 Opérations intermédiaires
Ces opérations transforment le Stream et retournent un nouveau Stream :
// Exemples d'opérations intermédiaires
.filter(Predicate<T>) // Filtre les éléments
.map(Function<T,R>) // Transforme les éléments
.sorted() // Trie les éléments
.distinct() // Élimine les doublons
.limit(n) // Limite le nombre d'éléments
Méthodes intermédiaires
⌛ Opération terminale
Une opération qui produit un résultat ou un effet de bord :
// Exemples d'opérations terminales
.collect() // Collecte les résultats
.forEach() // Parcourt les éléments
.reduce() // Réduit à une seule valeur
.count() // Compte les éléments
.anyMatch() // Vérifie une condition
Méthodes terminales
Caractéristiques importantes
L'API Stream possède des caractéristiques innovantes qui ont contribuées à son succès par rapport aux versions Java précédentes.
🦥 Évaluation paresseuse
Stream<String> stream = list.stream()
.filter(s -> {
System.out.println("filtrage : " + s);
return s.startsWith("a");
})
.map(s -> {
System.out.println("mapping : " + s);
return s.toUpperCase();
});
Lazy loading
Si l'on évalue le code précédent, rien ne s'affichera ! En effet, avec l'API Stream, aucune opération n'est exécutée jusqu'à l'appel d'une opération terminale.
🕵️ Pipeline de traitement
long count = personnes.stream()
.filter(p -> p.getAge() > 18) // Filtre l'élément reçu
.map(Personne::getNom) // Transforme l'élément éligible
.distinct() // Supprime les doublons (statefull)
.count(); // Compte le nombre d'éléments
Pipeline de traitement
Le fait que chaque opération intermédiaire retourne un Stream permet de chainer les appels et ainsi de créer des pipelines de traitement avec une lisibilité de code accrue.
🚀 Traitement parallèle
// Conversion d'un stream séquentiel en parallèle
long count = list.stream()
.parallel()
.filter(predicate)
.count();
Stream Parallel
⚠️ Attention aux opérations bloquantes dans les Streams parallèles
Lors de l'utilisation de parallelStream()
ou stream().parallel()
, il est crucial d'éviter les opérations bloquantes qui pourraient dégrader les performances :
// À NE PAS FAIRE ❌
list.parallelStream()
.map(item -> appelBloquant(item))
.toList();
// PRÉFÉRER ✅
List<CompletableFuture<String>> asyncResults = list.stream()
.map(item -> CompletableFuture.supplyAsync(
()-> appelBloquant(item),
Executors.newVirtualThreadPerTaskExecutor())
)
.toList();
// Traiter la liste des tâches asynchrone
// (allOf() / anyOf() / join() etc.)
// ⚠️ Exemple minimaliste qui ne prend pas en compte les échecs
List<String> results = asyncResults.stream()
.map(CompletableFuture::join)
.toList();
Opérations bloquante dans un Stream
Les opérations bloquantes comme les appels synchrones à des APIs, les opérations I/O peuvent :
- Monopoliser les threads du Common ForkJoinPool
- Réduire significativement les performances
- Créer des deadlocks potentiels
Pour les opérations asynchrones, privilégiez l'utilisation de CompletableFuture,
de frameworks réactifs ou l'utilisation des Virtual Thread.
Les streams parallèles, et plus largement l'utilisation des threads du Common ForkJoinPool sont réservés à des tâches de calcul.
📚 Source de données et Spliterator
Les Streams s'appuient sur un mécanisme de "push" où la source de données pousse les éléments vers le Stream via un Spliterator. Ce dernier est responsable de la traversée et du partitionnement des éléments de la source.
Connexion avec une base de données
// Dans le repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data JPA fournit directement les méthodes stream()
@Query("SELECT u FROM User u")
Stream<User> streamAllUsers();
}
// Dans le service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public void processUsers() {
try (var userStream = userRepository.streamAllUsers()) {
userStream
.filter(user -> user.getAge() > 18)
.map(User::getName)
.forEach(System.out::println);
} // Le Stream est automatiquement fermé
}
}
Streamer les résultats d'une requête en base de données
Le try-with-resources
est important pour s'assurer que la session Hibernate est correctement fermée après l'utilisation du Stream.
L'avantage par rapport à un findAll qui renverrai une liste est qu'ici, nous n'avons qu'un seul objet en mémoire à l'instant T.
🗝️ Points clés à retenir
- Usage Unique : Un Stream ne peut être utilisé qu'une seule fois. Après une opération terminale, le Stream est fermé.
- Immutabilité : Les opérations de Stream ne modifient pas la source de données originale.
- Chaînage : Les opérations peuvent être chaînées pour créer des pipelines de traitement complexes.
- Short-Circuiting : Certaines opérations (comme findFirst(), limit()) peuvent arrêter le traitement avant de parcourir tous les éléments.
Exemple complet
List<String> resultat = personnes.stream()
.filter(p -> p.getAge() > 18) // Filtre les majeurs
.map(Personne::getNom) // Extrait les noms
.sorted() // Trie par ordre alphabétique
.distinct() // Élimine les doublons
.toList(); // Collecte dans une liste imutable
Exemple API Stream
Cette structure permet une manipulation efficace et expressive des données, particulièrement adaptée aux traitements complexes sur des collections.
Conclusion
Les Streams Java, introduits avec Java 8, ont transformé la manière dont les développeurs manipulent les collections de données. Grâce à une approche fonctionnelle, des opérations en chaîne et un traitement optimisé, cette API permet d’écrire un code plus lisible, concis et performant.
L’évaluation paresseuse et le support du traitement parallèle offrent des gains significatifs en efficacité, à condition de bien maîtriser leur utilisation pour éviter les pièges des opérations bloquantes.
Que ce soit pour le filtrage, la transformation ou la réduction des données, les Streams constituent un outil puissant et incontournable pour tout développeur Java moderne. 🚀
👉 Et vous, utilisez-vous déjà les Streams dans vos projets ? 😊
Si vous souhaitez en savoir plus sur l'histoire de Java, découvrez cet article 👇
