logo le blog invivoo blanc

DEVOXX 2023 : Les nouveautés Java 19 et 20

19 juillet 2023 | Java | 0 comments

Au cours de la Devoxx 2023 se déroulant au Palais des Congrès à Paris, Jean Michel Doudoux, Senior Techlead Java chez SCIAM et Java Champion auteur des deux didacticiels « Développons en Java » et « Développons en Java avec Eclipse », nous a présenté au cours de sa conférence intitulée “Les nouveautés Java 19 et 20”, les évolutions du langage relatives aux deux dernières versions 19 et 20. Il nous a détaillé les évolutions issues principalement des projets Amber, Panama et Loom ainsi que les différents JEPs (JDK Enhancement Proposal) correspondants en passant également par d’autres évolutions en cours du langage.

Dans cet article je vous propose de revenir sur ces nouveautés du Java 19 et 20 et de partager avec vous quelques notes de la présentation.

Depuis sa version 9, Java suit un cycle de release raccourci. En effet, une release majeure est proposée chaque 6 mois et une release de support à long terme (LTS) chaque 3 ans. Aujourd’hui, Oracle annonce même une LTS chaque 2ans. La prochaine version prévue pour septembre devrait être donc une LTS. Les deux dernières versions de Java 19 et 20 sorties respectivement en septembre 2022 et mars 2023 ont fait l’objet respectivement des deux JSRs 394 et 395 donnant naissance aux deux implémentations de référence OpenJDK 19 et OpenJDK 20.

Pour ces deux versions un nombre de JEPs relativement faible est proposé dont la majorité est en incubation ou en preview. 7 JEPs concernent le langage et un JEP supplémentaire qui apporte un nouveau portage de la JDK JEP 422.

1. Projet Amber

Le projet Amber concerne essentiellement la syntaxe. Plusieurs évolutions ont déjà été apportées au langage dans le cadre de ce projet tout au long des versions antérieures, notamment les évolutions concernant les Records et le Pattern Matching. Nous pouvons citer :

  • Switch expression introduite dans la version 12 en preview JEP 325 et finalisée dans la version 14 JEP 361
  • Pattern Matching pour l’opérateur instanceof introduit dans la version 14 en preview JEP 305  et finalisé dans la version 16 JEP 394
  • La classe Record introduite dans Java 14 en preview JEP 359 et finalisée dans la version 16 JEP 395
  • Pattern Matching et Guard Pattern pour les switch cases en preview depuis la version 17 JEP 406

Les classes Records fournissent une façon simple moins verbeuse de coder un conteneur de données, simplifiant le code et réduisant la marge d’erreur et le recours à l’utilisation excessive des IDEs ou des libraires externe comme Lombok pour la génération des constructeurs, getters, setters, equals, hashCode, toString…

Par rapport au Pattern Matching, nous avons une nouvelle proposition d’amélioration, le Record Pattern JEP 405 en preview en Java 19 et continue en preview en Java 20 JEP 432 . Ce pattern permet de déconstruire un record pour utiliser directement ses attributs, à l’image de la destruction des variables dans d’autres langages comme Typescript par exemple.

if (o instanceof Employe(String nom, String prenom)) {
  System.out.println("nom: " + nom + " prenom: " + prenom);
}

Dans Java 19, nous pouvons utiliser le Record pattern avec le Type pattern dans un même switch :

switch (o) {
  case Employe emp -> System.out.println(emp);
  case Grade(String code, String designation) -> System.out.println("Grade " + designation + "(" + code + ")");
  default -> System.out.println("Type non supporté");
}
 

Et pour Java 20, nous avons une nouvelle proposition d’évolution pour l’utiliser dans une boucle for améliorée.

List<Employe> employes = List.of(new Employe("nom1", "prenom1"));
for (Employe(var nom, var prenom) : employes) {
  System.out.println(nom + " " + prenom);
}

A noter une MatchException sera levée si l’élément en cours est nul.

