Java va bientôt fêter ses 30 ans. 30 ans, c’est a peu près mon expérience dans le développement : 10 ans de C++, 10 ans de C# et maintenant 10 ans de Java. Sur cette période, l’évolution n’est pas que logicielle, elle est aussi matérielle et infra-structurelle. Mon tout premier programme tournait sur un Z80 à 2.5MHz et aujourd’hui nos applications tournent 24/7 sur des serveurs dual-CPU AMD EPYC 64 cores à 3.5GHz connectés en 10Gbits.
Pour les plus jeunes, Java a probablement une image désuète représentative d’une époque qu’il n’ont pas connu et associée à des pratiques d’entreprise qui sentent la naphtaline. Alors que de nouveaux langages apparaissent et que certains montent en puissance, c’est l’occasion de se poser la question de la pertinence de Java dans le développement back-end moderne.
Dans cet article, je vais rappeler brièvement les forces historiques de Java et de son écosystème puis nous verrons ensemble l’évolution qu’a connu le langage ces 10 dernières années.
Java, un socle solide
Un langage avec une syntaxe simple, typé statiquement et compilé
Java offre une syntaxe lisible, inspirée d’une version simplifiée du C++. Son système de typage statique supporte les types génériques et permet la déclaration d’interfaces claires tout en offrant des garanties fortes lors des évolutions futures du code. Le compilateur nous évite les mauvaises surprises à l’exécution (AttributeError
anyone? 🤡) ainsi que les ruptures dans l’encapsulation (public
, private
).
Le JDK : une bibliothèque standard complète
Le JDK (Java Development Kit) fournit un riche ensemble d’API standards couvrant divers aspects du développement, dont les structures de données, le réseau et une gestion avancée des threads et de la concurrence (via java.util.concurrent
).
La JVM : un moteur polyvalent
La JVM (Java Virtual Machine) permet d’exécuter plusieurs langages (Java, Scala, Kotlin) sur une plateforme unique. Elle assure la gestion automatique de la mémoire grâce au garbage collector (GC) et optimise les performances avec le compilateur Just-In-Time (JIT) et l’interopérabilité vers C/C++ via JNI (Java Native Interface) en cas de besoin.
Choix du Garbage Collector
Java propose plusieurs algorithmes de garbage collection (G1, Parallel, etc.), permettant d’ajuster les performances en fonction des besoins : minimisation de la latence pour des applications de type serveur de requêtes ou maximisation du débit pour les traitements intensifs. Des options permettent un contrôle fin de l’espace mémoire disponible selon la durée de vie des objets en mémoire.
Un écosystème open-source mature
L’écosystème Java est riche et diversifié. Chez Babbar nous utilisons par exemple :
- Apache Spark (en Scala) et Hadoop pour le big data
- Apache Pulsar et Zookeeper pour le messaging et le clustering
- RocksDB (en C++ via JNI) pour le stockage clef-valeur
- gPRC et Protobuf pour la communication inter-services
- Prometheus pour le monitoring
Un système de build éprouvé
Apache Maven est largement répandu pour la gestion de projet Java, avec une gestion des dépendances qui permet d’éviter ou de comprendre et résoudre les inévitables conflits de version. Son système de plugin permet d’automatiser un large éventail de tâches (compilation, tests, packaging, etc.). Bon, le build c’est rarement la partie sexy du projet pour un dev 🙄.
Le profiling en production
VisualVM permet de se connecter à distance afin de monitorer directement l’application Java : analyse des threads, statistiques du GC, profiling CPU, stack & heap dump pour le diagnostic dans les pires cas. C’est précieux, car c’est évidemment en prod que les trucs bizarres se produisent 🤬
Les bases d’un Java moderne : Java 8 & 11
C’est en 2014 avec Java 8 qu’arrivent des changements majeurs. Avec Java 11 et l’inférence de type, on obtient une belle expressivité tout en réduisant considérablement le boilerplate.
Static & Default Methods in Interface
Juste un amuse-bouche mais ça manquait. Et il aura fallut attendre Java 17 pour avoir les méthodes statiques pour des classes non-statiques.
Stream API, Lambda Expressions & Method References
Les Stream
s permettent de manipuler des collections de façon déclarative et concises en tirant profit de la nouvelle interface fonctionnelle.
- Map / Reduce
- Et le tout avec un support du parallélisme !
Programmation Asynchrone
Les CompletableFuture
s introduisent une programmation non bloquante fluide en offrant une exécution de tâche asynchrone (supplyAsync()
), la continuation (thenApply()
, thenAccept()
), la combinaison (thenCombine()
, allOf()
) et la gestion des erreurs (exceptionally()
).
Vers un Java plus expressif Java 17 & 21
Les version suivantes apportent un ensemble de fonctionnalités qui, associées, ouvrent de nouvelles perspectives aux développeurs pour modéliser leurs données et implémenter leurs algorithmes.
switch
expressions
Jusqu’à présent, switch
était déclaratif (statement). Dans l’exemple suivant, il faut déclarer la variable, faire une affectation (=
) dans chaque branche, ne pas oublier break
(sauf quand il ne faut pas le mettre) et il faut une clause default
car exhaustivité des cas n’est pas vérifiée par le compilateur 😬.
Java 17 ajoute une syntaxe supplémentaire a switch
pour en faire une expression (un truc qui s’évalue en une valeur) et gagne au passage la vérification de l’exhaustivité, ce qui est une garantie très forte. Bon, vous allez me dire que c’est pas demain qu’on va ajouter un jour dans la semaine, mais ce n’est qu’un exemple ! 🧐
Records
Les record
s sont une nouvelle forme de classe adaptée pour des objets représentants des données structurées. Les champs sont privés et ne peuvent pas être modifiés une fois initialisés (private final
). Les accesseurs sont automatiquement définis pour autoriser l’accès aux champs via des appels ou des références de méthode.
Sealed classes
Les classes et interfaces sealed
permettent de limiter la déclaration des classes héritées a un ensemble défini à l’avance via l’usage de permits
. Dit comme ça, ça ne semble pas très utile mais ça prend toute son importance par la suite.
Pattern Matching
Maintenant qu’on dispose des sealed classes et des switch expressions, il n’y a plus qu’un pas à faire pour proposer le pattern matching exhaustif en Java 🤩
Exhaustivité
Dans l’exemple suivant, le compilateur est à même de vérifier que toutes les formes autorisées sont testées dans le switch
:
Si d’aventure une nouvelle forme était ajoutée dans la liste des forme autorisées, le code précédent ne compile plus. Cette garantie est ce qui fait la force de cette fonctionnalité, donc il faut se garder d’ajouter une clause default
dans le switch
qui agirait comme un attrape-tout :
Déconstruction et conditions
Java permet également la déconstruction des record classes lors du pattern matching et autorise des conditions avec le mot clef when
, toujours en conservant la validation de l’exhaustivité :
Le pattern matching fonctionne également avec if
mais nécessite l’usage de instanceof
:
Avec tout ça, Java propose plus d’expressivité, plus de validation par le compilateur et moins de boilerplate 🥳.
La concurrence revisitée – Projet Loom
Java 21 introduit les virtual threads via le Projet Loom, visant à simplifier la gestion de la concurrence en éliminant les limitations des threads traditionnels – notamment en termes de consommation de ressources – ainsi que les complications liées à la programmation asynchrone.
Carrier threads vs virtual threads
Les virtual threads sont des threads légers gérés par la JVM, qui s’exécutent sur un nombre réduit de threads natifs appelés carrier threads (ou OS threads). Contrairement aux threads classiques, un grand nombre de virtual threads peuvent être multiplexés sur un nombre limité de carrier threads.
Les virtual threads sont bien plus légers que les threads OS : leur création et leur gestion ne nécessitent pas d’allocation système ni de context switching, permettant d’en créer “des millions” sans impacter significativement la mémoire et les performances. Ils s’appuient sur un ForkJoinPool
dédié, qui répartit intelligemment les virtual threads entre les carrier threads via une approche de work-stealing, optimisant ainsi l’utilisation des cœurs CPU.
Impact sur le code existant
L’intégration profonde des virtual threads au sein du JDK et de la JVM a fait naître une promesse alléchante pour les projets qui utilisent massivement les threads natifs pour la gestion de la concurrence : vous remplacez vos threads par des threads virtuels, ça compile, ça fonctionne, le tout en consommant moins de ressources.
Chez Babbar, nous en avons fait l’expérience avec notre crawler, et la promesse est tenue ! Le commit qui effectue le passage des threads natifs aux threads virtuels ne concerne que les quelques lignes qui déclarent et initialisent les threads. On divise par 10 le nombre de threads système au passage. Chapeau bas au projet Loom 👏
Le retour du code impératif
De manière générale, les virtual threads proposent ce que d’autres langages ont introduits via des green threads et le couple async
/ await
, mais à la sauce Java et sans mots clefs supplémentaires.
- facilité d’écriture du code asynchrone (impératif)
Fini les chaînes de continuations et les terminologies confusantes ! Avec les virtual threads, le code concurrent s’écrit de manière impérative et lisible. Contrairement aux approches basées sur CompletableFuture
, un seul virtual thread peut traiter une succession d’étapes sans être divisé en de multiples tâches.
- Conservation de la call-stack et des exceptions
Contrairement aux approches non bloquantes traditionnelles, les virtual threads conservent l’intégralité de la pile d’appels (call-stack), facilitant la traçabilité. Les exceptions fonctionnent comme dans un code synchrone normal, ce qui évite les complications liées à la gestion des erreurs dans les modèles réactifs.
Cas d’usage
Le multiplexage des virtual threads sur des threads natifs a évidemment des implications sur le profil optimal des tâches exécutées. En gros, comme pour les implémentations dans les autres langages, c’est surtout adapté pour supporter un grand nombre de tâche qui passent la plupart de leur temps à attendre quelque chose (une réponse, un lock, etc.).
- Usage canonique : I/O bound tasks
Les virtual threads brillent lorsqu’ils sont utilisés pour des tâches à forte latence I/O (requêtes HTTP, accès base de données), où le thread peut être suspendu sans monopoliser un OS thread. Le projet Loom a fait en sorte que toutes les opérations bloquantes du JDK supportent le multiplexage.
- Anti-pattern : compute tasks
Les virtual threads ne sont pas adaptés aux tâches gourmandes en CPU (calculs intensifs, machine learning). Ces tâches doivent être déléguées à des threads dédiés du ForkJoinPool
ou à un ExecutorService
adapté. Notez qu’il est tout à fait possible de soumettre la tâche à partir d’un virtual thread et d’attendre son résultat, le carrier thread ne sera pas bloqué puisque le virtual thread “attend” le résultat du calcul !
Exemple
Dans cet exemple, contrairement à l’exemple donnée pour l’asynchronie auparavant, nos services ne sont pas asynchrones (ils ne retournent pas de CompletableFuture
) et les appels sont donc bloquants. Toutefois, nous voulons traiter un flux de requête de manière concurrente.
- Java 8 –
CompletableFuture
s
Avec Java 8, il est préférable de chaîner les opérations pour augmenter la concurrence et si un appel bloque dans l’attente d’une réponse, le thread courant est bloqué lui aussi.
- Java 21 – Virtual Threads
Avec Java 21, il n’y a rien à faire. Si les méthodes des services bloquent, le virtual thread sera automatiquement suspendu et le carrier thread pourra s’occuper d’une autre tâche.
Wish List
- Virtual Threads dans VisualVM
Avec l’intégration des virtual threads dans Java, l’observabilité via VisualVM a disparu car seuls les threads natifs sont visibles. Sachant qu’il est souhaitable qu’un tâche passe la majeure partie de son temps à attendre, il est dommage de ne pas pouvoir le mesurer in situ en production.
- Thread Factory pour les carrier threads
En l’état, il ne semble possible de définir le nombre de carrier threads que via des options passées au démarrage de la JVM. On aimerai pouvoir contrôler plus finement la concurrence, au sein d’une même application et selon les cas d’usage.
- Projet Valhalla
Mon expérience passée fait que j’ai gardé un lien assez fort avec le code bas niveau et le matériel. J’ai conservé une intuition sur le coût et l’agencement mémoire d’une structure de donnée, les pattern d’accès en lecture ou écriture d’un algorithme et les implications sur le cache par exemple.
Java ne supporte pas les types valeur (value type) ni les génériques portant sur des types primitifs (List<int>
n’est pas un type valide en Java). A chaque fois que je dois manipuler des Map.Entry<Integer, Float>
, j’ai un peu mal au cœur; c’est d’ailleurs pourquoi nous utilisons fastutil
quand c’est possible, mais ce n’est pas suffisant.
Le projet Valhalla ambitionne de résoudre ce problème, mais la route est encore longue alors que le projet a été initié il y a déjà plus de de 10 ans.
Conclusion
Après près de 30 ans d’existance, Java continue de démontrer une étonnante capacité à évoluer tout en restant fidèle à ses principes fondamentaux. Malgré l’introduction de concepts modernes comme le pattern matching ou les virtual threads, le langage a su conserver une syntaxe simple et cohérente, évitant l’accumulation de complexité qui aurait pu le rendre moins accessible. Au contraire, le langage est maintenant plus expressif, offre plus de garanties avec moins de boilerplate.
Sa compatibilité ascendante permet aux équipes d’adopter progressivement les nouvelles fonctionnalités sans rupture, garantissant une transition fluide vers les versions récentes. Chez Babbar, nous avons pu en faire l’expérience avec Java 21 : les gains apportés par les virtual threads se sont intégrés sans heurt dans notre code existant.
On peut faire le constat que Java ne se contente pas de survivre dans l’écosystème back-end : il s’y réinvente sans cesse, prouvant que maturité et innovation peuvent aller de pair. Et il y a encore des nouveautés dans les tuyaux !
Références
- Java 11 Language Changes – https://docs.oracle.com/en/java/javase/11/language/java-language-changes.html
- Java 17 Language Changes – https://docs.oracle.com/en/java/javase/17/language/java-language-changes.html
- Java 21 Language Changes – https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html
- JEP Records – https://openjdk.org/jeps/395
- JEP Record Patterns – https://openjdk.org/jeps/440
- JEP Sealed Classes – https://openjdk.org/jeps/409
- JEP Switch Expressions – https://openjdk.org/jeps/361
- JEP Pattern Matching for
instanceof
– https://openjdk.org/jeps/394 - JEP Pattern Matching for
switch
– https://openjdk.org/jeps/441 - JEP Virtual Threads – https://openjdk.org/jeps/444
- The Long Road to Java Virtual Threads – https://dzone.com/articles/the-long-road-to-java-virtual-threads
- Are Virtual Threads Going to Make Reactive Programming Irrelevant? – https://youtu.be/zPhkg8dYysY
- Handling Virtual Threads – https://dzone.com/articles/handling-virtual-threads
- JEP Virtual Threads | Pinning https://openjdk.org/jeps/444#Pinning
- JEP Synchronize Virtual Threads without Pinning – https://openjdk.org/jeps/491