4. Les évolutions dans les API

Les API du JDK connaissent régulièrement, au fur et à mesure des versions de Java, des ajouts et des évolutions.

4.1. Les évolutions dans l’API Stream

Depuis son introduction dans le JDK 8, l’API Stream a connu plusieurs améliorations.

4.1.1. Lab : l’opération Stream::toList()

L’obtention d’une List contenant des éléments issus des traitements d’un Stream est courant.

Avant Java 16, il faut utiliser l’API Collector en invoquant la méthode collect() en lui passant en paramètre une instance de type Collector. Le JDK en propose deux dans les fabriques de la classe Collectors :

  • Collectors.toList() : depuis Java 8, rassemble les éléments du Stream dans une List

        List<String> couleurs = Stream.of("rouge","vert","bleu").collect(Collectors.toList());

    Comme le précice la documentation, toList() renvoie un Collector qui accumule les éléments en entrée dans une nouvelle List. Il n’y a aucune garantie sur le type, la mutabilité, la sérialisation ou le thread safety de la List renvoyée. Si un contrôle plus poussé sur la liste renvoyée est nécessaire, il faut utiliser toCollection(Supplier), par exemple toCollection(ArrayList::new).

  • Collectors.toUnmodifiableList() : rassemble les éléments du Stream dans une List immuable, dans le JDK 22 l’implémentation est de type java.util.ImmutableCollections.ListN

        List<String> couleurs = Stream.of("rouge","vert","bleu").collect(Collectors.toUnmodifiableList());

Le JDK 16 propose l’opération terminale toList() qui rassemble les éléments du Stream dans une List immuable de type java.util.ImmutableCollections.ListN.

JDK

Standard en Java 16

L’opération Stream.toList() présente deux avantages :

  • réduction de la verbosité

  • requière moins de ressources car son implémentation est indépendante de l’interface Collector. Elle accumule les éléments du flux directement dans la liste.


Compléter la méthode obtenirListe() de la classe fr.sciam.workshop.javase.streamtolist.MainStreamToList pour obtenir une List des éléments d’un Stream.

  private static void obtenirListe() {
    List<String> couleurs = Stream.of("rouge","vert","bleu").toList();
    System.out.println(couleurs);
  }

Exécuter la classe MainStreamToList pour vérifier le résultat de l’exécution.

Obtenir liste
[rouge, vert, bleu]

Stream.toList() renvoie une List immuable : il n’est donc pas possible d’ajouter ou de supprimer des éléments de la List obtenue ni d’appliquer des opérations mutables sur la collection. Toute opération de type add(), sort(), … sur cette List lève une exception de type java.lang.UnsupportedOperationException.


Compléter la méthode modifierListe() de la classe MainStreamToList pour tenter d’ajouter un nouvel élément dans la List retournée par Stream::toList.

  private static void modifierListe() {
    List<String> couleurs = Stream.of("rouge","vert","bleu").toList();
    try {
      couleurs.add("jaune");
    } catch (UnsupportedOperationException uoe ) {
      uoe.printStackTrace(System.out);
    }
  }

Exécuter la classe MainStreamToList pour vérifier le résultat de l’exécution.

Modifier liste
java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
	at fr.sciam.workshop.javase.streamtolist.MainStreamToList.modifierListe(MainStreamToList.java:24)
	at fr.sciam.workshop.javase.streamtolist.MainStreamToList.main(MainStreamToList.java:12)
4.1.1.1. Le support des éléments null

Bien que Stream.toList() et Collectors.toUnmodifiableList() produisent une List non modifiable, leur support des éléments null est différent.

Collectors.toList() et Stream.toList() autorisent les éléments null, alors que Collectors.toUnmodifiableList() n’autorise pas les éléments null et lève une exception de type NullPointerException si des éléments du Stream sont null.


Compléter la méthode gererNull() de la classe MainStreamToList pour tenter d’obtenir des List des éléments d’un Stream, contenant des éléments null, obtenues en invoquant Collectors.toList(), Collectors.toUnmodifiableList() et Stream.toList().

  private static void gererNull() {
    List<Object> liste1 = Stream.of(null,null).collect(Collectors.toList());
    System.out.println("liste1 : " + liste1);

    try {
      List<Object> liste2 = Stream.of(null,null).collect(Collectors.toUnmodifiableList());
      System.out.println("liste2 : " + liste2);
    } catch (NullPointerException npe) {
      npe.printStackTrace(System.out);
    }

    List<Object> liste3 = Stream.of(null,null).toList();
    System.out.println("liste3 : " + liste3);
  }

Exécuter la classe MainStreamToList pour vérifier le résultat de l’exécution.

Gestion des null
liste1 : [null, null]
java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:220)
	at java.base/java.util.ImmutableCollections.listFromTrustedArray(ImmutableCollections.java:215)
	at java.base/java.util.ImmutableCollections$Access$1.listFromTrustedArray(ImmutableCollections.java:124)
	at java.base/java.util.stream.Collectors.lambda$toUnmodifiableList$6(Collectors.java:266)
	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:706)
	at fr.sciam.workshop.javase.streamtolist.MainStreamToList.gererNull(MainStreamToList.java:39)
	at fr.sciam.workshop.javase.streamtolist.MainStreamToList.main(MainStreamToList.java:15)
liste3 : [null, null]
4.1.1.2. Le support du polymorphisme

Une autre différence concerne le polymorphisme des éléments de la List retournée : collect(Collectors.toList()) et toList() se comportent différemment en ce qui concerne la compatibilité des sous-types des éléments des List retournées.

La signature de la méthode Stream::collect est : <R, A> R collect(Collector<? super T, A, R> collector). Comme le type générique des éléments traités est ? super T, il est possible d’utiliser un super type de T qui sera le type générique de la List retournée.

La signature de la méthode Stream::toList est : default List<T> toList(). Le type générique de la List retournée doit obligatoirement être celui du Stream.


Compléter la méthode gererPolymorphisme() de la classe MainStreamToList pour obtenir des List des éléments d’un Stream en invoquant Collectors.toList() et Stream.toList(), dont le type générique est un super-type des éléments.

  private static void gererPolymorphisme() {
    // Compile sans erreur
    List<CharSequence> couleursOk = Stream.of("rouge", "vert", "bleu").collect(Collectors.toList());
    List<Number> nombresOk = Stream.of(1, 2, 3).collect(Collectors.toList());

    // Erreur de compilation
    List<CharSequence> couleurs = Stream.of("rouge", "vert", "bleu").toList();
    List<Number> nombres = Stream.of(1, 2, 3).toList();

    System.out.println(couleurs);
    System.out.println(nombres);
  }

Le code de la classe ne se compile pas avec deux erreurs.

fr\sciam\workshop\javase\streamtolist\MainStreamToList.java:55: error: incompatible types: List<String> cannot be converted to List<CharSequence>
    List<CharSequence> couleurs = Stream.of("rouge", "vert", "bleu").toList();
                                                                           ^
fr\sciam\workshop\javase\streamtolist\MainStreamToList.java:56: error: incompatible types: List<Integer> cannot be converted to List<Number>
    List<Number> nombres = Stream.of(1, 2, 3).toList();
                                                     ^
2 errors

Il existe au moins 2 solutions pour corriger l’erreur de compilation :

  • convertir explicitement vers le type cible dans le pipeline d’opération du Stream

  • autoriser les sous-types dans la collection retournée


Compléter la méthode gererPolymorphisme() de la classe MainStreamToList pour mettre en œuvre ces deux solutions afin de corriger les erreurs de compilation.

  private static void gererPolymorphisme() {
    // Compile sans erreur
    List<CharSequence> couleursOk = Stream.of("rouge", "vert", "bleu").collect(Collectors.toList());
    List<Number> nombresOk = Stream.of(1, 2, 3).collect(Collectors.toList());

    // Erreur de compilation
    // List<CharSequence> couleurs = Stream.of("rouge", "vert", "bleu").toList();
    // List<Number> nombres = Stream.of(1, 2, 3).toList();
    List<? extends CharSequence> couleurs = Stream.of("rouge", "vert", "bleu").toList();
    List<Number> nombres = Stream.of(1, 2, 3).map(Number.class::cast).toList();

    System.out.println(couleurs);
    System.out.println(nombres);
  }

Exécuter la classe MainStreamToList pour vérifier le résultat de l’exécution.

Gestion du polymorphisme
[rouge, vert, bleu]
[1, 2, 3]

Il est donc important d’être attentif lors de la migration de code de Java 8 vers Java 10 ou Java 16. L’utilisation de Stream.toList() à la place de Collectors.toList() ou Collectors.toUnmodifiableList() n’est pas sans conséquence potentielle.

Le tableau ci-dessous résume les différences entre Stream.collect(Collectors.toList()), Stream.collect(Collectors.toUnmodifiableList()) et Stream.toList() :

Methode Garantie d’immutabilité Autorise les null

collect(toList())

Non

Oui

collect(toUnmodifiableList())

Oui

Non

toList()

Oui

Oui

 

4.1.2. Lab : l’opération Stream::mapMulti

Initialement, l’API Stream propose l’opération intermédiaire flatMap() pour permettre de transformer un élément du Stream vers zéro, un ou plusieurs éléments en lui appliquant une Function.

JDK

Standard en Java 16

Le JDK 16 propose l’opération intermédiaire mapMulti() de l’interface Stream dont l’objectif est similaire à la méthode flatMap() : elle applique une transformation aux éléments du Stream générant pour chacun 0 à N éléments et les aplatit dans un nouveau Stream en résultat.

La méthode mapMulti() propose une autre façon de le faire, où l’élément est fourni à un Consumer. La signature de la méthode est :

default <R> Stream<R> mapMulti(BiConsumer<? super T,? super Consumer<R>> mapper)

Le type R permet de préciser le type des éléments résultant de la transformation.

La méthode mapMulti() attend en paramètre un BiConsumer dont l’implémentation va appliquer une transformation sur l’élément en cours de traitement pour générer zéro à N éléments en résultats. Ces éléments sont consommés par le Consumer fourni en paramètre pour stocker les éléments dans un buffer.

Elle retourne, pour chaque élément du Stream, un nouveau Stream qui contient zéro ou plusieurs éléments de type R.

4.1.2.1. L’utilisation de mapMulti() pour une transformation 1-1


