Tomcat 11 & Virtual Threads đ§”
Apache Tomcat est le plus cĂ©lĂšbre des conteneurs de Servlets Java. Les versions se succĂšdent au fil des annĂ©es. Avec Spring Boot, et son utilisation de la version «embedded», son usage en tant que serveur «installé» a diminuĂ©, mais il reste encore au cĆur de la majoritĂ© de nos micro-services, parfois sans que les dĂ©veloppeurs s’en rendent compte.
Chaque version majeure de Tomcat apporte le support des nouvelles versions des API Java EE
ou JEE
.
La version ayant eu le plus d’impact sur les dĂ©veloppeurs est la version 10, qui a intĂ©grĂ© le support des API jakarta
, en remplacement des anciennes API javax
. Cette version 10 de Tomcat était liée à Java 11, dans laquelle la suppression des packages javax
liés à Java EE a eu lieu. Les modules supprimés sont documentés dans la JEP 320. On y retrouve les tristement célÚbres java.xml.bind
, javax.transaction
et javax.activation
, qui ont donné du fil à retordre aux développeurs souhaitant migrer leurs applications.
Les versions de Tomcat sont donc Ă chaque fois compatibles avec une version minimale de Java, et des API jakarta
.
Le tableau ci-dessous reprend la liste des versions compatibles :
Servlet Spec | Apache Tomcat Version | Supported Java Versions | Release date |
---|---|---|---|
6.1 | 11.0.x | 21 and later | (version alpha) |
6.0 | 10.1.x | 11 and later | dec. 2020 |
4.0 | 9.0.x | 8 and later | oct. 2017 |
3.1 | 8.5.x | 7 and later | jan. 2014 |
3.0 | 7.0.x (archived) | 6 and later | jan. 2011 |
La version 11 de Tomcat est donc destinĂ©e Ă la version 21 de Java. Cette stratĂ©gie n’est pas surprenante en soi, la version 21 Ă©tant la derniĂšre version LTS Ă date.
MĂȘme si Tomcat 11 n’est pas encore en version finale, les travaux pour son dĂ©veloppement durent depuis dĂ©jĂ plus d’un an Ă l’Ă©criture de ces lignes. La premiĂšre version milestone de Tomcat 11 a Ă©tĂ© publiĂ©e en dĂ©cembre 2022 ! La premiĂšre version Ă©tait prĂ©vue pour supporter Java 11 (cf. la release note Tomcat 11.0.0-M1). La version Java 17 a ensuite Ă©tĂ© choisie Ă partir de la milestone 3 de Tomcat 11 (cf. la release note Tomcat 11.0.0-M3). La version 21 a Ă©tĂ© choisie Ă partir de la milestone 7 de Tomcat 11, publiĂ©e en juin 2023, soit 3 mois avant la sortie de Java 21 (cf. la release note Tomcat 11.0.0-M7).
La version actuelle est la milestone 16, publiĂ©e le 9 janvier 2024. C’est cette version qui sera testĂ©e dans cet article.
Un des principaux avantages de cette version 11, avec le support de Java 21, est le support des Virtual Threads. Bien que le code nĂ©cessaire ait Ă©tĂ© ajoutĂ© Ă Tomcat en version 10.1, on peut considĂ©rer que le support n’Ă©tait qu’expĂ©rimental, puisque les Virtual Threads n’ont Ă©tĂ© intĂ©grĂ©s qu’en version preview Ă partir de Java 19, et en version finale en Java 21.
C’est quoi les Virtual Threads ?
Avant d’explorer l’implĂ©mentation de Tomcat et son usage des Virtual Threads, un rapide rappel de ce qu’ils sont et de la maniĂšre dont ils fonctionnent.
Les Virtual Threads sont des Thread dits «lĂ©gers», parfois appelĂ©s «Green Threads» ou «Routines/Coroutines» dans d’autres langages. Ils sont mis en opposition aux Threads dits «Plateforme». Les Threads plateforme sont des Threads gĂ©rĂ©s directement par le systĂšme d’exploitation.
Une excellente conférence de José Paumard sur le projet Loom, qui introduit les Virtual Threads en Java, est visible sur Youtube. Cette vidéo est une trÚs bonne introduction à ce sujet.
Les Threads Plateforme
Lorsqu’un programme demande la crĂ©ation d’un Thread, le systĂšme d’exploitation stoppe l’exĂ©cution du code et crĂ©e le Thread, avec sa mĂ©moire attribuĂ©e, appelĂ©e la Stack. Il redonne ensuite la main au programme pour qu’il continue son exĂ©cution. Ces deux Ă©tapes impliquent, Ă chaque fois, que le CPU sauvegarde l’Ă©tat courant de l’exĂ©cution du programme, et le restaure ensuite. C’est ce qu’on appelle un context switch, un changement de contexte d’exĂ©cution.
Lors de la crĂ©ation d’un Thread, le systĂšme d’exploitation doit donc effectuer plusieurs context switches, et allouer un peu de mĂ©moire au Thread. Ces Ă©tapes ont donc un coĂ»t, en temps et en mĂ©moire.
Le coût en temps
Le temps de crĂ©ation d’un Thread dĂ©pend principalement du systĂšme d’exploitation et de sa charge actuelle. Pour mesurer ce temps, un benchmark Ă©crit avec l’outil JMH (dont l’utilisation vaudrait un article Ă elle seule) permet d’estimer le temps de dĂ©marrage d’un Thread Java sur une machine :
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ThreadsBenchmark {
@Benchmark
public void computeInMainThread(){
// un calcul quelconque
Blackhole.consumeCPU(1024);
}
@Benchmark
public void computeInPlatformThread() throws InterruptedException {
// exécution dans un thread plateforme
var thread = Thread.ofPlatform().start(() -> {
Blackhole.consumeCPU(1024);
});
thread.join(); // attente de la fin de l'exécution
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ThreadsBenchmark.class.getSimpleName())
.warmupIterations(1) // une itération de préchauffage de la JVM
.measurementIterations(3) // 3 itérations de mesure
.forks(1)
.build();
new Runner(opt).run();
}
}
Les deux méthodes annotées @Benchmark
sont exécutées en boucle pendant 10 secondes pour mesurer le temps moyen de leur exécution, et cela 4 fois en tout : une premiÚre fois pour préchauffer la JVM (warm-up), et 3 fois pour mesurer les performances réelles. La ligne forks(1)
permet de prĂ©ciser de crĂ©er une JVM destinĂ©e Ă l’exĂ©cution des tests.
La premiÚre méthode effectue un calcul Î combien inutile, à travers la classe Blackhole
fournie par JMH. La seconde mĂ©thode effectue ce mĂȘme calcul, mais dans un Thread plateforme et attend la fin de son exĂ©cution. De cette maniĂšre, on peut extrapoler le surcoĂ»t de l’exĂ©cution de la tĂąche dans un Thread, surcoĂ»t qui comprend donc la crĂ©ation du Thread, et sa suppression.
Le rĂ©sultat de l’exĂ©cution du benchmark est le suivant :
Benchmark Mode Cnt Score Error Units
ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
ThreadsBenchmark.computeInPlatformThread avgt 3 0.038 ± 0.015 ms/op
On observe que le Blackhole.consumeCPU(1024)
du premier benchmark s’exĂ©cute en moyenne en 0,002 millisecondes. L’exĂ©cution de la mĂȘme instruction dans un Thread plateforme se fait en 0,038 millisecondes. Le surcoĂ»t liĂ© Ă la crĂ©ation et destruction du Thread est donc de 0,036 millisecondes.
CrĂ©er un Thread pour effectuer un calcul peut donc ĂȘtre contre-productif ! đ±
Le coût en mémoire
Le coĂ»t en mĂ©moire d’un Thread est connu Ă l’avance et contrĂŽlĂ© par les paramĂštres -Xss
ou -XX:ThreadStackSize
de la JVM. Cependant, attention aux confusions. On parle bien ici de mémoire réservée, et non pas de mémoire effectivement utilisée. Pour un_Thread_qui ne remplit pas sa Stack, sa consommation réelle sera bien moindre.
La commande suivante permet de constater les valeurs par dĂ©faut de la mĂ©moire d’un Thread Java :
$ java -XX:+PrintFlagsFinal --version | grep -i ThreadStack
intx CompilerThreadStackSize = 1024 {pd product} {default}
intx ThreadStackSize = 1024 {pd product} {default}
intx VMThreadStackSize = 1024 {pd product} {default}
La valeur est exprimée en kilo-octets. Un Thread réservera donc 1 024 ko de RAM, soit 1 Mo. 200 Threads réserveront donc 200 Mo de RAM native, en plus de la RAM allouée à la heap Java.
Les Virtual Threads
Les Virtual Threads sont crĂ©Ă©s, orchestrĂ©s et exĂ©cutĂ©s directement par la JVM, qui se charge de gĂ©rer leur Stack et leur exĂ©cution de maniĂšre interne. La crĂ©ation d’un Virtual Thread n’implique donc pas forcĂ©ment la crĂ©ation d’un Thread plateforme. Le coĂ»t de crĂ©ation d’un Virtual Thread est donc bien infĂ©rieur au coĂ»t d’un Thread plateforme, puisqu’il ne nĂ©cessite pas de context switch, ni d’allocation d’un bloc de mĂ©moire.
On peut mesurer le coĂ»t temporel de la crĂ©ation d’un Virtual Thread en ajoutant cette mĂ©thode Ă notre benchmark prĂ©cĂ©dent :
@Benchmark
public void computeInVirtualThread() throws InterruptedException {
var thread = Thread.ofVirtual().start(() -> {
Blackhole.consumeCPU(1024);
});
thread.join();
}
Notez l’usage de Thread.ofVirtual()
pour créer un Virtual Thread en lieu et place du Thread.ofPlatform()
.
Les durĂ©es d’exĂ©cution observĂ©es sont les suivantes :
Benchmark Mode Cnt Score Error Units
ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
ThreadsBenchmark.computeInPlatformThread avgt 3 0.037 ± 0.013 ms/op
ThreadsBenchmark.computeInVirtualThread avgt 3 0.005 ± 0.002 ms/op
Le benchmark utilisant les Virtual Threads prĂ©sente un surcoĂ»t d’exĂ©cution de 0,003 millisecondes par rapport Ă l’exĂ©cution dans le Thread principal, mais est largement infĂ©rieur au surcoĂ»t liĂ© Ă l’exĂ©cution dans un Thread plateforme.
Le coĂ»t de crĂ©ation en temps d’un Virtual Thread est donc 15 fois infĂ©rieur Ă un Thread plateforme.
Notez qu’avant l’avĂšnement des Virtual Threads, le problĂšme du coĂ»t de crĂ©ation des Threads plateforme Ă©tait souvent adressĂ© par l’utilisation de pools de Threads, qui permettent de rĂ©utiliser des Threads existants (vive le recyclage â»ïž), plutĂŽt que de les recrĂ©er.
L’implĂ©mentation de Tomcat
Dans le code de Tomcat, l’interface Executor
dĂ©crit les objets qui ont pour responsabilitĂ© d’exĂ©cuter les requĂȘtes entrantes. Depuis la version 10.1 de Tomcat, cette interface a deux implĂ©mentations. L’implĂ©mentation historique StandardThreadExecutor
, qui s’appuie sur un pool de Threads workers et une BlockingQueue
de taille fixe pour les requĂȘtes entrantes, et la nouvelle implĂ©mentation StandardVirtualThreadExecutor
qui utilise un Virtual Thread pour exĂ©cuter chaque requĂȘte entrante.
En fouillant dans le code de Tomcat, on peut observer cette implémentation dans la classe VirtualThreadExecutor
, qui est utilisée par le StandardVirtualThreadExecutor
 :
