Threads, pools et virtual threads

Publié le · Mis à jour le

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 final et injecte-les par le constructeur. Si un champ ne peut pas être final, 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 :

  1. Sans @EnableAsync, l'annotation @Async ne fait rien : la méthode s'exécute normalement, sans erreur ni avertissement.
  2. Appeler une méthode @Async depuis 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 @Async doit être appelée depuis une autre classe.
  3. Une erreur dans une méthode @Async qui retourne void est seulement écrite dans les logs. L'appelant n'en saura rien. Retourne un CompletableFuture si 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

  1. Pas de données de requête dans les champs d'un service. Paramètres et variables locales uniquement.
  2. Des champs final, injectés par le constructeur.
  3. 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).
  4. Ne change pas les tailles de pool au hasard. Augmenter les threads sans augmenter les connexions à la base ne sert à rien.
  5. 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.
  • @Async exécute une méthode en arrière-plan, à condition d'avoir @EnableAsync et de l'appeler depuis une autre classe.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note