Cet article est issu du blog de Kévin Llopis, Technical Officer chez CARBON. Vous pouvez retrouver tous ses articles sur son blog personnel.
Depuis la dernière version LTS, à savoir Java 17, deux ans se sont écoulés et plusieurs versions “non-LTS” ont été publiées les unes après les autres. Certaines fonctionnalités sont restées en “preview” sur ces versions, c’est notamment le cas du Pattern Matching, des Record Patterns et des Virtual Threads.
Avec Java 21, la nouvelle version LTS, ces fonctionnalités quittent enfin la “preview” et leur mise en place dans un projet peut être envisagée. Voyons dans quels scénarios nous pouvons en tirer parti.
Dans la suite de l’article, nous aborderons également les fonctionnalités toujours en “preview” et ce qu’elles présagent pour l’avenir de Java.
Rappels sur les Threads
Avant de présenter l’une des fonctionnalités majeures de cette version LTS, faisons quelques rappels concernant l’utilisation des Threads ainsi que leur limitation.
Dans le cadre de la programmation concurrente, les Threads Java sont utilisés au quotidien. Ils ont les inconvénients suivants :
- Une empreinte mémoire importante
- Leur création est lente et coûteuse du fait de l’allocation mémoire
Ce qui signifie que l’on ne peut pas se permettre de créer un nouveau Thread pour chaque tâche concurrente. On risque de se retrouver à cours de mémoire. C’est pour cela que les Threads sont réutilisés pour chaque nouvelle tâche concurrente, en s’appuyant sur un “pool” de Threads. Si les Threads sont tous actifs, les tâches sont placées en attente dans la Queue.
Pour réutiliser les Threads en Java dans un “pool”, on s’appuie sur l’interface ExecutorService
en vue de transmettre au “pool” de Threads les Task
dédiées à l’exécution. Ce “pool” peut être créé comme ceci : Executors.newFixedThreadPool(4)
.
L’extrait de code suivant décrit de quelle manière exploiter un “pool” de Threads avec ExecutorService
:
Les Virtual Threads
Depuis Java 19, les Virtual Threads sont en “preview” (Voir JEP 425), mais dans cette nouvelle version LTS, nous pouvons enfin les exploiter en tant que fonctionnalité “stable”.
A l’inverse des Threads présentés précédemment, les Virtual Threads sont plus légers et efficients pour les raisons suivantes :
- Leur consommation mémoire est moins importante que pour les Threads, d’autant plus que les Virtual Threads sont stockés en tant qu’objets Java dans la “heap”.
- Leur création est rapide, c’est une opération moins coûteuse en comparaison des Threads
De ce fait, on peut aisément créer un nouveau Virtual Thread pour chaque tâche concurrente. Pour créer un Virtual Thread, il suffit de s’appuyer sur Thread.ofVirtual().start(Runnable task)
comme illustré dans l’exemple ci-dessous.
D’autre part, l’exemple présenté précédemment concernant l’utilisation du “pool” de Threads, serait facile à faire évoluer si l’on souhaite remplacer les Threads par les Virtual Threads.
En effet, la solution de remplacement se base aussi sur ExecutorService
avec Executors.newVirtualThreadPerTaskExecutor()
. Cette méthode retourne de la même manière un ExecutorService
qui permettra de démarrer un nouveau Virtual Thread pour chaque Task
émise.
En ce qui concerne le fonctionnement des Virtual Threads, chacun d’entre eux est assigné à un Thread Java par le scheduler du JDK (ForkJoinPool). Dans ce cas là, le Thread est désigné comme étant un Carrier pour le Virtual Thread.
On dit que les Virtual Threads assignés à un Carrier sont alors “mounted”, tandis que les Virtual Threads non assignés et prêts à l’emploi sont “unmounted” comme illustré dans le schéma suivant.
Lorsqu’un Virtual Thread réalise une opération bloquante, il est retiré du Carrier tant que la réponse de l’opération bloquante n’a pas été reçue. Puis, dans l’attente de cette réponse, un autre Virtual Thread peut être assigné.
Par exemple, dans le schéma ci-dessous, le Virtual Thread 3 réalise une opération bloquante et est donc désassigné de son Carrier. Pendant ce temps, le Virtual Thread 4 initialement en attente, est assigné au Carrier laissé libre.
Au vu des avantages décrits précédemment, cela laisse penser que les Virtual Threads sont la solution à toutes les problématiques de concurrence. Or, ce n’est pas le cas puisque les Virtual Threads ont aussi leur limitation.
Les limitations des Virtual Threads
Un Virtual Thread est retiré d’un Carrier lors d’une opération bloquante, mais il peut arriver qu’il ne soit pas possible de désassigner un Virtual Thread dans les cas suivants :
- L’opération bloquante a lieu dans un bloc ou une méthode
synchronized
. - L’opération bloquante a lieu durant l’exécution d’une méthode native ou d’une “foreign function”.
Dans ces cas-là, on dit que le Virtual Thread est “pinned”.
Cette situation peut provoquer de la latence dans l’application et affecter ses performances si la situation se répète plusieurs fois. Or, selon les librairies utilisées dans l’application, ces situations seront d’autant plus courantes. En effet, certaines librairies requièrent l’exécution d’opérations bloquantes dans les conditions présentées précédemment, ce qui mettra le Virtual Thread dans un état “pinned”.
Si ces librairies sont indispensables, il est recommandé que l’utilisation des Virtual Threads soit évitée tant que les librairies concernées n’ont pas été révisées.
Le Data-Oriented Programming en Java 21
Avec Java 21, les autres fonctionnalités majeures que nous pouvons exploiter dans nos projets sont notamment le Pattern Matching et les Record Patterns. La combinaison de ces deux fonctionnalités permet de pratiquer le Data-Oriented Programming (DOP).
L’objectif en mettant en pratique le DOP en Java est de se concentrer davantage sur les données que l’on appelle communément le domaine métier, plutôt que sur le code.
Un cas concret présenté dans un article précédent dédié à l’introduction du DOP en Java 19, consistait à se baser sur le Pattern Matching et sur les Record Patterns pour traiter des cas métiers dans un switch
.
Puis, dès que le domaine métier évoluait, par exemple en modifiant la définition de Task
, un avantage majeur des Record Patterns entrait en action puisqu’une erreur apparaissait à la compilation au niveau du case
correspondant.
De cette manière, cela obligerait le développeur à faire évoluer tout le code dépendant du domaine métier, c’est notamment par ce biais que le DOP en Java nous permet de nous concentrer davantage sur le domaine métier que sur le code.
Sequenced Collections
Les collections gèrent toutes différemment l’ordonnancement des éléments et les méthodes qui tirent parti de cet ordre sont aussi très différentes. Par exemple, dans le cas d’une ArrayList
, il est nécessaire d’appeler list.get(index)
pour accéder au premier ou au dernier élément. En revanche, pour les Deque
et les SortedSet
, il existe des méthodes dédiées comme indiqué dans le tableau ci-dessous :
Afin d’uniformiser les APIs basées sur l’ordre des éléments, la JEP 431 introduit les interfaces SequencedCollection
, SequencedSet
et SequencedMap
:
SequencedCollection
Elle expose des méthodes pour accéder, ajouter, supprimer le premier et dernier élément. D’autre part, la méthode reversed()
nous permet d’itérer sur une collection d’éléments inversés. Cette interface s’applique aux structures de données de type Set
, List
et Deque
.
SequencedSet
Pour les types Set
ordonnés, SequencedSet
est l’interface dédiée et hérite également de SequencedCollection
.
SequencedMap
Dans le cas des Map
ordonnés, il y a aussi sa propre interface SequencedMap
.
JEP 443: Unnamed Patterns and Variables (Preview)
Cette fonctionnalité toujours en “preview” a pour objectif d’améliorer la lisibilité des expressions basées sur les Record Patterns en utilisant des “unnamed patterns”.
Par exemple, dans l’extrait de code suivant, une variable du Record Pattern n’est pas utilisée.
Dans cette situation, comment pourrions-nous favoriser la lisibilité de la condition if
comprenant le Record Pattern ?
La solution consiste à se baser sur les “unnamed patterns”.
Nous pouvons également utiliser des “unnamed variables”.
Mais dans le cas actuel, “unnamed pattern” est plus adapté.
Les “unnamed variables” peuvent être utiles dans les cas suivants, notamment lorsque cette même variable n’est pas exploitée :
- La variable est locale dans un bloc de code
- La variable est une ressource dans un try-with-resources
- Dans un entête d’une boucle
for/while
- Le paramètre d’une exception dans un bloc
catch
- Le paramètre d’une expression lambda
JEP 445: Unnamed Classes and Instance Main Methods (Preview)
Il s’agit également d’une fonctionnalité en “preview” et elle a pour objectif de simplifier le développement de programmes qui requièrent peu de code. Par exemple, il peut s’agir de scripts que l’on développerait habituellement en Python 3.
D’autre part, cette fonctionnalité a aussi pour but de faciliter l’apprentissage de Java pour les étudiants.
En effet, il n’est plus nécessaire de déclarer la classe et la méthode main
devient une méthode d’instance, au lieu d’une méthode static
, ce qui améliore d’autant plus la lisibilité du code.
Bien que cette fonctionnalité permette de rendre le code plus lisible et simple à écrire dans le cas des scripts, mon opinion personnelle est qu’elle n’a que peu de valeur ajoutée. De nos jours, les IDE comme IntelliJ permettent déjà de simplifier l’écriture du code en générant la déclaration de la classe ainsi que la méthode static void main(String[] args)
.
Conclusion
Suite à la sortie de Java 21, la meilleure raison pour réaliser une migration vers cette nouvelle version LTS repose principalement sur la fonctionnalité majeure des Virtual Threads. En effet, de toutes les fonctionnalités de Java 21, c’est la plus importante et la plus impactante, notamment dans la manière de rendre plus efficiente et légère la gestion des tâches concurrentes. D’autre part, l’emploi des Virtual Threads continuera d’être optimisé, en s’appuyant à l’avenir sur des fonctionnalités toujours en “preview” telles que les Scoped Values présentés dans un article précédent.
En dehors des Virtual Threads, les autres nouveautés majeures pour les projets sont le Pattern Matching et les Record Patterns, permettant d’introduire le DOP. De cette manière, nous pouvons nous concentrer davantage sur le domaine métier que sur le code. Cependant, il ne s’agit pas de remplacer l’Object-Oriented Programming (OOP) par le DOP mais plutôt de tirer parti des deux paradigmes.
Bien entendu, Java 21 comprend d’autres fonctionnalités spécifiées dans les JEPs ainsi que des évolutions dans les APIs du JDK.