public class VirtualThreadExecutor extends AbstractExecutorService {
private Thread.Builder threadBuilder;
public VirtualThreadExecutor(String namePrefix) {
threadBuilder = Thread.ofVirtual().name(namePrefix, 0);
}
@Override
public void execute(Runnable command) {
if (isShutdown()) {
throw new RejectedExecutionException();
}
threadBuilder.start(command);
}
}
Il est par ailleurs surprenant que Tomcat ait choisi de développer son propre
ExecutorService
, au lieu d’utiliser celui construit parExecutors.newVirtualThreadPerTaskExecutor()
. Il semble que ce choix soit liĂ© Ă la gestion de l’arrĂȘt de l’ExecutorService
qui est implémentée du cÎté duThreadPoolExecutor
.
Le benchmark
Dans cette section, nous allons tester les performances de deux versions de Tomcat :
- la version 10.1, sans support des Virtual Threads ;
- la version 11.0.0-M16, avec support des Virtual Threads activés.
Pour monter l’environnement de test, j’ai installĂ© une version 21 de Java, en particulier le build eclipse-temurin disponible chez adoptium.net :
java --version
openjdk 21.0.1 2023-10-17 LTS
OpenJDK Runtime Environment Temurin-21.0.1+12 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.1+12 (build 21.0.1+12-LTS, mixed mode, sharing)
J’ai aussi installĂ© les versions 10 et 11 de Tomcat :
- la derniÚre version disponible de Tomcat 10, la 10.1.18 ;
- la derniÚre version disponible de Tomcat 11, la 11.0.0-M16.
Ma machine de test est Ă©quipĂ©e d’un CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80 GHz et de 64 Go de RAM (!).
Les JVM sont démarrées avec les options -Xms512m -Xmx512m
pour positionner une taille de la heap à 512 Mo directement consommée.
L’option -XX:NativeMemoryTracking=summary
permet d’observer la consommation mĂ©moire de la JVM, pour analyser plus finement les tailles de mĂ©moire rĂ©servĂ©es et consommĂ©es auprĂšs du systĂšme d’exploitation.
export CATALINA_OPTS='-Xms512m -Xmx512m -XX:NativeMemoryTracking=summary'
Je n’ai pas positionnĂ© de paramĂ©trage propre au GC ou d’autres options, ce qui m’intĂ©resse ce sont uniquement la consommation de RAM liĂ©e aux Threads et les performances liĂ©es Ă des temps de rĂ©ponse aux requĂȘtes.
La configuration de Tomcat 11
Pour utiliser les Virtual Threads dans Tomcat 11, il faut paramĂ©trer l’Executor de Tomcat pour activer la classe qui instancie les Virtual Threads, en lieu et place de l’implĂ©mentation standard qui utilise un pool de Threads plateforme, et assigner l’Executor au Connector en charge d’Ă©couter sur le port HTTP. Ce paramĂ©trage n’est pas actif par dĂ©faut. Il se fait dans le fichier settings.xml
, dans la balise <Service>
, comme indiqué dans la documentation :
<Service name="Catalina">
<Executor
name="virtualThreadsExecutor"
className="org.apache.catalina.core.StandardVirtualThreadExecutor" />
<Connector executor="virtualThreadsExecutor"
port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
...
</Service>
On paramĂštre donc le StandardVirtualThreadExecutor
comme devant traiter les requĂȘtes allouĂ©es au Connector Ă©coutant sur le port 8080
.
Aucune autre configuration n’est nĂ©cessaire sur le Tomcat 11. Aucune configuration particuliĂšre n’est faite sur le Tomcat 10.1 pour les tests.
Les performances attendues
On s’attend, entre Tomcat 10.1 et Tomcat 11, avec l’utilisation des Virtual Threads, Ă avoir une empreinte mĂ©moire rĂ©servĂ©e infĂ©rieure, ainsi que de meilleures performances Ă l’exĂ©cution des requĂȘtes. En principe, les Virtual Threads utilisĂ©s par Tomcat 11 ne devraient utiliser que quelques Threads plateforme hĂŽtes pour leur exĂ©cution, et donc limiter les context switches en cas de charge importante.
Démarrage et empreinte mémoire à vide
Tomcat 10.1
Tomcat 10.1 est démarré avec la commande startup.sh
 :
