Depuis toujours, la gestion de la concurrence en Java repose sur les threads traditionnels du système d’exploitation. Ces derniers, bien que puissants, sont relativement coûteux en mémoire et en ressources lorsqu’il s’agit de gérer un grand nombre de tâches simultanées.
Avec l’arrivée de Java 21 et la finalisation du projet Loom, une nouvelle possibilité s’offre à nous : les threads virtuels.
Nous allons voir comment mettre en œuvre ces threads virtuels dans une application Spring Boot, illustrée par un cas concret d’intégration avec un serveur SFTP.
Présentation des threads virtuels et du projet Loom
Le projet Loom est une initiative de longue haleine de l’équipe Java visant à simplifier l’écriture d’applications concurrentes et massivement parallèles. Son objectif principal est d’introduire des threads légers gérés directement par la JVM et non plus par le système d’exploitation.
- Threads traditionnels (OS threads) : chaque thread correspond à une ressource du système, limitée et coûteuse.
- Threads virtuels : extrêmement légers, créés et gérés par la JVM, ils permettent de lancer des milliers, voire des millions de tâches concurrentes sans saturer les ressources.
En pratique, un thread virtuel se comporte comme un thread classique (même API java.lang.Thread), mais il est planifié différemment, ce qui rend son utilisation transparente pour le développeur.
Pour en savoir plus, c'est par ici :