Modifier la méthode transformer1a1() de la classe fr.sciam.workshop.javase.mapmulti.MainMapMulti` pour utiliser mapMulti() sur un Stream à partir des fruits pour les convertir en majuscules.

  private static void transformer1a1() {
    System.out.println("Fruits en maj : ");
    fruits.stream()
          .mapMulti((fruit, mapper) -> {
            mapper.accept(fruit.toUpperCase());
          })
          .forEach(System.out::println);
  }

Exécuter la classe MainMapMulti pour vérifier le résultat de l’exécution.

mapMulti 1 a 1
Fruits en maj :
ABRICOT
CITRON
KIWI
LITCHI
4.1.2.2. L’utilisation de mapMulti() pour une transformation 0-1


Modifier la méthode transformer0a1() pour utiliser mapMulti() sur un Stream à partir des fruits afin de ne conserver que ceux ayant 6 lettres et convertis en majuscules.

  private static void transformer0a1() {
    System.out.println("Fruits à 6 lettres en maj : ");
    fruits.stream()
          .mapMulti((fruit, mapper) -> {
            if (fruit.length() == 6)
              mapper.accept(fruit.toUpperCase());
          })
          .forEach(System.out::println);
  }

Exécuter la classe MapMulti pour vérifier le résultat de l’exécution.

mapMulti 0 a 1
Fruits à 6 lettres en maj :
CITRON
LITCHI
cette opération aurait pu être réalisée en utilisant les opérations filter() et map().
4.1.2.3. L’utilisation de mapMulti() pour une transformation 1-N


Modifier la méthode transformer1aN() pour utiliser mapMulti() sur un Stream à partir des fruits afin de produire en résultat le fruit et le fruit concaténé à " bien mûr".

  private static void transformer1aN() {
    System.out.println("Fruits avec bien mûr : ");
    fruits.stream()
          .mapMulti((fruit, mapper) -> {
            mapper.accept(fruit);
            mapper.accept(fruit + " bien mûr");
          })
          .forEach(System.out::println);
  }

Exécuter la classe MainMapMulti pour vérifier le résultat de l’exécution.

mapMulti 1 a N
Fruits avec bien mûr :
Abricot
Abricot bien mûr
Citron
Citron bien mûr
Kiwi
Kiwi bien mûr
Litchi
Litchi bien mûr
4.1.2.4. L’utilisation de mapMulti() pour une transformation vers un autre type

Dans les transformations précédentes, le type des instances retournées est Object car le compilateur n’est pas mesure d’inférer le type à cause de la signature de la méthode mapMulti(). Si le type n’est pas Object, il faut préciser le type générique à l’invocation de la méthode.


Modifier la méthode transformerVersAutreType() pour utiliser mapMulti() sur un Stream à partir des fruits afin de produire une Liste<Tarte> avec chacun des fruits.

  private static void transformerVersAutreType() {
    record Tarte(String fruit) {
    }

    System.out.println("Tartes aux fruits : ");
    List<Tarte> tartes = fruits.stream()
        .mapMulti((fruit, mapper) -> {
          mapper.accept(new Tarte(fruit));
        })
        .collect(Collectors.toList());
    System.out.println(tartes);
  }

Le code ne compile pas avec l’erreur « Type mismatch: cannot convert from List<Object> to List<Tarte> »

Modifier la méthode transformerVersAutreType() pour ajouter le type générique avant l’invocation de la méthode mapMulti().

  private static void transformerVersAutreType() {
    record Tarte(String fruit) {
    }

    System.out.println("Tartes aux fruits : ");
    List<Tarte> tartes = fruits.stream()
        .<Tarte>mapMulti((fruit, mapper) -> {
          mapper.accept(new Tarte(fruit));
        })
        .collect(Collectors.toList());
    System.out.println(tartes);
  }

Exécuter la classe MainMapMulti pour vérifier le résultat de l’exécution.

mapMulti vers autre type
Tartes aux fruits :
[Tarte[fruit=Abricot], Tarte[fruit=Citron], Tarte[fruit=Kiwi], Tarte[fruit=Litchi]]

L’opération mapMulti() peut offrir de meilleures performances dans certaines circonstances. Les traitements internes de mapMulti() permettent d’éviter la création d’un nouveau Stream pour chaque élément traité.

L’opération flatMap() peut être utilisée comme une opération à usage général alors que mapMulti() est une opération à utiliser dans des cas d’usage spécifiques.

Trois méthodes de l’interface Stream sont dédiées aux types primitifs int, long et double sont proposées : mapMultiToInt(), mapMultiToLong() et mapMultiToDouble().

4.1.3. Lab : les Stream Gatherers

L’API Stream fournit de nombreuses opérations intermédiaires, telles que map(), filter(), sorted(), distinct(), …​ permettant de répondre à bon nombre de cas d’usages.

Malgré cette richesse, cela ne couvre pas tous les besoins. Le concept de "gatherer" a pour but de définir ses propres opérations intermédiaires par le biais de l’opération intermédiaire Stream::gather qui accepte une instance de l’interface java.util.stream.Gatherer. Ceci de manière analogue à l’opération terminale Stream::collect qui accepte une instance de l’interface java.util.stream.Collector.

JDK

Première preview en Java 22 (JEP 461)
Seconde preview en Java 23 (JEP 473)

Standard en Java 24

JEP

485: Stream Gatherers

4.1.3.1. L’interface java.util.stream.Gatherer

Un Gatherer a pour but de représenter quasiment tout type d’opération intermédiaire, et il peut être :

  • exécuté en séquentiel ou en parallèle

  • stateless ou stateful

  • court-circuit (peut s’arrêter avant la fin) ou greedy (traite forcément tous les éléments)

L’interface Gatherer définit 4 opérations qui fonctionnent de concert, selon les besoins :

  • default Supplier<A> initializer() : optionnelle, permet de fournir un objet pour stocker un état privé qui pourra être utilisé lors de la consommation des éléments du flux.

  • Integrator<A, T, R> integrator() : fournit une instance d'Integrator, interface fonctionnelle qui définit la façon dont sont intégrés les éléments du flux entrant vers le flux de sortie, en tenant éventuellement compte de l’état privé.

  • default BinaryOperator<A> combiner() : optionnelle, combine deux états dans le cas d’un Stream parallèle.

  • default BiConsumer<A, Downstream<? super R>> finisher() : optionnelle, invoquée lorsqu’il n’y a plus d’éléments à traiter. Elle peut utiliser l’état privé pour éventuellement, émettre des éléments supplémentaires vers le flux de sortie.

4.1.3.2. Les fabriques de Gatherer

L’interface Gatherer fournit plusieurs fabriques permettant d’obtenir une instance de Gatherer à partir d’une implémentation de tout ou partie des quatre opérations. La fourniture d’une implémentation d’un integrator est le minimum requis, les autres opérations étant quant à elles optionnelles.

Cette instance de Gatherer peut être :

  • parallélisable via les surcharges de Gatherer::of

  • séquentielle via les surcharges de Gatherer::ofSequential

ofSequential() ne propose pas de surcharge faisant intervenir de combiner car cela est réservé aux Gatherer parallélisables.

4.1.3.3. La définition d’un Integrator

Il est possible d’émettre ou non un ou plusieurs éléments vers le flux de sortie, tout comme d’interrompre prématurément le traitement avant d’avoir atteint la fin des éléments. La signature de la méthode est la suivante :

boolean integrate(A state, T element, Downstream<? super R> downstream)

Le retour de type booléen indique s’il faut continuer à traiter de nouveaux éléments ou court-circuiter.


Il est possible de définir avec un Gatherer une des opérations intermédiaires déjà existante de Stream, par exemple Stream::map. Compléter la méthode definirGathererMapDouble() de la classe fr.sciam.workshop.javase.gatherers.MainGatherers afin qu’elle retourne une implémentation de Gatherer qui renvoie le double d’un nombre entier, en utilisant la fabrique Gatherer.of(Integrator<Void, T, R> integrator).

  private static Gatherer<Integer, Void, Integer> definirGathererMapDouble() { (1)
    return Gatherer.of((state, element, downstream) -> {
      downstream.push(element * 2);
      return true;
    });
  }
1 Le type générique Void est utilisé car on ne gère pas d’état privé

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Map double
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 2, 2, 4, 6, 10, 16, 26, 42, 2, 0]

Il est possible de ne pas émettre tous les éléments du flux d’entrée.


Compléter la méthode definirGathererMapGrandsDoubles() afin qu’elle retourne une implémentation de Gatherer qui renvoie le double d’un nombre entier, mais ce uniquement pour les nombres plus grands que 10.

  private static Gatherer<Integer, Void, Integer> definirGathererMapGrandsDoubles() {
    return Gatherer.of((state, element, downstream) -> {
      if (element > 10) {
        downstream.push(element * 2);
      }
      return true;
    });
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Map grands doubles
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [26, 42]

Il est également possible de terminer prématurément, via un "court-circuit" en renvoyant false lorsque l’on souhaite s’arrêter.


Compléter la méthode definirGathererCourtCircuit() afin qu’elle retourne une implémentation de Gatherer qui transmet les éléments du flux d’entrée sans modification, mais qui s’arrête de traiter les éléments dès lors qu’une valeur supérieure à 10 est rencontrée.

  private static Gatherer<Integer, Void, Integer> definirGathererCourtCircuit() {
    return Gatherer.of((state, element, downstream) -> {
      if (element > 10) {
        return false;
      } else {
        downstream.push(element);
        return true;
      }
    });
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Court-circuit
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 1, 1, 2, 3, 5, 8]
4.1.3.4. La gestion d’un état privé avec un initializer

La méthode Gatherer::initializer permet de fournir un objet pour gérer un état privé durant certains traitements du Gatherer, selon les besoins.


Compléter la méthode definirGathererSequentielAvecEtat() afin qu’elle retourne une implémentation de Gatherer séquentiel qui transmet les éléments du flux d’entrée sans modification, mais qui s’arrête après avoir traité 5 éléments. Utiliser la fabrique <T, A, R> Gatherer<T, A, R> ofSequential(Supplier<A> initializer, Integrator<A, T, R> integrator).

  private static Gatherer<Integer, ?, Integer> definirGathererSequentielAvecEtat() {
    return Gatherer.ofSequential(
      AtomicInteger::new,
      (state, element, downstream) -> {
        downstream.push(element);
        return state.incrementAndGet() < 5;
      }
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Séquentiel avec état
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 1, 1, 2, 3]
4.1.3.5. L’utilisation d’un finisher

Le finisher permet de réaliser des traitements une fois tous les éléments du flux d’entrée consommés, pouvant impliquer l’état privé ainsi que le Downstream fournis en paramètres.


Un cas d’usage d’un Gatherer peut être de regrouper les éléments du flux d’entrée vers le flux de sortie en lots, par exemple deux par deux. La définition d’un finisher est nécessaire, car on voudra émettre le dernier élément seul quand le nombre d’éléments du flux d’entrée est impair.

Compléter la méthode definirGathererGroupeDeuxParDeux() pour définir un Gatherer séquentiel qui groupe les éléments deux par deux. Utiliser la fabrique Gatherer.ofSequential() qui accepte un initializer, un integrator et un finisher.

  private static Gatherer<Integer, ?, List<Integer>> definirGathererGroupeDeuxParDeux() {
    return Gatherer.ofSequential(
      () -> new ArrayList<Integer>(),
      (state, element, downstream) -> {
        state.add(element);
        if (state.size() == 2) {
          downstream.push(new ArrayList<>(state));
          state.clear();
        }
        return true;
      },
      (state, downstream) -> {
        if (!state.isEmpty()) {
          downstream.push(state);
        }
      }
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Groupe deux par deux
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [[0, 1], [1, 2], [3, 5], [8, 13], [21, 1], [0]]
4.1.3.6. L’utilisation d’un combiner

Le Gatherer peut être défini comme étant parallélisable, auquel cas, il faut fournir un combiner qui définit la façon dont sont combinés les états des lots parallèles entre eux.

Pour cela, la fabrique Gatherer.of() qui accepte les quatre opérations : initializer, integrator, combiner et finisher est adaptée pour des traitements parallélisés. Comme c’est la seule qui accepte un combiner, il est possible de fournir des opérations par défaut pour les opérations qui ne nécessitent pas de comportement spécifique :

  • Gatherer.defaultInitializer() pour un gatherer sans état (stateless)

  • Gatherer.defaultCombiner() pour un gatherer qui ne peut être évalué que séquentiellement (autrement une UnsupportedOperationException est levée)

  • Gatherer.defaultFinisher() pour un gatherer qui ne réalise pas de traitement final


Compléter la méthode definirGathererParallelePlusGrandElement() pour définir un Gatherer parallélisable qui permette de trouver le plus grand élément d’un flux d’entiers.

Utiliser la fabrique Gatherer.of() qui accepte les quatre opérations : initializer, integrator, combiner et finisher.

  private static Gatherer<Integer, ?, Integer> definirGathererParallelePlusGrandElement() {

    class Etat {
      Integer max = null;
    }

    return Gatherer.of(

      // Initializer
      Etat::new,

      // Integrator
      (state, element, downstream) -> {

        // On sauvegarde l'élément s'il est le plus grand connu
        if (state.max == null || element > state.max) {
          state.max = element;
        }

        return true;
      },

      // Combiner : on renvoie le plus grand des deux éléments
      (e1, e2) ->  (e1.max > e2.max) ? e1 : e2,

      // Finisher
      (state, downstream) -> downstream.push(state.max)
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Parallèle plus grand élément
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [21]
4.1.3.7. Les optimisations

L’API fournit quelques outils pour optimiser le traitement des streams utilisant des gatherers.

4.1.3.7.1. La méthode Downstream::isRejecting

L’interface Downstream fournit la méthode boolean isRejecting() qui indique si le downstream continue d’accepter de nouveaux éléments ou non. Comme son nom l’indique, si l’invocation de la méthode renvoie true, le downstream n’accepte plus de nouvel élément.

Cette information peut être exploitée par un gatherer pour s’éviter de réaliser des traitements qui s’avéreraient inutiles, puisque le downstream rejette tout nouvel élément qui lui serait transmis.


Refactorer la méthode definirGathererMapDouble() pour faire un appel préalable à Downstream::isRejecting dans l’integrator, et transmettre le résultat au downstream seulement s’il continue d’accepter des éléments.

  private static Gatherer<Integer, Void, Integer> definirGathererMapDouble() {
    return Gatherer.of((state, element, downstream) -> {
      if (!downstream.isRejecting()) {
        downstream.push(element * 2);
      }
      return true;
    });
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Map double
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 2, 2, 4, 6, 10, 16, 26, 42, 2, 0]
4.1.3.7.2. Le retour de la méthode Downstream::push

La méthode Downstream::push renvoie un booléen : si sa valeur est false, alors le downstream n’accepte plus de nouveaux éléments. On pourra donc utiliser cette valeur et la renvoyer dans notre integrator afin de propager l’information à l’upstream.


Refactorer la méthode definirGathererMapDouble() pour exploiter le retour de la méthode Downstream::push.

  private static Gatherer<Integer, Void, Integer> definirGathererMapDouble() {
    return Gatherer.of((state, element, downstream) -> {
      if (!downstream.isRejecting()) {
        return downstream.push(element * 2);
      }
      return true;
    });
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Map double
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 2, 2, 4, 6, 10, 16, 26, 42, 2, 0]

On peut retenir le fonctionnement suivant :

  • un nouveau downstream est toujours initialisé dans un état qui accepte un nouvel élément

  • un downstream peut passer de l’état "non-rejecting" à "rejecting", une seule fois, et uniquement dans ce sens

  • un downstream ne peut changer d’état que lorsqu’un élément lui est envoyé via la méthode push()

4.1.3.7.3. La fabrique Integrator::ofGreedy

L’interface Integrator fournit la fabrique ofGreedy() permettant d’obtenir une instance de type Integrator conçue pour consommer l’intégralité de ses données d’entrée (si l’en est que le downstream continue d’accepter des éléments). Elle accepte et renvoie une instance de type Greedy qui étend simplement Integrator : interface Greedy<A, T, R> extends Integrator<A, T, R> {}.

Outre la sémantique explicite qu’apporte cette fabrique (l’integrator n’est pas court-circuit), l’API peut utiliser cette information pour réaliser des optimisations lors de l’exécution du stream. En effet, les streams utilisent des java.util.Spliterator pour parcourir les éléments de la source de données. Leur nom vient de split (découper) et iterator (itérateur), car ils permettent non seulement d’itérer sur les éléments, mais aussi de diviser la source en plusieurs sous-parties pour le traitement parallèle. Lorsque l’integrator est greedy, on sait que l’on doit traiter tous les éléments donc le stream peut utiliser Spliterator::forEachRemaining qui sera plus optimisé pour un parcours complet. Dans l’autre cas, le stream utilisera Spliterator::tryAdvance car l’on ne sait pas si et quand le parcours se termine prématurément.


Refactorer la méthode definirGathererMapDouble() en utilisant la fabrique Integrator::ofGreedy.

  private static Gatherer<Integer, Void, Integer> definirGathererMapDouble() {
    return Gatherer.of(
      Integrator.ofGreedy((state, element, downstream) -> {
        if (!downstream.isRejecting()) {
          return downstream.push(element * 2);
        }
        return true;
      })
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Map double
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 2, 2, 4, 6, 10, 16, 26, 42, 2, 0]
4.1.3.8. La classe Gatherers

Un certain nombre de fabriques pour des implémentations de Gatherer répondant à des usages courants sont disponibles dans la classe java.util.stream.Gatherers.

4.1.3.8.1. La fabrique Gatherers::fold

fold(Supplier<R> initial, BiFunction<? super R, ? super T, ? extends R> folder) renvoie un Gatherer séquentiel de type "many-to-one" qui agrège les données du flux de manière incrémentale et renvoie le résultat une fois tous les éléments du flux entrant consommés. Le type de l’état initial et du flux de sortie sont identiques (<R>).


Compléter la méthode definirGathererFold() afin qu’elle renvoie un Gatherer qui concatène tous les éléments du flux d’entrée dans une chaîne de caractères.

  private static Gatherer<Integer, ?, String> definirGathererFold() {
    return Gatherers.fold(
      // Etat initial
      () -> "",

      // Fonction d'agrégation
      (etat, nombre) -> etat + nombre
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Gatherers::fold
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0112358132110]
4.1.3.8.2. La fabrique Gatherers::scan

scan(Supplier<R> initial, BiFunction<? super R, ? super T, ? extends R> scanner renvoie un Gatherer séquentiel de type "one-to-one" qui applique la fonction fournie à l’état actuel et à l’élément courant pour produire l’élément suivant, qu’il transmet en sortie.


Compléter la méthode definirGathererScan() afin qu’elle renvoie un Gatherer qui concatène l’état avec l’élément courant.

  private static Gatherer<Integer, ?, String> definirGathererScan() {
    return Gatherers.scan(
      () -> "",
      (etat, nombre) -> etat + nombre
    );
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Gatherers::scan
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 01, 011, 0112, 01123, 011235, 0112358, 011235813, 01123581321, 011235813211, 0112358132110]
4.1.3.8.3. Les fabriques Gatherers::windowFixed et Gatherers::windowSliding

windowFixed(int windowSize) renvoie un Gatherer séquentiel de type "many-to-many" qui regroupe les éléments d’entrée dans des listes de la taille fournie et transmet les listes en sortie lorsqu’elles sont pleines ou qu’il n’y a plus d’éléments. Cette fabrique aurait pu être utilisée pour définir notre Gatherer qui rassemble les éléments deux par deux.


Compléter la méthode definirGathererWindowFixed() afin qu’elle renvoie un Gatherer qui regroupe les éléments trois par trois.

  private static Gatherer<Integer, ?, List<Integer>> definirGathererWindowFixed() {
    return Gatherers.windowFixed(3);
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Gatherers::windowFixed
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [[0, 1, 1], [2, 3, 5], [8, 13, 21], [1, 0]]

windowSliding(int windowSize) renvoie un Gatherer du même type qui regroupe les éléments d’entrée dans des listes de la taille fournie. Après la première fenêtre, chaque liste suivante est créée à partir d’une copie de la précédente en supprimant le premier élément et en ajoutant l’élément suivant à partir du flux d’entrée.


Compléter la méthode definirGathererWindowSliding() afin qu’elle renvoie un Gatherer qui regroupe les éléments dans une fenêtre glissante de trois éléments.

  private static Gatherer<Integer, ?, List<Integer>> definirGathererWindowSliding() {
    return Gatherers.windowSliding(3);
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Gatherers::windowSliding
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [[0, 1, 1], [1, 1, 2], [1, 2, 3], [2, 3, 5], [3, 5, 8], [5, 8, 13], [8, 13, 21], [13, 21, 1], [21, 1, 0]]
4.1.3.8.4. La fabrique Gatherers::mapConcurrent

mapConcurrent(final int maxConcurrency, final Function<? super T, ? extends R> mapper) renvoie un Gatherer "one-to-one" qui invoque la fonction fournie sur chaque élément du flux en parallèle avec des threads virtuels, dont le nombre maximal est défini par maxConcurrency.


Consulter la classe fr.sciam.workshop.javase.gatherers.Guichet pour information puis compléter la méthode definirGathererMapConcurrent() afin de réaliser des accès concurrents à la méthode Guichet::acceder, avec une limite de deux opérations simultanées.

  private static Gatherer<Integer, ?, Integer> definirGathererMapConcurrent() {
    return Gatherers.mapConcurrent(
      2, (1)
      Guichet::acceder
    );
  }
1 Nombre maximal d’accès simultanés

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Gatherers::mapConcurrent
Accès au guichet de #1 / (2 accès simultané(s))
Accès au guichet de #0 / (1 accès simultané(s))
Accès au guichet de #1 / (2 accès simultané(s))
Accès au guichet de #2 / (2 accès simultané(s))
Accès au guichet de #3 / (1 accès simultané(s))
Accès au guichet de #5 / (2 accès simultané(s))
Accès au guichet de #8 / (1 accès simultané(s))
Accès au guichet de #13 / (2 accès simultané(s))
Accès au guichet de #21 / (1 accès simultané(s))
Accès au guichet de #1 / (2 accès simultané(s))
Accès au guichet de #0 / (1 accès simultané(s))
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0]

Constater qu’il n’y a jamais eu plus de deux accès concurrents.

4.1.3.8.5. La composition de Gatherer

Les gatherers supportent la composition via la méthode andThen(Gatherer) qui joint deux gatherers où le premier produit des éléments que le second peut consommer.

Ainsi sémantiquement :

source.gather(a).gather(b).gather(c).collect(…​)

Est équivalent à :

source.gather(a.andThen(b).andThen(c)).collect(…​)


Compléter la méthode composerGatherers() afin qu’elle renvoie un Gatherer qui est la composition des deux gatherers renvoyés par definirGathererMapDouble() et definirGathererWindowSliding();

  private static Gatherer<Integer, ?, List<Integer>> composerGatherers() {

    var g1 = definirGathererMapDouble();
    var g2 = definirGathererWindowSliding();

    return g1.andThen(g2);
  }

Exécuter la classe MainGatherers et vérifier qu’elle affiche dans la console :

Composition
[0, 1, 1, 2, 3, 5, 8, 13, 21, 1, 0] -> [[0, 2, 2], [2, 2, 4], [2, 4, 6], [4, 6, 10], [6, 10, 16], [10, 16, 26], [16, 26, 42], [26, 42, 2], [42, 2, 0]]

4.2. Les nouvelles API

De nouvelles API ou compléments dans des API existantes ont été ajoutées.

4.2.1. Lab : l’API Sequenced Collections

L’API Collection souffre de quelques manques concernant les collections qui ont un ordre de parcours :

  • il n’existe pas de super-type commun,

  • il n’existe pas de méthode uniforme pour accéder au premier et au dernier élément d’une collection, ou pour parcourir ses éléments dans l’ordre inverse.

Trois nouvelles interfaces (SequencedCollection, SequencedSet et SequencedMap) sont intégrées dans la hiérarchie des types existants de l’API Collections afin de résoudre ces problèmes. Leur implémentation est un compromis qui privilégie la rétrocompatibilité.

JDK

Standard en Java 21

JEP

431: Sequenced collections

4.2.1.1. L’interface SequencedCollection

L’interface SequencedCollection hérite de l’interface Collection.

Elle concerne un type de collection qui représente une séquence d’éléments possédant un ordre de parcours défini et simplifie la gestion des données ordonnées d’une collection, en offrant un accès facile et uniforme pour manipuler les éléments aux deux extrémités et en fournissant une méthode pour obtenir une vue de la collection dans l’ordre inverse.


Compléter la méthode sequencedCollection() de la classe MainSequencedCollections pour utiliser les méthodes de l’interface SequencedCollection pour ajouter, obtenir et retirer des éléments dans une List.

  private static void sequencedCollection() {
    List<Integer> nombres = new ArrayList<>();
    nombres.add(2);
    nombres.addFirst(1);
    nombres.addLast(3);
    System.out.println(nombres);
    System.out.println(nombres.getFirst());
    System.out.println(nombres.getLast());
    nombres.removeLast();
    nombres.removeFirst();
    System.out.println(nombres);
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Sequenced collection
[1, 2, 3]
1
3
[2]

L’interface SequencedCollection propose la méthode reversed() qui renvoie une SequencedCollection pour obtenir une vue inversée des éléments de la collection d’origine. L’ordre de parcours des éléments dans la vue renvoyée est l’inverse de l’ordre de parcours des éléments dans cette collection.

Les modifications apportées à la collection sous-jacente peuvent ou non être visibles dans la vue inversée, en fonction de l’implémentation. Si elles sont autorisées, les modifications apportées à la vue modifient la collection d’origine.


Compléter la méthode reversedSequencedCollection() de la classe MainSequencedCollections pour manipuler une collection et sa collection inverse.

  private static void reversedSequencedCollection() {
    var elements = new ArrayList<>(Arrays.asList("1", "2", "3", "4"));
    System.out.println("elements : " + elements);
    var inverse = elements.reversed();
    System.out.println("inverse  : " + inverse);

    elements.add(2, "5");
    System.out.println("\nelements : " + elements);
    System.out.println("inverse  : " + inverse);

    inverse.add(1, "6");
    System.out.println("\ninverse  : " + inverse);
    System.out.println("elements : " + elements);
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Reversed sequenced collection
elements : [1, 2, 3, 4]
inverse  : [4, 3, 2, 1]

elements : [1, 2, 5, 3, 4]
inverse  : [4, 3, 5, 2, 1]

inverse  : [4, 6, 3, 5, 2, 1]
elements : [1, 2, 5, 3, 6, 4]
4.2.1.2. L’interface SequencedSet

Un SequencedSet peut être considéré soit comme un Set qui possède également un ordre de parcours bien défini, soit comme une SequencedCollection qui possède des éléments uniques.

L’interface SequencedSet<E> hérite des interfaces Set<E> et SequencedCollection<E>. Elle n’offre aucune méthode supplémentaire, mais contient une redéfinition covariante de la méthode reversed() qui renvoie une instance de type SequenceSet<E>.


Compléter la méthode sequencedSet() de la classe MainSequencedCollections pour utiliser les méthodes de l’interface SequencedSet pour ajouter et obtenir des éléments dans une LinkedHashSet.

  private static void sequencedSet() {
    var nombres = new LinkedHashSet<>(List.of(2, 3, 4));
    System.out.println(nombres);

    Integer premier = nombres.getFirst();
    Integer dernier = nombres.getLast();
    System.out.println(premier);
    System.out.println(dernier);

    nombres.addFirst(1);
    nombres.addLast(5);
    System.out.println(nombres);

    System.out.println(nombres.reversed());
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

SequencedSet
[2, 3, 4]
2
4
[1, 2, 3, 4, 5]
[5, 4, 3, 2, 1]
4.2.1.3. L’interface SequencedMap

L’interface SequencedMap est une interface spécialisée conçue pour les Map dont les clés, les valeurs et les entrées ont un ordre de parcours défini tout comme LinkedHashMap, qui introduit une nouvelle approche de la gestion des données ordonnées dans les Maps.

L’interface SequencedMap<K, V> hérite de l’interface Map<K, V> et fournit des méthodes pour accéder à ses entrées et les manipuler en tenant compte de leur ordre de parcours :

  • Map.Entry<K, V> firstEntry() / lastEntry() : renvoyer la première / dernière entrée de la Map

  • Map.Entry<K, V> pollFirstEntry() / pollLastEntry() : supprimer et renvoyer la première / dernière entrée de la Map

  • Map.Entry<K, V> putFirst(K k, V v) / putLast(K k, V v) : insérer une entrée au début / à la fin de la Map

  • SequencedMap<K, V> reversed() : obtenir une vue inversée de la Map

  • SequencedSet<Map.Entry<K,V>> sequencedEntrySet() : renvoyer un SequencedSet des entrées de la Map, en conservant l’ordre de parcours

  • SequencedSet<K> sequencedKeySet() : renvoyer un SequencedSet des clés de la Map, en conservant l’ordre de parcours

  • SequencedCollection<V> sequencedValues() : renvoyer une SequencedCollection des valeurs Map, en conservant l’ordre de parcours


Compléter la méthode sequencedMap() de la classe MainSequencedCollections pour utiliser les méthodes de l’interface SequencedMap pour ajouter et obtenir des éléments dans une LinkedHashMap.

  private static void sequencedMap() {
    SequencedMap<Integer, String> map = new LinkedHashMap<>();

    map.put(1, "Valeur1");
    map.put(2, "Valeur2");
    map.put(3, "Valeur3");

    System.out.println(map);

    System.out.println(map.firstEntry());
    System.out.println(map.lastEntry());

    Map.Entry<Integer, String> premier = map.pollFirstEntry();
    Map.Entry<Integer, String> dernier = map.pollLastEntry();

    System.out.println("\n"+premier);
    System.out.println(dernier);
    System.out.println(map);

    map.putFirst(1, "Valeur1");
    map.putLast(3, "Valeur3");

    System.out.println("\n"+map);

    System.out.println("\n"+map.reversed());
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

SequencedMap
{1=Valeur1, 2=Valeur2, 3=Valeur3}
1=Valeur1
3=Valeur3

1=Valeur1
3=Valeur3
{2=Valeur2}

{1=Valeur1, 2=Valeur2, 3=Valeur3}

{3=Valeur3, 2=Valeur2, 1=Valeur1}

Les objets retournés par les méthodes firstEntry(), lastEntry(), pollFirstEntry() et pollLastEntry() de l’interface SequencedMap ne prennent pas en charge la mutation de la Map sous-jacente en utilisant leur méthode optionnelle setValue(). L’invocation de la méthode setValue() dans ce contexte lève une exception de type UnsupportedOperationException.


Compléter la méthode majSequencedMap() de la classe MainSequencedCollections pour tenter de modifier le premier élément d’une LinkedHashMap.

  private static void majSequencedMap() {
    SequencedMap<Integer, String> map = new LinkedHashMap<>();
    map.put(1, "Valeur1");
    map.put(2, "Valeur2");
    map.put(3, "Valeur3");

    try  {
    Entry<Integer, String> entry = map.firstEntry();
    entry.setValue("Valeur1 modifiee");
    } catch (Exception e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Maj SequencedMap
java.lang.UnsupportedOperationException: not supported
	at java.base/jdk.internal.util.NullableKeyValueHolder.setValue(NullableKeyValueHolder.java:126)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.majSequencedMap(MainSequencedCollections.java:118)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.main(MainSequencedCollections.java:19)
4.2.1.4. Les exceptions levées par certaines méthodes

L’invocation des nouvelles méthodes addXxx() ou removeXxx() sur une collection non modifiable lève une exception de type UnsupportedOperationException.


Compléter la méthode collectionNonModifiable() de la classe MainSequencedCollections pour tenter d’ajouter un élément dans une liste non modifiable.

  private static void collectionNonModifiable() {
    try {
      List<Integer> nombres = List.of(1, 2, 3);
      nombres.addFirst(0);
    } catch (Exception e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Collection non modifiable
java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableList.add(ImmutableCollections.java:258)
	at java.base/java.util.List.addFirst(List.java:794)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.collectionNonModifiable(MainSequencedCollections.java:129)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.main(MainSequencedCollections.java:20)

Dans les collections qui ont déjà un ordre de tri défini, l’invocation des méthodes forçant l’ordre, par exemple addFirst(), addLast(), …​, n’a pas de sens et lève une exception de type UnsupportedOperationException.


Compléter la méthode collectionTriee() de la classe MainSequencedCollections pour ajouter un nouvel élément en première position dans une collection de type TreeSet.

  private static void collectionTriee() {
    try {
      SequencedSet<Integer> set = new TreeSet<>();
      set.addFirst(0);
    } catch (Exception e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Collection triee
java.lang.UnsupportedOperationException
	at java.base/java.util.TreeSet.addFirst(TreeSet.java:478)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.collectionTriee(MainSequencedCollections.java:79)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.main(MainSequencedCollections.java:40)

Toute tentative d’utiliser une méthode des interfaces séquencées sur une collection vide lève une exception de type NoSuchElementException.


Compléter la méthode collectionVide() de la classe MainSequencedCollections pour obtenir le premier élément d’une collection vide.

  private static void collectionVide() {
    try {
      SequencedCollection<String> elements = List.of();
      elements.getFirst();
    } catch (Exception e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

Collection vide
java.util.NoSuchElementException
	at java.base/java.util.List.getFirst(List.java:823)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.collectionVide(MainSequencedCollections.java:154)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.main(MainSequencedCollections.java:23)
4.2.1.5. Les collections séquencées immutables

Trois nouvelles fabriques ont été ajoutées dans la classe java.util.Collections pour obtenir des collections non modifiables sur les collections séquencées passées en paramètre :

  • SequencedCollection<T> unmodifiableSequencedCollection(SequencedCollection<? extends T>)

  • SequencedSet<T> unmodifiableSequencedSet(SequencedSet<? extends T>)

  • <K, V> SequencedMap<K, V> unmodifiableSequencedMap(SequencedMap<? extends K, ? extends V>)


Compléter la méthode unmodifiableSequenced() de la classe MainSequencedCollections pour tenter d’obtenir et retirer le premier élément d’une collection de type LinkedHashMap non modifiable.

  private static void unmodifiableSequenced() {
    SequencedMap<Integer, String> map = new LinkedHashMap<>();
    map.put(1, "Valeur1");
    map.put(2, "Valeur2");
    map.put(3, "Valeur3");

    SequencedMap<Integer, String> unmodifiableSequencedMap = Collections.unmodifiableSequencedMap(map);

    try {
      unmodifiableSequencedMap.pollFirstEntry();
    } catch (UnsupportedOperationException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainSequencedCollections pour vérifier le résultat de l’exécution.

UnmodifiableSequenced
java.lang.UnsupportedOperationException
	at java.base/java.util.Collections$UnmodifiableSequencedMap.pollFirstEntry(Collections.java:2019)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.unmodifiableSequenced(MainSequencedCollections.java:175)
	at fr.sciam.workshop.javase.sequencedcollections.MainSequencedCollections.main(MainSequencedCollections.java:26)

 

4.2.2. Lab : l’API Foreign Function & Memory

Depuis le JDK 1.1, il est possible de manipuler des données "off-heap" et d’interagir avec du code natif via l’API JNI : "Java Native Interface" :

  • L’appel à des fonctions natives se fait via l’utilisation du modificateur native et d’une "glue" en langage natif permettant de faire le lien entre code C et Java dans laquelle il faudra effectuer les conversions des types de données

  • La gestion de la mémoire "off-heap" peut être réalisée via ByteBuffer::allocateDirect et sun.misc.Unsafe

Ces fonctionnalités présentent un certain nombre d’inconvénients :

  • Lourdeur de mise en œuvre, nécessité d’écrire du code natif

  • Gestion manuelle de la mémoire

  • Performance

  • Unsafe est non standard et devant à terme être remplacée

L’API Foreign Function & Memory "FFM" a pour ambition de fournir un moyen sûr et performant de répondre à ces problématiques.

JDK

Première incubation en Java 14 JEP 412. Standard en Java 22

JEP

JEP 454: Foreign Function & Memory API

4.2.2.1. La gestion de la mémoire "off-heap"

La mémoire "off-heap" fait référence à la mémoire allouée en dehors du tas (heap). Cette zone mémoire n’est donc pas gérée par la JVM ni soumise à la collecte par le Garbage Collector.

L’API FFM nous propose une représentation de la mémoire "off-heap" sous la forme de l’interface MemorySegment. Un tel objet peut être obtenu par l’intermédiaire d’une Arena qui implémente l’interface AutoCloseable et va en contrôler la portée en nous permettant de déterminer à quel moment la mémoire allouée à ce segment sera libérée.

Une instance de l’interface Arena peut être obtenue via l’une de ses 4 fabriques : global(), ofAuto(), ofConfined() et ofShared(). Ces Arena diffèrent par les propriétés suivantes :

  • L’accessibilité aux segments depuis le thread qui a créé l'Arena ou depuis n’importe quel thread

  • La durée de vie des segments alloués

  • La possibilité ou non de libérer explicitement les segments alloués

Ces différences sont synthétisées dans ce tableau :

Type Durée de vie limitée Arrêt explicite possible Accessible depuis plusieurs threads

Global

Automatic

Confined

Shared

Arena possède des surcharges des méthodes allocate() et allocateFrom() permettant d’allouer un MemorySegment.

Il est ensuite possible de lire dans un MemorySegment via les méthodes get(), getAtIndex() ou getString(), et d’écrire via les méthodes set(), setAtIndex() ou setString().

Une fois alloué, la dimension d’un MemorySegment ne peut plus être modifiée.

Tout accès en lecture ou écriture en dehors des bornes du segment lève une exception de type IndexOutOfBoundsException.
4.2.2.2. L’écriture et la lecture de chaînes de caractères

Écrire et lire des chaînes de caractères via un MemorySegment se fait de manière simple. L’API se charge des considérations techniques d’encodage et de décodage, notamment du caractère de terminaison de chaînes de caractères en C \0.

  • allocateFrom() permet d’initialiser un MemorySegment à partir d’une chaîne de caractères

  • setString() et getString() permettent respectivement d’écrire et lire une chaîne de caractères avec un offset donné

Chacune de ces méthodes possède une surcharge permettant de spécifier le Charset à utiliser, UTF-8 étant utilisé par défaut.


Compléter la méthode manipulerChaineDeCaracteres() de fr.sciam.workshop.javase.ffm.MainFFM afin d’utiliser une Arena de type "confined" pour allouer un MemorySegment à partir de la chaîne de caractères "Hello FFM".

Ensuite, lire et afficher le contenu de cette chaîne de caractères depuis le MemorySegment.

Afficher également la taille en octet de ce MemorySegment grâce à la méthode byteSize().

  private static void manipulerChaineDeCaracteres() {
    try (Arena arena = Arena.ofConfined()) {
      MemorySegment memorySegment = arena.allocateFrom("Hello FFM");
      String message = memorySegment.getString(0);

      System.out.println(message);
      System.out.println(memorySegment.byteSize());
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Manipulation d'une chaîne de caractères :
Hello FFM
10
On constate une taille de 10 octets, un octet supplémentaire ayant été nécessaire pour stocker le caractère de terminaison de chaîne de caractères \0.
4.2.2.3. L’écriture et la lecture de types primitifs

Chacune des méthodes get()/getAtIndex() et set()/setAtIndex() nécessite en paramètre une instance de type ValueLayout qui explicite la structure de la donnée à lire ou écrire. Il s’agit d’une interface scellée dont les implémentations correspondent aux différents types primitifs du langage Java (boolean, byte, int, double, …​) et contient les informations nécessaires :

  • la taille de la donnée,

  • son boutisme (little ou big endian),

  • l’alignement,

  • le type de la donnée Java correspondante.

L’alignement contraint les données à ce qu’elles soient stockées à des adresses mémoire particulières, par exemple des multiples de 4 octets pour les entiers, pour des raisons de performances d’accès. Des octets de "padding" peuvent être ajoutés pour respecter cet alignement.


Dans la méthode manipulerEntiers(), allouer un MemorySegment d’une taille de 32 octets.

Ensuite, réaliser l’écriture puis la lecture de chacune des valeurs contenues dans le tableau ENTIERS à l’aide des méthodes set() et get(). Celles-ci se basent sur la position (en octets) à laquelle on souhaite accéder : il faut donc incrémenter cette position après chaque itération pour accéder à la donnée suivante.

  private static void manipulerEntiers() {
    try (Arena arena = Arena.ofConfined()) {
      MemorySegment memorySegment = arena.allocate(32);

      // Ecriture
      long increment = ValueLayout.JAVA_INT.byteSize(); // 4 octets
      for (int i = 0; i < ENTIERS.length; i++) {
        memorySegment.set(ValueLayout.JAVA_INT, i * increment, ENTIERS[i]);
      }

      // Lecture
      for (int i = 0; i < ENTIERS.length; i++) {
        int entier = memorySegment.get(ValueLayout.JAVA_INT, i * increment);
        System.out.print(entier + " ");
      }
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Manipulation d'entiers
0 123 456 789 -1 -2 -3 -4

Les méthodes getAtIndex() et setAtIndex() sont plus adaptées à notre cas de figure, car elles prennent comme paramètre l’index de la donnée, et effectuent en interne le calcul de la position en multipliant par la taille.


Modifier la méthode manipulerEntiers() afin d’utiliser getAtIndex() et setAtIndex().

  private static void manipulerEntiers() {
    try (Arena arena = Arena.ofConfined()) {
      MemorySegment memorySegment = arena.allocate(32);

      // Ecriture
      for (int i = 0; i < ENTIERS.length; i++) {
        memorySegment.setAtIndex(ValueLayout.JAVA_INT, i, ENTIERS[i]);
      }

      // Lecture
      for (int i = 0; i < ENTIERS.length; i++) {
        int entier = memorySegment.getAtIndex(ValueLayout.JAVA_INT, i);
        System.out.print(entier + " ");
      }
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient le même résultat :

Manipulation d'entiers
0 123 456 789 -1 -2 -3 -4
4.2.2.4. Les Memory layouts et les accès structurés

Accéder à des données structurées en mémoire en ne se limitant qu’à des opérations basiques s’avérerait limitant. L’API FFM fournit l’interface MemoryLayout qui permet de définir une structuration de la donnée et d’y accéder de manière simplifiée. Plusieurs possibilités sont offertes au travers de certaines de ses interfaces filles :

  • StructLayout pour une structure agrégeant plusieurs données,

  • SequenceLayout pour une répétition d’une donnée ou structure de données,

  • PaddingLayout pour une plage non utilisée (typiquement pour de l’alignement),

  • UnionLayout pour définir des unions.

Une fois le layout défini, la méthode varHandle() renvoie un VarHandle permettant d’accéder à un élément de la structure. Cette méthode accepte des paramètres de type MemoryLayout.PathElement, dont le choix définira l’élément auquel on accède.

Les paramètres de type MemoryLayout.PathElement peuvent s’obtenir via les fabriques :

  • groupElement() pour obtenir un élément à partir de son index ou son nom au sein d’un groupe,

  • sequenceElement() pour obtenir un élément au sein d’un SequenceLayout.

Arena possède une surcharge de la méthode allocate() qui accepte un MemoryLayout afin d’allouer un MemorySegment de la taille correspondante.
4.2.2.4.1. Le layout StructLayout

MemoryLayout::structLayout permet de définir une structure de données à partir des MemoryLayout qui la constituent.


Consulter le record CoordonneesGps, puis dans la méthode manipulerStructLayout(), créer un layout représentant cette structure de données en mémoire.

Puis, allouer un MemorySegment pour y écrire la première coordonnée GPS du tableau COORDONNEES_GPS avec le layout créé. On utilisera MemoryLayout.PathElement::groupElement(index) pour accéder aux éléments : index 0 pour la latitude, index 1 pour la longitude.

Enfin, lire les valeurs dans le MemorySegment via le VarHandle pour vérifier que les données ont correctement été écrites, aux bons emplacements.

  private static void manipulerStructLayout() {
    try (Arena arena = Arena.ofConfined()) {
      StructLayout structLayout = MemoryLayout.structLayout(
        ValueLayout.JAVA_FLOAT,
        ValueLayout.JAVA_FLOAT
      );

      MemorySegment memorySegment = arena.allocate(structLayout);

      VarHandle latitudeHandle = structLayout.varHandle(PathElement.groupElement(0));
      VarHandle longitudeHandle = structLayout.varHandle(PathElement.groupElement(1));

      CoordonneesGps coordonnees = COORDONNEES_GPS[0];
      latitudeHandle.set(memorySegment, 0, coordonnees.latitude());
      longitudeHandle.set(memorySegment, 0, coordonnees.longitude());

      float latitude = (float) latitudeHandle.get(memorySegment, 0);
      float longitude = (float) longitudeHandle.get(memorySegment, 0);

      System.out.println("Ecrit : " + coordonnees.latitude() + ", lu : " + latitude);
      System.out.println("Ecrit : " + coordonnees.longitude() + ", lu : " + longitude);
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Manipulation d'un StructLayout
Ecrit : 43.1242, lu : 43.1242
Ecrit : 5.9285, lu : 5.9285

Les MemoryLayout passés en paramètres peuvent être nommés via la méthode withName(). On pourra ensuite utiliser ces noms pour obtenir les chemins des éléments MemoryLayout.PathElement.


Modifier la méthode manipulerStructLayout() afin de nommer les ValueLayout et d’utiliser les noms choisis pour obtenir les VarHandle.

  private static void manipulerStructLayout() {
    try (Arena arena = Arena.ofConfined()) {
      StructLayout structLayout = MemoryLayout.structLayout(
        ValueLayout.JAVA_FLOAT.withName("latitude"), (1)
        ValueLayout.JAVA_FLOAT.withName("longitude")
      );

      MemorySegment memorySegment = arena.allocate(structLayout);

      VarHandle latitudeHandle = structLayout.varHandle(PathElement.groupElement("latitude")); (2)
      VarHandle longitudeHandle = structLayout.varHandle(PathElement.groupElement("longitude"));

      CoordonneesGps coordonnees = COORDONNEES_GPS[0];
      latitudeHandle.set(memorySegment, 0, coordonnees.latitude());
      longitudeHandle.set(memorySegment, 0, coordonnees.longitude());

      float latitude = (float) latitudeHandle.get(memorySegment, 0);
      float longitude = (float) longitudeHandle.get(memorySegment, 0);

      System.out.println("Ecrit : " + coordonnees.latitude() + ", lu : " + latitude);
      System.out.println("Ecrit : " + coordonnees.longitude() + ", lu : " + longitude);
    }
  }
1 Nommage des éléments de la structure
2 Obtention du chemin de l’élément avec le nom choisi

Exécuter la classe MainFFM et vérifier que l’on obtient le même résultat :

Manipulation d'un StructLayout
Ecrit : 43.1242, lu : 43.1242
Ecrit : 5.9285, lu : 5.9285
4.2.2.4.2. Le layout SequenceLayout

SequenceLayout permet de définir une répétition d’un champ ou structure.

Les surcharges de PathElement::sequenceElement permettent d’obtenir les VarHandle permettant de lire et écrire au sein d’une séquence.


Utiliser un SequenceLayout pour écrire le contenu du tableau ENTIERS dans un MemorySegment, à l’aide du VarHandle obtenu avec le PathElement renvoyé par PathElement.sequenceElement().

Enfin, lire les valeurs dans le MemorySegment via le VarHandle pour vérifier que les données ont correctement été écrites, aux bons emplacements.

  private static void manipulerSequenceLayout() {
    try (Arena arena = Arena.ofConfined()) {

      SequenceLayout sequenceLayout = MemoryLayout.sequenceLayout(ENTIERS.length, ValueLayout.JAVA_INT);
      VarHandle handle = sequenceLayout.varHandle(PathElement.sequenceElement());

      MemorySegment memorySegment = arena.allocate(sequenceLayout);

      for (int i = 0; i < ENTIERS.length; i++) {
        handle.set(memorySegment, 0, i, ENTIERS[i]);
      }

      for (int i = 0; i < ENTIERS.length; i++) {
        int entier = (int) handle.get(memorySegment, 0, i);
        System.out.print(entier + " ");
      }
      System.out.println();
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Manipulation d'un SequenceLayout
0 123 456 789 -1 -2 -3 -4
4.2.2.4.3. La combinaison d’un StructLayout et SequenceLayout

Il est possible de combiner les layouts. Si l’on souhaite répéter plusieurs fois la même structure, l’utilisation conjointe de StructLayout et SequenceLayout est adaptée.


Compléter la méthode manipulerStructLayoutDansSequenceLayout() afin d’écrire puis lire les valeurs de COORDONNEES_GPS en utilisant conjointement StructLayout et SequenceLayout des mises en pratique précédentes.

  private static void manipulerStructLayoutDansSequenceLayout() {
    try (Arena arena = Arena.ofConfined()) {

      // Structure
      StructLayout structLayout = MemoryLayout.structLayout(
        ValueLayout.JAVA_FLOAT.withName("latitude"),
        ValueLayout.JAVA_FLOAT.withName("longitude")
      );

      // Sequence
      SequenceLayout sequenceLayout = MemoryLayout.sequenceLayout(ENTIERS.length, structLayout);

      // Obtention des VarHandle
      PathElement element = PathElement.sequenceElement();
      VarHandle latitudeHandle = sequenceLayout.varHandle(element, PathElement.groupElement("latitude"));
      VarHandle longitudeHandle = sequenceLayout.varHandle(element, PathElement.groupElement("longitude"));

      // Allocation d'un segment dont la taille correspond à la structure
      MemorySegment segment = arena.allocate(sequenceLayout);

      // Écriture
      for (int i = 0; i < COORDONNEES_GPS.length; i++) {
        CoordonneesGps c = COORDONNEES_GPS[i];
        latitudeHandle.set(segment, 0, i, c.latitude());
        longitudeHandle.set(segment, 0, i, c.longitude());
      }

      // Lecture
      for (int i = 0; i < COORDONNEES_GPS.length; i++) {
        float latitude = (float) latitudeHandle.get(segment, 0, i);
        float longitude = (float) longitudeHandle.get(segment, 0, i);
        System.out.println("Latitude : " + latitude + ", Longitude " + longitude);
      }
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Manipulation d'un StructLayout dans un SequenceLayout
Latitude : 43.1242, Longitude 5.9285
Latitude : 43.1176, Longitude 5.9404
Latitude : 43.0928, Longitude 6.0756
Latitude : 43.1055, Longitude 5.8869
4.2.2.5. L’appel de fonctions natives

L’API FFM met à dispositions les moyens permettant la recherche et le chargement de bibliothèques natives ainsi que l’invocation de fonctions native (downcall), ou l’appel de méthodes Java depuis le code natif (upcall).

4.2.2.5.1. La recherche et le chargement de bibliothèques natives

Chaque bibliothèque adhère à une "Application Binary Interface" (ABI) qui est un ensemble de conventions et types de données qui dépendent du système d’exploitation, du compilateur et du processeur. L’interface Linker a connaissance de ces conventions et joue le rôle de médiateur entre le code Java et le code natif.

Une instance de Linker s’obtient via la fabrique nativeLinker().

L’interface SymbolLookup permet de fournir un accès aux bibliothèques et fonctions natives qui adhèrent aux spécifications de la plateforme. Pour en obtenir une instance, on dispose de 3 fabriques :

  • SymbolLookup.libraryLookup(String, arena) et SymbolLookup.libraryLookup(Path, arena) permettent de charger dynamiquement une bibliothèque par son nom ou son chemin et en liant son cycle de vie à celui de l'Arena,

  • SymbolLookup.loaderLookup() crée un SymbolLookup qui recherchera dans les bibliothèques chargées par le ClassLoader, par exemple via System.load() ou System.loadLibrary() comme on le ferait avec JNI.

Ainsi que de la méthode Linker.defaultLookup() qui permet de rechercher parmi un ensemble de bibliothèques standard, telle que la bibliothèque libc sous Linux.

Dans la suite de ce lab, nous allons manipuler la bibliothèque SQLite qui est écrite en langage C et permet de créer et interagir avec une base de données locale.


Compléter la méthode chargerBibliothequeNative() dans le but de charger la bibliothèque sqlite3 correspondant à votre système d’exploitation présente dans le répertoire sqlite/ via son Path. Ajouter un message indiquant le succès de chargement puis renvoyer l’instance de SymbolLookup obtenue.

  private static SymbolLookup chargerBibliothequeNativeAvecPath(Arena arena) {
    Path path = Path.of("sqlite/sqlite3.dll"); (1)
    SymbolLookup lookup = SymbolLookup.libraryLookup(path, arena);
    System.out.println("Bibliothèque chargée avec succès");
    return lookup;
  }
1 .dll sous Windows, .so sous Linux et .dylib sous MacOS

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Chargement de la bibliothèque sqlite3
Bibliothèque chargée avec succès

WARNING: A restricted method in java.lang.foreign.SymbolLookup has been called
WARNING: java.lang.foreign.SymbolLookup::libraryLookup has been called by fr.sciam.workshop.javase.ffm.MainFFM in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Les méthodes de l’API FFM annotées @Restricted ne sont pas "sûres". Les interactions entre code Java et natif comportent des risques intrinsèques. Une utilisation incorrecte peut amener des problèmes tels qu’une corruption de la mémoire pouvant amener à un crash de la JVM. Il est nécessaire d’autoriser explicitement ces opérations pour ne pas obtenir le warning obtenu ci-dessus.

Pour autoriser l’appel aux méthodes restreintes, il faut ajouter l’option :

  • --enable-native-access=ALL-UNNAMED pour des classes chargées du classpath,

  • --enable-native-access=M1,M2 pour autoriser explicitement pour les modules M1 et M2.

Il est également possible de spécifier le comportement en cas d’accès illégal via l’option --illegal-native-access

  • --illegal-native-access=allow : l’opération est autorisée (il n’est alors pas nécessaire d’ajouter l’option --enable-native-access)

  • --illegal-native-access=warn (par défaut) : la JVM émet un avertissement la première fois qu’un accès natif illégal est détecté

  • --illegal-native-access=deny : la JVM lève une IllegalCallerException à chaque appel natif illégal détecté

Cela s’inscrit dans une démarche globale visant à rendre la JVM "intègre par défaut". S’il ne s’agit que d’un warning à l’heure actuelle, il est possible que cela laisse la place à une exception dans de futures versions du JDK, l’option --illegal-native-access=deny devenant alors celle par défaut.


Ajouter l’option --enable-native-access=ALL-UNNAMED puis exécuter à nouveau la classe MainFFM.

Si besoin, se référer à la mise en œuvre des options de la JVM

Vérifier qu’il n’y a plus d’avertissement affiché :

Chargement de la bibliothèque sqlite3
Bibliothèque chargée avec succès
4.2.2.5.2. La localisation d’une fonction native

SymbolLookup nous permet de localiser l’adresse mémoire correspondant à la fonction, via sa méthode find(). Son type de retour est Optional<MemorySegment>, ce qui permet de gérer le cas où la recherche aurait échoué.


Compléter la méthode localiserFonctionNativeOpen afin de localiser la fonction native sqlite3_open() et afficher puis renvoyer le MemorySegment correspondant.

  private static MemorySegment localiserFonctionNativeOpen(SymbolLookup lookup) {
    MemorySegment memorySegment = lookup.find("sqlite3_open").orElseThrow();
    System.out.println("Fonction sqlite3_open() : " + memorySegment);
    return memorySegment;
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Localisation de la fonction sqlite3_open()
Fonction sqlite3_open() : MemorySegment{ address: 0x7ff89c77324c, byteSize: 0 }
4.2.2.5.3. L’invocation d’une fonction native (downcall)

Linker nous permet d’obtenir une instance de MethodHandle sur la fonction native. Pour l’invoquer, il faut fournir une description de la signature de la méthode.

L’interface FunctionDescriptor via sa fabrique of() permet de définir le type de retour et les paramètres acceptés par la méthode.


Compte tenu de la description de la fonction native sqlite3_open() :

int sqlite3_open(
  const char *filename,   /* Database filename (UTF-8) */
  sqlite3 **ppDb          /* OUT: SQLite db handle */
);

Compléter la méthode obtenirMethodHandleOpen() pour obtenir le MethodHandle vers la fonction, puis l’invoquer.

  private static MethodHandle obtenirMethodHandleOpen(Linker linker, MemorySegment memorySegment) {
    FunctionDescriptor descripteur = FunctionDescriptor.of(
      ValueLayout.JAVA_INT, (1)
      ValueLayout.ADDRESS, (2)
      ValueLayout.ADDRESS (3)
    );

    MethodHandle methodHandle = linker.downcallHandle(memorySegment, descripteur);
    System.out.println("MethodHandle obtenu : " + methodHandle);
    return methodHandle;
  }
1 Type de retour de la méthode
2 Type du premier paramètre : pointeur vers le nom du fichier .db
3 Type du second paramètre : pointeur vers un handle de la base de données

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Obtention d'un MethodHandle vers sqlite3_open()
MethodHandle obtenu : MethodHandle(MemorySegment,MemorySegment)int

Le MethodHandle peut être invoqué en lui fournissant en paramètres les MemorySegment correspondant aux types attendus.


Compléter la méthode invoquerFonctionOpen() en préparant les paramètres sous forme de MemorySegment puis en invoquant la méthode sqlite3_open() via son MethodHandle précédemment obtenu.

Afficher le code de retour obtenu (vaut 0 si l’opération est un succès).

Enfin, renvoyer le MemorySegment correspondant au pointeur vers la base de données (2ème paramètre de la fonction sqlite3_open())

  private static MemorySegment invoquerFonctionOpen(final MethodHandle handle, final Arena arena) {
    String nomFichierBaseDeDonnees = "sqlite/ffm.db";
    MemorySegment segmentNomFichier = arena.allocateFrom(nomFichierBaseDeDonnees);
    MemorySegment pointeurBase = arena.allocate(ValueLayout.ADDRESS);
    try {
      int code = (int) handle.invokeExact(segmentNomFichier, pointeurBase);
      if (code == 0) {
        System.out.println("Lien avec la base " + nomFichierBaseDeDonnees + " établi avec succès");
        return pointeurBase;
      } else {
        throw new IllegalStateException("Erreur au chargement de la base : code = " + code);
      }
    } catch (Throwable e) {
      throw new IllegalStateException("Erreur lors de l'invocation de la fonction native sqlite3_open", e);
    }
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Invocation de la fonction sqlite3_open()
Lien avec la base sqlite/ffm.db établi avec succès
4.2.2.5.4. L’appel montant : natif vers Java (upcall)

L’interface Linker permet également de réaliser des upcalls, à savoir des appels montants depuis le code natif jusqu’au code Java.

Cela se réalise par le biais de la méthode upcallStub() qui prendre en paramètres :

  • Un MethodHandle de la fonction Java à appeler depuis le code natif

  • Une description de cette fonction sous la forme de FunctionDescriptor

  • Une instance de type Arena


La fonction native sqlite3_trace_v2() permet de configurer des traces avec l’appel d’une fonction callback. Sa signature est la suivante :

SQLITE_API int sqlite3_trace_v2(
  sqlite3*,
  unsigned uMask,
  int(*xCallback)(unsigned,void*,void*,void*),
  void *pCtx
);

La méthode de callback Java a déjà été définie : il s’agit de tracerCallback(). Constater que sa signature correspond à la fonction de callback attendue par sqlite3_trace_v2().

Dans la méthode configurerUpcall() :

  • Utiliser MethodHandles::lookup pour obtenir un MethodHandle sur la méthode tracerCallback(),

  • créer un FunctionDescriptor correspondant,

  • configurer l’upcall grâce à Linker::upcallStub,

  • enfin, afficher puis renvoyer l’instance de MethodHandle correspondant à l’upcall.

  private static MemorySegment configurerUpcall(Linker linker, Arena arena) {
    try {
      // Obtention du MethodHandle vers la méthode Java de callback
      MethodHandle tracerCallbackHandle = MethodHandles.lookup().findStatic(
        MainFFM.class,
        "tracerCallback",
        MethodType.methodType(
          int.class,
          MemorySegment.class,
          MemorySegment.class,
          MemorySegment.class,
          MemorySegment.class
        )
      );

      // Descripteur de la méthode Java de callback
      FunctionDescriptor tracerCallbackDesc = FunctionDescriptor.of(
        ValueLayout.JAVA_INT,
        ValueLayout.ADDRESS,
        ValueLayout.ADDRESS,
        ValueLayout.ADDRESS,
        ValueLayout.ADDRESS
      );

      // Création de l'upcall
      MemorySegment upcall = linker.upcallStub(tracerCallbackHandle, tracerCallbackDesc, arena);
      System.out.println("Upcall obtenu : " + upcall);
      return upcall;

    } catch (NoSuchMethodException | IllegalAccessException e) {
      throw new IllegalStateException(e);
    }
  }
La méthode invoquerTraceAvecCallbackPuisRequeter() déjà existante se charge d’appeler la fonction sqlite3_trace_v2() pour configurer les traces avec l’upcall, ainsi que de réaliser une requête SQL en base pour déclencher l’appel au callback.

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Configuration de l'appel montant (upcall)
Upcall obtenu : MemorySegment{ address: 0x1324c8a0ca0, byteSize: 0 }

Invocation de sqlite3_trace_v2() avec callback et requête en base
Appel de tracerCallback()
MemorySegment{ address: 0x1, byteSize: 0 }
MemorySegment{ address: 0x0, byteSize: 0 }
MemorySegment{ address: 0x132a70713c0, byteSize: 0 }
MemorySegment{ address: 0x132a7073aa0, byteSize: 0 }
4.2.2.5.5. Les fonctions natives renvoyant un pointeur

Certaines fonctions natives peuvent renvoyer un pointeur vers une région mémoire. La JVM n’a pas la possibilité de connaître la taille ni la structure de cette région, ni même sa durée de vie. Pour cela, l’API utilise un MemorySegment de taille nulle pour représenter ce type de pointeur. Ceci est utilisé pour :

  • Les pointeurs renvoyés par une fonction native

  • Les pointeurs passés depuis le code natif vers un upcall

  • Les pointeurs lus depuis un MemorySegment

Il est impossible de manipuler directement un tel MemorySegment, sous peine de voir la JVM lever l’exception IndexOutOfBoundsException. En effet, elle ne peut pas accéder ou valider en toute sécurité une opération d’accès à une région mémoire dont la taille est inconnue.

La méthode MemorySegment::reinterpret permet de travailler sur de tels segments en y accédant de manière sûre et en rattachant la zone mémoire associée à une Arena.

Il existe plusieurs surcharges de cette méthode dont les paramètres font intervenir :

  • La taille en octet à laquelle le segment va être redimensionné

  • L'Arena à associer avec le MemorySegment

  • Une action à exécuter lorsque l'Arena sera fermée, sous la forme d’un Consumer<MemorySegment>


Avec SQLite, lorsque l’on exécute une requête via sqlite3_exec(), on doit fournir le paramètre char **errmsg qui est un pointeur vers la chaîne de caractères contenant un éventuel message d’erreur.

La taille de cette chaîne étant inconnue, il est nécessaire d’utiliser la méthode MemorySegment::reinterpret pour en lire le contenu, en y affectant une taille arbitraire, mais suffisante.

Modifier la chaîne de caractères REQUETE de la classe MainFFM afin d’ajouter volontairement une erreur de syntaxe pour lever une erreur lors de l’exécution.

  private static final String REQUETE = "xxxSELECT * FROM users"; (1)
1 Ajout des caractères 'xxx' pour lever une erreur de syntaxe

Ensuite, compléter la méthode traiterMessageErreur() afin de lire le message d’erreur et l’afficher. On utilisera une taille de 500 octets lors de la réinterprétation du MemorySegment.

  private static MemorySegment traiterMessageErreur(MemorySegment pointeur) {
    MemorySegment segmentReinterprete = pointeur.reinterpret(500);
    String message = segmentReinterprete.getString(0);
    System.err.println("Erreur : " + message);

    return segmentReinterprete;
  }

Exécuter la classe MainFFM et vérifier que l’on obtient dans la console :

Erreur : near "xxxSELECT": syntax error

4.2.3. Lab : l’API Class-File

L’API Class-File permet d’analyser, générer et transformer des fichiers de classe Java.

JDK

Première preview en Java 22 JEP 457
Seconde en Java 23 JEP 466
Standard en Java 24

JEP

484: Class-File API

4.2.3.1. Les motivations

De nombreux frameworks ou bibliothèques manipulent le bytecode afin d’enrichir les programmes de diverses fonctionnalités, de façon transparente sans avoir à modifier le code source et le recompiler.

Par exemple, Quarkus modifie et génère du bytecode à la volée pour réaliser un certain nombre d’optimisations durant le build. Hibernate peut générer et modifier des classes dynamiquement pour optimiser l’accès aux entités en base de données, en ajoutant par exemple des proxies pour le lazy loading.

De nombreuses bibliothèques permettent ces manipulations comme ASM, ByteBuddy, Apache BCEL, Javassist, Gizmo…​ chacune avec leurs forces, faiblesses, paradigmes de programmation ou niveau d’abstraction qui leur sont propres. Le JDK lui-même se repose sur ASM, notamment pour la génération dynamique de bytecode pour les expressions lambdas via invokedynamic.
Comme une version N du JDK se base sur une version N-1 d’ASM et que le format des fichiers de classe évolue constamment (particulièrement depuis le passage au rythme de release tous les six mois), on est confronté au problème de "l’œuf et la poule".

L’API Class-File a pour vocation de fournir une API évoluant avec le format officiel, afin d’assurer une compatibilité immédiate avec les dernières versions du JDK sans dépendre de bibliothèques tierces.

Cette API trouve sa place au sein du package java.lang.classfile.

4.2.3.2. L’interface java.lang.classfile.ClassFile

L’interface ClassFile représente un contexte pour analyser, transformer et générer des fichiers de classe. La fabrique ClassFile::of permet d’en obtenir une instance.

On peut lui associer un ensemble d’options qui conditionnent la manière dont l’analyse et la génération sont effectuées. Pour cela, of() possède une surcharge qui accepte des ClassFile.Option sous forme de varargs.

4.2.3.3. L’interface java.lang.classfile.ClassModel

Une classe est modélisée par l’interface ClassModel, qui est une structure arborescente sous forme d’éléments héritant de ClassElement.

Ces éléments, interfaces filles de ClassElement peuvent représenter diverses propriétés de la classe, tels que :

  • FieldModel pour un champ

  • MethodModel pour une méthode

  • Superclass pour la classe mère

  • Interfaces pour les interfaces que la classe implémente

  • ConstantPool pour le pool de constantes

  • …​

Il est possible d’en inspecter la structure grâce notamment aux méthodes ClassModel::fields et ClassModel::methods qui renvoient respectivement la liste des champs et la liste des méthodes de la classe.

Chaque "famille" de modèle (ClassModel, MethodModel, FieldModel…​) est composée d’éléments qui lui sont propres (respectivement ClassElement, MethodElement, FieldElement…​).

La Javadoc du package java.lang.classfile fournit des informations sur le modèle de données et sa structuration.

4.2.3.4. L’analyse de fichiers de classe

L’interface ClassFile dispose de plusieurs surcharges de la méthode parse() permettant d’obtenir une instance de ClassModel.

  • parse(byte[] bytes) permet de parser un fichier de classe directement à partir de son bytecode

  • parse(Path path) throws IOException permet de parser un fichier de classe à partir de son emplacement


Consulter la classe fr.sciam.workshop.javase.classfile.AccumulateurBasique.

Dans la classe fr.sciam.workshop.javase.classfile.MainClassFileAPI, compléter la méthode analyserFichierDeClasse() afin d’obtenir une instance de ClassModel modélisant la classe AccumulateurBasique.
Utiliser la méthode utilitaire obtenirChemin(Class<?> clazz) de MainClassFileAPI pour obtenir une instance de Path correspondant à la classe AccumulateurBasique.
Enfin, utiliser l’instance de ClassModel obtenue pour afficher les champs et méthodes.

  private static void analyserFichierDeClasse() throws IOException {
    Path classFilePath = obtenirChemin(AccumulateurBasique.class);
    ClassModel classModel = ClassFile.of().parse(classFilePath);
    classModel.fields().forEach(System.out::println);
    classModel.methods().forEach(System.out::println);
  }

Exécuter la classe MainClassFileAPI et vérifier que l’on obtient dans la console :

Analyse d'un fichier de classe
FieldModel[fieldName=valeur, fieldType=I, flags=2]
MethodModel[methodName=<init>, methodType=()V, flags=1]
MethodModel[methodName=ajouter, methodType=(I)I, flags=1]
On notera la présence du constructeur sans paramètre qui, bien qu’absent dans notre code source, a été ajouté par le compilateur Java.

ClassModel étend Iterable<ClassElement>, il est donc possible de parcourir ses éléments en itérant directement sur l’instance et en utilisant par exemple le pattern matching pour effectuer des traitements propres aux types parcourus.


Modifier la méthode analyserAvecPatternMatching() afin d’itérer directement sur les éléments du ClassModel.
Utiliser une instruction switch et le pattern matching pour distinguer les éléments de type champ, méthode ou autre.

  private static void analyserAvecPatternMatching() throws IOException {
    Path classFilePath = obtenirChemin(AccumulateurBasique.class);
    ClassModel classModel = ClassFile.of().parse(classFilePath);
    for (ClassElement element : classModel) {
      switch (element) {
        case FieldModel fm -> System.out.printf("Champ : %s%n", fm.fieldName().stringValue());
        case MethodModel mm -> System.out.printf("Méthode : %s%n", mm.methodName().stringValue());
        default -> System.out.printf("Autre élément : %s%n", element);
      }
    }
  }

Exécuter la classe MainClassFileAPI et vérifier que l’on obtient dans la console :

Analyse d'un fichier de classe avec le Pattern Matching
Autre élément : AccessFlags[flags=33]
Autre élément : ClassFileVersion[majorVersion=68, minorVersion=0]
Autre élément : Superclass[superclassEntry=java/lang/Object]
Autre élément : Interfaces[interfaces=]
Champ : valeur
Méthode : <init>
Méthode : ajouter
Autre élément : Attribute[name=SourceFile]

MethodModel modélise une méthode par le biais d’éléments java.lang.classfile.MethodElement, qui peuvent représenter des propriétés telles que :

  • les modificateurs d’accès (public, private, static …​) avec AccessFlags

  • les exceptions déclarées par la clause throws avec ExceptionsAttribute

  • le contenu du corps de la méthode avec CodeModel, constitué d’éléments CodeElement ordonnés qui représentent les instructions de bas niveau

  • …​


Compléter la méthode analyserMethode() afin d’afficher les modificateurs d’accès de la méthode AccumulateurBasique::ajouter ainsi que les instructions qui la composent. Les éléments CodeElement peuvent être de type Instruction ou PseudoInstruction, on se limitera à l’affiche des Instruction.

  private static void analyserMethode() throws IOException {
    Path classFilePath = obtenirChemin(AccumulateurBasique.class);
    ClassModel classModel = ClassFile.of().parse(classFilePath);

    for (MethodModel method : classModel.methods()) {
      if ("ajouter".equals(method.methodName().stringValue())) {
        for (MethodElement methodElement : method) {
          switch (methodElement) {
            case AccessFlags flags -> System.out.printf("Flags : %s%n", flags.flags());
            case CodeModel code -> {
              for (CodeElement codeElement : code) {
                if (codeElement instanceof Instruction instruction) {
                  System.out.printf("Instruction : %s%n", instruction);
                }
              }
            }
            default -> {}
          }
        }
      }
    }
  }

Exécuter la classe MainClassFileAPI et vérifier que l’on obtient dans la console :

Analyse d'une méthode
Flags : [PUBLIC]
Instruction : Load[OP=ALOAD_0, slot=0]
Instruction : UnboundStackInstruction[op=DUP]
Instruction : Field[OP=GETFIELD, field=fr/sciam/workshop/javase/classfile/AccumulateurBasique.valeur:I]
Instruction : Load[OP=ILOAD_1, slot=1]
Instruction : UnboundOperatorInstruction[op=IADD]
Instruction : Field[OP=PUTFIELD, field=fr/sciam/workshop/javase/classfile/AccumulateurBasique.valeur:I]
Instruction : Load[OP=ALOAD_0, slot=0]
Instruction : Field[OP=GETFIELD, field=fr/sciam/workshop/javase/classfile/AccumulateurBasique.valeur:I]
Instruction : Return[OP=IRETURN]
4.2.3.5. L’écriture de fichiers de classe

La génération de fichiers de classe se fait par l’intermédiaire de "builders".
Plutôt que de devoir créer manuellement les instances de builders, l’API requière de fournir une expression lambda de type Consumer qui "consomme" le builder.
Cette API est "fluent" : les méthodes du builder renvoient l’instance même du builder, permettant de chaîner les appels.

La méthode build(ClassDesc, Consumer<? super ClassBuilder>) permet de générer le bytecode (sous forme de tableau d’octets byte[]) à partir d’un descripteur de classe et d’invocations réalisées sur les builders.


Dans la méthode ecrireFichierDeClasse(), générer le bytecode correspond à une classe fr.sciam.MaClassGeneree, dont le corps est vide (pour l’instant).

Afficher le contenu du tableau d’octets byte[] obtenu formaté avec java.util.HexFormat

  private static void ecrireFichierDeClasse() {
    ClassDesc classDesc = ClassDesc.of("fr.sciam.MaClassGeneree");
    byte[] bytes = ClassFile.of().build(classDesc, builder -> {});

    System.out.println("Contenu hexadécimal");
    HexFormat.of().formatHex(System.out, bytes);
    System.out.println();
  }

Exécuter la classe MainClassFileAPI et vérifier que l’on obtient dans la console quelque chose de la forme :

Écriture d'un fichier de classe
Contenu hexadécimal
cafebabe00000044000501001766722f736369616d2f4d61436c61737347656e657265650700010100106a6176612f6c616e672f4f626a6563740700030001000200040000000000000000
Le code hexadécimal nous indique que le fichier débute par la séquence 0xcafebabe (magic number indiquant un fichier de classe) ainsi que la version 0x44 (qui correspond à 68 en décimal) identifiant la version 24 de Java.
La documentation complète du format est disponible sur le site d’Oracle : The class File Format.

Le builder ClassBuilder nous permet de générer le contenu de la classe grâce à diverses méthodes, parmi lesquelles :

  • withSuperclass() permet de définir la classe mère

  • withInterfaceSymbols() permet de définir les interfaces implémentées par notre classe

  • withFlags() permet de définir les modificateurs d’accès à notre classe

  • withField() permet d’ajouter un champ

  • withMethod() et withMethodBody() permettent d’ajouter des méthodes

Certaines de ces méthodes requièrent également un Consumer, par exemple Consumer<MethodBuilder> pour withMethod() ou encore Consumer<CodeBuilder> pour withMethodBody().
Ce fonctionnement permet de définir, en cascade, le contenu de la classe à générer par l’invocation successive des builders correspondants.

Parmi les autres paramètres requis pour ces méthodes, on retrouve les types :

  • ClassDesc pour décrire une classe (package, nom), que nous avons déjà utilisé pour définir notre classe

  • AccessFlag pour un modificateur d’accès comme public

  • int pour le paramètre "methodFlags", pour lequel on peut piocher dans les constantes de ClassFile telles que ClassFile.ACC_PUBLIC ou ClassFile.ACC_STATIC dont les valeurs correspondent au bit de masquage du modificateur.
    On peut cumuler les modificateurs en appliquant un "OU" logique | entre les valeurs désirées.

  • MethodTypeDesc pour définir le type de retour et le type des paramètres de la méthode :

    • la fabrique ofDescriptor() accepte une chaîne de caractères correspondant à la signature (par exemple ([Ljava/lang/String;)V pour une méthode qui renvoie void et qui accepte un tableau de String)

    • la fabrique of() qui accepte des instances de ClassDesc, dont les plus courantes sont disponibles sous forme de constantes dans la classe ConstantDescs.


Compléter la méthode ecrireFichierDeClasseAvance() afin de générer une classe MaClasseGenereeAvancee qui :

  • étende la classe java.util.Date

  • implémente l’interface java.io.Serializable

  • soit final

  • définisse une méthode public static void main(String[]) via ClassBuilder::withMethodBody dont la seule instruction est return.

Enfin, utiliser la méthode utilitaire MainClassFileAPI::sauvegarderFichier afin de sauvegarder le fichier .class sur le disque.
Pour cela, utiliser les paramètres :

  • byte[] : bytes, le tableau d’octets généré par l’API ClassFile

  • String : "fr/sciam/MaClasseGenereeAvancee.class", le chemin relatif au dossier classfile du project

  private static void ecrireFichierDeClasseAvancee() {
    ClassDesc classDesc = ClassDesc.of("fr.sciam.MaClasseGenereeAvancee");
    byte[] bytes = ClassFile.of().build(
      classDesc,
      builder -> {
        // Superclass
        builder.withSuperclass(ClassDesc.of("java.util.Date"))

          // Interfaces
          .withInterfaceSymbols(ClassDesc.of("java.io.Serializable"))

          // Flags
          .withFlags(AccessFlag.FINAL)

          // Méthode main()
          .withMethodBody(
            "main",
            MethodTypeDesc.of(
              ConstantDescs.CD_void,
              ConstantDescs.CD_String.arrayType()
            ),
            ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
            CodeBuilder::return_
          );
      }
    );

    sauvegarderFichier(bytes, "fr/sciam/MaClasseGenereeAvancee.class");
  }

Exécuter la classe MainClassFileAPI puis la commande javap du JDK avec l’option -verbose pour inspecter le fichier de classe généré.

Depuis le dossier classfile/ contenant la classe générée
javap -classpath . -verbose fr.sciam.MaClasseGenereeAvancee

Vérifier que l’on obtient dans la console :

final class fr.sciam.MaClasseGenereeAvancee extends java.util.Date implements java.io.Serializable
  minor version: 0
  major version: 68
  flags: (0x0010) ACC_FINAL
  this_class: #2                          // fr/sciam/MaClasseGenereeAvancee
  super_class: #4                         // java/util/Date
  interfaces: 1, fields: 0, methods: 1, attributes: 0
Constant pool:
   #1 = Utf8               fr/sciam/MaClasseGenereeAvancee
   #2 = Class              #1             // fr/sciam/MaClasseGenereeAvancee
   #3 = Utf8               java/util/Date
   #4 = Class              #3             // java/util/Date
   #5 = Utf8               main
   #6 = Utf8               ([Ljava/lang/String;)V
   #7 = Utf8               java/io/Serializable
   #8 = Class              #7             // java/io/Serializable
   #9 = Utf8               Code
{
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
}

L’API Class-File nous permet d’interagir avec le "Constant Pool" de classe, pour inspecter ou créer des constantes, par exemple.
Un builder lui est associé : ConstantPoolBuilder dont une instance est obtenue par la méthode ClassBuilder::constantPool.
Parmi les nombreuses méthodes proposées par ce builder, certaines correspondent aux types primitifs int, float, long, …​ avec intEntry(), floatEntry(), longEntry(), …​

Le builder FieldBuilder propose les méthodes :

  • withFlags(), analogue aux builders vus précédemment

  • with(), qui accepte un FieldElement qui peut être de type ConstantValueAttribute pour désigner une constante


Compléter la méthode ecrireFichierDeClasseAvancee() afin d’ajouter un champ avec les caractéristiques suivantes :

  • nommé "YEAR"

  • public, static et final

  • de type int

  • de valeur constante 2025

  private static void ecrireFichierDeClasseAvancee() {
    ClassDesc classDesc = ClassDesc.of("fr.sciam.MaClasseGenereeAvancee");
    byte[] bytes = ClassFile.of().build(
      classDesc,
      builder -> {
        // Superclass
        builder.withSuperclass(ClassDesc.of("java.util.Date"))

          // Interfaces
          .withInterfaceSymbols(ClassDesc.of("java.io.Serializable"))

          // Flags
          .withFlags(AccessFlag.FINAL)

          // Field
          .withField(
            "YEAR",
            ConstantDescs.CD_int,
            fieldBuilder ->
              fieldBuilder
                .withFlags(AccessFlag.PRIVATE, AccessFlag.STATIC, AccessFlag.FINAL)
                .with(ConstantValueAttribute.of(builder.constantPool().intEntry(2025))))

          // Méthode main()
          .withMethodBody(
            "main",
            MethodTypeDesc.of(
              ConstantDescs.CD_void,
              ConstantDescs.CD_String.arrayType()
            ),
            ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
            CodeBuilder::return_
          );
      }
    );

    sauvegarderFichier(bytes, "fr/sciam/MaClasseGenereeAvancee.class");
  }

Exécuter la classe MainClassFileAPI puis la commande javap pour inspecter le fichier de classe généré, et vérifier la présence de la constante dans le "Constant Pool" :

Constant pool:
   #1 = Utf8               fr/sciam/MaClasseGenereeAvancee
   #2 = Class              #1             // fr/sciam/MaClasseGenereeAvancee
   #3 = Utf8               java/util/Date
   #4 = Class              #3             // java/util/Date
   #5 = Utf8               YEAR
   #6 = Utf8               I
   #7 = Integer            2025
   #8 = Utf8               main
   #9 = Utf8               ([Ljava/lang/String;)V
  #10 = Utf8               java/io/Serializable
  #11 = Class              #10            // java/io/Serializable
  #12 = Utf8               ConstantValue
  #13 = Utf8               Code

Le builder CodeBuilder nous permet de descendre jusqu’au niveau du code et de ses instructions. Cela nécessite une certaine connaissance de la JVM et de ses opérations de bas niveau. La documentation est disponible sur le site d’Oracle.

Pour que notre méthode main() générée affiche un traditionnel "Hello World", nous allons avoir besoin :

  • d’invoquer l’opération getstatic pour obtenir une référence vers le champ statique out (de type java.io.PrintStream) de la classe java.lang.System, sur la pile

  • charger la chaîne de caractères "Hello World" depuis le "Constant Pool" sur la pile via l’opération ldc

  • enfin, invoquer la méthode PrintStream::println sur l’instance out avec pour paramètre la chaîne de caractères, via l’opération invokevirtual

La génération de ces appels successifs d’opérations de la JVM se fait par l’intermédiaire des méthodes de CodeBuilder qui portent les noms respectifs : invokevirtual(), loadconstant() et invokevirtual().


Compléter la méthode ecrireFichierDeClasseHelloWorld() afin de générer la classe fr.sciam.MonHelloWorld avec une méthode main() qui affiche Hello World dans la console.

  private static void ecrireFichierDeClasseHelloWorld() {
    ClassDesc classDesc = ClassDesc.of("fr.sciam.MonHelloWorld");
    byte[] bytes = ClassFile.of().build(
      classDesc,
      builder -> builder.withMethodBody(
        "main",
        MethodTypeDesc.of(
          ConstantDescs.CD_void,
          ConstantDescs.CD_String.arrayType()
        ),
        ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
        codeBuilder -> {
          ClassDesc printStreamClassDesc = ClassDesc.of("java.io.PrintStream");

          codeBuilder.getstatic(
              ClassDesc.of("java.lang.System"),
              "out",
              printStreamClassDesc
            )
            .loadConstant("Hello World")
            .invokevirtual(
              printStreamClassDesc,
              "println",
              MethodTypeDesc.of(
                ConstantDescs.CD_void,
                ConstantDescs.CD_Object
              )
            )
            .return_();
        }
      )
    );

    sauvegarderFichier(bytes, "fr/sciam/MonHelloWorld.class");
  }

Exécuter la classe MainClassFileAPI puis la commande javap pour inspecter le fichier de classe généré, et vérifier la séquence d’instructions de la méthode main().

Depuis le dossier classfile/ contenant la classe générée
javap -classpath . -verbose fr.sciam.MonHelloWorld

Vérifier que l’on obtient notamment :

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #10                 // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #12                 // String Hello World
         5: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
         8: return

Enfin, exécuter la classe fr.sciam.MonHelloWorld avec la commande java et vérifier que le message Hello World s’affiche dans la console

$ java -classpath . fr.sciam.MonHelloWorld
Hello World
4.2.3.6. La transformation de fichiers de classe

De très fréquents cas d’usage consistent à parser un fichier de classe puis opérer un certain nombre de transformations localisées. On combine alors lecture et écriture dans la même phase en laissant la majorité des éléments non-altérés.

L’API Class-File fournit pour chaque type de builder une méthode with() acceptant le type d’élément correspondant au builder, afin de passer au builder les éléments qui doivent rester inchangés par le processus de transformation :

  • with(ClassElement element) pour ClassBuilder

  • with(FieldElement element) pour FieldBuilder

  • with(MethodElement element) pour MethodBuilder

  • …​

Pour faciliter les transformations, l’API fournit également pour chaque type de modèle :

  • un transformateur capable d’opérer sur les éléments du modèle

    • ClassTransform opère sur les éléments ClassElement

    • MethodTransform opère sur les éléments MethodElement

    • …​

  • des méthodes de transformation sur le builder pour traiter les éléments enfants

    • ClassBuilder possède les méthodes transformField() et transformMethod()

    • MethodBuilder possède une méthode transformCode()

    • …​

Les transformateurs sont des interfaces fonctionnelles qui s’expriment de manière pratique sous forme d’expression lambda définie avec deux paramètres : le builder et l’élément concernés.

Exemple pour ClassTransformbuilder est de type ClassBuilder et element de type ClassElement
ClassTransform ct = (builder, element) -> {
  // Transformation
};

L’interface ClassFile fournit la méthode transformClass(ClassModel model, ClassTransform transform) pour appliquer la transformation à l’instance. Elle retourne le tableau d’octets byte[] correspondant au bytecode transformé.


Consulter la classe fr.sciam.workshop.javase.classfile.AccumulateurAvance.

Compléter la méthode transformerFichierDeClasse() afin d’obtenir une instance de ClassModel correspondante, puis définir le transformateur qui permet de transformer la classe en ajoutant le modificateur synchronized à toutes ses méthodes public.

Enfin, appliquer la transformation et sauvegarder le fichier .class dans le répertoire classfile.

Le constructeur étant modélisé par un MethodModel, pour ne pas le transformer on filtrera sur son nom <init> renvoyé par methodName().
  private static void transformerFichierDeClasse() throws IOException {
    // Obtention du ClassModel correspondant à AccumulateurAvance
    Path path = obtenirChemin(AccumulateurAvance.class);
    ClassModel classModel = ClassFile.of().parse(path);

    ClassTransform ct = (builder, element) -> {

      // Filtrage sur les méthodes publiques (hors constructeur)
      if (element instanceof MethodModel mm
        && mm.flags().has(AccessFlag.PUBLIC)
        && !"<init>".equals(mm.methodName().stringValue())) {

        // Ajout de la méthode à l'identique avec modificateur synchronized
        builder.withMethod(
          mm.methodName().stringValue(),
          mm.methodTypeSymbol(),
          mm.flags().flagsMask(),
          methodBuilder -> {
            mm.forEach(methodBuilder::with);
            methodBuilder.withFlags(AccessFlag.PUBLIC, AccessFlag.SYNCHRONIZED);
          }
        );

      } else {
        // Conservation de l'élément à l'identique
        builder.with(element);
      }
    };

    byte[] bytecode = ClassFile.of().transformClass(classModel, ct);
    sauvegarderFichier(bytecode, "fr/sciam/AccumulateurAvance.class");
  }

Exécuter la classe MainClassFileAPI puis la commande javap du JDK pour inspecter le fichier de classe généré

Depuis le dossier classfile/ contenant la classe générée
javap -classpath . -verbose fr.sciam.AccumulateurAvance

Vérifier que l’on obtient notamment :

  public fr.sciam.workshop.javase.classfile.AccumulateurAvance();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC

[...]

  public synchronized int ajouter(int);
    descriptor: (I)I
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED

[...]

  public synchronized int soustraire(int);
    descriptor: (I)I
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED

Ce code reste relativement verbeux et force à recréer les éléments que l’on souhaite transformer à partir de ceux lus, avant de pouvoir les modifier.
Heureusement, l’API fournit un certain nombre de méthodes statiques pour faciliter un grand nombre de cas d’usages.

Par exemple, pour appliquer des transformations sur des éléments précis du modèle de données :

  • ClassTransform fournit les méthodes transformingFields(), transformingMethodBodies(), transformingMethods()

  • MethodTransform fournit la méthode transformingCode()

ClassTransform, FieldTransform, MethodTransform, CodeTransform fournissent les méthodes :

  • atStart() pour opérer une transformation avant que tout élément ne soit parcouru

  • atEnd() pour opérer une transformation après que tous les éléments ont été parcourus

  • andThen() pour chaîner les transformations

  • ofStateful() pour fournir un transformateur qui sera appelé à chaque transformation, afin de maintenir un état tout au long du processus de transformation

  • endHandler() crée un transformateur qui passe au builder tous les éléments traversés (en invoquant with()), puis invoque à la fin le Consumer passé en paramètres pour effectuer une transformation

ClassTransform, FieldTransform, MethodTransform fournissent une méthode dropping() qui permet d’omettre de la transformation les éléments qui répondent au prédicat passé en paramètre, pour le type d’élément concerné (respectivement ClassElement, FieldElement et MethodElement).


Simplifier la méthode transformerFichierDeClasse() en utilisant ClassTransform::transformingMethods et MethodTransform::endHandler.

  private static void transformerFichierDeClasse() throws IOException {
    Path path = obtenirChemin(AccumulateurAvance.class);
    ClassModel classModel = ClassFile.of().parse(path);

    // Filtrage sur les méthodes publiques (hors constructeur)
    Predicate<MethodModel> predicate = methodModel ->
      methodModel.flags().has(AccessFlag.PUBLIC)
        && !"<init>".equals(methodModel.methodName().stringValue());

    // Transformation de la méthode avec ajout du modificateur synchronized
    MethodTransform mt = MethodTransform.endHandler(builder ->
      builder.withFlags(AccessFlag.PUBLIC, AccessFlag.SYNCHRONIZED)
    );
    ClassTransform ct = ClassTransform.transformingMethods(predicate, mt);

    byte[] bytecode = ClassFile.of().transformClass(classModel, ct);
    sauvegarderFichier(bytecode, "fr/sciam/AccumulateurAvance.class");
  }

Exécuter la classe MainClassFileAPI puis la commande javap du JDK pour inspecter le fichier de classe généré, et vérifier que l’on obtient le même résultat que précédemment.

4.3. Le formatage et le parsing de données

Plusieurs évolutions dans les API concernent le formatage et le parsing de certaines données.

4.3.1. Lab : le formatage des nombres compacts

Le formatage compact des nombres fait référence à la représentation d’un nombre sous une forme plus courte. Il consiste à appliquer un ensemble de règles sur une valeur numérique pour en obtenir une représentation compacte. Ces règles sont basées sur une Locale, qui détermine les symboles et les règles de formatage utilisés pour les différentes plages de nombres. Elles sont définies par la spécification LDML pour les formats de nombres compacts.

La forme compacte permet un affichage dans un environnement restreint mais aussi facilite la lecture de grand nombre. Par exemple avec la Locale FR, des nombres comme 1000 peuvent être formatés comme "1 k" (style court) ou "1 millier" (style long). De même, "M" sera utilisé pour million et "Md" pour milliard.

JDK

Standard en Java 12

OpenJDK Issue

JDK-8188147

La classe java.text.CompactNumberFormat, qui hérite de la classe java.text.NumberFormat, permet de formater et analyser un nombre décimal sous sa forme compacte/courte en tenant compte de la Locale avec une gestion de l’arrondi.

Les nombres sont exprimés dans les formats de nombres compacts "court" et "long" grâce à la nouvelle énumération NumberFormat.Style qui propose deux valeurs : LONG et SHORT.

4.3.1.1. Le formatage compact par défaut

La fabrique getCompactNumberInstance() de NumberFormat sans paramètre renvoie une instance de type CompactNumberFormat pour la Locale par défaut et le style SHORT.

Les surcharges de la méthode format() permettent d’obtenir la représentation compacte de la valeur passée en paramètre.


Compléter la méthode formaterSimple() de la classe fr.sciam.workshop.javase.compactformat.MainCompactNumberFormat pour obtenir une instance de type NumberFormat configurée par défaut pour formater la liste nombreRonds et l’afficher.

  private static void formaterSimple() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance();
    LongStream.of(nombreRonds).forEach( n -> System.out.printf("%-12s -> %s\n", n, fmt.format(n)));
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Formatage par défaut
100          -> 100
1000         -> 1 k
10000        -> 10 k
100000       -> 100 k
1000000      -> 1 M
1000001      -> 1 M
10000000     -> 10 M
10000001     -> 10 M
10000000000  -> 10 Md
10000000001  -> 10 Md
Le caractère d’espacement utilisé dans le format SHORT pour la Locale FR est NO-BREAK SPACE (U+00A0 ou le caractère 160), et non SPACE (U+0020 ou le caractère 32).
4.3.1.2. Le formatage compact en précisant la Locale et le style

La fabrique getCompactNumberInstance(Locale locale, NumberFormat.Style formatStyle) renvoie une instance de type NumberFormat pour le formatage compact configurée avec la Locale et le style fournis en paramètres.


Compléter la méthode formaterAvecLocaleLong() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée par défaut pour formater la liste nombreronds et l’afficher.

  private static void formaterAvecLocaleLong() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.FRANCE, NumberFormat.Style.LONG);
    LongStream.of(nombreRonds).forEach( n -> System.out.printf("%-12s -> %s\n", n, fmt.format(n)));
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Formatage avec Locale long
100          -> 100
1000         -> 1 millier
10000        -> 10 mille
100000       -> 100 mille
1000000      -> 1 million
1000001      -> 1 million
10000000     -> 10 million
10000001     -> 10 millions
10000000000  -> 10 milliard
4.3.1.3. La gestion de l’arrondi

Par défaut le formatage utilise l’arrondi RoundingMode.HALF_EVEN mais il est possible d’utiliser d’autres arrondis en utilisant la méthode setRoundingMode(RoundingMode).


Compléter la méthode formaterAvecArrondi() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée par défaut pour formater la liste nombreArrondis. L’utiliser pour afficher chaque valeur avec les arrondis HALF_DOWN, HALF_EVEN et HALF_UP.

  private static void formaterAvecArrondi() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.FRANCE, NumberFormat.Style.SHORT);

    System.out.printf("%-12s %-12s %-12s %-12s\n", "", "HALF_DOWN", "HALF_EVEN", "HALF_UP");
    LongStream.of(nombresArrondis).forEach( n -> {
      fmt.setRoundingMode(RoundingMode.HALF_DOWN);
      String down = fmt.format(n);
      fmt.setRoundingMode(RoundingMode.HALF_EVEN);
      String even = fmt.format(n);
      fmt.setRoundingMode(RoundingMode.HALF_UP);
      String up = fmt.format(n);
      System.out.printf("%-12s %-12s %-12s %-12s\n", n, down, even, up);
    });
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Formatage avec arrondi
             HALF_DOWN    HALF_EVEN    HALF_UP
1200         1 k          1 k          1 k
1500         1 k          2 k          2 k
1700         2 k          2 k          2 k
2500         2 k          2 k          3 k
999          999          999          999
9999         10 k         10 k         10 k
999999       1 M          1 M          1 M
4.3.1.4. la gestion de la partie décimale

Par défaut, le nombre de chiffres de la partie décimale est 0 mais il est possible de le modifier en utilisant la méthode setMinimumFractionDigits().


Compléter la méthode formaterDecimaux() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée par défaut. Modifier le nombre de chiffre décimaux entre 0 et 2 pour formater la liste nombreDecimaux et l’afficher.

  private static void formaterDecimaux() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance();
    fmt.setMinimumFractionDigits(0);
    fmt.setMaximumFractionDigits(2);
    LongStream.of(nombresDecimaux).forEach( n -> System.out.printf("%-12s -> %s\n", n, fmt.format(n)));
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Formatage des décimaux
101          -> 101
1023         -> 1,02 k
1234         -> 1,23 k
10345        -> 10,35 k
10678        -> 10,68 k
999999       -> 1 M
4.3.1.5. La personnalisation des patterns

Il est également possible d’obtenir une instance personnalisée et définir la manière dont les nombres seront représentés sous une forme plus courte à l’aide du constructeur CompactNumberFormat(String, DecimalFormatSymbols, String[]).

Le format du compactPattern est fourni sous forme d’un tableau de chaînes de caractères qui peut contenir un à plusieurs motifs dont l’index dans le tableau correspond au début de la plage de valeurs 10index. Il est décrit dans la section Compact Number Patterns de la Javadoc.


Compléter la méthode formaterAvecPatterns() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée avec un pattern dédié pour formater la liste nombreRonds et l’afficher.

  private static void formaterAvecPatterns() {
    String[] compactPatterns = { "", "", "", "0k", "00k", "000k", "0m", "00m", "000m",
                                 "0md", "00md", "000md", "0t", "00t", "000t" };

    DecimalFormat decimalFormat = (DecimalFormat) NumberFormat.getNumberInstance(Locale.FRANCE);

    CompactNumberFormat fmt = new CompactNumberFormat(decimalFormat.toPattern(),
        decimalFormat.getDecimalFormatSymbols(), compactPatterns);

    LongStream.of(nombreRonds).forEach( n -> System.out.printf("%-12s -> %s\n", n, fmt.format(n)));
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Formatage avec patterns
100          -> 100
1000         -> 1k
10000        -> 10k
100000       -> 100k
1000000      -> 1m
1000001      -> 1m
10000000     -> 10m
10000001     -> 10m
10000000000  -> 10md
10000000001  -> 10md
4.3.1.6. L’extraction de la valeur d’un nombre compact

Une instance de type CompactNumberFormat permet aussi d’analyse une chaîne de caractères contenant un nombre compact en utilisant la méthode parse(String).


Compléter la méthode parser() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée pour la Locale FRANCE et le style LONG, utilisée pour analyser plusieurs chaînes contenant des valeurs compactes en français et les afficher.

  private static void parser() {
    var nombresTextes = List.of("100", "1 millier", "10 mille", "10 millions", "1 milliard");

    NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.FRANCE, NumberFormat.Style.LONG);

    for (String n : nombresTextes) {
      try {
        System.out.printf("%-12s -> %s\n", n, fmt.parse(n));
      } catch (ParseException e) {
        e.printStackTrace(System.out);
      }
    }
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Parser
100          -> 100
1 millier    -> 1000
10 mille     -> 10000
10 millions  -> 10000000
1 milliard   -> 1000000000

Par défaut, l’analyse ne prend pas en compte d’éventuels séparateurs de groupe. La méthode setGroupingUsed(boolean) en lui passant en paramètre la valeur true permet de modifier ce comportement.


Compléter la méthode parserAvecGroupe() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée pour la Locale US et le style SHORT, utilisée pour analyser la chaîne "1,000K" avec et la gestion du groupement et les afficher.

  private static void parserAvecGroupe() {
    String texte = "";
    NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT);
    try {
      texte = "1,000K";
      System.out.printf("%-12s -> %s\n", texte, fmt.parse(texte));
      fmt.setGroupingUsed(true);
      System.out.printf("%-12s -> %s\n", texte, fmt.parse(texte));
    } catch (ParseException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Parser avec groupe
1,000K       -> 1
1,000K       -> 1000000

Par défaut, l’analyse se fait sur la partie entière et la partie décimale. Pour que l’analyse ne tienne compte que de la partie entière, il faut utiliser la méthode setParseIntegerOnly() en lui passant en paramètre la valeur true.


Compléter la méthode parserEntier() de la classe MainCompactNumberFormat pour obtenir une instance de type CompactNumberFormat configurée pour la Locale FRANCE et le style LONG, utilisée pour analyser la chaîne "1,5 milliers" avec la partie décimale et avec la partie entière uniquement et les afficher.

  private static void parserEntier() {
    NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.FRANCE, NumberFormat.Style.LONG);
    try {
      String texte = "1,5 milliers";
      System.out.printf("%-12s -> %s\n", texte, fmt.parse(texte));
      fmt.setParseIntegerOnly(true);
      System.out.printf("%-12s -> %s\n", texte, fmt.parse(texte));
    } catch (ParseException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainCompactNumberFormat pour vérifier l’affichage.

Parser entier
1,5 milliers -> 1.5
1,5 milliers -> 1

 

4.3.2. Lab : le formatage des listes de chaînes

La classe java.text.ListFormat, qui hérite de java.text.Format, formate ou analyse une liste de chaînes de caractères en tenant compte des spécificités locales, d’un type et d’un style.

JDK

Standard en Java 22

OpenJDK Issue

JDK-8041488

Le type détermine la ponctuation entre les chaînes et les mots de liaison, le cas échéant. Trois types de formatages sont proposés via l’énumération java.text.ListFormat.Type qui contient trois valeurs :

  • STANDARD : pour une liste avec "et" (par défaut)

  • OR : pour une liste avec "ou"

  • UNIT : pour une liste unitaire soit avec "et" soit avec seulement des virgules selon la Locale

Le style détermine la façon dont les chaînes sont abrégées ou non. Trois styles de formatages sont également proposés pour chaque type via l’énumération java.text.ListFormat.Style qui contient trois valeurs :

  • FULL : les mots de liaison tels que "et" et "ou" sont écrits en toutes lettres (par défaut)

  • SHORT : les mots de liaison sont écrits en entier ou en abrégé, selon la Locale

  • NARROW : selon la langue, les mots de liaison sont écrits ou omis et les virgules peuvent également être omises

La surcharge de la méthode getInstance() sans paramètre permet d’obtenir une instance pour la Locale, le type et le style par défaut.


Compléter la méthode formaterParDefaut() de la classe fr.sciam.workshop.javase.listformat.MainListFormat pour obtenir une instance de type ListFormat configurée par défaut pour formatter la liste couleurs et l’afficher.

  private static void formaterParDefaut() {
    System.out.println(ListFormat.getInstance().format(couleurs));
  }

Exécuter la classe MainListFormat pour vérifier l’affichage.

Formatage par défaut
vert, orange et rouge

La surcharge getInstance(Locale locale, ListFormat.Type type, ListFormat.Style style) permet de préciser la Locale, le type et le style à utiliser.


Compléter la méthode formaterAvecLocaleFr() de la classe MainListFormat pour obtenir des instances de type ListFormat configurées avec la Locale.FRANCE et différents types et styles pour formatter la liste couleurs et l’afficher.

  private static void formaterAvecLocaleFr() {
    System.out.println(ListFormat.getInstance(Locale.FRANCE, ListFormat.Type.STANDARD, ListFormat.Style.FULL)
        .format(couleurs));
    System.out.println(ListFormat.getInstance(Locale.FRANCE, ListFormat.Type.STANDARD, ListFormat.Style.NARROW)
        .format(couleurs));
    System.out.println(ListFormat.getInstance(Locale.FRANCE, ListFormat.Type.OR, ListFormat.Style.FULL)
        .format(couleurs));
    System.out.println(ListFormat.getInstance(Locale.FRANCE, ListFormat.Type.UNIT, ListFormat.Style.NARROW)
        .format(couleurs));
  }

Exécuter la classe MainListFormat pour vérifier l’affichage.

Formatage avec Locale FR
vert, orange et rouge
vert, orange, rouge
vert, orange ou rouge
vert orange rouge

Le formatage dépend de la Locale utilisée et s’y adapte.


Compléter la méthode formaterAvecLocaleUs() de la classe MainListFormat pour obtenir une instance de type ListFormat configurée avec Locale.US pour formatter la liste couleurs aux formats STANDARD/FULL, STANDARD/SHORT et OR/FULL et les afficher.

  private static void formaterAvecLocaleUs() {
    System.out.println(ListFormat.getInstance(Locale.US, ListFormat.Type.STANDARD, ListFormat.Style.FULL)
        .format(couleurs));
    System.out.println(ListFormat.getInstance(Locale.US, ListFormat.Type.STANDARD, ListFormat.Style.SHORT)
        .format(couleurs));
    System.out.println(ListFormat.getInstance(Locale.US, ListFormat.Type.OR, ListFormat.Style.FULL)
        .format(couleurs));
  }

Exécuter la classe MainListFormat pour vérifier l’affichage.

Formatage avec Locale US
vert, orange, and rouge
vert, orange, & rouge
vert, orange, or rouge

Il est également possible d’obtenir une instance indépendante de la Locale, du type et du style à l’aide de la surcharge getInstance(String[]).


Compléter la méthode formaterAvecPatterns() de la classe MainListFormat pour obtenir une instance de type ListFormat configurée pour formater la liste couleurs avec des patterns dédiés et l’afficher.

  private static void formaterAvecPatterns() {
    System.out.println(ListFormat.getInstance(new String[]{"{0} et {1}", "{0} puis {1}", "{0} et finalement {1}", "", "{0} puis {1} et finalement {2}"})
        .format(couleurs));
  }

Exécuter la classe MainListFormat pour vérifier l’affichage.

Formatage avec patterns
vert puis orange et finalement rouge
Pour le détail du contenu des patterns dans le tableau de chaîne de caractères, il faut consulter la Javadoc de la classe java.text.ListFormat.

La classe ListFormat peut aussi analyser une chaîne formatée selon la Locale, le type et le style fournis pour extraire une List<String>.


Compléter la méthode parser() de la classe MainListFormat pour obtenir une instance de type ListFormat configurée par défaut pour analyser une chaîne et en extraire les différentes chaînes et les afficher.

  private static void parser() {
    try {
      List<String> elements = ListFormat.getInstance()
          .parse("Ni, Cu, Zn et Fe");
      System.out.println(elements);
    } catch (ParseException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainListFormat pour vérifier l’affichage.

Parser
[Ni, Cu, Zn, Fe]

 

4.4. La programmation parallèle et concurrente

Plusieurs API concernent des évolutions relatives à la programmation parallèle et concurrente.

4.4.1. Lab : les threads virtuels

Un thread virtuel est un nouveau type de thread "léger" géré dans la JVM. Contrairement aux threads conventionnels pour lesquels il existe un mapping un-pour-un avec les threads de l’OS, un thread virtuel n’est pas spécifiquement lié à un thread de l’OS.

La JVM possède un ForkJoinPool de threads "porteurs" (carrier threads) dont le rôle est d’exécuter les traitements non bloquants des threads virtuels. Les threads virtuels dont les opérations sont bloquantes peuvent être détachés de leur thread porteur, rendant ce dernier disponible pour exécuter des traitements d’autres threads virtuels.

Ils présentent donc un avantage lorsqu’ils réalisent majoritairement des opérations non intensives en CPU puisque l’on mutualise des threads de l’OS, évitant ainsi d’en créer de nouveaux.

D’un point de vue utilisateur, les threads virtuels héritent de java.lang.Thread pour des raisons de compatibilité.

JDK

Première preview en Java 19 JEP 425
Standard en Java 21

JEP

444: Virtual Threads

4.4.1.1. La création et le démarrage d’un thread virtuel

La classe java.lang.Thread est enrichie de nouvelles méthodes, notamment startVirtualThread() qui permet de créer et démarrer un thread virtuel qui exécute le Runnable passé en paramètre.


Compléter la méthode creerEtDemarrerThreadVirtuel de la classe fr.sciam.workshop.javase.virtualthreads.MainThreadsVirtuels afin de créer et démarrer un thread virtuel qui affiche son nom et un message.

  private static void creerEtDemarrerThreadVirtuel() throws InterruptedException {
    Runnable runnable = () -> System.out.println(Thread.currentThread() + " Bonjour depuis un thread virtuel");
    Thread monThreadVirtuel = Thread.startVirtualThread(runnable);
    monThreadVirtuel.join();
  }

Exécuter la classe MainThreadsVirtuels et vérifier qu’elle affiche dans la console :

VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1 Bonjour depuis un thread virtuel

L’interface scellée Thread.Builder permet de configurer et obtenir une instance de thread virtuel. Elle possède deux interfaces filles Thread.Builder.OfVirtual et Thread.Builder.OfPlatform respectivement pour configurer et obtenir une instance d’un thread virtuel ou d’un thread de la plateforme.

La classe Thread propose les fabriques :

  • Thread.ofVirtual() pour obtenir une instance de Thread.Builder.OfVirtual

  • Thread.ofPlatform() pour obtenir une instance de Thread.Builder.OfPlatform.


Compléter la méthode creerThreadsVirtuelsAvecFabrique() afin d’utiliser la fabrique ofVirtual() pour obtenir un Thread.Builder et invoquer ses méthodes start() et unstarted() pour créer deux threads virtuels dont le premier est démarré.

  private static void creerThreadsVirtuelsAvecFabrique() throws InterruptedException {
    Runnable r1 = () -> System.out.println(Thread.currentThread() + " Thread t1 créé et démarré avec ofVirtual()");
    Thread t1 = Thread.ofVirtual().start(r1); (1)

    Runnable r2 = () -> System.out.println(Thread.currentThread() + " Thread t2 créé avec ofVirtual()");
    Thread t2 = Thread.ofVirtual().unstarted(r2); (2)

    t1.join();
    t2.join();
  }
1 Crée et démarre un thread virtuel qui exécute le Runnable
2 Crée, mais ne démarre pas un thread virtuel avec le Runnable

Exécuter la classe MainThreadsVirtuels et vérifier que seul le premier thread a été démarré. La console doit afficher dans la console un message de la forme :

VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1 Thread t1 créé et démarré avec ofVirtual()

L’interface Thread.Builder permet de paramétrer le nommage des threads créés.


Dans la méthode creerThreadsNommesAvecFabrique(), configurer le builder via la méthode name() afin de nommer les threads créés avec celle-ci MonThreadNommé. Démarrer deux threads avec le builder.

  private static void creerThreadsNommesAvecFabrique() throws InterruptedException {
    Thread.Builder builder = Thread.ofVirtual().name("MonThreadNommé");
    builder.start(() -> System.out.println("Thread 1 " + Thread.currentThread())).join();
    builder.start(() -> System.out.println("Thread 2 " + Thread.currentThread())).join();
  }

Exécuter la classe MainThreadsVirtuels pour vérifier que les threads créés avec cette fabrique sont nommés :

Thread 1 VirtualThread[#35,MonThreadNommé]/runnable@ForkJoinPool-1-worker-1
Thread 2 VirtualThread[#36,MonThreadNommé]/runnable@ForkJoinPool-1-worker-1

La méthode name(String) nomme tous les threads créés de la même manière. La surcharge name(String, int) permet de nommer en ajoutant un suffixe avec un numéro qui s’incrémente afin de différencier les threads. La valeur entière passée en paramètre indique le numéro de départ.


Modifier la méthode creerThreadsNumerotesAvecFabrique() pour :

  • définir l’implémentation d’un Runnable qui affiche le thread courant

  • configurer le builder avec le nommage incrémental.

  • démarrer 5 threads qui exécutent le Runnable

  private static void creerThreadsNumerotesAvecFabrique() throws InterruptedException {
    Runnable runnable = () -> System.out.println(Thread.currentThread());
    Thread.Builder builder = Thread.ofVirtual().name("BaseNom-", 0);
    for (int i = 0; i < 5; i++) {
      builder.start(runnable).join();
    }
  }

Exécuter la classe MainThreadsVirtuels pour vérifier que les threads créés avec cette fabrique sont nommés :

VirtualThread[#36,BaseNom-0]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#37,BaseNom-1]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#38,BaseNom-2]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#39,BaseNom-3]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#40,BaseNom-4]/runnable@ForkJoinPool-1-worker-1

La classe utilitaire java.util.concurrent.Executors fournit par l’intermédiaire de sa fabrique newVirtualThreadPerTaskExecutor() une instance d'ExecutorService permettant de soumettre des tâches qui seront exécutées par des threads virtuels. Comme son nom l’indique, chaque tâche soumise est exécutée dans un nouveau thread virtuel compte tenu de leur faible coût de création.

Depuis le JDK 19, la classe ExecutorService implémente l’interface AutoCloseable : une instance est gérable par un try-with-resources.


Dans la méthode creerThreadsVirtuelsAvecExecuteur(), utiliser la fabrique newVirtualThreadPerTaskExecutor() pour soumettre des tâches à exécuter par des threads virtuels.

  private static void creerThreadsVirtuelsAvecExecuteur() {
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
      executor.submit(() -> System.out.println("Tache 1 " + Thread.currentThread()));
      executor.submit(() -> System.out.println("Tache 2 " + Thread.currentThread()));
      executor.submit(() -> System.out.println("Tache 3 " + Thread.currentThread()));
    }
  }

Exécuter la classe MainThreadsVirtuels et vérifier qu’un thread virtuel dédié a été employé pour chaque tâche.

Tache 1 - VirtualThread[#42]/runnable@ForkJoinPool-1-worker-1
Tache 2 - VirtualThread[#43]/runnable@ForkJoinPool-1-worker-2
Tache 3 - VirtualThread[#45]/runnable@ForkJoinPool-1-worker-3
4.4.1.2. Les propriétés et restrictions des threads virtuels

Un thread virtuel est soumis à certaines restrictions :

  • il est obligatoirement un thread démon,

  • sa priorité est obligatoirement Thread.NORM_PRIORITY,

  • les méthodes stop(), resume() et suspend() lèvent une UnsupportedOperationException,

  • il ne peut pas être associé à un ThreadGroup,

  • la méthode getThreadGroup() renvoie un groupe fictif vide nommé "VirtualThreads",

  • la méthode getAllStackTraces() renvoie désormais une Map qui ne contient que les threads de la plateforme plutôt que tous les threads.


Compléter la méthode testerProprietesDuThreadVirtuel() afin de vérifier un certain nombre des propriétés énoncées.

  private static void testerProprietesDuThreadVirtuel() throws InterruptedException {
    Thread thread = Thread.ofVirtual().name("ThreadVirtuel-", 0).start(() -> {
      try {
        Thread.sleep(1_000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });

    System.out.println("isVirtual   : " + thread.isVirtual());
    System.out.println("priority    : " + thread.getPriority() + " (Thread.NORM_PRIORITY = " + Thread.NORM_PRIORITY + ")");
    System.out.println("isDeamon    : " + thread.isDaemon());
    System.out.println("threadgroup : " + thread.getThreadGroup());

    try {
      thread.stop();
    } catch (UnsupportedOperationException e) {
      e.printStackTrace();
    }

    thread.join();
  }

Exécuter la classe MainThreadsVirtuels et vérifier les informations affichées :

isVirtual   : true
priority    : 5 (Thread.NORM_PRIORITY = 5)
isDeamon    : true
threadgroup : java.lang.ThreadGroup[name=VirtualThreads,maxpri=10]
java.lang.UnsupportedOperationException
	at java.base/java.lang.Thread.stop(Thread.java:1654)
	at fr.sciam.workshop.javase.virtualthreads.MainThreadsVirtuels.testerProprietesDuThreadVirtuel(MainThreadsVirtuels.java:83)
	at fr.sciam.workshop.javase.virtualthreads.MainThreadsVirtuels.main(MainThreadsVirtuels.java:26)

Les traitements d’un thread virtuels sont exécutés par un thread du ForkJoinPool dédié de la JVM. Lorsqu’un thread virtuel réalise une opération bloquante, il est détaché de son thread porteur. Lorsque le traitement bloquant se termine, son exécution pourra être réalisée par un autre thread porteur du pool.


Dans la méthode creerThreadsVirtuelsEtAfficherPorteurs(), créer plusieurs threads virtuels qui effectuent des opérations bloquantes à plusieurs reprises et afficher à chaque fois le thread porteur.

  private static void creerThreadsVirtuelsEtAfficherPorteurs() throws InterruptedException {

    Map<Long, List<String>> resultat = new ConcurrentHashMap<>(); (1)

    // Démarrage des threads et attente
    List<Thread> threads = IntStream.range(0, 3)
      .mapToObj(index -> Thread.ofVirtual().start(() -> executerTraitement(resultat)))
      .toList();

    for (Thread thread : threads) {
      thread.join();
    }

    // Affichage du résultat de l'exécution
    resultat.forEach((threadId, list) -> {
      System.out.println("\nThread virtuel #" + threadId);
      list.forEach(s -> System.out.println("    " + s));
    });
  }

  private static void executerTraitement(Map<Long, List<String>> resultat) {
    for (int i = 0; i < 6; i++) {
      long threadId = Thread.currentThread().threadId();
      List<String> list = resultat.computeIfAbsent(threadId, key -> new ArrayList<>());
      list.add(Thread.currentThread().toString());

      try {
        Thread.sleep(500);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
1 Utilisation d’une map pour stocker l’historique des threads porteurs

Exécuter la classe MainThreadsVirtuels et vérifier que lorsque le même thread virtuel est exécuté, cela peut être par des threads porteurs différents.

Thread virtuel #32
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-3
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-3
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#32]/runnable@ForkJoinPool-1-worker-2

Thread virtuel #33
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-2
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
    VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3

Thread virtuel #30
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-2
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-2
    VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1

Il existe cependant quelques situations dans lesquelles le thread virtuel reste "épinglé" (pinned) au même thread porteur :

  • jusqu’au JDK 24, l’exécution de code au sein d’un bloc synchronized. Mais cela a été résolu grâce à la JEP 491 : Synchronize Virtual Threads without Pinning. À partir du JDK 24, ce n’est donc plus un cas de figure qui provoque un cas de "pinning"

  • l’exécution d’une méthode native, lors d’un "upcall" (appel natif vers Java) dans lequel le code exécuté côté Java est bloquant

  • certains rares cas d’initialisation de classe explicités dans la JEP 491

Ces cas d’usages font perdre l’intérêt de l’utilisation des threads virtuels : ils restent associés à leurs threads porteurs et les bloquent.

L’événement JFR jdk.VirtualThreadPinned permet de tracer ces cas de "pinning". Par défaut, il est déclenché pour les blocages au-delà d’un seuil de 20ms et la stackTrace associée est activée.

Voici un exemple d’un tel événement :

jdk.VirtualThreadPinned {
  startTime = 17:04:34.528 (2025-03-19)
  duration = 99,0 ms
  blockingOperation = "LockSupport.park"
  pinnedReason = "Native or VM frame on stack"
  carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 52)
  eventThread = "" (javaThreadId = 51, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 834
    java.lang.VirtualThread.parkNanos(long) line: 802
    java.lang.VirtualThread.sleepNanos(long) line: 967
    java.lang.Thread.sleepNanos(long) line: 480
    java.lang.Thread.sleep(long) line: 513
    QsortPin$Qsort.qsortCompare(MemorySegment, MemorySegment) line: 77
    QsortPin.qsortTest(int[], Arena) line: 101
    QsortPin.lambda$main$1(Arena) line: 120
    java.lang.VirtualThread.run(Runnable) line: 466
    jdk.internal.vm.Continuation.enterSpecial(Continuation, boolean, boolean)
  ]
}

4.4.2. Lab : la Structured Concurrency

Écrire du code multi-threadé est souvent une tâche délicate. La JEP 462 : Structured Concurrency propose un modèle de programmation visant à en simplifier l’écriture en traitant comme une seule unité de travail un certain nombre de sous-tâches, améliorant ainsi la gestion des erreurs, l’annulation ainsi que la fiabilité et l’observabilité.

JDK

Première incubation en Java 19 JEP 428
Seconde incubation en Java 20 JEP 437
Première preview en Java 21 JEP 453
Seconde preview en Java 22 JEP 462
Troisième preview en Java 23 JEP 480

JEP

499: Structured Concurrency (Fourth Preview)

En Java 24, cette fonctionnalité est en preview.
4.4.2.1. Le fonctionnement général

La classe principale de l’API Structured Concurrency est la classe java.util.concurrent.StructuredTaskScope.

Sa mise en œuvre implique les étapes suivantes :

  • Création d’une instance dans un try-with-resources (StructuredTaskScope implémente AutoCloseable)

  • Définition des sous-tâches sous forme d’instances de Callable<T>

  • Invocation de la méthode fork() pour chaque sous-tâche à exécuter

  • Appel à la méthode joinUntil() ou join() de StructuredTaskScope pour attendre la fin de l’exécution (respectivement avec ou sans timeout)

  • Exploitation des résultats sous forme d’instances de type StructuredTaskScope.Subtask<T>

Les sous-tâches sont exécutées dans des threads virtuels, fonctionnalité détaillée dans le lab les threads virtuels


Dans le package fr.sciam.workshop.javase.structuredconcurrency, consulter l’interface ServiceMeteo.

Dans la classe MainStructuredConcurrency, compléter la méthode creerBulletinMeteo() afin de réaliser les actions suivantes :

  • Utiliser une instance de StructuredTaskScope pour créer des sous-tâches pour récupérer les températures de plusieurs Ville via l’instance serviceMeteoPrincipal de type ServiceMeteo

  • Exploiter les résultats des sous-tâches pour construire un appel à ServiceMeteo::afficherBulletin

  private void creerBulletinMeteo() throws InterruptedException {

    try (StructuredTaskScope<Double> scope = new StructuredTaskScope<>()) {

      Subtask<Double> nice = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.NICE));
      Subtask<Double> paris = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.PARIS));
      Subtask<Double> pontAMousson = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.PONT_A_MOUSSON));
      Subtask<Double> toulon = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.TOULON));

      scope.join();

      serviceMeteoPrincipal.afficherBulletin(
        Map.of(
          Villes.NICE, nice.get(),
          Villes.PARIS, paris.get(),
          Villes.PONT_A_MOUSSON, pontAMousson.get(),
          Villes.TOULON, toulon.get()
        )
      );
    }
  }

Exécuter la classe MainStructuredConcurrency et vérifier que l’on obtient dans la console :

Création du bulletin météo
Paris : 16.0
Nice : 18.0
Pont-à-Mousson : 13.0
Toulon : 17.0

Il est préférable de faire appel à la méthode joinUntil() afin d’avoir un timeout lors de l’attente de l’aboutissement de toutes les sous-tâches.


Modifier à nouveau la méthode creerBulletinMeteo() pour utiliser un timeout de 20 secondes et afficher la stacktrace en cas de TimeoutException.

Ajout d’un timeout
scope.joinUntil(Instant.now().plusSeconds(20));
Gestion de l’exception
} catch (TimeoutException e) {
  e.printStackTrace(System.out);
}

Exécuter la classe MainStructuredConcurrency et constater que l’on obtient le même résultat que précédemment.

Si le temps d’attente dépasse le délai accordé par le timeout passé en paramètre de joinUntil(), alors les sous-tâches en cours seront terminées et une exception de type TimeoutException est levée.


Modifier à nouveau la méthode creerBulletinMeteo() pour réduire le timeout à 1 seconde.

scope.joinUntil(Instant.now().plusSeconds(1));

Exécuter la classe MainStructuredConcurrency et constater que l’on obtient dans la console :

Création du bulletin météo
ServiceMeteoPrincipal : interruption lors de l'obtention de la température de Paris
ServiceMeteoPrincipal : interruption lors de l'obtention de la température de Toulon
ServiceMeteoPrincipal : interruption lors de l'obtention de la température de Nice
ServiceMeteoPrincipal : interruption lors de l'obtention de la température de Pont-à-Mousson
java.util.concurrent.TimeoutException
	at java.base/jdk.internal.misc.ThreadFlock.awaitAll(ThreadFlock.java:362)
	at java.base/java.util.concurrent.StructuredTaskScope.implJoin(StructuredTaskScope.java:616)
	at java.base/java.util.concurrent.StructuredTaskScope.joinUntil(StructuredTaskScope.java:682)
	at fr.sciam.workshop.javase.structuredconcurrency.MainStructuredConcurrency.creerBulletinMeteo(MainStructuredConcurrency.java:53)
	at fr.sciam.workshop.javase.structuredconcurrency.MainStructuredConcurrency.main(MainStructuredConcurrency.java:29)

Rétablir le timeout à 20 secondes :

scope.joinUntil(Instant.now().plusSeconds(20));
4.4.2.2. La classe StructuredTaskScope.ShutdownOnFailure

La classe StructuredTaskScope.ShutdownOnFailure qui hérite de StructuredTaskScope propose un modèle de type "invoke all" qui exécute toutes les sous-tâches et termine toutes les sous-tâches en cours si une sous-tâche lève une exception.

La méthode throwIfFailed() lève une exception de type ExecutionException si une sous-tâche ne se termine pas normalement.

Son invocation doit être précédée par l’invocation de la méthode join() ou joinUntil().

throwIfFailed() peut également lever deux types de RuntimeException : WrongThreadException et IllegalStateException, se référer à la javadoc pour plus de détails.


Compléter la méthode creerBulletinMeteoShutdownOnFailure() pour :

  • utiliser le scope ShutdownOnFailure au lieu du StructuredTaskScope précédemment utilisé

  • réaliser l’attente via la méthode joinUntil() avec un timeout de 20 secondes

  • effectuer un appel à throwIfFailed() et afficher l’exception dans le bloc catch en cas d’erreur

  private void creerBulletinMeteoShutdownOnFailure() throws InterruptedException {

    try (ShutdownOnFailure scope = new ShutdownOnFailure()) {

      Subtask<Double> nice = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.NICE));
      Subtask<Double> paris = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.PARIS));
      Subtask<Double> pontAMousson = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.PONT_A_MOUSSON));
      Subtask<Double> toulon = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.TOULON));

      scope.joinUntil(Instant.now().plusSeconds(20));
      scope.throwIfFailed();

      serviceMeteoPrincipal.afficherBulletin(
        Map.of(
          Villes.NICE, nice.get(),
          Villes.PARIS, paris.get(),
          Villes.PONT_A_MOUSSON, pontAMousson.get(),
          Villes.TOULON, toulon.get()
        )
      );
    } catch (TimeoutException | ExecutionException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainStructuredConcurrency et vérifier que toutes les sous-tâches ont abouti et que l’on a pu exploiter les résultats.

Vérifier l’affichage dans la console :

Création du bulletin météo avec ShutdownOnFailure
Toulon : 17.0
Pont-à-Mousson : 13.0
Nice : 18.0
Paris : 16.0


Toujours dans la méthode creerBulletinMeteoShutdownOnFailure(), utiliser l’instance serviceMeteoRestreint en lieu et place de serviceMeteoPrincipal. Cette implémentation lève une erreur lorsqu’une ville contient le caractère "-".

Subtask<Double> nice = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.NICE));
Subtask<Double> paris = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.PARIS));
Subtask<Double> pontAMousson = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.PONT_A_MOUSSON));
Subtask<Double> toulon = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.TOULON));

Exécuter la classe MainStructuredConcurrency et constater que l’on obtient :

Création du bulletin météo avec ShutdownOnFailure
java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: ServiceMeteoRestreint : le service ne sait pas traiter les villes contenant un trait d'union : Pont-à-Mousson
[...]
La méthode throwIfFailed() possède une surcharge qui lève l’exception retournée par l’implémentation de la fonction Function<Throwable, ? extends X> passée en paramètre.


Toujours dans la méthode creerBulletinMeteoShutdownOnFailure(), faire appel à la méthode throwIfFailed() qui accepte une Function afin de lever une exception de type ExceptionCustom.

Configuration de l’exception à lever
scope.throwIfFailed(ExceptionCustom::new);
Gestion de l’exception
} catch (TimeoutException | ExceptionCustom e) {
  e.printStackTrace(System.out);
}

Exécuter la classe MainStructuredConcurrency et constater que l’exception personnalisée est levée :

Création du bulletin météo avec ShutdownOnFailure
fr.sciam.workshop.javase.structuredconcurrency.ExceptionCustom: java.lang.IllegalArgumentException: ServiceMeteoRestreint : le service ne sait pas traiter les villes contenant un trait d'union : Pont-à-Mousson
[...]
4.4.2.3. La classe StructuredTaskScope.ShutdownOnSuccess

La classe StructuredTaskScope.ShutdownOnSuccess qui hérite de StructuredTaskScope propose un modèle de type "invoke any" qui renvoie le résultat de la première sous-tâche terminée et termine les autres sous-tâches restantes.

En cas de succès, la méthode result() permet d’obtenir le résultat de la première sous-tâche terminée. En cas d’échec, une exception est levée.


Compléter la méthode obtenirTemperatureShutdownOnSuccess() afin d’obtenir la température de la ville de Nice via serviceMeteoPrincipal et serviceMeteoRestreint en simultané, en renvoyant le premier des deux résultats ayant abouti.

Enfin, afficher les états des sous-tâches et le résultat obtenu.

  private void obtenirTemperatureShutdownOnSuccess() throws InterruptedException {

    try (ShutdownOnSuccess<Double> scope = new ShutdownOnSuccess<>()) {
      Subtask<Double> principal = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.NICE));
      Subtask<Double> restreint = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.NICE));

      scope.joinUntil(Instant.now().plusSeconds(20));

      System.out.println("Principal " + principal.state());
      System.out.println("Restreint " + restreint.state());
      System.out.println("Temperature : " + scope.result());

    } catch (TimeoutException | ExecutionException e) {
      e.printStackTrace();
    }
  }

Exécuter la classe MainStructuredConcurrency et constater que l’appel via l’instance serviceMeteoRestreint a abouti en premier (elle n’a pas de délai contrairement à serviceMeteoPrincipal, dont l’appel a été interrompu).

Obtenir température avec ShutdownOnSuccess
Principal UNAVAILABLE
Restreint SUCCESS
Temperature : 18.0
ServiceMeteoPrincipal : interruption lors de l'obtention de la température de Nice

Si une exception est levée dans une sous-tâche, mais qu’une autre sous-tâche parvient à obtenir le résultat, aucune exception n’est levée et le scope renvoie le résultat.


Modifier à nouveau la méthode obtenirTemperatureShutdownOnSuccess() afin d’obtenir cette fois-ci la température de la ville de Pont-à-Mousson.

Enfin, afficher les états des sous-tâches et le résultat obtenu.

Subtask<Double> principal = scope.fork(() -> serviceMeteoPrincipal.obtenirTemperature(Villes.PONT_A_MOUSSON));
Subtask<Double> restreint = scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.PONT_A_MOUSSON));

Constater que l’appel via l’instance serviceMeteoRestreint est en échec, mais que le résultat a pu être obtenu via l’instance serviceMeteoPrincipal.

Obtenir température avec ShutdownOnSuccess
Principal SUCCESS
Restreint FAILED
Temperature : 13.0
4.4.2.4. Les scopes personnalisés

Il est possible de créer son propre scope en héritant de la classe StructuredTaskScope et en y implémentant ses propres règles métiers.

Basiquement, il faut :

  • Redéfinir la méthode handleComplete() qui est invoquée à la terminaison de chaque sous-tâche : en cas de succès, son implémentation stocke de manière thread-safe les informations obtenues de la Subtask passée en paramètre.

  • Définir une méthode pour fournir le résultat en appliquant les règles métiers adéquates et pour obtenir les éventuelles exceptions.


Modifier la classe ScopePlusHauteTemperature de manière à ce qu’elle renvoie la température la plus haute parmi les tâches qui lui ont été soumises.

  • Dans la redéfinition de la méthode handleComplete(), se baser sur l’état renvoyé par la méthode Subtask::state pour gérer le résultat ou l’erreur

  • exceptions() doit renvoyer une Collection contenant les exceptions rencontrées

  • max() doit renvoyer la température la plus haute parmi celles obtenues

class ScopePlusHauteTemperature extends StructuredTaskScope<Double> {

  private final Collection<Double> temperatures = new ConcurrentLinkedQueue<>();
  private final Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();

  @Override
  protected void handleComplete(final Subtask<? extends Double> subtask) {
    switch (subtask.state()) {
      case SUCCESS -> temperatures.add(subtask.get());
      case FAILED -> exceptions.add(subtask.exception());
      case UNAVAILABLE -> {}
    }
  }

  public Collection<Throwable> exceptions() {
    return exceptions;
  }

  public double max() {
    return temperatures.stream()
      .mapToDouble(Double::doubleValue)
      .max()
      .orElseThrow();
  }
}

Compléter la méthode obtenirPlusHauteTemperature() dans la classe MainStructuredConcurrency en utilisant ScopePlusHauteTemperature pour obtenir la température obtenue la plus élevée parmi les 4 villes.

  private void obtenirPlusHauteTemperature() throws InterruptedException {

    try (ScopePlusHauteTemperature scope = new ScopePlusHauteTemperature()) {
      scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.NICE));
      scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.PARIS));
      scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.PONT_A_MOUSSON));
      scope.fork(() -> serviceMeteoRestreint.obtenirTemperature(Villes.TOULON));

      scope.joinUntil(Instant.now().plusSeconds(20));

      System.out.println("Température la plus haute : " + scope.max());
      System.out.println("Exceptions rencontrées : " + scope.exceptions());
    } catch (TimeoutException e) {
      e.printStackTrace(System.out);
    }
  }

Exécuter la classe MainStructuredConcurrency et vérifier le résultat affiché dans la console :

Obtenir plus haute température
Température la plus haute : 18.0
Exceptions rencontrées : [java.lang.IllegalArgumentException: ServiceMeteoRestreint : le service ne sait pas traiter les villes contenant un trait d'union : Pont-à-Mousson]

4.4.3. Lab : les Scoped Values

Historiquement depuis Java 1.2, on utilise une variable de type java.lang.ThreadLocal pour partager des objets dans le code exécuté par un thread, évitant ainsi de les passer en paramètres des différentes méthodes invoquées.

Son utilisation est notoirement connue pour présenter plusieurs risques : les objets sont mutables, fuite de mémoire, consommation de ressources, …

JDK

Première preview en Java 21 JEP 446
Seconde preview en Java 22 JEP 464
Troisième preview en Java 23 JEP 481

JEP

487: Scoped Values (Fourth Preview)

En Java 24, cette fonctionnalité est en preview.

L’API java.lang.ScopedValue tente de remédier à ces inconvénients.

Une ScopedValue est une valeur qui est définie une fois et qui est ensuite accessible en lecture uniquement pendant une période limitée d’exécution par des traitements dans un thread.

Une ScopedValue permet ainsi de partager des données de manière sûre et efficace pendant une période d’exécution limitée sans passer les données en paramètres des méthodes invoquées.

4.4.3.1. La création d’une instance

La création d’une instance de type java.lang.ScopedValue est similaire à celle d’une instance de type java.lang.ThreadLocal : il faut définir une variable public static final pour faciliter son accès et l’initialiser avec l’invocation de la fabrique ScopedValue::newinstance.


Compléter la classe fr.sciam.workshop.javase.scopedvalues.Contexte pour ajouter un champ public static final NOM_UTILISATEUR de type ScopedValue<String> initialisé avec l’invocation de ScopedValue::newinstance.

class Contexte {
  public static final ScopedValue<String> NOM_UTILISATEUR = ScopedValue.newInstance();
}
4.4.3.2. L’association d’une valeur

La méthode ScopedValue::where permet d’obtenir une instance de type java.lang.ScopedValue.Carrier qui associe une ScopedValue à une valeur pour le thread actuel.

La classe ScopedValue.Carrier propose plusieurs méthodes pour associer une valeur à une autre ScopedValue et pour exécuter des traitements qui pourront accéder aux ScopedValue dans le thread actuel sans avoir à les passer en paramètres des différentes méthodes utilisées :

  • <T> ScopedValue.Carrier where(ScopedValue<T> key, T value) : renvoyer une nouvelle instance qui associe en plus la ScopedValue à la valeur

  • <R, X extends Throwable> R call(ScopedValue.CallableOp<? extends R, X> op) : exécuter le CallableOp qui aura accès aux ScopedValue définies pour le thread courant

  • void run(Runnable op) : exécuter le Runnable qui aura accès aux ScopedValue définies pour le thread courant


Compléter la méthode associerValeur() de la classe MainScopedValues pour :

  • exécuter la méthode MonService::executer via la ScopedValue NOM_UTILISATEUR qui associe la valeur admin-main au thread courant

  • démarrer un thread virtuel qui associe la valeur admin-virtuel à la ScopedValue NOM_UTILISATEUR pour exécuter la méthode MonService::executer

  • attendre la fin de l’exécution du thread

  • démarrer un thread de la plateforme nommé Tache1 qui associe la valeur admin-plateforme à la ScopedValue NOM_UTILISATEUR pour exécuter la méthode MonService::executer

  • attendre la fin de l’exécution du thread

  • exécuter la méthode MonService::executer

Une tâche est exécutée dans un thread virtuel, cette fonctionnalité est détaillée dans le lab les threads virtuels
  private static void associerValeur() throws InterruptedException {

    ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin-main").run(MonService::executer);

    var tache1 = Thread.ofVirtual().name("Tache1")
        .start(() -> ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin-virtuel").run(MonService::executer));
    tache1.join(Duration.ofSeconds(1L));

    var tache2 = Thread.ofPlatform().name("Tache2")
        .start(() -> ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin-plateforme").run(MonService::executer));
    tache2.join(Duration.ofSeconds(1L));
  }

Pour obtenir une valeur associée au thread courant, il faut utiliser la méthode ScopedValue::get.


Compléter la méthode executer() de la classe MonService pour obtenir la valeur de NOM_UTILISATEUR en invoquant sa méthode get() et afficher le thread courant et la valeur obtenue.

  static int executer() {
    String nomUtilisateur = Contexte.NOM_UTILISATEUR.get();
    System.out.println(Thread.currentThread() + " Monservice utilisateur : " + nomUtilisateur);
    return 0;
  }

Exécuter la classe MainScopedValues pour vérifier l’affichage.

Associer une valeur à un traitement
Thread[#3,main,5,main] Monservice utilisateur : admin-main
VirtualThread[#36,Tache1]/runnable@ForkJoinPool-1-worker-1 Monservice utilisateur : admin-virtuel
Thread[#39,Tache2,5,main] Monservice utilisateur : admin-plateforme

La valeur est associée pendant la durée d’exécution des traitements associés. Une fois l’exécution terminée, la valeur n’est plus accessible.


Compléter la méthode associerValeur() de la classe MainScopedValues pour ajouter à la fin l’invocation de la méthode MonService::executer.

    MonService.executer();

Exécuter la classe MainScopedValues pour vérifier la levée d’une exception car la valeur n’est pas associée dans le thread courant.

Associer une valeur à un traitement
Thread[#3,main,5,main] Monservice utilisateur : admin-main
VirtualThread[#36,Tache1]/runnable@ForkJoinPool-1-worker-1 Monservice utilisateur : admin-virtuel
Thread[#39,Tache2,5,main] Monservice utilisateur : admin-plateforme
Exception in thread "main" java.util.NoSuchElementException: ScopedValue not bound
        at java.base/java.lang.ScopedValue.slowGet(ScopedValue.java:575)
        at java.base/java.lang.ScopedValue.get(ScopedValue.java:568)
        at fr.sciam.workshop.javase.scopedvalues.MonService.executer(MainScopedValues.java:117)
        at fr.sciam.workshop.javase.scopedvalues.MainScopedValues.associerValeur(MainScopedValues.java:48)
        at fr.sciam.workshop.javase.scopedvalues.MainScopedValues.main(MainScopedValues.java:13)

Il est possible d’utiliser la méthode ScopedValue::isBound qui renvoie un booléen indiquant si une valeur est associée au thread courant.


Modifier la méthode executer() de la classe MonService pour obtenir la valeur de NOM_UTILISATEUR en invoquant sa méthode get() si elle est associée ou "Inconnu" dans le cas contraire.

  static int executer() {
    String nomUtilisateur = Contexte.NOM_UTILISATEUR.isBound() ? Contexte.NOM_UTILISATEUR.get() : "Inconnu";
    System.out.println(Thread.currentThread() + " Monservice utilisateur : " + nomUtilisateur);
    return 0;
  }

Exécuter la classe MainScopedValues pour vérifier l’affichage.

Associer une valeur à un traitement
Thread[#1,main,5,main] Monservice utilisateur : admin-main
VirtualThread[#29,Tache1]/runnable@ForkJoinPool-1-worker-1 Monservice utilisateur : admin-virtuel
Thread[#32,Tache2,5,main] Monservice utilisateur : admin-plateforme
Thread[#1,main,5,main] Monservice utilisateur : Inconnu
4.4.3.3. La réassociation d’une valeur

Les valeurs associées sont immutables mais il est possible de réassocier une autre valeur pour un traitement sous-jacent exécuté avec la ScopedValue où une valeur différente est associée.


Compléter la méthode reassocierValeur() de la classe MainScopedValues pour lancer un thread nommé Tache3 qui associe la valeur admin à la ScopedValue NOM_UTILISATEUR pour exécuter les traitements suivants :

  • invoquer la méthode MonService::executer

  • lancer un thread nommé Tache4 qui associe la valeur admin-rebind à la ScopedValue NOM_UTILISATEUR pour exécuter la méthode MonService::executer

  • attendre la fin du thread Tache4

  • invoquer la méthode MonService::executer

  • attendre la fin du thread Tache3

  private static void reassocierValeur() throws InterruptedException {

    var tache3 = Thread.ofPlatform()
        .name("Tache3")
        .start(() -> ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin").run(() -> {
          MonService.executer();

          var tache4 = Thread.ofPlatform()
              .name("Tache4")
              .start(() -> ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin-rebind")
                  .run(MonService::executer));
          try {
            tache4.join();
          } catch (InterruptedException e) {
            e.printStackTrace(System.err);
          }

          MonService.executer();
        }));
    tache3.join();
  }

Exécuter la classe MainScopedValues pour vérifier l’affichage.

Reassocier une valeur à un traitement
Thread[#33,Tache3,5,main] Monservice utilisateur : admin
Thread[#34,Tache4,5,main] Monservice utilisateur : admin-rebind
Thread[#33,Tache3,5,main] Monservice utilisateur : admin
4.4.3.4. L’association de plusieurs valeurs

La méthode ScopedValue::where retourne une instance de type ScopedValue.Carrier qui permet au travers de sa méthode where() d’associer une valeur à une autre ScopedValue et ses méthodes run() ou call() de préciser le traitement à exécuter.


Compléter la classe Contexte pour ajout un champ public static final ID_UTILISATEUR de type ScopedValue<Long> initialisé avec l’invocation de ScopedValue::newInstance.

  public static final ScopedValue<Long> ID_UTILISATEUR = ScopedValue.newInstance();

Compléter la méthode associerPlusieursValeurs() de la classe MainScopedValues pour exécuter la méthode MonService::executer dans le thread courant via la ScopedValue NOM_UTILISATEUR qui associe la valeur admin-main et la ScopedValue ID_UTILISATEUR qui associe la valeur 12345.

  private static void associerPlusieursValeurs() throws Exception {

    ScopedValue.where(Contexte.NOM_UTILISATEUR, "admin-main")
        .where(Contexte.ID_UTILISATEUR, 12345L)
        .call(MonService::traiter);
  }

Compléter la méthode traiter() de la classe MonService pour obtenir les valeurs de NOM_UTILISATEUR et de ID_UTILISATEUR en invoquant leur méthode get() et afficher le thread courant et les valeurs obtenues.

  static int traiter() {
    String nomUtilisateur = Contexte.NOM_UTILISATEUR.isBound() ? Contexte.NOM_UTILISATEUR.get() : "Inconnu";
    Long idUtilisateur = Contexte.ID_UTILISATEUR.isBound() ? Contexte.ID_UTILISATEUR.get() : -1L;
    System.out.println(Thread.currentThread() + " Monservice utilisateur : " + nomUtilisateur + " (id=" + idUtilisateur + ")");
    return 0;
  }

Exécuter la classe MainScopedValues pour vérifier l’affichage.

Associer plusieurs valeurs à un traitement
Thread[#1,main,5,main] Monservice utilisateur : admin-main (id=12345)
4.4.3.5. Le partage de valeurs dans les threads virtuels d’une StucturedTaskScope

Une valeur d’une ScopedValue n’est associée qu’au thread dans lequel elle est définie.

Il y a une exception concernant le partage d’une valeur d’une ScopedValue avec les threads virtuels d’une StucturedTaskScope. Pour cela, il faut définir les traitements d’une StructuredTaskScope dans ceux exécutés par une ScopedValue. Tous les threads virtuels auront alors accès à la valeur associée dans la ScopedValue.

Cette fonctionnalité est détaillée dans le lab la Structured Concurrency


Compléter la méthode associerValeurConcurrenceStructuree() de la classe MainScopedValues pour :

  • itérer sur une collection de utilisateurs et pour chacun :

    • associer l’utilisateur à la ScopedValue NOM_UTILISATEUR pour exécuter l’invocation trois fois de la méthode MonService::executer dans une StucturedTaskScope.ShutdownOnFailure.

    • attendre la fin de l’exécution des threads

  private static void associerValeurConcurrenceStructuree() {
    List<String> utilisateurs = List.of("utilisateur1", "utilisateur2");
    for (String utilisateur : utilisateurs) {
      ScopedValue.where(Contexte.NOM_UTILISATEUR, utilisateur)
          .run(() -> {
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
              scope.fork(MonService::executer);
              scope.fork(MonService::executer);
              scope.fork(MonService::executer);
              try {
                scope.join();
              } catch (InterruptedException e) {
                throw new RuntimeException(e);
              }
            }
          });
    }
  }

Exécuter la classe MainScopedValues pour vérifier l’affichage.

Associer une valeur à un StructuredTaskScope
VirtualThread[#35]/runnable@ForkJoinPool-1-worker-1 Monservice utilisateur : utilisateur1
VirtualThread[#36]/runnable@ForkJoinPool-1-worker-2 Monservice utilisateur : utilisateur1
VirtualThread[#37]/runnable@ForkJoinPool-1-worker-2 Monservice utilisateur : utilisateur1
VirtualThread[#38]/runnable@ForkJoinPool-1-worker-2 Monservice utilisateur : utilisateur2
VirtualThread[#40]/runnable@ForkJoinPool-1-worker-3 Monservice utilisateur : utilisateur2
VirtualThread[#39]/runnable@ForkJoinPool-1-worker-1 Monservice utilisateur : utilisateur2