1. Tout ce qui peut être parallélisé ne doit pas forcément l’être
En Java, il existe de nombreuses façons de paralléliser des tâches. Mais « parallélisme = toujours plus rapide » — c’est comme penser que si l’on ajoute encore du sel à une soupe, elle sera meilleure : jusqu’à un certain point — oui, mais au-delà — mieux vaut s’abstenir.
ExecutorService convient parfaitement lorsque vous avez des tâches explicites à lancer et à contrôler : traitement de requêtes, chargements asynchrones de données, calculs indépendants. Vous choisissez vous‑même le nombre de threads dans le pool et gérez le cycle de vie des tâches.
parallelStream — un moyen rapide de paralléliser le traitement de collections lorsque les opérations sont indépendantes et sans effets de bord. Rentable pour les collections « lourdes » (des dizaines de milliers d’éléments et plus).
ForkJoinPool — le choix pour les tâches qui se divisent bien en sous‑tâches (divide & conquer) : tri, recherche, agrégation de grands tableaux. Utilisé à l’intérieur de parallelStream, mais on peut aussi le piloter directement.
N’utilisez pas le parallélisme « au cas où ». Si la tâche est petite, les surcoûts de planification, de changement de contexte et de synchronisation peuvent absorber tout le gain.
Exemple : quand le parallélisme n’est pas nécessaire
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Paralléliser pour 5 nombres — overkill !
2. Thread-safety : éviter les données partagées modifiables
Dans le monde parallèle, la principale menace — les conditions de course (race conditions). Si plusieurs threads modifient une même variable, le résultat peut être inattendu.
- Évitez les variables partagées modifiables. Même une expression comme counter++ n’est pas atomique.
- Utilisez des collections thread-safe et des opérations atomiques. Par exemple, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- Pas d’effets de bord dans les streams parallèles. Ne modifiez pas des structures externes depuis parallelStream.
Mauvais exemple de code
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // DANGEREUX !
Ici, result.add() n’est pas thread-safe. Résultat : éléments perdus ou exceptions.
Comment faire correctement ?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Performance : plus de threads n’est pas toujours mieux
Les petites tâches ne sont pas rentables à paralléliser. Si le travail prend des millisecondes, un lancement parallèle ralentira souvent l’exécution à cause des surcoûts.
Mesurez la performance. Pour des mesures rapides, System.nanoTime() convient :
long start = System.nanoTime();
// ... votre code ...
long end = System.nanoTime();
System.out.println("Temps d'exécution: " + (end - start) + " ns");
Pour de vrais microbenchmarks, utilisez JMH (Java Microbenchmark Harness).
Exemple : comparaison d’un stream séquentiel et d’un stream parallèle
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Séquentiel: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Parallèle: " + (t3 - t2) / 1_000_000 + " ms");
Essayez sur votre machine — le gain est perceptible sur des collections vraiment grandes et des opérations lourdes.
4. Gestion des erreurs : n’ignorez pas les exceptions dans les threads
Future et gestion des exceptions
Si vous lancez une tâche via ExecutorService.submit(), les exceptions ne « remontent » pas automatiquement — vous devez les gérer via Future.get() :
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Oups !");
return 42;
});
try {
Integer result = future.get(); // peut lancer ExecutionException
} catch (ExecutionException e) {
System.err.println("Erreur dans la tâche : " + e.getCause());
}
ForkJoin et gestion des erreurs
Dans ForkJoinPool, les exceptions sont encapsulées dans la tâche. Lors de l’appel de join()/get(), elles remonteront :
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Erreur dans ForkJoin : " + e);
}
N’oubliez pas de gérer InterruptedException
De nombreuses méthodes (par exemple, Future.get(), Thread.sleep()) peuvent lancer InterruptedException. Ne l’« avalez » pas — réagissez correctement : réglez le drapeau d’interruption ou terminez la tâche.
5. Débogage et tests du code parallèle
Journalisation et débogage
Les bugs parallèles sont sournois et se manifestent souvent de manière instable. Journalisez en indiquant le thread : Thread.currentThread().getName(). Cela aide à comprendre qui exécute le code et quand.
Dans les cas complexes, utilisez un débogueur avec prise en charge du multithreading (par exemple, IntelliJ IDEA). Des Thread.sleep() temporaires aident parfois à « attraper » une course de données rare.
Tests de scénarios multithread
Dédiez des tests séparés aux opérations parallèles et utilisez des utilitaires d’attente de conditions, par exemple Awaitility. Exécutez ces tests de nombreuses fois : certains problèmes n’apparaissent qu’au 100e ou au 1000e lancement.
6. Nuances et conseils utiles
Lisibilité et maintenabilité : écrivez un code parallèle clair
- Documentez. Commentez les sections complexes et le choix des outils.
- Utilisez des abstractions de haut niveau. Préférez ExecutorService, parallelStream, ForkJoinPool au pilotage manuel des threads.
- Évitez la « magie ». Ne compliquez pas la synchronisation s’il est possible de faire plus simple.
Tableau : quand utiliser quel outil
| Scénario | Outil recommandé |
|---|---|
| Beaucoup de tâches indépendantes | |
| Traitement d’une grande collection | parallelStream ou ForkJoin |
| Tâche « divide & conquer » | |
| Tâche asynchrone simple | |
| Beaucoup de petites tâches | Stream séquentiel |
| Tâches avec effets de bord | Uniquement des collections thread-safe ! |
« Commandements » du programmeur parallèle
- N’utilisez pas de variables partagées modifiables — sauf si vous êtes sûr qu’elles sont thread-safe.
- Ne parallélisez pas pour le plaisir de paralléliser : évaluez le gain potentiel.
- N’oubliez pas de fermer les pools : shutdown()/shutdownNow().
- N’utilisez pas parallelStream pour des opérations avec effets de bord.
- N’oubliez pas de gérer les exceptions de Future et de ForkJoinTask.
- N’« avalez » pas InterruptedException — terminez la tâche correctement.
7. Erreurs typiques en programmation parallèle
Erreur n° 1 : Parallélisation de petites tâches. Les débutants parallélisent souvent tout, même lorsque le travail prend des microsecondes. Résultat — plus lent à cause des surcoûts.
Erreur n° 2 : Effets de bord dans les streams. Dans parallelStream, il est interdit de modifier des variables ou collections externes — vous aurez des conditions de course et des bugs imprévisibles.
Erreur n° 3 : Ignorer les exceptions. Si vous ne gérez pas les erreurs de Future.get() ou ForkJoinTask, vous ne saurez pas pourquoi une tâche a échoué.
Erreur n° 4 : Oublier shutdown() sur ExecutorService. Sans arrêt explicite, l’application peut « se bloquer » à la sortie.
Erreur n° 5 : Utiliser des collections non thread-safe. Écrire depuis plusieurs threads dans un ArrayList ordinaire — voie directe vers les erreurs.
Erreur n° 6 : « Avaler » InterruptedException. Si un thread est interrompu — respectez‑le et terminez correctement le travail.
Erreur n° 7 : Logique de synchronisation trop complexe. Des blocs synchronized excessifs mènent à deadlock/livelock. Préférez les abstractions de haut niveau.
GO TO FULL VERSION