Concernant ce Record pattern, les caractéristiques suivantes sont applicables :

  • Seul l’ordre et le type des attributs doivent être respectés
  • Les Record patterns peuvent être imbriqués
  • L’utilisation de l’inférence de type est possible (var)
  • La valeur nulle dans un switch ne correspond à aucun Record pattern
  • Le Record pattern peut être générique avec un support de l’inférence pour les génériques proposé en java 20

Par ailleurs, Java 19 a introduit un Record pattern nommé pour déclarer une variable suite au pattern matching :

if (o instanceof Employe(String nom, String prenom) emp) {
  System.out.println(emp.nom + " " + emp.prenom);
}

A noter que cette fonctionnalité a été retirée en java 20.

Par rapport au Pattern Matching pour les switch introduit en java 17, dans Java 19 nous distinguons les propositions d’évolution suivantes :

  • L’utilisation du mot clé When pour le Guard pattern à la place d’un simple &&
  • Un meilleur support des valeurs nulles en levant une exception dès l’invocation du switch si la valeur nulle n’est gérée par aucun case.

En ce qui concerne la version 20, elle embarque les propositions d’évolutions suivantes :

  • Une MatchException est levée à la place d’IncompatibleClassChangeError, si le type a été modifié sans recompilation de la classe contenant le Switch
  • Un support de l’inférence de type pour les records pattern avec génériques

2. Projet Panama

Le projet Panama concerne principalement les interactions avec le système. Ces dernières versions de Java abordent deux évolutions déjà en incubation depuis un moment et qui sont proposées aujourd’hui en preview :

2.1 Foreign Function & Memory API

Il s’agit d’une API de bas niveau qui permet de :

  1. Accéder d’une manière sure, simple et efficace à la mémoire hors du tas (off heap memory)
  2. Invoquer des fonctions natives

Historiquement, nous avons deux alternatives concernant l’accès mémoire hors du tas :

  1. java.nio.ByteBuffer (non performante mais sure)
  2. sun.misc.Unsafe (non standard)

Concernant l’invocation du code natif, nous avons l’API JNI depuis java 1.1, toutefois, il s’agit d’une API complexe à mettre en œuvre.

Cette nouvelle API résulte de la fusion des deux JEPs suivants déjà proposés en incubation depuis les versions précédentes :

  1. JEP 370 et JEP 393 pour l’accès à la mémoire hors du tas depuis Java 14
  2. JEP 389 pour le linker depuis Java 16

Comme ces deux fonctionnalités sont étroitement liées, en effet dès qu’il s’agit d’invoquer du code natif nous aurons généralement besoin de définir des objets d’échange hors du tas, ils ont été fusionnés dans une même fonctionnalité. Cette dernière nous a été proposée en incubation en java 17 (JEP 412) et java 18 (JEP 419). Elle est maintenant proposée en preview en java 19 et 20. Par conséquent, le code est déplacé depuis le module jdk.incubator vers le module java.base ce qui exige un Refactoring pour les projets l’utilisant déjà depuis son incubation.

Cette API repose essentiellement sur les classes suivantes :

  • MemorySegment, MemoryAddress, SegmentAllocator : pour l’allocation de zones continues de la mémoire
  • MemoryLayout, VarHandle : pour l’accès et la manipulation des zones mémoires
  • MemorySession : pour contrôler l’allocation de la mémoire et sécuriser la mémoire dans le temps et dans l’espace
  • Linker, FunctionDescriptor, SymbolLookup : pour l’invocation des fonctions

2.2 Vector API

Cette API permet d’utiliser les SIMD (Single Instruction Multiple Data) sur les CPU qui supportent ce genre d’instruction. Cela permet d’exécuter la même instruction sur plusieurs valeurs simultanément, Nous parlons de vectorisation des instructions, en effet, cela permet à une instruction d’opérer sur un vecteur de donnée en même temps.

