Threads, pools et virtual threads
L'idée en une phrase
Dans une application Spring Boot, chaque requête HTTP est traitée par un thread. Tu n'en crées jamais toi-même : Spring Boot s'en occupe. Mais savoir comment ça marche t'évite des bugs difficiles à trouver.
C'est quoi un thread ?
Un thread, c'est un fil d'exécution : une suite d'instructions que la machine déroule. Plusieurs threads permettent de faire plusieurs choses en même temps, par exemple répondre à plusieurs utilisateurs à la fois.
Une image pour comprendre : un restaurant.
- Les cuisiniers, ce sont les cœurs du processeur. Il y en a un nombre fixe.
- Les serveurs, ce sont les threads. Chaque client (chaque requête HTTP) est pris en charge par un serveur.
La plupart du temps, un serveur attend : que le client choisisse, que la cuisine envoie le plat. Dans une application web, c'est pareil : un thread passe surtout son temps à attendre la base de données ou une API externe.
Ce que Spring Boot fait pour toi
Quand tu lances une application Spring Boot web, deux pools (des réserves d'objets créés à l'avance et réutilisés) sont mis en place automatiquement :
| Pool | Rôle | Taille par défaut |
|---|---|---|
| Threads de Tomcat | traiter les requêtes HTTP | 200 threads |
| Connexions HikariCP | parler à la base de données | 10 connexions |
Quand une requête arrive, Tomcat prend un thread libre dans sa réserve, exécute ton contrôleur puis ton service, et rend le thread à la fin.
Conséquence à connaître : si 200 requêtes sont déjà en cours, la 201ᵉ attend qu'un thread se libère. Et si 10 requêtes utilisent déjà la base, la 11ᵉ attend une connexion libre.
Note : ces valeurs se changent dans
application.properties(server.tomcat.threads.max,spring.datasource.hikari.maximum-pool-size), mais les valeurs par défaut conviennent à la plupart des projets. N'y touche pas sans raison mesurée.
La règle n°1 : un service est partagé entre tous les threads
C'est le point à retenir de cette fiche.
Un @Service, un @Component ou un @RestController n'existe qu'en un seul exemplaire dans l'application (on dit que c'est un singleton). Ce même objet est utilisé en même temps par tous les threads, donc par toutes les requêtes.
Si tu stockes une donnée dans un champ de ton service, toutes les requêtes la partagent :
@Service
public class PanierService {
private Panier panierEnCours; // ❌ partagé entre TOUS les utilisateurs
public void ajouter(Long produitId) {
panierEnCours.ajouter(produitId); // l'utilisateur A peut écrire dans le panier de B
}
}
Ce bug n'apparaît pas quand tu testes seul en local. Il apparaît en production, quand plusieurs personnes utilisent l'application en même temps.
La bonne façon : les données d'une requête passent en paramètre ou vivent dans des variables locales. Les champs d'un service ne servent qu'à ses dépendances.
@Service
public class PanierService {
private final PanierRepository panierRepository; // ✅ dépendance, jamais modifiée
public PanierService(PanierRepository panierRepository) {
this.panierRepository = panierRepository;
}
public void ajouter(Long utilisateurId, Long produitId) {
Panier panier = panierRepository.findByUtilisateurId(utilisateurId); // ✅ variable locale
panier.ajouter(produitId);
panierRepository.save(panier);
}
}
Astuce : déclare les champs de tes services en
finalet injecte-les par le constructeur. Si un champ ne peut pas êtrefinal, pose-toi la question : est-ce une donnée de requête qui n'a rien à faire là ?
Les virtual threads : une seule ligne de configuration
Les threads classiques sont « lourds » : chacun consomme de la mémoire, d'où la limite de 200. Or ils passent leur temps à attendre.
Depuis Java 21, Java propose des virtual threads : des threads très légers. Quand l'un d'eux attend (base de données, API), il libère sa place pour qu'un autre travaille. On peut en avoir des centaines de milliers. Pour reprendre le restaurant : le serveur qui attend ne bloque plus personne.
Avec Spring Boot 3.2 ou plus et Java 21 ou plus, il suffit d'une ligne dans application.properties :
spring.threads.virtual.enabled=true
Spring Boot traite alors chaque requête avec un virtual thread. Ton code ne change pas : tes contrôleurs et tes services restent identiques.
Quand est-ce utile ?
- ✅ Quand l'application attend beaucoup : beaucoup d'appels à des API externes, beaucoup d'utilisateurs simultanés.
- ❌ Pour du calcul pur (traitement d'images, gros calculs) : les virtual threads n'ajoutent pas de cœur au processeur, ça n'ira pas plus vite.
- ⚠️ Ça ne règle pas tout : si la base n'a que 10 connexions, la 11ᵉ requête attendra toujours. Le goulot se déplace, il ne disparaît pas.
Le piège de synchronized (Java 21 à 23)
En Java 21, 22 et 23, un virtual thread qui attend à l'intérieur d'un bloc synchronized reste coincé et ne libère pas sa place. Si ça arrive souvent, l'application ralentit.
Dans ton propre code, tu ne devrais pas avoir besoin de synchronized (voir la règle n°1). Mais certaines bibliothèques anciennes en utilisent. Ce problème est corrigé à partir de Java 24 : si tu actives les virtual threads, une version récente de Java est préférable.
@Async : lancer un traitement en arrière-plan
Parfois, une requête doit déclencher un traitement long (envoyer un e-mail, générer un PDF) sans faire attendre l'utilisateur. Spring propose @Async : la méthode s'exécute sur un autre thread, et la requête répond tout de suite.
@Configuration
@EnableAsync // ✅ obligatoire, sinon @Async est ignoré
public class AsyncConfig {
}
@Service
public class EmailService {
@Async
public CompletableFuture<Void> envoyerConfirmation(String adresse) {
// envoi de l'e-mail, qui peut prendre quelques secondes
return CompletableFuture.completedFuture(null);
}
}
Trois pièges classiques :
- Sans
@EnableAsync, l'annotation@Asyncne fait rien : la méthode s'exécute normalement, sans erreur ni avertissement. - Appeler une méthode
@Asyncdepuis la même classe ne marche pas. Spring ajoute le comportement asynchrone « autour » de l'objet ; un appel interne passe à côté. La méthode@Asyncdoit être appelée depuis une autre classe. - Une erreur dans une méthode
@Asyncqui retournevoidest seulement écrite dans les logs. L'appelant n'en saura rien. Retourne unCompletableFuturesi l'appelant doit savoir si ça a marché.
Note : si les virtual threads sont activés, Spring Boot les utilise aussi pour
@Async.
Les bonnes habitudes
- Pas de données de requête dans les champs d'un service. Paramètres et variables locales uniquement.
- Des champs
final, injectés par le constructeur. - Ne crée pas de threads à la main (
new Thread(...),Executors...). Si tu penses en avoir besoin, parles-en d'abord à quelqu'un de l'équipe : Spring a presque toujours un outil prévu pour ça (@Async,@Scheduled). - Ne change pas les tailles de pool au hasard. Augmenter les threads sans augmenter les connexions à la base ne sert à rien.
- Avec
@Async, vérifie@EnableAsync, appelle la méthode depuis une autre classe, et ne laisse pas les erreurs disparaître.
À retenir
- Chaque requête HTTP est traitée par un thread que Spring Boot gère pour toi.
- Un service est partagé entre toutes les requêtes : jamais de données d'une requête dans ses champs.
- Les virtual threads (Java 21+) s'activent avec une ligne de configuration et aident quand l'application attend beaucoup.
- La base de données a son propre pool de connexions : c'est souvent elle la vraie limite.
@Asyncexécute une méthode en arrière-plan, à condition d'avoir@EnableAsyncet de l'appeler depuis une autre classe.