Spring Boot 3.4 Virtual Threads : questions d'entretien et benchmarks de performance
Maîtrisez les Virtual Threads Java 21 avec Spring Boot 3.4 : 15 questions d'entretien, benchmarks de performance et patterns de migration pour réussir vos entretiens techniques.

Les Virtual Threads représentent l'une des évolutions majeures de Java 21, et Spring Boot 3.4 les intègre nativement. Cette fonctionnalité issue du Project Loom transforme la gestion de la concurrence dans les applications backend. Les entretiens techniques évaluent désormais la compréhension des mécanismes internes, des cas d'usage appropriés et des pièges à éviter.
Les recruteurs distinguent les candidats qui comprennent les Virtual Threads de ceux qui les utilisent aveuglément. Savoir expliquer quand NE PAS les utiliser est aussi important que de connaître leurs avantages.
Fondamentaux des Virtual Threads
Question 1 : Qu'est-ce qu'un Virtual Thread et en quoi diffère-t-il d'un Platform Thread ?
Un Virtual Thread est un thread léger géré par la JVM plutôt que par le système d'exploitation. Contrairement aux Platform Threads (threads classiques), les Virtual Threads ne mappent pas directement sur un thread OS. La JVM peut en créer des millions avec une empreinte mémoire minimale.
// Comparaison de la création de threads
public class VirtualThreadDemo {
public void demonstrateDifference() {
// Platform Thread : ~1MB de stack par thread
// Limite pratique : quelques milliers sur une JVM standard
Thread platformThread = new Thread(() -> {
performBlockingOperation();
});
// Virtual Thread : ~quelques KB par thread
// Peut en créer des millions sans problème
Thread virtualThread = Thread.ofVirtual().start(() -> {
performBlockingOperation();
});
}
// Un Virtual Thread "monte" sur un Platform Thread (carrier)
// Lors d'un blocage I/O, il libère le carrier pour d'autres Virtual Threads
private void performBlockingOperation() {
try {
Thread.sleep(1000); // Le Virtual Thread se détache du carrier ici
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}La différence fondamentale réside dans la gestion du blocage. Quand un Platform Thread bloque (I/O, sleep), il reste attaché au thread OS. Un Virtual Thread se "démonte" du carrier thread, permettant à d'autres Virtual Threads d'utiliser ce carrier.
Question 2 : Comment activer les Virtual Threads dans Spring Boot 3.4 ?
Spring Boot 3.4 simplifie l'activation des Virtual Threads à une seule propriété de configuration. L'ensemble du framework s'adapte automatiquement : Tomcat, les contrôleurs REST, et les appels bloquants bénéficient immédiatement de cette optimisation.
# application.yml
# Activation globale des Virtual Threads
spring:
threads:
virtual:
enabled: true
# Configuration optionnelle du pool Tomcat
server:
tomcat:
threads:
max: 200 # Moins critique avec Virtual Threads
min-spare: 10// Activation programmatique si nécessaire
@Configuration
public class WebConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
// Chaque requête HTTP s'exécute sur un Virtual Thread
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}L'activation modifie le comportement de Tomcat : au lieu d'un pool fixe de threads, chaque requête obtient son propre Virtual Thread. Cette approche élimine le goulot d'étranglement du pool de threads traditionnel.
Question 3 : Expliquez le concept de "mounting" et "unmounting"
Le mounting désigne l'attachement d'un Virtual Thread à un carrier thread (Platform Thread). L'unmounting survient lors d'opérations bloquantes, libérant le carrier pour d'autres Virtual Threads. Ce mécanisme permet une utilisation optimale des ressources CPU.
// Illustration du cycle mounting/unmounting
public class MountingDemo {
public void demonstrateMounting() {
Thread.ofVirtual().start(() -> {
// MOUNTED : le Virtual Thread utilise un carrier thread
System.out.println("Carrier: " + getCurrentCarrier());
// UNMOUNTING : libère le carrier pendant le blocage
performDatabaseQuery(); // Appel JDBC bloquant
// REMOUNTED : peut être sur un carrier différent
System.out.println("Nouveau carrier: " + getCurrentCarrier());
});
}
// Les opérations bloquantes déclenchent l'unmounting automatiquement
private void performDatabaseQuery() {
// Connection JDBC, lecture fichier, appel réseau...
// Le Virtual Thread se détache pendant l'attente I/O
}
private String getCurrentCarrier() {
// Récupère le nom du thread porteur actuel
return Thread.currentThread().toString();
}
}Ce mécanisme est transparent pour le développeur. Le code s'écrit de manière impérative classique, mais la JVM optimise automatiquement l'utilisation des carriers. Un pool de quelques carriers peut servir des millions de Virtual Threads.
Le nombre de carrier threads correspond généralement au nombre de cœurs CPU. La JVM ajuste ce pool dynamiquement via le ForkJoinPool.
Cas d'usage et anti-patterns
Question 4 : Quand les Virtual Threads apportent-ils un gain de performance ?
Les Virtual Threads excellent pour les workloads I/O-bound : appels REST externes, requêtes base de données, lecture de fichiers. Ces opérations passent la majorité du temps en attente, période durant laquelle le Virtual Thread libère son carrier.
// Cas idéal pour Virtual Threads
@Service
public class IOBoundService {
private final RestClient restClient;
private final UserRepository userRepository;
// Chaque appel implique de l'attente réseau et BDD
// Les Virtual Threads brillent ici
public UserProfile enrichUserProfile(Long userId) {
// Appel BDD - le VT se détache pendant la requête SQL
User user = userRepository.findById(userId).orElseThrow();
// Appel REST externe - le VT se détache pendant l'attente HTTP
ExternalData externalData = restClient
.get()
.uri("/api/external/{id}", userId)
.retrieve()
.body(ExternalData.class);
// Agrégation des données
return new UserProfile(user, externalData);
}
}Le gain provient de la capacité à traiter plus de requêtes concurrentes. Avec 200 Platform Threads et des requêtes de 100ms, le débit maximal est de 2000 req/s. Avec des Virtual Threads, ce débit peut atteindre 50 000+ req/s sur la même machine.
Question 5 : Quels sont les anti-patterns à éviter avec les Virtual Threads ?
Les Virtual Threads ne conviennent pas aux workloads CPU-bound ni aux cas impliquant du "pinning". Le pinning survient quand un Virtual Thread reste attaché à son carrier malgré un blocage, annulant les bénéfices de la virtualisation.
// Exemples de cas à éviter
@Service
public class AntiPatterns {
// ANTI-PATTERN 1 : Calcul CPU-intensif
// Les Virtual Threads n'apportent rien ici
public BigInteger computeFactorial(int n) {
// 100% CPU, aucun I/O, pas d'unmounting possible
BigInteger result = BigInteger.ONE;
for (int i = 2; i <= n; i++) {
result = result.multiply(BigInteger.valueOf(i));
}
return result; // Le carrier est monopolisé tout le long
}
// ANTI-PATTERN 2 : Synchronized cause le pinning
private final Object lock = new Object();
public void pinnedOperation() {
synchronized (lock) { // PINNING : le VT reste sur le carrier
performDatabaseQuery(); // L'unmounting ne se produit pas !
}
}
// SOLUTION : Utiliser ReentrantLock
private final ReentrantLock reentrantLock = new ReentrantLock();
public void unpinnedOperation() {
reentrantLock.lock();
try {
performDatabaseQuery(); // Unmounting possible
} finally {
reentrantLock.unlock();
}
}
}Le pinning transforme un Virtual Thread en Platform Thread du point de vue des ressources. Les causes principales sont les blocs synchronized et les appels JNI natifs. La migration vers ReentrantLock résout le premier cas.
Question 6 : Comment détecter le pinning dans une application ?
La JVM offre des options de diagnostic pour identifier les cas de pinning. Ces informations sont essentielles lors de la migration vers les Virtual Threads, car le pinning peut dégrader les performances au lieu de les améliorer.
// Configuration et détection du pinning
public class PinningDiagnostics {
// Option JVM pour logger le pinning
// -Djdk.tracePinnedThreads=full (détaillé)
// -Djdk.tracePinnedThreads=short (résumé)
// Exemple de code causant du pinning
public void demonstratePinning() {
Thread.ofVirtual().start(() -> {
synchronized (this) {
// Ce log apparaîtra avec tracePinnedThreads activé
try {
Thread.sleep(100); // Pinned pendant le sleep !
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
}
}# Sortie typique avec -Djdk.tracePinnedThreads=full
Thread[#23,VirtualThread[#1]/runnable@ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
com.example.PinningDiagnostics.demonstratePinning(PinningDiagnostics.java:15)L'analyse des logs de pinning révèle les points chauds à corriger. Une application avec un pinning fréquent n'exploite pas pleinement les Virtual Threads et peut même être plus lente qu'avec des Platform Threads.
Prêt à réussir tes entretiens Spring Boot ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Benchmarks de performance
Question 7 : Quels gains de performance attendre avec les Virtual Threads ?
Les benchmarks montrent des améliorations significatives pour les applications I/O-bound typiques. Le gain dépend du ratio temps I/O / temps CPU et du niveau de concurrence requis.
// Endpoint pour mesurer les performances
@RestController
@RequestMapping("/api/benchmark")
public class BenchmarkController {
private final ExternalApiClient apiClient;
// Simulation d'un endpoint typique avec appels externes
@GetMapping("/user/{id}")
public ResponseEntity<UserData> getUser(@PathVariable Long id) {
// Trois appels I/O séquentiels
UserInfo info = apiClient.fetchUserInfo(id); // ~50ms
List<Order> orders = apiClient.fetchOrders(id); // ~80ms
CreditScore score = apiClient.fetchCreditScore(id); // ~100ms
return ResponseEntity.ok(new UserData(info, orders, score));
}
}# Résultats benchmark - 10 000 requêtes concurrentes
# Configuration : 8 cœurs, 16GB RAM, latence simulée 230ms/requête
Platform Threads (pool 200) :
- Throughput : 850 req/s
- P99 latence : 1250ms
- Mémoire heap : 2.1GB
Virtual Threads :
- Throughput : 4200 req/s
- P99 latence : 280ms
- Mémoire heap : 850MB
Gain : x5 throughput, x4.5 réduction latence P99Les Virtual Threads réduisent également la consommation mémoire car chaque thread n'alloue que quelques KB au lieu de ~1MB pour un Platform Thread.
Question 8 : Comment configurer les pools de connexion avec les Virtual Threads ?
Les pools de connexion (HikariCP, lettuce) deviennent le nouveau goulot d'étranglement avec les Virtual Threads. Un pool de 10 connexions limite à 10 requêtes BDD simultanées, même avec des millions de Virtual Threads.
# application.yml
# Configuration HikariCP optimisée pour Virtual Threads
spring:
datasource:
hikari:
# Plus de connexions car les Virtual Threads permettent plus de concurrence
maximum-pool-size: 50
minimum-idle: 10
# Timeout plus court car plus de demandes concurrentes
connection-timeout: 5000
# Validation rapide
validation-timeout: 3000
# Redis avec Lettuce - déjà async-friendly
data:
redis:
lettuce:
pool:
max-active: 50
max-idle: 20// Monitoring du pool pour éviter la contention
@Component
public class ConnectionPoolMonitor {
private final HikariDataSource dataSource;
@Scheduled(fixedRate = 10000)
public void logPoolStats() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
log.info("Pool stats - Active: {}, Idle: {}, Waiting: {}",
pool.getActiveConnections(),
pool.getIdleConnections(),
pool.getThreadsAwaitingConnection());
// Alerte si trop de threads en attente
if (pool.getThreadsAwaitingConnection() > 100) {
log.warn("Connection pool contention detected!");
}
}
}Le dimensionnement du pool dépend de la capacité de la base de données, pas du nombre de Virtual Threads. Une base PostgreSQL standard supporte ~100-200 connexions actives.
Question 9 : Comment mesurer l'impact des Virtual Threads avec Micrometer ?
Micrometer et Spring Boot Actuator fournissent des métriques essentielles pour évaluer l'efficacité des Virtual Threads. Ces métriques permettent de valider les gains et d'identifier les problèmes potentiels.
// Métriques personnalisées pour Virtual Threads
@Component
public class VirtualThreadMetrics {
private final MeterRegistry registry;
private final AtomicLong activeVirtualThreads = new AtomicLong(0);
@PostConstruct
public void registerMetrics() {
// Compteur de Virtual Threads actifs
Gauge.builder("virtual.threads.active", activeVirtualThreads, AtomicLong::get)
.description("Number of active virtual threads")
.register(registry);
// Métriques JVM pour les carriers
Gauge.builder("virtual.threads.carriers", this::getCarrierCount)
.description("Number of carrier threads")
.register(registry);
}
private double getCarrierCount() {
// Récupère le nombre de carriers du ForkJoinPool
return ForkJoinPool.commonPool().getPoolSize();
}
// Intercepteur pour tracer les requêtes
public void trackVirtualThread(Runnable task) {
activeVirtualThreads.incrementAndGet();
try {
task.run();
} finally {
activeVirtualThreads.decrementAndGet();
}
}
}# application.yml
# Exposition des métriques
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}L'analyse des métriques révèle le ratio Virtual Threads / Carriers et permet d'identifier les périodes de contention sur le pool de connexions.
Migration et compatibilité
Question 10 : Quelles bibliothèques sont compatibles avec les Virtual Threads ?
La compatibilité dépend de l'utilisation de blocs synchronized et d'appels natifs. L'écosystème Spring est largement compatible, mais certaines bibliothèques requièrent des versions spécifiques.
// Vérification de la compatibilité des dépendances
@Configuration
public class CompatibilityCheck {
// Bibliothèques compatibles (versions recommandées)
// - Spring Boot 3.2+ (support natif)
// - HikariCP 5.1+ (ReentrantLock au lieu de synchronized)
// - Lettuce 6.3+ (I/O non-bloquant)
// - Jackson 2.16+ (pas de synchronized)
// Bibliothèques nécessitant attention
// - JDBC drivers : vérifier la version
// - Certains clients HTTP legacy
@Bean
public CommandLineRunner checkCompatibility() {
return args -> {
// Log des versions pour audit
log.info("Java version: {}", System.getProperty("java.version"));
log.info("Virtual threads available: {}",
Thread.ofVirtual() != null);
// Vérification du support
if (Runtime.version().feature() < 21) {
throw new IllegalStateException(
"Java 21+ required for Virtual Threads");
}
};
}
}La plupart des frameworks modernes ont migré vers ReentrantLock. Pour les dépendances legacy, des tests de charge avec -Djdk.tracePinnedThreads=short révèlent les problèmes de pinning.
Question 11 : Comment migrer progressivement vers les Virtual Threads ?
Une migration progressive permet de valider les gains et d'identifier les problèmes sans risquer la production. La stratégie recommandée isole les endpoints Virtual Threads des endpoints traditionnels.
// Configuration pour migration progressive
@Configuration
public class DualExecutorConfig {
// Executor traditionnel pour les endpoints legacy
@Bean("platformExecutor")
public ExecutorService platformExecutor() {
return Executors.newFixedThreadPool(200);
}
// Executor Virtual Threads pour les nouveaux endpoints
@Bean("virtualExecutor")
public ExecutorService virtualExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@RestController
@RequestMapping("/api/v2")
public class MigratedController {
@Qualifier("virtualExecutor")
private final ExecutorService executor;
// Endpoint migré vers Virtual Threads
@GetMapping("/users/{id}")
public CompletableFuture<User> getUser(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> {
// Code métier inchangé
return userService.findById(id);
}, executor);
}
}# application.yml
# Feature flag pour la migration
features:
virtual-threads:
enabled: true
endpoints:
- /api/v2/**
- /api/reports/**
# Désactiver pour rollback rapide
# features.virtual-threads.enabled: falseNe pas activer les Virtual Threads globalement avant d'avoir testé toutes les dépendances. Le pinning peut dégrader les performances de manière significative.
Question 12 : Quels tests effectuer avant la mise en production ?
La validation des Virtual Threads nécessite des tests de charge, des tests de pinning et des tests de compatibilité. Ces tests doivent simuler des conditions de production réalistes.
// Test de charge avec JUnit et Virtual Threads
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class VirtualThreadLoadTest {
@LocalServerPort
private int port;
@Test
void shouldHandleHighConcurrency() throws Exception {
int concurrentRequests = 5000;
CountDownLatch latch = new CountDownLatch(concurrentRequests);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger errorCount = new AtomicInteger(0);
HttpClient client = HttpClient.newHttpClient();
// Lance 5000 requêtes simultanées
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < concurrentRequests; i++) {
executor.submit(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/test"))
.build();
HttpResponse<String> response = client.send(
request, BodyHandlers.ofString());
if (response.statusCode() == 200) {
successCount.incrementAndGet();
} else {
errorCount.incrementAndGet();
}
} catch (Exception e) {
errorCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
}
latch.await(60, TimeUnit.SECONDS);
// Assertions sur les performances
assertThat(successCount.get()).isGreaterThan(4900); // >98% succès
assertThat(errorCount.get()).isLessThan(100);
}
}Les tests doivent couvrir les scénarios de contention (pool de connexions saturé), de timeout, et de charge soutenue sur plusieurs minutes.
Questions avancées
Question 13 : Comment les Virtual Threads interagissent-ils avec le Structured Concurrency ?
Le Structured Concurrency (JEP 453) complète les Virtual Threads en garantissant que les tâches concurrentes partagent le même cycle de vie. Cette approche simplifie la gestion des erreurs et des annulations.
// Combinaison Virtual Threads + Structured Concurrency
public class StructuredConcurrencyExample {
public UserDashboard fetchDashboard(Long userId) throws Exception {
// StructuredTaskScope garantit que toutes les tâches se terminent ensemble
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Trois tâches parallèles sur Virtual Threads
Subtask<UserProfile> profileTask = scope.fork(() ->
userService.getProfile(userId));
Subtask<List<Notification>> notifTask = scope.fork(() ->
notificationService.getRecent(userId));
Subtask<AccountBalance> balanceTask = scope.fork(() ->
accountService.getBalance(userId));
// Attend toutes les tâches ou échoue sur la première erreur
scope.join();
scope.throwIfFailed();
// Toutes les tâches ont réussi - agrégation sécurisée
return new UserDashboard(
profileTask.get(),
notifTask.get(),
balanceTask.get()
);
}
// Si une tâche échoue, les autres sont automatiquement annulées
}
}Le Structured Concurrency évite les fuites de threads et simplifie le débogage car la pile d'appels reflète la structure logique du code.
Question 14 : Quelle est la différence entre Virtual Threads et reactive programming ?
Les deux approches résolvent le même problème (efficacité I/O) mais avec des modèles de programmation différents. Les Virtual Threads permettent un code impératif classique, tandis que le reactive nécessite une réécriture en flux.
// Même logique : impératif vs reactive
@Service
public class ComparisonService {
// APPROCHE VIRTUAL THREADS : code impératif classique
// Simple à lire, déboguer et maintenir
public UserData getUserDataImperative(Long id) {
User user = userRepository.findById(id).orElseThrow();
List<Order> orders = orderRepository.findByUserId(id);
PaymentInfo payment = paymentService.getInfo(id);
return new UserData(user, orders, payment);
}
// APPROCHE REACTIVE : flux et opérateurs
// Plus complexe mais backpressure natif
public Mono<UserData> getUserDataReactive(Long id) {
return userRepository.findById(id)
.zipWith(orderRepository.findByUserId(id).collectList())
.zipWith(paymentService.getInfo(id))
.map(tuple -> new UserData(
tuple.getT1().getT1(),
tuple.getT1().getT2(),
tuple.getT2()
));
}
}| Critère | Virtual Threads | Reactive | |---------|-----------------|----------| | Courbe d'apprentissage | Faible | Élevée | | Débogage | Stack traces classiques | Complexe | | Backpressure | Manuel | Natif | | Écosystème | Croissant | Mature | | Migration legacy | Simple | Réécriture |
Les Virtual Threads sont recommandés pour les nouvelles applications et les migrations. Le reactive reste pertinent pour les cas nécessitant un backpressure sophistiqué.
Question 15 : Comment gérer les ThreadLocal avec les Virtual Threads ?
Les ThreadLocal fonctionnent avec les Virtual Threads mais consomment de la mémoire pour chaque instance. Les Scoped Values (JEP 446) offrent une alternative plus efficace pour le partage de contexte.
// Comparaison des approches de contexte
public class ThreadLocalVsScopedValue {
// APPROCHE CLASSIQUE : ThreadLocal
// Fonctionne mais coûteux en mémoire avec millions de VT
private static final ThreadLocal<RequestContext> requestContext =
new ThreadLocal<>();
public void processWithThreadLocal(Request request) {
requestContext.set(new RequestContext(request.getTraceId()));
try {
// Le contexte est accessible partout dans le thread
processRequest();
} finally {
requestContext.remove(); // Important pour éviter les fuites
}
}
// APPROCHE MODERNE : ScopedValue (Java 21+)
// Plus efficace, immuable, scope explicite
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
public void processWithScopedValue(Request request) {
ScopedValue.where(CONTEXT, new RequestContext(request.getTraceId()))
.run(() -> {
// Le contexte est accessible dans ce scope
processRequest();
// Pas besoin de cleanup - automatique à la fin du scope
});
}
private void processRequest() {
// Accès au contexte
String traceId = CONTEXT.isBound()
? CONTEXT.get().getTraceId()
: "unknown";
log.info("Processing with trace: {}", traceId);
}
}Les ScopedValues sont recommandés pour les nouveaux développements. Pour le code legacy utilisant ThreadLocal, une migration progressive est conseillée.
Prêt à réussir tes entretiens Spring Boot ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Conclusion
Les Virtual Threads transforment le développement Java backend en permettant un code simple et performant. Les points essentiels à retenir :
Fondamentaux :
- ✅ Activer avec
spring.threads.virtual.enabled=true - ✅ Idéal pour les workloads I/O-bound (REST, BDD, fichiers)
- ✅ Éviter pour les calculs CPU-intensifs
Performance :
- ✅ Dimensionner les pools de connexion (nouveau goulot d'étranglement)
- ✅ Surveiller le pinning avec
-Djdk.tracePinnedThreads - ✅ Migrer
synchronizedversReentrantLock
Migration :
- ✅ Tester progressivement par groupe d'endpoints
- ✅ Valider la compatibilité des dépendances
- ✅ Utiliser Structured Concurrency pour la concurrence parallèle
La maîtrise des Virtual Threads distingue les candidats qui comprennent les enjeux de performance des applications modernes. Ces concepts sont désormais incontournables dans les entretiens techniques Spring Boot.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Spring Modulith : architecture modulaire monolithique expliquée
Découvrez Spring Modulith pour construire des monolithes modulaires en Java. Architecture, modules, événements asynchrones et tests avec exemples Spring Boot 3.

Spring Batch 5 en entretien technique : partitioning, chunks et fault tolerance
Préparez vos entretiens Spring Batch 5 : 15 questions essentielles sur le partitioning, chunk-oriented processing, fault tolerance avec exemples de code Java 21.

Testcontainers avec Spring Boot : tests d'intégration sans douleur
Guide complet pour configurer Testcontainers avec Spring Boot 3.4. PostgreSQL, Redis, Kafka en containers Docker pour des tests d'intégration fiables et reproductibles.