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 SpecApache Tomcat VersionSupported Java VersionsRelease date
6.111.0.x21 and later(version alpha)
6.010.1.x11 and laterdec. 2020
4.09.0.x8 and lateroct. 2017
3.18.5.x7 and laterjan. 2014
3.07.0.x (archived)6 and laterjan. 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 par Executors.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Ă© du ThreadPoolExecutor.

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 !

Le plus élevé est le meilleur

Nombre de requĂȘtes par seconde

Le plus élevé est le meilleur

Le plus petit est le meilleur

Temps de réponse moyen

Le plus petit est le meilleur

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 !

Le plus élevé est le meilleur

Nombre de requĂȘtes par seconde

Le plus élevé est le meilleur

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