Threads et concurrence¶
Java a des vrais threads parallèles depuis toujours, et depuis Java 21 des virtual threads très légers. Mais contrairement à Rust, rien ne t'empêche de partager une variable mutable entre threads : les data races compilent. Ce chapitre est essentiel pour Spring, où chaque requête HTTP tourne sur son propre thread.
Dans ce chapitre
Thread,RunnableetExecutorService(avec try-with-resources)- Les virtual threads de Java 21 et
CompletableFutureen bref - Pas de
Send/Sync: la démonstration d'un compteur qui perd des incréments synchronized,AtomicInteger,ConcurrentHashMap,volatile, et l'immutabilité- Le lien avec Spring : les beans singletons partagés entre les threads des requêtes
Lancer des threads¶
Thread et Runnable¶
Un Runnable est une interface fonctionnelle (void run()), donc une lambda suffit :
Comme std::thread en Rust, un Thread Java classique (dit thread plateforme) est un vrai thread du système d'exploitation. Et contrairement à Python, il n'y a pas de GIL : plusieurs threads exécutent du code Java en parallèle sur plusieurs cœurs.
ExecutorService : un pool de threads¶
En pratique, on crée rarement des Thread à la main : on soumet des tâches à un ExecutorService, qui gère un pool de threads réutilisés.
try (ExecutorService pool = Executors.newFixedThreadPool(4)) { // (1)!
Future<Integer> f = pool.submit(() -> calcul(7)); // Callable : renvoie une valeur
pool.submit(() -> System.out.println("tâche sans résultat"));
System.out.println("résultat : " + f.get()); // (2)!
} // close() attend la fin de toutes les tâches
// affiche : tâche sans résultat
// résultat : 49
ExecutorServiceestAutoCloseabledepuis Java 19 : le try-with-resources remplace l'ancien coupleshutdown()+awaitTermination().Future.get()bloque jusqu'au résultat, commeJoinHandle::joinen Rust. Si la tâche a levé une exception,get()lève uneExecutionExceptionqui l'enveloppe.
Les virtual threads (Java 21)¶
Un thread plateforme coûte cher (pile de l'ordre du mégaoctet, appel système pour le créer) : on en a quelques centaines, pas des centaines de milliers. Les virtual threads sont des threads gérés par la JVM : quand l'un d'eux bloque (sommeil, lecture réseau, requête SQL), la JVM le décroche de son thread système et en exécute un autre à la place. Tu écris du code bloquant ordinaire, sans async/await, et il passe à l'échelle.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Virtuels {
public static void main(String[] args) {
Instant debut = Instant.now();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
exec.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simule un appel BDD ou HTTP
return null;
});
}
}
System.out.println("Virtual threads : " + Duration.between(debut, Instant.now()).toMillis() + " ms");
debut = Instant.now();
try (ExecutorService exec = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < 10_000; i++) {
exec.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return null;
});
}
}
System.out.println("200 threads plateforme : " + Duration.between(debut, Instant.now()).toMillis() + " ms");
}
}
// affiche : Virtual threads : 1106 ms
// 200 threads plateforme : 50075 ms
10 000 tâches qui attendent chacune une seconde : environ 1 s avec un virtual thread par tâche, 50 s avec un pool de 200 threads (10 000 / 200 = 50 vagues).
Sous le capot
Les virtual threads sont l'équivalent des goroutines de Go, ou d'un runtime tokio caché derrière une API bloquante. Un petit pool de threads « porteurs » (un par cœur) exécute des milliers de virtual threads. Depuis Java 24, même un bloc synchronized libère le thread porteur.
Ils n'accélèrent pas le calcul pur : si tes tâches font chauffer le CPU au lieu d'attendre, un pool de la taille du nombre de cœurs reste la bonne réponse. Et inutile de les mettre en pool : on en crée un par tâche.
CompletableFuture en bref¶
CompletableFuture est le Future composable de Java, l'équivalent des promesses JavaScript ou de la combinaison de futures en Rust :
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<String> profil = CompletableFuture.supplyAsync(() -> chargerProfil(1), exec);
CompletableFuture<Integer> commandes = CompletableFuture.supplyAsync(() -> chargerCommandes(1), exec);
String resume = profil
.thenApply(String::toUpperCase) // ≈ map
.thenCombine(commandes, (nom, n) -> nom + " a " + n + " commandes") // ≈ join! puis map
.exceptionally(e -> "erreur : " + e.getMessage()) // en cas d'exception
.join(); // attend le résultat
System.out.println(resume);
}
// affiche : ADA a 3 commandes (en 318 ms : les deux appels de 300 ms tournent en parallèle)
Avec les virtual threads, on a de moins en moins besoin de ces chaînes : lancer deux tâches bloquantes et faire deux get() est souvent plus lisible.
Mémoire partagée : le compilateur ne te protège pas¶
Pas de Send ni de Sync¶
En Rust, partager une donnée mutable entre threads sans Mutex ne compile pas. En Java, tout objet est accessible depuis tous les threads, et le compilateur ne vérifie rien. Démonstration avec 4 threads qui incrémentent chacun 100 000 fois un compteur partagé :
import java.util.ArrayList;
import java.util.List;
public class Course {
static int compteur = 0;
public static void main(String[] args) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 4; t++) {
Thread th = new Thread(() -> {
for (int i = 0; i < 100_000; i++) {
compteur++; // lire, ajouter 1, écrire : 3 étapes
}
});
th.start();
threads.add(th);
}
for (Thread th : threads) th.join();
System.out.println("attendu : 400000, obtenu : " + compteur);
}
}
Cinq exécutions réelles sur une machine à 6 cœurs :
attendu : 400000, obtenu : 322573
attendu : 400000, obtenu : 131723
attendu : 400000, obtenu : 134558
attendu : 400000, obtenu : 222680
attendu : 400000, obtenu : 236706
Jusqu'à deux incréments sur trois perdus, sans erreur ni avertissement : deux threads lisent la même valeur, ajoutent 1, et écrivent le même résultat. Le résultat change à chaque lancement, ce qui rend ce genre de bug très difficile à reproduire.
synchronized : le verrou intégré¶
Chaque objet Java possède un verrou (moniteur). synchronized le prend en entrée et le rend en sortie, même en cas d'exception :
static synchronized void incrementer() { compteur++; } // verrou de la classe
private final Object verrou = new Object();
void incrementer() {
synchronized (verrou) { // bloc : verrou d'un objet choisi
compteur++;
}
}
// avec l'une ou l'autre version : obtenu : 400000
Différence de fond avec Mutex<T>
En Rust, le Mutex<T> contient la donnée : impossible d'y accéder sans prendre le verrou. En Java, le verrou et la donnée sont séparés. Rien ne t'empêche d'oublier synchronized dans une autre méthode qui lit ou écrit compteur. C'est à toi de garder la discipline.
Les atomiques¶
Pour un simple compteur ou un drapeau, les classes de java.util.concurrent.atomic sont plus simples et plus rapides qu'un verrou (instructions atomiques du processeur, comme AtomicUsize en Rust) :
static final AtomicInteger compteur = new AtomicInteger();
compteur.incrementAndGet(); // atomique
compteur.get(); // 400000
Pour un compteur très sollicité (statistiques), LongAdder évite que tous les threads se battent pour la même case mémoire.
Les collections concurrentes¶
ArrayList et HashMap ne sont pas thread-safe. Le même test avec map.merge("cle", 1, Integer::sum) lancé par 4 threads donne {cle=234265} avec une HashMap, et {cle=400000} avec une ConcurrentHashMap, dont les opérations merge, compute, putIfAbsent sont atomiques.
Map<String, Integer> compte = new ConcurrentHashMap<>();
compte.merge(mot, 1, Integer::sum); // sûr depuis plusieurs threads
Pour une liste lue souvent et modifiée rarement (des écouteurs, une config), il y a CopyOnWriteArrayList.
volatile en une phrase¶
volatile garantit qu'une écriture sur un champ est immédiatement visible par les autres threads (sans lui, un thread peut garder une valeur périmée en cache), mais ne rend pas compteur++ atomique : il sert surtout pour un drapeau du type volatile boolean arret.
L'immutabilité, la meilleure protection¶
Un objet qui ne change jamais peut être partagé par tous les threads sans aucun verrou : c'est l'équivalent d'un Arc<T> sans Mutex en Rust. Les records, les champs final, List.of(...), String, LocalDate sont immuables. Moins il y a d'état mutable partagé, moins il y a de bugs de concurrence : c'est la règle d'or, avant tous les verrous.
Comparaison avec Rust et Python¶
| Java | Rust | Python (CPython) | |
|---|---|---|---|
| Parallélisme réel | Oui | Oui | Non avec le GIL |
| Data race possible | Oui, et ça compile | Non (sauf unsafe) |
Oui sur tes invariants |
| Qui te protège | Toi (synchronized, atomiques) |
Le compilateur (Send, Sync) |
Le GIL, en partie |
Le GIL de Python empêche deux threads d'exécuter du bytecode en même temps, mais compteur += 1 n'est pas garanti atomique pour autant, et les versions sans GIL (free-threaded, depuis Python 3.13) rendent le verrou indispensable. Java est dans la situation de ces versions sans GIL : parallélisme réel, aucune protection automatique.
Le lien avec Spring¶
Voilà pourquoi ce chapitre compte autant pour la suite :
- Le serveur web embarqué de Spring Boot (Tomcat) traite chaque requête HTTP sur un thread, pris dans un pool de 200 threads par défaut (
server.tomcat.threads.max). - Les objets gérés par Spring (les beans : tes services, contrôleurs, repositories) sont des singletons : une seule instance, partagée par tous ces threads (voir Injection de dépendances).
Conséquence : un champ mutable dans un service est partagé entre toutes les requêtes en cours. Simulation sans Spring, avec un seul objet et trois « requêtes » simultanées :
class SalutService {
private String nom; // état partagé par toutes les requêtes : BUG
String saluer(String nom) {
this.nom = nom;
pause(); // simule un appel à la base de données (50 ms)
return "Bonjour " + this.nom;
}
}
SalutService service = new SalutService(); // UN seul objet, comme un bean Spring
try (ExecutorService serveur = Executors.newVirtualThreadPerTaskExecutor()) {
for (String nom : new String[]{"Ada", "Bob", "Carl"}) {
serveur.submit(() -> System.out.println(nom + " reçoit : " + service.saluer(nom)));
}
}
// affiche (un exemple réel) :
// Ada reçoit : Bonjour Bob
// Bob reçoit : Bonjour Bob
// Carl reçoit : Bonjour Bob
Ada et Carl reçoivent la réponse de Bob. Dans une vraie appli, ce serait le panier ou les données personnelles d'un autre utilisateur.
La règle pour tes beans Spring :
- pas d'état mutable lié à une requête dans un champ : ce qui concerne la requête reste en variables locales et en paramètres ;
- les champs servent aux dépendances (injectées par le constructeur,
final, jamais modifiées) et à la configuration ; - l'état partagé légitime (un cache, un compteur) passe par une structure thread-safe (
ConcurrentHashMap,AtomicLong) ou, le plus souvent, par la base de données.
Activer les virtual threads dans Spring Boot¶
Une ligne dans application.properties (voir Configuration) :
Chaque requête est alors traitée sur un virtual thread, et la limite de 200 threads de Tomcat ne s'applique plus. Ton code ne change pas, et les règles sur l'état partagé non plus.
Sous le capot : ThreadLocal
Comment Spring sait-il, au fond d'un repository, quelle transaction ou quel utilisateur connecté correspond à la requête en cours ? Il les range dans des ThreadLocal : des variables qui ont une valeur différente pour chaque thread. Conséquence pratique : si tu lances toi-même un autre thread dans une méthode @Transactional, ce thread ne voit pas la transaction. (Java 25 a finalisé ScopedValue, une alternative plus sûre aux ThreadLocal.)
Exercices¶
1. Corrige Course.java avec un AtomicInteger.
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Course {
static final AtomicInteger compteur = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < 4; t++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 100_000; i++) {
compteur.incrementAndGet();
}
}));
}
for (Thread th : threads) th.join();
System.out.println("obtenu : " + compteur.get()); // affiche : obtenu : 400000
}
}
2. Compte les mots de trois documents ("le chat dort", "le chien dort", "le chat et le chien jouent") en traitant chaque document dans un virtual thread, avec une seule Map partagée. Affiche le résultat trié.
Solution
Map<String, Integer> compte = new ConcurrentHashMap<>();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (String doc : documents) {
exec.submit(() -> {
for (String mot : doc.split(" ")) {
compte.merge(mot, 1, Integer::sum); // atomique sur ConcurrentHashMap
}
});
}
} // ici, toutes les tâches sont terminées
System.out.println(new TreeMap<>(compte));
// affiche : {chat=2, chien=2, dort=2, et=1, jouent=1, le=4}
Avec une HashMap, le résultat serait faux de temps en temps, ou la HashMap pourrait même être corrompue.
3. Ce service (un singleton, appelé par 8 threads) renvoie parfois des dates fausses, et lève parfois des exceptions. Pourquoi ? Corrige-le.
class DateService {
private final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
String normaliser(String date) throws ParseException {
return format.format(format.parse(date));
}
}
Solution
Le champ est final, mais l'objet SimpleDateFormat est mutable : il garde un état interne pendant parse et format, que les threads s'écrasent les uns les autres. Sur 10 000 appels répartis sur 8 threads, un essai réel a donné 4 803 dates fausses et 814 exceptions.
La correction : utiliser l'API java.time, dont les objets sont immuables, donc partageables sans risque.
class DateService {
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String normaliser(String date) {
return LocalDate.parse(date, FORMAT).format(FORMAT);
}
}
// même test : 0 date fausse
final rend la référence constante, pas l'objet : c'est l'immutabilité de l'objet qui protège.
4. Trois appels de 500 ms (appel("stock", 10), appel("prix", 20), appel("remise", 3)) sont indépendants. Lance-les en parallèle avec CompletableFuture et additionne les résultats. Combien de temps faut-il ?
Solution
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> appel("stock", 10), exec);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> appel("prix", 20), exec);
CompletableFuture<Integer> c = CompletableFuture.supplyAsync(() -> appel("remise", 3), exec);
int total = a.thenCombine(b, Integer::sum).thenCombine(c, Integer::sum).join();
System.out.println(total); // affiche : 33 (en 513 ms, pas 1 500)
}
À retenir¶
- Un
ThreadJava est un vrai thread système, sans GIL : le parallélisme est réel. - Utilise un
ExecutorServicedans un try-with-resources ;Executors.newVirtualThreadPerTaskExecutor()pour les tâches qui attendent (I/O). - Les virtual threads (Java 21) permettent des milliers de tâches bloquantes sans
async; ils n'accélèrent pas le calcul pur. - Pas de
Send/Sync: une data race compile et perd des données en silence. synchronizedpour une section critique,AtomicIntegerpour un compteur,ConcurrentHashMappour une map partagée.- L'immutabilité est la meilleure protection :
final+ objets immuables (records,java.time,List.of). - Dans Spring, les beans sont des singletons partagés par les threads des requêtes : aucun état mutable de requête dans un champ.
spring.threads.virtual.enabled=truepasse les requêtes sur des virtual threads, sans changer ton code.