Avantages et inconvénients
Avantages
- Scalabilité accrue : on peut gérer un grand nombre de connexions simultanées (ex. : requêtes HTTP, échanges SFTP).
- Modèle de programmation inchangé : pas besoin de changer de paradigme comme avec les APIs réactives (Reactor, RxJava, etc.).
- Lisibilité : on conserve un code séquentiel, facile à maintenir, tout en profitant de la concurrence.
- Intégration transparente : Spring Boot 3 et Java 21 offrent déjà une compatibilité native avec les threads virtuels.
Inconvénients
- Jeunesse de la technologie : bien que stabilisée, l’écosystème autour des threads virtuels est encore en évolution.
- Interopérabilité : certaines bibliothèques très bas niveau peuvent ne pas encore être totalement optimisées.
- Debugging : les outils d’analyse de performance et de suivi des threads doivent encore s’adapter pleinement à ce nouveau modèle.
Exemple concret : upload et organisation de fichiers SFTP avec threads virtuels
Prenons une application Spring Boot qui gère l’upload et l’organisation de fichiers sur un serveur SFTP. Sans threads virtuels, nous devions utiliser un pool de threads limité pour exécuter les tâches en parallèle. Avec les threads virtuels, la gestion devient bien plus flexible.
Configuration du support des threads virtuels
Il est possible d’activer les threads virtuels simplement par une propriété :
spring.threads.virtual.enabled=trueCette configuration indique à Spring d’utiliser un AsyncTaskExecutor basé sur des threads virtuels. Ainsi, chaque tâche soumise sera exécutée dans un thread virtuel.
Définition de la configuration SFTP
On configure l’accès au serveur SFTP via Spring Integration.
Pour ce faire nous ajoutons les dépendances suivantes dans notre fichier pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-sftp</artifactId>
</dependency>
</dependencies>Et créons une classe de configuration qui s'occupera de gérer les accès à notre serveur SFTP
@Configuration
@EnableIntegration
public class SftpConfig {
@Value("${sftp.host}")
private String sftpHost;
@Value("${sftp.port}")
private int sftpPort;
@Value("${sftp.user}")
private String sftpUser;
@Value("${sftp.password}")
private String sftpPassword;
@Value("${sftp.remote.dir}")
private String sftpRemoteDir;
@Bean
public SessionFactory<SftpClient.DirEntry> sftpSessionFactory() {
DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(true);
factory.setHost(sftpHost);
factory.setPort(sftpPort);
factory.setUser(sftpUser);
factory.setPassword(sftpPassword);
factory.setAllowUnknownKeys(true);
return new CachingSessionFactory<>(factory);
}
@Bean
@ServiceActivator(inputChannel = "toSftpChannel")
public MessageHandler sftpMessageHandler() {
SftpMessageHandler handler = new SftpMessageHandler(sftpSessionFactory());
handler.setRemoteDirectoryExpression(new LiteralExpression(sftpRemoteDir));
handler.setAutoCreateDirectory(true);
return handler;
}
}Ici, rien n’est spécifique aux threads virtuels. Mais le choix du taskExecutor utilisé dans le service changera tout.
Service utilisant l’AsyncTaskExecutor basé sur des threads virtuels
Le cœur de notre application réside dans le service SFTP, qui utilise :
- un
SftpGatewaypour déléguer les transferts, - un
AsyncTaskExecutorpour exécuter les tâches en parallèle, - et la
SessionFactorypour interagir avec le serveur SFTP.
Déclaration du service
@Service
public class SftpService {
private static final Logger log = LoggerFactory.getLogger(SftpService.class);
private final SftpGateway sftpGateway;
private final AsyncTaskExecutor taskExecutor;
private final SessionFactory<SftpClient.DirEntry> sftpSessionFactory;
@Value("${sftp.remote.dir}")
private String sftpRemoteDir;
public SftpService(SftpGateway sftpGateway,
AsyncTaskExecutor taskExecutor,
SessionFactory<SftpClient.DirEntry> sftpSessionFactory) {
this.sftpGateway = sftpGateway;
this.taskExecutor = taskExecutor;
this.sftpSessionFactory = sftpSessionFactory;
}
Ici, taskExecutor est injecté automatiquement par Spring. Grâce à la propriétéspring.threads.virtual.enabled=true, il est basé sur des threads virtuels.
Upload de fichiers avec exécution concurrente
public void uploadFiles(List<MultipartFile> files) {
Path tempDir;
try {
tempDir = Files.createTempDirectory("upload-");
} catch (IOException e) {
throw new RuntimeException(e);
}
files.forEach(file -> taskExecutor.submit(() -> {
String filename = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
if (filename.isEmpty()) {
log.warn("Skipping file with empty name");
return;
}
try {
Path tempFile = tempDir.resolve(filename);
file.transferTo(tempFile);
log.info("Uploading file {} to SFTP server", tempFile.toFile().getName());
sftpGateway.upload(tempFile.toFile());
Files.delete(tempFile);
log.info("File {} transferred with success", filename);
} catch (Exception e) {
log.error("Error processing file {}", filename, e);
}
}));
}
- Chaque fichier est transféré dans un thread virtuel distinct grâce au
taskExecutor.submit(...). - Cela permet de gérer des centaines de fichiers en parallèle sans épuiser les ressources de la machine.
- Les logs permettent de suivre finement la progression de chaque upload.
Organisation concurrente des fichiers
public void organizeFiles() {
long startTime = System.currentTimeMillis();
try (var session = sftpSessionFactory.getSession()) {
var files = session.list(sftpRemoteDir);
log.info("Found {} entries in {}", files.length, sftpRemoteDir);
for (var file : files) {
String filename = file.getFilename();
if (file.getAttributes().isDirectory() || filename.equals(".") || filename.equals("..")) {
continue;
}
taskExecutor.submit(() -> {
try {
if (filename.length() < 14 || filename.charAt(8) != '_') {
log.warn("File '{}' does not match expected format yyyyMMdd_HHmm_name, skipping.", filename);
return;
}
String year = filename.substring(0, 4);
String month = filename.substring(4, 6);
String day = filename.substring(6, 8);
String targetDir = sftpRemoteDir + "/" + year + "/" + month + "/" + day;
// création récursive des répertoires
String[] dirs = targetDir.replace(sftpRemoteDir + "/", "").split("/");
String currentPath = sftpRemoteDir;
for (String dir : dirs) {
currentPath = currentPath + "/" + dir;
try {
session.mkdir(currentPath);
log.info("Created directory: {}", currentPath);
} catch (IOException e) {
if (!session.exists(currentPath)) throw e;
}
}
String fromPath = sftpRemoteDir + "/" + filename;
String toPath = targetDir + "/" + filename;
log.info("Moving file from {} to {}", fromPath, toPath);
session.rename(fromPath, toPath);
log.info("Successfully moved file {}", filename);
} catch (Exception e) {
log.error("Error processing file {}: {}", filename, e.getMessage(), e);
}
});
}
} catch (IOException e) {
log.error("Failed to list or move files on SFTP server", e);
throw new RuntimeException(e);
}
long endTime = System.currentTimeMillis();
log.info("Organize files concurrently took {} ms", (endTime - startTime));
}Examinons maintenant en détail le fonctionnement :
L’objectif est simple : nous disposons d’un répertoire SFTP rempli de fichiers nommés selon une convention précise : yyyyMMdd_HHmm_nom.extension
20250910_1430_facture.pdf
20250911_0830_rapport.docxCes fichiers doivent être automatiquement classés dans des sous-dossiers correspondant à leur date :
/remoteDir/2025/09/10/20250910_1430_facture.pdf
/remoteDir/2025/09/11/20250911_0830_rapport.docxÉtape 1 - Récupération des fichiers
La méthode commence par ouvrir une session SFTP et récupérer la liste des fichiers dans le répertoire cible :
try (var session = sftpSessionFactory.getSession()) {
var files = session.list(sftpRemoteDir);
log.info("Found {} entries in {}", files.length, sftpRemoteDir);Ici, session.list(...) retourne l’ensemble des fichiers et sous-dossiers. On logge également le nombre d’entrées trouvées pour suivre le traitement.
Étape 2 - Filtrage des fichiers valides
On élimine les entrées qui ne correspondent pas à des fichiers à déplacer :
String filename = file.getFilename();
if (file.getAttributes().isDirectory() || filename.equals(".") || filename.equals("..")) {
continue;
}Les répertoires et les pseudo-entrées (. et ..) sont ignorés. Seuls les vrais fichiers sont pris en compte.
Étape 3 - Lancement concurrent de tâches
C’est ici que les threads virtuels entrent en scène. Pour chaque fichier valide, on soumet une tâche asynchrone au taskExecutor :
taskExecutor.submit(() -> {
try {
if (filename.length() < 14 || filename.charAt(8) != '_') {
log.warn("File '{}' does not match expected format yyyyMMdd_HHmm_name, skipping.", filename);
return;
}
String year = filename.substring(0, 4);
String month = filename.substring(4, 6);
String day = filename.substring(6, 8);
String targetDir = sftpRemoteDir + "/" + year + "/" + month + "/" + day;
// Ensure target directory exists, handling concurrency
String[] dirs = targetDir.replace(sftpRemoteDir + "/", "").split("/");
String currentPath = sftpRemoteDir;
for (String dir : dirs) {
currentPath = currentPath + "/" + dir;
try {
session.mkdir(currentPath);
log.info("Created directory: {}", currentPath);
} catch (IOException e) {
// It's possible another thread created the directory.
// We check if it exists now. If it does, we can ignore the exception.
if (!session.exists(currentPath)) {
// If it still doesn't exist, the error was real.
throw e;
}
}
}
String fromPath = sftpRemoteDir + "/" + filename;
String toPath = targetDir + "/" + filename;
log.info("Moving file from {} to {}", fromPath, toPath);
session.rename(fromPath, toPath);
log.info("Successfully moved file {}", filename);
} catch (Exception e) {
log.error("Error processing file {}: {}", filename, e.getMessage(), e);
}
});Chaque tâche sera exécutée dans un thread virtuel distinct.
Contrairement aux threads classiques, on peut en lancer des centaines ou des milliers sans crainte de saturer la JVM.
- Avant de déplacer un fichier, on s’assure qu’il correspond bien au format attendu
yyyyMMdd_HHmm_nom.extension, les fichiers mal nommés sont ignorés, évitant des erreurs lors du calcul des répertoires cibles. - On extrait l’année, le mois et le jour directement du nom de fichier, cela permet de construire dynamiquement la hiérarchie de dossiers dans laquelle le fichier sera déplacé.
- Le plus délicat est de gérer la création concurrente des répertoires. Plusieurs threads peuvent tenter de créer le même dossier en même temps (par ex.
2025/09/10).- Chaque thread crée récursivement les sous-répertoires nécessaires.
- Si un autre thread a déjà créé le répertoire, une exception est levée, mais on la neutralise si le répertoire existe bel et bien.
- Cette approche évite les collisions entre tâches concurrentes.
- Une fois la structure prête, on peut déplacer le fichier, chaque thread gère son fichier indépendamment, ce qui permet un traitement parallèle massif.
Comparaison avec un traitement séquentiel
Dans mon service, j'ai une méthode qui fait exactement la même chose que celle dont je vous ai détaillé le fonctionnement, mais sans utiliser les threads virtuel.
Ceci afin de vous démontrer la différence dans les temps de traitement
Organize files concurrently took 19 ms
Organize files sequentially took 122 msDans le cadre de mon exemple, je n'ai utilisé que 32 fichiers, mais on peut constater une large différence dans le temps pris par les 2
processus.
Conclusion
Les threads virtuels du projet Loom ouvrent une nouvelle ère pour les applications Java concurrentes.
Ils combinent la simplicité du modèle de programmation synchrone avec la puissance de la scalabilité propre aux solutions réactives.
Dans notre exemple, nous avons vu qu’une application Spring Boot pouvait, sans modification majeure du code, tirer parti des threads virtuels pour exécuter en parallèle des transferts et des réorganisations de fichiers sur un serveur SFTP.
En définitive, l’adoption des threads virtuels est une évolution naturelle pour les projets Java modernes :
- elle réduit la complexité du code,
- elle améliore la performance,
- et elle prépare nos applications à répondre aux besoins croissants de concurrence et de scalabilité.
Tout le code relatif à cet article est disponible ici :