Aujourd’hui les SIMD sont supportés par les deux processeurs X64 et AArch64. Cette API déjà proposée en incubation depuis java 16 JEP 338, 17 JEP 414, 18 JEP 417 continue aujourd’hui en incubation en java 19 JEP 426 et java 20 JEP 438. Elle est incubée dans le module jdk.incubator.vector

A noter que cette API de bas niveau reste verbeuse et n’est pas complètement indépendante du CPU. Toutefois, l’utilisation de cette vectorisation des instructions offre généralement une meilleure performance.

3. Projet Loom

Le projet Loom concerne essentiellement les évolutions relatives à la programmation parallèle et concurrente. Dans le cadre de ce projet, ces deux dernières versions embarquent les évolutions suivantes.

  • Virtual Threads
  • Structured concurrency
  • Scoped values

3.1 Thread Virtual

Java, depuis sa toute première version, attribue à un thread Java un thread du système d’exploitation (OS), appelé thread de la plateforme. Cela permet de s’affranchir de la gestion de l’ordonnancement et du changement de contexte en les déléguant au système d’exploitation. Toutefois, ce modèle n’est pas optimal, en effet la création d’un thread de la plateforme est une opération couteuse en ressource notamment à cause de la taille par défaut de la pile ce qui limite le nombre de threads qui peuvent être utilisés. De plus avec le développement de l’informatique distribuée l’utilisation des threads s’est aujourd’hui intensifiée. Ainsi, très souvent les threads se trouve en attente d’une ou plusieurs opérations bloquantes lors d’une opération d’entrée/sortie ou d’un échange réseau.

Dans ce contexte, Java introduit dans sa version 19 JEP 425 et sa version 20 JEP 436 une évolution concernant les threads. Il s’agit d’une proposition d’un nouveau type de thread : thread virtuel. Ce nouveau type de thread est dit « léger » car il ne correspond pas à un thread de la plateforme mais plutôt un thread géré au niveau de la JVM. Ainsi nous aurons un lien M-N thread virtuel – thread de la plateforme. Le thread virtuel s’appuie sur le thread porteur pour les exécutions CPU et la JVM libère le thread porteur lors des opérations bloquantes.

Cette nouvelle API conserve le modèle existant « un thread par requête ». Elle étend les threads existants en héritant de la classe Thread ce qui offre une meilleure cohabitation avec le code existant et la réutilisation des fonctionnalités des threads existants (débogage, profilage…). Lors d’une opération bloquante la JVM sauvegarde l’état de la pile du thread et libère le thread de la plateforme, à la fin de l’opération bloquante, la JVM restore l’état du thread et affecte un thread porteur pour poursuivre le traitement. Cela se fait d’une façon transparente et complètement gérée par la JVM en arrière-plan.

Les APIs suivantes ont été ajoutées pour pouvoir bénéficier des Threads virtuels :

  • Thread.startVirtualThread()
  • Thread.Builder.ofVirtual
  • Thread.Builder.ofPlatform
  • Thread ::ofVirtual
  • Thread ::ofPlatform

A noter que l’utilisation habituelle des pools de threads pour optimiser le cout de leur création n’est plus nécessaire avec les threads virtuels, en effet, il n’y aucun intérêt de créer un pool de threads virtuels vu leur faible cout de création.

A noter également les restrictions suivantes concernant les threads légers :

  • Ils sont obligatoirement des threads démons
  • La priorité est obligatoirement Thread.NORM_PRIORITY
  • Stop, resume, suspend non supportées
  • Ils ne peuvent pas être associés à un thread group
  • getThreadGroup() renvoie un groupe fictif vide
  • getAllStackTraces() renvoie un Map contenant uniquement les threads de la plateforme

