Java : l’art d’évoluer sans se trahir

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 (publicprivate).

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.

Java

Stream API, Lambda Expressions & Method References

Les Streams permettent de manipuler des collections de façon déclarative et concises en tirant profit de la nouvelle interface fonctionnelle.

Java
  • Map / Reduce
Java
  • Et le tout avec un support du parallélisme !
Java

Programmation Asynchrone

Les CompletableFutures 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()).

Java

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

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 ! 🧐

Java

Records

Les records 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.

Java

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.

Java

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:

Java

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 switchqui agirait comme un attrape-tout :

Java

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é :

Java

Le pattern matching fonctionne également avec if mais nécessite l’usage de instanceof :

Java

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
  • Java 8 – CompletableFutures

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
  • 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.

Java

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
  1. Java 11 Language Changes – https://docs.oracle.com/en/java/javase/11/language/java-language-changes.html
  2. Java 17 Language Changes – https://docs.oracle.com/en/java/javase/17/language/java-language-changes.html
  3. Java 21 Language Changes – https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html
  4. JEP Records – https://openjdk.org/jeps/395
  5. JEP Record Patterns – https://openjdk.org/jeps/440
  6. JEP Sealed Classes – https://openjdk.org/jeps/409
  7. JEP Switch Expressions – https://openjdk.org/jeps/361
  8. JEP Pattern Matching for instanceof – https://openjdk.org/jeps/394
  9. JEP Pattern Matching for switch – https://openjdk.org/jeps/441
  10. JEP Virtual Threads – https://openjdk.org/jeps/444
  11. The Long Road to Java Virtual Threads – https://dzone.com/articles/the-long-road-to-java-virtual-threads
  12. Are Virtual Threads Going to Make Reactive Programming Irrelevant? – https://youtu.be/zPhkg8dYysY
  13. Handling Virtual Threads – https://dzone.com/articles/handling-virtual-threads
  14. JEP Virtual Threads | Pinning https://openjdk.org/jeps/444#Pinning
  15. JEP Synchronize Virtual Threads without Pinning – https://openjdk.org/jeps/491