./startup.sh
Using CATALINA_BASE: /opt/apache-tomcat-10.1.18
Using CATALINA_HOME: /opt/apache-tomcat-10.1.18
Using CATALINA_TMPDIR: /opt/apache-tomcat-10.1.18/temp
Using JRE_HOME: /opt/jdk-21.0.2+13
Using CLASSPATH: /opt/apache-tomcat-10.1.18/bin/bootstrap.jar:/opt/apache-tomcat-10.1.18/bin/tomcat-juli.jar
Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
Tomcat started.
La rĂ©cupĂ©ration de l’empreinte mĂ©moire de notre Tomcat se fait Ă l’aide des commandes jps
et jcmd
 :
# listing des JVM en cours d'exécution
$ jps -l
# récupération directe de l'identifiant lié à Tomcat
$ jps -l | grep -v 'jps' | cut -d ' ' -f 1
# récupération de l'empreinte mémoire
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2014475KB, committed=635935KB
malloc: 26831KB #72747
mmap: reserved=1987644KB, committed=609104KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=42108KB, committed=2792KB)
(thread #41)
(stack: reserved=41984KB, committed=2668KB)
(malloc=78KB #251) (peak=89KB #261)
(arena=46KB #80) (peak=317KB #52)
On observe que notre Heap est bien réservée à 512 Mo (524 288 KB), et que 41 Threads ont été démarrés (dont les 25 Threads liés à notre Executor
), pour une consommation de 41 Mo supplĂ©mentaires. Nous avons un total de mĂ©moire consommĂ©e de prĂšs de 630 Mo, car d’autres espaces sont rĂ©servĂ©s par la JVM (espaces de code, etc.).
En générant un peu de charge sur les applications exemples par défaut, on force Tomcat à instancier les Threads supplémentaires pour atteindre les 200 Threads.
La charge est générée avec la commande hey
, en utilisant 400 workers pour envoyer un million de requĂȘtes Ă la Servlet d’exemple.
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
On rĂ©cupĂšre ensuite l’empreinte mĂ©moire de notre Tomcat pour observer les nouvelles valeurs :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2214510KB, committed=671762KB
malloc: 32306KB #93366
mmap: reserved=2182204KB, committed=639456KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=237307KB, committed=24319KB)
(thread #231)
(stack: reserved=236544KB, committed=23556KB)
(malloc=494KB #1403) (peak=506KB #1413)
(arena=269KB #460) (peak=317KB #52)
On observe que le nombre de Threads est passĂ© Ă 231, et qu’on a maintenant plus de 230 Mo rĂ©servĂ©s pour les Threads.
Tomcat 11
Comme pour Tomcat 10.1, Tomcat 11 est démarré :
$ ./bin/startup.sh
Using CATALINA_BASE: /opt/apache-tomcat-11.0.0-M16
Using CATALINA_HOME: /opt/apache-tomcat-11.0.0-M16
Using CATALINA_TMPDIR: /opt/apache-tomcat-11.0.0-M16/temp
Using JRE_HOME: /opt/jdk-21.0.2+13
Using CLASSPATH: /opt/apache-tomcat-11.0.0-M16/bin/bootstrap.jar:/opt/apache-tomcat-11.0.0-M16/bin/tomcat-juli.jar
Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
Tomcat started.
La consommation mémoire observée :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2004350KB, committed=635010KB
malloc: 26946KB #72371
mmap: reserved=1977404KB, committed=608064KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=31835KB, committed=1719KB)
(thread #31)
(stack: reserved=31744KB, committed=1628KB)
(malloc=57KB #191) (peak=67KB #201)
(arena=34KB #60) (peak=317KB #52)
On observe qu’Ă froid, moins de Threads sont allouĂ©s au dĂ©marrage, seulement 31 au lieu des 41 Threads dĂ©marrĂ©s par Tomcat 10.1.
AprÚs avoir passé une charge identique au test du Tomcat 10.1, toujours avec la commandehey
 :
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
On rĂ©cupĂšre Ă nouveau l’empreinte mĂ©moire de Tomcat 11 :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2022976KB, committed=655120KB
malloc: 37380KB #88191
mmap: reserved=1985596KB, committed=617740KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=40054KB, committed=2798KB)
(thread #39)
(stack: reserved=39936KB, committed=2680KB)
(malloc=74KB #239) (peak=87KB #255)
(arena=44KB #76) (peak=317KB #52)
On observe que Tomcat a instancié quelques Threads en plus, pour passer à 39 et on atteint donc les 39 Mo de stack alloués. On économise donc pas loin de 200 Mo comme attendu.
Attention, cette mĂ©moire est bien de la mĂ©moire rĂ©servĂ©e, et non pas l’empreinte de la mĂ©moire rĂ©elle consommĂ©e (dĂ©nommĂ©e committed). Les OS utilisent des mĂ©canismes de mĂ©moire virtuelle qui permettent de promettre de la mĂ©moire Ă un process qui la demande, mĂȘme si la mĂ©moire n’est pas disponible physiquement. Cette mĂ©moire n’est pas Ă©crite sur la RAM tant qu’elle n’est pas rĂ©ellement consommĂ©e.
Comme on pouvait s’y attendre, l’empreinte de la mĂ©moire rĂ©servĂ©e par Tomcat pour les Threads est plus faible. Cependant, comme cette mĂ©moire n’est pas utilisĂ©e, l’impact sur les performances est faible. L’intĂ©rĂȘt des Virtual Threads ne rĂ©side pas principalement dans cette Ă©ventuelle Ă©conomie.
Performances avec une Servlet simple
Pour mesurer les performances de Tomcat 10 et 11, j’utilise la commande hey
, pour exĂ©cuter 1 million de requĂȘtes, dans 400 workers diffĂ©rents.
Notez que je lance cette commande sur la mĂȘme machine que ma machine de test, ce qui n’est clairement pas idĂ©al, mais c’est suffisant pour ces tests.
Je requĂȘte la Servlet HelloWorldExample
, qui est fournie avec Tomcat. Cette Servlet affiche simplement une page web contenant le message Hello World.
Tomcat 10.1 - Threads Plateforme
hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
Summary:
Total: 8.4899 secs
Slowest: 0.0997 secs
Fastest: 0.0000 secs
Average: 0.0034 secs
Requests/sec: 117787.3647
Total data: 387000000 bytes
Size/request: 387 bytes
Response time histogram:
0.000 [1] |
0.010 [991307] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.020 [8096] |
0.030 [167] |
0.040 [16] |
0.050 [22] |
0.060 [5] |
0.070 [0] |
0.080 [223] |
0.090 [129] |
0.100 [34] |
Latency distribution:
10% in 0.0017 secs
25% in 0.0023 secs
50% in 0.0030 secs
75% in 0.0040 secs
90% in 0.0054 secs
95% in 0.0066 secs
99% in 0.0097 secs
Sur ce premier tir avec Tomcat 10.1, le temps moyen d’exĂ©cution est de 3,4 millisecondes, et 99 % des requĂȘtes ont reçu une rĂ©ponse en moins de 9,7 millisecondes.
Tomcat 11 - Virtual Threads
Le mĂȘme test a Ă©tĂ© lancĂ© sur Tomcat 11 configurĂ© avec des Virtual Threads :
hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
Summary:
Total: 7.7188 secs
Slowest: 0.1194 secs
Fastest: 0.0000 secs
Average: 0.0031 secs
Requests/sec: 129554.4854
Total data: 387000000 bytes
Size/request: 387 bytes
Response time histogram:
0.000 [1] |
0.012 [998863] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.024 [588] |
0.036 [138] |
0.048 [10] |
0.060 [0] |
0.072 [102] |
0.084 [245] |
0.096 [13] |
0.107 [36] |
0.119 [4] |
Latency distribution:
10% in 0.0015 secs
25% in 0.0021 secs
50% in 0.0028 secs
75% in 0.0037 secs
90% in 0.0048 secs
95% in 0.0056 secs
99% in 0.0080 secs
Le temps moyen d’exĂ©cution est de 3,1 millisecondes, et 99 % des rĂ©ponses ont Ă©tĂ© donnĂ©es en moins de 9 millisecondes. On a une amĂ©lioration des performances de prĂšs de 10 % pour une simple Servlet !
On peut facilement interprĂ©ter cette amĂ©lioration. Les performances accrues sont probablement liĂ©es au fait que le systĂšme d’exploitation ne doit pas switcher entre l’exĂ©cution de 200 Threads en paralĂšlle dans le cas de Tomcat 11, ce qui occasionne donc plus de temps disponible, et donc des meilleurs temps de rĂ©ponse.
Performances avec une Servlet effectuant un appel bloquant
Pour aller un peu plus loin, nous allons exécuter un tir de performances similaire, avec une Servlet effectuant un appel bloquant de 50 millisecondes avec Thread.sleep(50)
 :
public class ThreadInfo extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
try {
Thread.sleep(50L); // fais dodo
} catch (InterruptedException ex) {
}
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
doGet(request, response);
}
}
Quel est l’impact attendu ? Pour Tomcat 10.1, qui dispose de 200 Threads maximum, on s’attend Ă obtenir un dĂ©bit de 4 000 requĂȘtes par seconde maximum (200 Threads * 1 000 ms / 50 ms), donc un temps d’exĂ©cution total de 250 secondes (1 million de requĂȘtes / 4 000 req / s).
Pour Tomcat 11, non limitĂ© par des Threads, on s’attend Ă obtenir un dĂ©bit similaire au test de la Servlet prĂ©cĂ©dente sans les appels bloquants.
Tomcat 10.1 - Threads plateforme - appels bloquants
Le tir de performances sur Tomcat 10.1 donne le résultat suivant :
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
Summary:
Total: 252.0313 secs
Slowest: 0.1661 secs
Fastest: 0.0501 secs
Average: 0.1006 secs
Requests/sec: 3967.7610
Total data: 133460003 bytes
Size/request: 133 bytes
Response time histogram:
0.050 [1] |
0.062 [24721] |â
0.073 [73] |
0.085 [12] |
0.097 [3051] |
0.108 [943320] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.120 [3356] |
0.131 [4993] |
0.143 [4193] |
0.155 [16063] |â
0.166 [217] |
Latency distribution:
10% in 0.1002 secs
25% in 0.1004 secs
50% in 0.1006 secs
75% in 0.1010 secs
90% in 0.1016 secs
95% in 0.1024 secs
99% in 0.1451 secs
Les 250 secondes attendues pour le temps d’exĂ©cution sont bien rĂ©elles et on observe un dĂ©bit Ă 3 967 requĂȘtes par seconde. 99 % des requĂȘtes ont une rĂ©ponse en moins de 145 millisecondes. Cette performance n’est pas terrible, quand on met en lumiĂšre le fait que l’opĂ©ration bloquante n’est que de 50 millisecondes. La requĂȘte la plus rapide a bien Ă©tĂ© exĂ©cutĂ©e en 50 millisecondes, mais en moyenne, l’exĂ©cution est de 100 millisecondes.
Cette lenteur supplĂ©mentaire est liĂ©e au temps d’attente des requĂȘtes pour obtenir un Thread disponible. PassĂ© le premier lot de 200 requĂȘtes, les autres attendent 50 millisecondes avant d’obtenir un Thread, qui lui mĂȘme bloque pendant 50 millisecondes le traitement d’autres requĂȘtes. Le dĂ©bit observĂ© de moins de 4 000 requĂȘtes par seconde est bien liĂ© Ă la contrainte des 200 Threads bloquĂ©s et occupĂ©s pendant 50 millisecondes chacun.
Tomcat 11 - Virtual Threads - appels bloquants
Le mĂȘme tir de performances sur Tomcat 11 configurĂ© avec les Virtual Threads donne un rĂ©sultat complĂštement diffĂ©rent :
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
Summary:
Total: 126.8828 secs
Slowest: 0.3636 secs
Fastest: 0.0501 secs
Average: 0.0507 secs
Requests/sec: 7881.2884
Total data: 129884989 bytes
Size/request: 129 bytes
Response time histogram:
0.050 [1] |
0.081 [999544] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.113 [115] |
0.144 [93] |
0.175 [114] |
0.207 [132] |
0.238 [0] |
0.270 [0] |
0.301 [0] |
0.332 [0] |
0.364 [1] |
Latency distribution:
10% in 0.0502 secs
25% in 0.0503 secs
50% in 0.0504 secs
75% in 0.0507 secs
90% in 0.0512 secs
95% in 0.0517 secs
99% in 0.0544 secs
On observe que le temps moyen de rĂ©ponse Ă une requĂȘte est bien de 50 millisecondes. Aucune surcharge liĂ©e Ă du context switch n’est observĂ©e ici. 99 % des requĂȘtes obtiennent une rĂ©ponse en 54 millisecondes.
Attention cependant, on observe que le dĂ©bit est de seulement 7 900 requĂȘtes par seconde. La limitation ici est liĂ©e au nombre de workers positionnĂ© Ă 400 sur la ma commande hey
. La commande n’envoie pas suffisamment de requĂȘtes pour atteindre le dĂ©bit thĂ©orique maximum.
Un second test avec le nombre de workers Ă 1 000 permet d’observer la diffĂ©rence de dĂ©bit :
$ hey -c 1000 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
Summary:
Total: 50.8318 secs
Slowest: 0.2331 secs
Fastest: 0.0501 secs
Average: 0.0507 secs
Requests/sec: 19672.7068
Total data: 128987581 bytes
Size/request: 130 bytes
Response time histogram:
0.050 [1] |
0.068 [983411] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.087 [277] |
0.105 [59] |
0.123 [28] |
0.142 [27] |
0.160 [0] |
0.178 [124] |
0.197 [271] |
0.215 [309] |
0.233 [205] |
Latency distribution:
10% in 0.0501 secs
25% in 0.0502 secs
50% in 0.0502 secs
75% in 0.0504 secs
90% in 0.0512 secs
95% in 0.0523 secs
99% in 0.0568 secs
Avec 1 000 workers, le temps moyen de rĂ©ponse reste autour de 50 millisecondes. 99 % des requĂȘtes reçoivent une rĂ©ponse en moins de 58 millisecondes. Le dĂ©bit passe Ă 19 000 requĂȘtes par seconde !
On atteint malheureusement ici les limites de ma machine, puisque à ce stade quelques erreurs sont observées : dial tcp 127.0.0.1:8080: socket: too many open files
.
Cependant, ces performances laissent deviner qu’il serait possible d’aller encore plus loin.
Bonus, avec Spring Boot 3
đ€ « Julien, tu es bien gentil avec tes Servlets, mais plus personne n’en dĂ©veloppe. »
Cette partie «bonus» teste le mĂȘme comportement, mais avec Spring Boot 3 !
Configurer Spring Boot 3
Malheureusement, il n’est pas possible pour le moment d’utiliser Tomcat 11 avec Spring Boot 3. NĂ©anmoins, Spring Boot 3 a intĂ©grĂ© le support des Virtual Threads et de l’Executor VirtualThreadExecutor Ă Tomcat 10 !
Pour utiliser les Virtual Threads dans Spring Boot 3, il faut positionner la properties suivante :
spring.threads.virtual.enabled=true
Aucune autre modification n’est nĂ©cessaire !
Pour comprendre comment cette properties opĂšre sa magie, il faut parcourir le code de Spring Boot.
Cette properties est interprĂ©tĂ©e par l’annotation @ConditionalOnThreading
et configure un TomcatVirtualThreadsWebServerFactoryCustomizer
 :
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Tomcat.class, UpgradeProtocol.class })
public static class TomcatWebServerFactoryCustomizerConfiguration {
@Bean
@ConditionalOnThreading(Threading.VIRTUAL)
TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
return new TomcatVirtualThreadsWebServerFactoryCustomizer();
}
}
Le TomcatVirtualThreadsWebServerFactoryCustomizer
configure le Tomcat embedded pour utiliser l’Executor VirtualThreadExecutor
 :
public class TomcatVirtualThreadsWebServerFactoryCustomizer
implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
@Override
public void customize(ConfigurableTomcatWebServerFactory factory) {
factory.addProtocolHandlerCustomizers(
(protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-")));
}
}
Dans notre code, un simple @Controller
Spring permet de recréer le comportement équivalent à la Servlet utilisée pour le benchmark précédent :
@RestController
public class ThreadController {
@GetMapping("/")
String getThreadName() throws InterruptedException {
Thread.sleep(50L); // gros dodo
return Thread.currentThread().getName();
}
}
Avec la properties spring.threads.virtual.enabled=false
, on obtient les performances suivantes, similaires Ă ce qu’on avait en utilisant Tomcat 10.1, sans support des Virtual Threads :
$ hey -c 400 -n 1000000 http://localhost:8080
Summary:
Total: 253.9172 secs
Slowest: 0.2498 secs
Fastest: 0.0501 secs
Average: 0.1013 secs
Requests/sec: 3938.2910
Total data: 21459960 bytes
Size/request: 21 bytes
Response time histogram:
0.050 [1] |
0.070 [22735] |â
0.090 [353] |
0.110 [949895] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.130 [7069] |
0.150 [19591] |â
0.170 [67] |
0.190 [74] |
0.210 [99] |
0.230 [46] |
0.250 [70] |
Latency distribution:
10% in 0.1004 secs
25% in 0.1008 secs
50% in 0.1014 secs
75% in 0.1021 secs
90% in 0.1036 secs
95% in 0.1058 secs
99% in 0.1352 secs
Les temps de rĂ©ponse sont autour de 100 millisecondes, pour un dĂ©bit de moins de 4 000 requĂȘtes par seconde, et 99 % des requĂȘtes reçoivent une rĂ©ponse en moins de 135 millisecondes.
Avec la properties spring.threads.virtual.enabled=true
, on obtient les performances suivantes, qui sont similaires aux performances de Tomcat 11 avec les Virtual Threads :
$ hey -c 400 -n 1000000 http://localhost:8080
Summary:
Total: 126.7462 secs
Slowest: 0.1738 secs
Fastest: 0.0501 secs
Average: 0.0507 secs
Requests/sec: 7889.7847
Total data: 20941836 bytes
Size/request: 20 bytes
Response time histogram:
0.050 [1] |
0.062 [999571] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â
0.075 [15] |
0.087 [68] |
0.100 [0] |
0.112 [65] |
0.124 [0] |
0.137 [61] |
0.149 [57] |
0.161 [16] |
0.174 [146] |
Latency distribution:
10% in 0.0502 secs
25% in 0.0503 secs
50% in 0.0504 secs
75% in 0.0507 secs
90% in 0.0513 secs
95% in 0.0519 secs
99% in 0.0539 secs
Les temps de rĂ©ponse sont autour de 50 millisecondes, pour un dĂ©bit d’un peu moins de 8 000 requĂȘtes par seconde, et 99 % des requĂȘtes obtiennent une rĂ©ponse en moins de 53 millisecondes !
Conclusion
Les résultats sont impressionnants. En utilisant le VirtualThreadExecutor
, dans Tomcat 11, on observe dĂ©jĂ 10 % de gains de performances sans rien faire de particulier, pour des Servlets n’effectuant pas d’appel bloquant.
Mais c’est vraiment Ă partir du moment oĂč des appels bloquants sont effectuĂ©s que les gains de performances sont les plus importants. Sur un Tomcat avec 200 Threads plateforme, une fois les 200 Threads bloquĂ©s, les autres requĂȘtes sont mises en attente, ce qui occasionne des temps de rĂ©ponse moyens plus longs. Ces impacts semblent purement annulĂ©s avec l’utilisation des Virtual Threads, puisque le nombre de Threads n’est plus limitĂ©. Le dĂ©bit thĂ©orique d’une application n’est maintenant plus limitĂ© par son nombre de Threads.
Pour aller plus loin, l’utilisation des Virtual Threads dans Tomcat rendrait presque inutile l’utilisation des approches de programmation rĂ©active. Le fait de rendre les Threads peu coĂ»teux Ă instancier, liĂ© Ă leur mode d’exĂ©cution sur un _Thread_hĂŽte, limite la charge dĂ©portĂ©e sur le systĂšme d’exploitation en context switches, et maximise l’utilisation du CPU.
Il n’est maintenant plus problĂ©matique de bloquer un Thread.
On peut déjà bénéficier de ces améliorations de performances avec Spring Boot 3 et Tomcat 10.1, à condition de bien utiliser une JVM 21. Donc pourquoi se priver ?
Ă suivre lors de la sortie future de Tomcat 11, quelle en sera l’intĂ©gration dans Spring Boot. Spring Boot ayant annoncĂ© supporter Java 17 en version de base, la properties spring.threads.virtual.enabled
restera toujours disponible, avec probablement une valeur false
par défaut.
Liens et références
- JEPÂ 320 - Suppression des modules Java EE et CORBA
- JEPÂ 444 - Virtual Threads
- Documentation de Tomcat
- RELEASE-NOTES Tomcat 11.0.0-M16
- Tomcat 11 Virtual Thread Implementation - Configuration des Virtual Threads dans Tomcat
- Virtual Threads - Configuration des Virtual Threads dans Spring Boot
- Programmation Concurrente et Asynchrone : Loom en Java 20 et 21 - José Paumard
- JMHÂ : Java Microbenchmark Harness
- hey : HTTP load generator, ApacheBench (ab) replacement