Les threads virtuels sont bien adaptés aux traitements concurrents n’ayant pas besoin d’une utilisation intensive de la CPU. Toutefois, si le traitement nécessite une forte utilisation de la CPU le thread virtuel reste longtemps attaché à son thread porteur limitant ainsi les avantages des threads virtuels. Ce genre de cas de figure peut se reproduire dans les deux cas suivants :

  • L’exécution d’un bloc de code synchronized : car aujourd’hui synchronized utilise les adresses mémoires et non les références. Pour pouvoir bénéficier des avantages des threads légers, nous pouvons envisager le remplacement des synchronized avec des ReentrantLock
  • L’utilisation des méthodes natives

Pour pouvoir détecter ce genre de situation, nous pouvons nous appuyer sur les deux options suivantes :

  • La propriété system jdk.tracePinnedThread (full or short)
  • L’évènement JFR: jdk.VirtualThreadPinned

3.2 Structured concurrency

Cette évolution en incubation nous propose un nouveau modèle de programmation. Par analogie au framework Fork/Join déjà existant depuis java 7 et qui repose sur les threads de la plateforme, ce nouveau modèle permet de standardiser et de simplifier le traitement concurrent via des threads virtuels. Il s’agit d’un module en incubation dans jdk.incubator.concurrent. Il nous permet d’écrire du code dans un style synchrone tout en étant exécuté par la JVM en asynchrone d’une manière complétement transparente, simplifiant ainsi le code et les tests au passage.

Nous distinguons la classe StructuredTaskScope permettant d’invoquer plusieurs threads légers :

public Facture getFacture(int idClient, long idCommande)
    throws InterruptedException, TimeoutException, ExecutionException {
  Facture result = null;
  try (var scope = new StructuredTaskScope<>()) {
    Future<Client> clientFuture = scope.fork(() -> this.getClient(idClient));
    Future<Commande> commandeFuture = scope.fork(() -> this.getCommande(idCommande));
    scope.joinUntil(Instant.now().plusSeconds(15));
    result = this.generateFacture(clientFuture.get(), commandeFuture.get());
  }
  return result;
}

La spécialisation ShutdownOnFailure qui interrompt le traitement dès la première exception rencontrée :

public Facture getFacture(int idClient, long idCommande)
    throws InterruptedException, TimeoutException, ExecutionException {
  Facture result = null;
  try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<Client> clientFuture = scope.fork(() -> this.getClient(idClient));
    Future<Commande> commandeFuture = scope.fork(() -> this.getCommande(idCommande));
    scope.joinUntil(Instant.now().plusSeconds(15));
    scope.throwIfFailed();
    result = this.generateFacture(clientFuture.get(), commandeFuture.get());
  }
  return result;
}

Et la version ShutdownOnSuccess qui retient le premier retour en succès et arrête le traitement des autres threads :

public Temperature getTemperature(String ville) throws InterruptedException, ExecutionException {
  Temperature result = null;
  try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Temperature>()) {
    serviceMeteos.forEach(f -> scope.fork(() -> f.getTemperature(ville)));
    scope.join();
    result = scope.result();
  }
  return result;
}

A noter également la possibilité de créer un custom scope.

3.3 Scoped values

La classe Threadlocal a été largement utilisée par le passé pour stocker les données au niveau du Thread (portée Thread), toutefois, comme elle est mutable, son utilisation induit très souvent des problèmes de gestion de ressources (fuite mémoire, données restant attachées au thread tout au long de son exécution si la méthode remove n’est pas appelée, etc..). Afin de pallier ce genre de problèmes la Scoped value permet de partager des données immutables scopées au niveau thread ayant une durée de vie limitée. Elle devrait à terme remplacer le Threadlocal. Elle peut être définie par exemple, comme suit :

public static final ScopedValue<String> VALUER = ScopedValue.newInstance();

Ainsi son utilisation s’effectue via la méthode Where que nous pouvons chainer pour affecter plusieurs Scoped values et par la suite invoquer le Runnable ou le Callable associé :

ScopedValue.where(VALUER, "ABC").run(() -> System.out.println(VALUER.get()));

String valeur = ScopedValue.where(VALUER, "test").call(TestScoped::traiter);

