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 duStream
dans uneList
List<String> couleurs = Stream.of("rouge","vert","bleu").collect(Collectors.toList());
Comme le précice la documentation,
toList()
renvoie unCollector
qui accumule les éléments en entrée dans une nouvelleList
. Il n’y a aucune garantie sur le type, la mutabilité, la sérialisation ou le thread safety de laList
renvoyée. Si un contrôle plus poussé sur la liste renvoyée est nécessaire, il faut utilisertoCollection(Supplier)
, par exempletoCollection(ArrayList::new)
. -
Collectors.toUnmodifiableList()
: rassemble les éléments duStream
dans uneList
immuable, dans le JDK 22 l’implémentation est de typejava.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 |
---|---|---|
|
Non |
Oui |
|
Oui |
Non |
|
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) Standard en Java 24 |
JEP |
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’unStream
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 uneUnsupportedOperationException
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 |
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 laMap
-
SequencedMap<K, V>
reversed()
: obtenir une vue inversée de laMap
-
SequencedSet<Map.Entry<K,V>>
sequencedEntrySet()
: renvoyer unSequencedSet
des entrées de laMap
, en conservant l’ordre de parcours -
SequencedSet<K>
sequencedKeySet()
: renvoyer unSequencedSet
des clés de laMap
, en conservant l’ordre de parcours -
SequencedCollection<V>
sequencedValues()
: renvoyer uneSequencedCollection
des valeursMap
, 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
etsun.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 |
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 unMemorySegment
à partir d’une chaîne de caractères -
setString()
etgetString()
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’unSequenceLayout
.
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)
etSymbolLookup.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 unSymbolLookup
qui recherchera dans les bibliothèques chargées par leClassLoader
, par exemple viaSystem.load()
ouSystem.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 Pour autoriser l’appel aux méthodes restreintes, il faut ajouter l’option :
Il est également possible de spécifier le comportement en cas d’accès illégal via l’option
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 |
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 unMethodHandle
sur la méthodetracerCallback()
, -
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 leMemorySegment
-
Une action à exécuter lorsque l'
Arena
sera fermée, sous la forme d’unConsumer<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 |
JEP |
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
…) avecAccessFlags
-
les exceptions déclarées par la clause
throws
avecExceptionsAttribute
-
le contenu du corps de la méthode avec
CodeModel
, constitué d’élémentsCodeElement
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()
etwithMethodBody()
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 commepublic
-
int
pour le paramètre "methodFlags", pour lequel on peut piocher dans les constantes deClassFile
telles queClassFile.ACC_PUBLIC
ouClassFile.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 renvoievoid
et qui accepte un tableau deString
) -
la fabrique
of()
qui accepte des instances deClassDesc
, dont les plus courantes sont disponibles sous forme de constantes dans la classeConstantDescs
.
-
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[])
viaClassBuilder::withMethodBody
dont la seule instruction estreturn
.
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 dossierclassfile
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é.
classfile/
contenant la classe généréejavap -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 unFieldElement
qui peut être de typeConstantValueAttribute
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
etfinal
-
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 statiqueout
(de typejava.io.PrintStream
) de la classejava.lang.System
, sur la pile -
charger la chaîne de caractères
"Hello World"
depuis le "Constant Pool" sur la pile via l’opérationldc
-
enfin, invoquer la méthode
PrintStream::println
sur l’instanceout
avec pour paramètre la chaîne de caractères, via l’opérationinvokevirtual
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()
.
classfile/
contenant la classe généréejavap -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)
pourClassBuilder
-
with(FieldElement element)
pourFieldBuilder
-
with(MethodElement element)
pourMethodBuilder
-
…
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émentsClassElement
-
MethodTransform
opère sur les élémentsMethodElement
-
…
-
-
des méthodes de transformation sur le builder pour traiter les éléments enfants
-
ClassBuilder
possède les méthodestransformField()
ettransformMethod()
-
MethodBuilder
possède une méthodetransformCode()
-
…
-
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.
ClassTransform
où builder
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é
classfile/
contenant la classe généréejavap -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éthodestransformingFields()
,transformingMethodBodies()
,transformingMethods()
-
MethodTransform
fournit la méthodetransformingCode()
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 invoquantwith()
), puis invoque à la fin leConsumer
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 |
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 |
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 laLocale
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 laLocale
-
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 |
JEP |
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 deThread.Builder.OfVirtual
-
Thread.ofPlatform()
pour obtenir une instance deThread.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()
etsuspend()
lèvent uneUnsupportedOperationException
, -
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 uneMap
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 |
JEP |
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émenteAutoCloseable
) -
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()
oujoin()
deStructuredTaskScope
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 plusieursVille
via l’instanceserviceMeteoPrincipal
de typeServiceMeteo
-
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
.
scope.joinUntil(Instant.now().plusSeconds(20));
} 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 duStructuredTaskScope
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 bloccatch
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
.
scope.throwIfFailed(ExceptionCustom::new);
} 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 laSubtask
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éthodeSubtask::state
pour gérer le résultat ou l’erreur -
exceptions()
doit renvoyer uneCollection
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 |
JEP |
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 laScopedValue
à la valeur -
<R, X extends Throwable> R call(ScopedValue.CallableOp<? extends R, X> op)
: exécuter leCallableOp
qui aura accès auxScopedValue
définies pour le thread courant -
void run(Runnable op)
: exécuter leRunnable
qui aura accès auxScopedValue
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 laScopedValue
NOM_UTILISATEUR
qui associe la valeuradmin-main
au thread courant -
démarrer un thread virtuel qui associe la valeur
admin-virtuel
à laScopedValue
NOM_UTILISATEUR
pour exécuter la méthodeMonService::executer
-
attendre la fin de l’exécution du thread
-
démarrer un thread de la plateforme nommé
Tache1
qui associe la valeuradmin-plateforme
à laScopedValue
NOM_UTILISATEUR
pour exécuter la méthodeMonService::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 valeuradmin-rebind
à laScopedValue
NOM_UTILISATEUR
pour exécuter la méthodeMonService::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éthodeMonService::executer
dans uneStucturedTaskScope.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