Au cours de l’exécution, la méthode isBound permet de vérifier la présence d’une Scoped value attachée et la méthode get renvoie sa valeur stockée, une NoSuchElementException sinon.

System.out.println(VALUER.isBound()? VALUER.get() : "non definie");

A noter que la Scoped value peut être imbriquée pour la même entrée avec plusieurs valeurs, une par niveau d’imbrication en complète isolation :

ScopedValue.where(VALUER, "valeur").run(() -> {
  System.out.println(VALUER.get());
  ScopedValue.where(VALUER, "autre-valeur")
      .run(() -> System.out.println(VALUER.get()));
  System.out.println(VALUER.get());
});

Les threads virtuels peuvent également partager une Scoped value si cette dernière est utilisée conjointement avec une StructuredTaskScope :

ScopedValue.where(VALUER, "valeur", () -> {
  try (var scope = new StructuredTaskScope<String>()) {
    System.out.println(VALUER.get());
    Future<String> first = scope.fork(TestScoped::traiter);
    Future<String> second = scope.fork(TestScoped::traiter);
    scope.joinUntil(Instant.now().plusSeconds(15));
    System.out.println(first.get());
    System.out.println(second.get());
  } catch (InterruptedException | TimeoutException | ExecutionException e) {
    throw new RuntimeException(e);
  }
});

4. Autres évolutions

D’autres évolutions remarquables embarquées par la version 19 et 20 de Java sont à considérer. Parmi lesquelles nous pouvons noter :

  • Un nouveau JEP 422 pour la prise en charge de l’architecture de jeu d’instruction Linux/RISC-V
  • Avec l’arrivée des threads virtuels quelques changements ont été effectués pour les prendre en compte. Les modifications suivantes par exemple ont été apportées à la classe thread group (à terme il est fort probable que les thread groups seraient dépréciés) :
    • Destroy : ignorée
    • isDaemon : renvoie le flag qui n’a plus d’utilité
    • isDestroyed : toujours false
    • setDaemon : positionne le flag mais sans aucun effet
  • Un nouveau formatage date/heure a été ajouté pour tenir en compte de la locale par défaut :
    • DateTimeFormatter::ofLocalizedPattern
    • DateTimeFormatterBuilder::appendLocalized
  • Des nouvelles fabriques pour créer des HashMap et des HashSets en précisant exactement la taille afin d’éviter les Resizes potentiels (en complément à l’option déjà existante avec la capacité initiale et le Load Factor) :
    • HashMap.newHashMap(int)
    • HashSet.newHashSet(int)
    • LinkedHashMap.newLinkedHashMap(int)
    • LinkedHashSet.newLinkedHashSet(int)
    • WeakHashMap.newWeakHashMap(int)
  • A partir de la version 20 la version 7 ne sera plus supportée par le compilateur.
  • Des améliorations apportées à l’affichage JShell
  • Des améliorations apportées à la recherche dans Javadoc
  • Un renforcement de la sécurité
    • En Java 19
      • Des performances de TLS améliorées
      • Un support pour accéder aux certificats de la machine locale sous Windows
      • Le support des CBT : pour l’authentification Negotiate/Kerberos sur HTTPS
    • En Java 20
      • Désactivation du TLS 1.0
      • Certains algorithmes tels que EDCH de TLS sont désormais désactivés par défaut
  • Des améliorations de la performance :
    • Amélioration du Garbage collector G1
    • Ajout des nouveaux Intrinsics pour x86_64 et aarch64 pour différents algorithmes.

Conclusion

Java poursuit son amélioration continue en adoptant un cycle de release court tout en mettant l’accent sur la sécurité et la performance et en apportant une meilleure expérience de développement. Reste à adopter ce rythme au niveau des différents projets utilisateurs et planifier des montées de versions régulières pour pouvoir tirer pleinement profit du langage et des évolutions apportées particulièrement en matière de sécurité et de performance.