Avant propos...▲
Je tiens à remercier l'équipe de developpez.com pour leurs précieux conseils, suggestions ou corrections, et tout particulièrement keulkeulNemek et ClaudeLELOUP.
Je les en remercie.
JSR 334 - Petites améliorations du langage (le projet "Coin")▲
L'objectif du projet "Coin" est d'apporter de petites modifications au langage. On est loin ici des "révolutions" de Java 5.0 (Generics, enums, annotations...). L'idée principale étant d'apporter de petits correctifs ou évolutions simples et efficaces pour faciliter la vie du développeur.
Expression littérale binaire▲
En Java, comme dans un grand nombre de langages, les expressions numériques entières peuvent être représentées de trois manières distinctes :
- le plus couramment, en notation décimale (exemple : 255) ;
- en notation hexadécimale, préfixée par 0x (exemple : 0xff) ;
- en notation octale, préfixée par 0 (exemple : 0377).
Désormais, le langage s'enrichit avec la notation binaire, préfixée par 0b, qui permet donc de représenter un nombre directement sous sa forme binaire (exemple : 0b11111111).
// Un 'byte' (sur 8 bits)
byte
aByte =
(
byte
)0b00100001
;
// Un 'short' (sur 16 bits)
short
aShort =
(
short
)0b1010000101000101
;
// Quelques 'int' (sur 32 bits) :
int
anInt1 =
0b10100001010001011010000101000101
;
int
anInt2 =
0b101
; // Les zéros inutiles peuvent être ignorés
int
anInt3 =
0B101
; // Le B peut être en minuscule/majuscule
// Un 'long' (sur 64-bit)
long
aLong =
0b1010000101000101101000010100010110100001010001011010000101000101
L;
Le principal intérêt étant de simplifier la manipulation de valeurs binaires, en permettant de les représenter directement sous cette forme, ce qui facilite la lecture du code pour la manipulation des bits.
Formatage des expressions numériques▲
Comme dans la majorité des langages, les expressions numériques sont représentées par une suite de chiffres sans aucun séparateur que ce soit, si ce n'est le point qui sépare la partie entière de la partie décimale pour les valeurs flottantes.
Toutefois, lorsqu'on manipule des expressions numériques représentant des nombres assez grands, on peut vite se retrouver avec des problèmes de lisibilité. Il faut alors "compter" le nombre de chiffres présents dans l'expression. Java 7 apporte une solution à ce problème, en permettant d'utiliser le caractère underscore ( _ ) au milieu de n'importe quelle expression numérique afin de séparer des groupes de chiffres de manière totalement arbitraire, afin de rendre le tout plus lisible :
double
oneMilliard =
1__000_000_000.000_000
;
long
creditCardNumber =
1111_2222_3333_4444
L;
long
hexBytes =
0xFF_EC_DE_5E
;
long
hexWords =
0xCAFE___BABE
;
long
bytes =
0b11010010_01101001_10010100_10010010
;
Le seul et unique intérêt consiste à améliorer la relecture du code. La présence des underscores dans une expression numérique n'a bien sûr strictement aucun impact sur le code généré ni sur la valeur de l'expression.
Les underscores doivent impérativement être situés entre deux caractères numériques. Ils ne peuvent donc pas être utilisés en début ou fin de l'expression, ni autour du séparateur de décimales pour les nombres réels.
Exemples de valeurs incorrectes : 3_.14, _1000, 10_, 0x_1...
Switch sur les String▲
Fini les multiples if/else pour comparer la valeur d'une chaine ! Il est enfin possible d'utiliser un switch sur une String, afin de comparer une chaîne de caractères avec des valeurs constantes (définies dès la compilation) :
String action =
...
switch
(
action) {
case
"save"
:
save
(
);
break
;
case
"save-as"
:
saveAs
(
);
break
;
case
"update"
:
update
(
);
break
;
case
"delete"
:
delete
(
);
break
;
case
"delete-all"
:
deleteAll
(
);
break
;
default
:
unknownAction
(
action);
}
Tout comme le switch(enum), le switch(String) n'accepte pas de valeur null (cela provoquera une exception).
En plus d'une meilleure lisibilité, cela permet également au compilateur de générer un code bien plus efficace qu'une succession de if/else. Le switch(String) se décompose en réalité en deux switches. Le premier se base sur le hashCode() afin de séparer les différentes valeurs plus facilement, avant de vérifier l'égalité via equals(). Ceci permettra de déterminer l'index du second switch, qui reprend le même format que le switch(String) original, mais en utilisant des valeurs numériques (ce qui permet de conserver le même ordre des case dans tous les cas).
Ainsi le code ci-dessus est à peu près équivalent au code suivant :
String action =
...
int
switchIndex =
-
1
;
// On découpe
switch
(
action.hashCode
(
)) {
case
3522941
: // "save".hashCode()
if
(
"save"
.equals
(
action))
switchIndex =
1
;
break
;
case
1872766594
: // "save-as".hashCode()
if
(
"save-as"
.equals
(
action))
switchIndex =
2
;
break
;
case
-
838846263
: // "update".hashCode()
if
(
"update"
.equals
(
action))
switchIndex =
3
;
break
;
case
-
1335458389
: // "delete".hashCode()
if
(
"delete"
.equals
(
action))
switchIndex =
4
;
break
;
case
1763419775
: // "delete-all".hashCode()
if
(
"delete-all"
.equals
(
action))
switchIndex =
5
;
break
;
}
switch
(
switchIndex) {
case
1
: // "save"
save
(
);
break
;
case
2
: // "save-as"
saveAs
(
);
break
;
case
3
: // "update"
update
(
);
break
;
case
4
: // "delete"
delete
(
);
break
;
case
5
: // "delete-all"
deleteAll
(
);
break
;
default
:
unknownAction
(
action);
}
Lorsque les différentes chaînes du switch disposent de hashCode() différents, l'accès se fera donc en temps constant quelle que soit la valeur recherchée, contrairement aux multiples if/else où le temps dépend de l'emplacement dans la liste.
Dans le cas de deux chaînes du switch(String) (ce qui est peu probable dans un cas réel), le premier switch utilisera une succession de if/else pour les distinguer. Par exemple les chaînes "bb" et "cC" possèdent le même hashCode(). Lorsqu'on les utilise dans un switch(String), cela générera dans le premier switch un seul case gérant les deux valeurs :
case
3136
: // "bb".hashCode() OU "cC".hashCode();
if
(
"bb"
.equals
(
action))
switchIndex =
3
; // selon l'index du case dans le switch original
else
if
(
"cC"
.equals
(
action))
switchIndex =
7
; // selon l'index du case dans le switch original
break
;
Type Inference sur la création d'instance Generics (le losange)▲
Le "Type Inference" est une notion apparue avec les Generics de Java 5.0. Elle permet au compilateur de déterminer le type du paramétrage Generics selon le contexte, afin d'éviter d'avoir à le préciser explicitement dans le code.
Dans Java 7 le "Type Inference" a été amélioré afin de prendre en compte le cas particulier de la création d'une instance Generics. En effet, la plupart du temps une création d'instance est affectée à une référence dont le type est déclaré avec le même paramétrage Generics. Cela aboutit à une répétition qui peut s'avérer assez lourde dans certains cas :
List<
String>
list =
new
ArrayList<
String>(
);
Map<
Reference<
Object>
,Map<
String,List<
Object>>>
map =
new
HashMap<
Reference<
Object>
,Map<
String,List<
Object>>>(
);
Avec l'amélioration du "Type Inference" de Java 7, le compilateur pourra donc retrouver implicitement le paramétrage Generics de la création de l'instance, selon celui utilisé dans la variable à laquelle on l'affectera.
List<
String>
list =
new
ArrayList<>(
);
Map<
Reference<
Object>
,Map<
String,List<
Object>>>
map =
new
HashMap<>(
);
Le paramétrage Generics du constructeur est simplement omis, si bien qu'il ne reste plus que les crochets <>, d'où le surnom de "syntaxe en losange".
Cela permet d'éviter de saisir deux fois la liste des paramètres Generics (une fois à la déclaration et une fois lors de la construction de l'instance).
Warning amélioré sur les ellipses paramétrées via les Generics▲
Attention : ceci est une fonctionnalité très spécifique et assez obscure qui sera sûrement méconnue par la plupart des développeurs.
Avant toute chose, il faut bien comprendre certaines spécificités du langage.
- Les tableaux Java ne sont pas type-safe. C'est-à -dire que le compilateur ne peut pas vérifier la cohérence du type des données. Tout ceci est effectué à l'exécution et peut donc provoquer des erreurs (ArrayStoreException).
- À l'inverse, le compilateur assure la cohérence du typage des Generics via des vérifications dès la compilation du code, ce qui garantit un code type-safe qui s'exécutera sans aucune erreur de typage à l'exécution (à condition que le code compile sans warning).
Du fait de leur approche à l'opposé l'une de l'autre, l'utilisation de tableaux couplés aux Generics est très risquée. En effet du fait que le paramétrage Generics est perdu à l'exécution, la vérification du type des tableaux ne peut pas être effectuée complètement à l'exécution, et dans certains cas il est impossible de vérifier cela à la compilation.
La faille vient du fait que tous les tableaux peuvent être castés vers le type "parent" jusqu'à Object[], et dans ce cas-là , il est impossible de vérifier la cohérence du code à la compilation. En effet cela permet de mettre un peu n'importe quoi dans le tableau, avec un typage vérifié à l'exécution. Or avec les Generics, on perd une partie de cette information à l'exécution, puisqu'on ne connait alors que le 'raw-type', c'est-à -dire le type de base sans son paramétrage Generics. Il est donc impossible d'effectuer une vérification complète à l'exécution. Pour éviter ce genre de problème, la création de tableau Generics est interdite. En effet si ceci était autorisé, on pourrait facilement "casser" (volontairement ou non) le côté typesafe des Generics :
List<
String>
listString =
...
List<
Integer>
listInteger =
...
List<
String>
[] strings =
new
List<
String>
[2
]; // ceci est interdit par le langage
Object[] objects =
strings;
objects[0
] =
listString; // OK
objects[1
] =
listInteger; // OK car le 'raw-type' est List !!!
// objects[1] contient un List<Integer> ce qui est "légal".
// Or objects[1] correspond à strings[1], et là c'est incorrect !
Le compilateur ne peut pas détecter ce genre de problème, et il ne peut pas générer de warning sur ce type de code, car en dehors de la création du tableau Generics (qui provoque une erreur) toutes les autres opérations sont parfaitement "légales" !
Note : on peut rencontrer le même genre de problème lorsqu'on manipule des tableaux non paramétrés (List[] au lieu de List<String>[]) ou des types sans paramétrage (List au lieu de List<String>). Ces syntaxes sont requises pour des raisons de compatibilité avec d'anciennes API, mais à l'inverse des tableaux paramétrés, le compilateur pourra signaler tout problème potentiel via un warning...
Il y a toutefois une faille dans le système : l'ellipse de Java 5.0. En effet cette dernière est implémentée via l'utilisation d'un tableau, et comme elle peut être utilisée avec des Generics il devient possible de créer des tableaux paramétrés :
public
void
exemple
(
List<
String>
...strings) {
// 'strings' est un tableau de List<String>
}
Dès lors qu'on utilise une méthode à ellipse avec un type paramétré, on obtient un warning assez curieux pour nous prévenir du problème. Exemple :
List<
Reference<
String>>
list =
new
ArrayList<
Reference<
String>>(
);
Collections.addAll
(
list, ref1, ref2, ref3); // warning
// warning: [unchecked] unchecked generic array creation of type Reference<String>[] for varargs parameter
// Collections.addAll(list, ref1, ref2, ref3);
Ce warning nous signale la création implicite d'un tableau de type paramétré, dont la cohérence du typage ne peut pas être garantie par le compilateur.
Le problème vient du fait que jusqu'alors, ce warning était uniquement généré lors de l'appel de la méthode, mais jamais sur son implémentation. Ainsi le code suivant compilait sans erreur ni warning :
public
static
<
T>
boolean
addAll
(
Collection<
? super
T>
c, T... elements) {
boolean
result =
false
;
for
(
T element : elements)
result |=
c.add
(
element);
return
result;
}
Or cela dépend uniquement de la manière dont ce tableau sera utilisé à l'intérieur de la méthode à ellipse. Avec Java 7 le code ci-dessous génère donc un nouveau warning de compilation pour signaler le problème au plus tôt :
warning: [unchecked] Possible heap pollution from parameterized vararg type T
public static <
T>
boolean addAll
(
Collection<
? super T>
c, T... elements) {
where T is a type-variable:
T extends Object declared in
method <
T>
addAll
(
Collection<
? super T>
,T...)
Du fait de la création possible d'un tableau de type paramétré, le développeur doit prendre en charge la responsabilité du typage, car cela ne peut pas être assuré par le compilateur. Pour cela il faut d'abord respecter quelques règles :
- il ne faut pas caster le tableau vers un type parent (Object[] ou autres)Â ;
- il ne faut pas modifier les éléments du tableau, puisque le paramétrage pourrait alors être incorrect ;
- il ne faut pas retourner le tableau en valeur de retour, car on ignore l'usage qui en sera fait ;
- il ne faut pas partager le tableau avec d'autres méthodes qui pourraient effectuer les actions ci-dessus.
En clair : il faut vraiment se contenter de parcourir le tableau afin de traiter ses éléments.
Si on respecte bien ces règles, on ne risque rien, et on peut alors apposer sur la méthode l'annotation @SafeVarargs afin de l'indiquer au compilateur, qui supprimera alors tout warning.
@SafeVarargs
public
static
<
T>
boolean
addAll
(
Collection<
? super
T>
c, T... elements) {
boolean
result =
false
;
for
(
T element : elements)
result |=
c.add
(
element);
return
result;
}
Comme on se contente bien de parcourir les divers éléments du tableau, on peut utiliser l'annotation @SafeVarargs qui nous épargnera de tout warning à la compilation, que ce soit le "possible heap pollution" sur la méthode, ou les "unchecked generic array creation" lorsqu'on l'appellera...
L'annotation @SafeVarargs est avant tout un simple marqueur. Le compilateur ne peut pas vérifier que vous l'utilisez correctement, et il est donc impératif de respecter les règles ci-dessus et de se contenter de parcourir les éléments du tableau généré par l'ellipse, sous peine d'introduire des bogues qui pourraient être très difficiles à déceler !
Attention : l'annotation SafeVarargs ne peut s'appliquer que sur une méthode static, une méthode final ou un constructeur. Elle ne peut pas s'appliquer sur une méthode virtuelle car rien ne peut garantir que ses redéfinitions respecteront bien ces règles...
Gestion automatique des ressources (try-with-resources)▲
Contrairement à la mémoire qui est gérée automatiquement par le Garbage Collector, il existe plusieurs types de ressources qui doivent être gérées manuellement. C'est-à -dire qu'elles doivent être libérées explicitement, généralement par un appel à la méthode close(). C'est le cas par exemple de toutes les ressources gérées par le système d'exploitation (fichiers, sockets) et assimilés (connexion JDBC?).
Le fait d'omettre de libérer les ressources peut avoir diverses conséquences. Elles pourraient être bloquées et rendues inaccessibles pour d'autres process, ou encore provoquer une utilisation abusive inutile.
Bien sûr généralement le mécanisme de finalisation de Java est utilisé pour mettre en place un garde-fou qui libérera la ressource une fois l'instance nettoyée par le GC. Mais cela se révèle insuffisant pour plusieurs raisons :
- on ne peut pas prévoir le moment exact où la ressource sera libérée, ce qui peut bloquer d'autres traitements ou programmes, voire même atteindre des limites imposées par l'OS (en particulier dans une application serveur) ;
- bien souvent, l'instance de l'objet Java est insignifiante vis-à -vis des données mises en place par la ressource. Le GC n'aura donc pas forcément besoin de libérer la mémoire alors qu'il serait préférable de libérer la ressource au plus tôt.
Pour pallier cela, et libérer la ressource au plus tôt, on utilise généralement un bloc try/finally par ressource, afin d'y appeler explicitement chaque méthode close(), de la manière suivante :
Res r =
... // 1. Création de la ressource
try
{
// 2. Utilisation de la ressource
...
}
finally
{
// 3. Fermeture de la ressource
r.close
(
);
}
Par exemple pour une copie de fichier cela nous donne quelque chose comme ceci :
try
{
InputStream input =
new
FileInputStream
(
in.txt);
try
{
OutputStream output =
new
FileOutputStream
(
out.txt);
try
{
byte
[] buf =
new
byte
[8192
];
int
len;
while
(
(
len=
input.read
(
buf)) >=
0
)
output.write
(
buf, 0
, len);
}
finally
{
output.close
(
);
}
}
finally
{
input.close
(
);
}
}
catch
(
IOException e) {
System.err.println
(
"Une erreur est survenue lors de la copie"
);
e.printStrackTrace
(
);
}
Mais cela n'est pas sans défauts. Premièrement cela pollue énormément le code : la libération des ressources correspond à la moitié du code puisque chaque ressource doit être associée à un bloc try/finally ce qui peut amener à avoir plusieurs blocs d'indentation. De même pour un traitement des erreurs efficace on doit utiliser un bloc try/catch supplémentaire, sous peine de ne pas intercepter toutes les exceptions...
Mais il y a également des limites à cela puisque les libérations des ressources peuvent également générer des exceptions. Mais dans ce cas, seule la dernière exception générée sera remontée et les exceptions qui auraient été générées plus tôt seront perdues, ce qui peut fausser le problème initial et rendre plus délicate sa résolution.
En pratique ce problème d'exception perdue se révèle quand même assez rare, et ce modèle du try/finally est recommandé pour les codes pré-Java 7...
Le try-with-resources vient pallier tous ces problèmes via une nouvelle syntaxe plus simple. Les ressources déclarées dans un try() seront automatiquement libérées à la fin du bloc correspondant, quoi qu'il arrive. Le code précédent pourra désormais s'écrire de la manière suivante :
try
(
InputStream input =
new
FileInputStream
(
in.txt);
OutputStream output =
new
FileOutputStream
(
out.txt)) {
byte
[] buf =
new
byte
[8192
];
int
len;
while
(
(
len=
input.read
(
buf)) >=
0
)
output.write
(
buf, 0
, len);
}
catch
(
IOException e) {
System.err.println
(
"Une erreur est survenue lors de la copie"
);
e.printStrackTrace
(
);
}
On peut noter que le try-with-resources accepte plusieurs déclarations de ressources (séparées par des points-virgules), et qu'il est donc inutile de les imbriquer. La fermeture des ressources sera bien gérée même en cas d'erreur lors de la création de l'une d'entre elles. On peut facilement lui accoler un bloc catch permettant de bien capturer toutes les exceptions, que ce soit lors de la création des ressources, de leur utilisation ou de leur libération...
De même le problème des exceptions perdues est géré correctement : si le code a déjà généré une exception, les suivantes seront ajoutées directement dans son stacktrace via un nouveau mécanisme intégré dans la classe Throwable via la méthode addSuppressed(). Ainsi aucune exception n'est perdue !
Il faut malgré tout continuer de se méfier des encapsulations de flux. En effet si l'un des flux génère une exception, le flux qu'il encapsule ne sera pas visible par le try-with-resources et il ne pourra donc pas être fermé correctement :
// DANGER car new ObjectInputStream() peut générer une IOException si le fichier est mal formé.
// Dans ce cas le FileInputStream ne pourra pas être fermé :
try
(
ObjectInputStream ois =
new
ObjectInputStream
(
new
FileInputStream
(
"file"
))) {
...
}
On peut éviter cela en séparant bien les différents flux. Ils seront alors bien ouverts dans l'ordre de déclaration, puis correctement fermés dans l'ordre inverse :
// OK car le fichier sera bien fermé même en cas d'exception sur l'ObjectInputStream :
try
(
FileInputStream fis =
new
FileInputStream
(
"file"
);
ObjectInputStream ois =
new
ObjectInputStream
(
fis)) {
...
}
Le try-with-ressources ne peut être utilisé qu'avec des instances d'objets implémentant la nouvelle interface java.lang.AutoCloseable. Cette dernière se contente de définir la méthode close() throws Exception qui sera utilisée pour libérer la ressource.
L'interface existante java.io.Closeable n'a pas pu être utilisée pour cela à cause de sa méthode close() trop spécifique (elle utilise une IOException). Mais elle étend désormais d'AutoCloseable ce qui permet d'utiliser le try-with-ressources avec tous les flux d'entrées/sorties.
L'utilisation d'AutoCloseable permet ainsi de généraliser cela à tout type d'élément plus varié. Toute classe possédant une méthode void close() peut donc implémenter AutoCloseable, qu'elle déclare une exception ou pas. C'est le cas par exemple les ressources JDBC qui utilisent des SQLExceptions (Connection, Statement, ResultSet...), ou encore les ressources liés à l'API Audio javax.sound dont la fermeture ne génère aucune exception (Line, MidiDevice, etc.).
Java 7 introduit ainsi un nouveau concept, les "Suppressed Exceptions", afin de gérer proprement les multiples exceptions qui peuvent survenir lors de la fermeture des flux. Si ces dernières surviennent alors qu'une exception est déjà en train de remonter, elles seront "ajoutées" à l'exception originale via addSuppressed() au lieu de la remplacer, ce qui permet d'éviter de perdre de l'information (elles seront bien visibles dans le stacktrace).
Multicatch : traiter plus d'une exception à la fois▲
Il est désormais possible d'intercepter plus d'une exception à la fois dans le même bloc catch, ce qui permet d'éviter les duplications de code.
try
{
...
}
catch
(
IOException e) {
// traitement
}
catch
(
SQLException e) {
// traitement
}
Si les traitements sont identiques, le code ci-dessus peut désormais s'écrire en utilisant un seul et unique bloc catch, de la manière suivante :
try
{
...
}
catch
(
IOException |
SQLException e) {
// traitement
}
Il est possible de rajouter autant de types d'exception que l'on souhaite, en les séparant via le caractère pipe |. Le type de l'exception à l'intérieur du bloc catch correspond au type parent commun le plus proche (soit Exception dans ce cas précis).
À noter que le bytecode ainsi généré sera également plus performant du fait qu'il n'y aura aucune duplication du code de traitement.
Amélioration du rethrow▲
Le rethrow n'est pas un nouveau concept en soi, puisque cela consiste simplement à remonter une exception après l'avoir catchée, comme dans le code suivant :
public
void
method
(
) throws
Exception {
try
{
// code qui remonte uniquement des IOException ou SQLException...
}
catch
(
Exception e) {
// traitement
throw
e; // throws Exception
}
}
L'intérêt consiste à pouvoir effectuer certains traitements en cas d'exception (logger, réinitialiser des champs, etc.) tout en laissant remonter l'exception à un niveau plus haut (afin de permettre son traitement).
Comme dans la plupart des langages, le rethrow est très basique : le type d'exception remontée correspond simplement au type déclaré de la variable. Mais, à l'inverse de la plupart des langages, Java utilise un mécanisme de vérification des exceptions (les checked-exceptions). Et ça change tout ! En effet le code ci-dessus déclarera remonter n'importe quelle Exception, alors qu'en réalité ce ne pourrait être uniquement qu'une IOException, une SQLException ou n'importe quelle "unchecked-exception" (RuntimeException ou Error, qui peuvent toujours être remontées sans avoir à être déclarées).
Avec Java 7, le rethrow a donc été affiné afin de mieux correspondre à la réalité. C'est-à -dire que le type d'exception remontée dépend également du type d'exception pouvant être remontée par le bloc try, et donc d'obtenir une clause throws bien plus précise, ce qui évite de polluer la clause throws de la méthode :
public
void
method
(
) throws
IOException, SQLException {
try
{
// code qui remonte uniquement des IOException ou SQLException...
}
catch
(
Exception e) {
// traitement
throw
e; // throws IOException, SQLException (ou une unchecked exception)
}
}
En toute logique, le rethrow ne peut fonctionner que si la variable "e" du catch n'est pas modifiée dans le bloc catch. Elle doit être implicitement final.
JSR 292 - Support des langages dynamiques (invokedynamic)▲
Bien que le langage Java soit prédominant, la plateforme Java peut accepter tout type de langage à partir du moment où il est compilé en bytecode Java. Parmi les langages les plus populaires, on retrouve ainsi Groovy, Scala, JRuby, Jython et d'autres...
Mais le langage Java est fortement typé, et tous les appels de méthode sont vérifiés par le compilateur, afin de détecter un maximum d'erreurs au plus tôt. En Java il est ainsi impossible d'appeler une méthode qui n'existe pas dans le type déclaré. Et jusqu'à présent, le bytecode Java s'alignait tout logiquement sur ce fonctionnement.
Toutefois, les langages de scripts sont généralement plus souples en proposant un typage faible. C'est-à -dire que les variables ne sont pas typées ou faiblement typées, et que du coup il est possible d'appeler n'importe quelle méthode sans vérification lors de la compilation. Ce n'est que lors de l'exécution que tout ceci sera vérifié. Mais comme le bytecode Java n'offrait aucune facilité pour gérer ces appels de méthodes dynamiques, la plupart de ces langages devaient donc simuler cela, généralement en générant un appel de méthode static et en utilisant la reflection pour rechercher la méthode à exécuter.
Une API pour rechercher des éléments▲
Pour commencer, l'API standard s'enrichit d'un nouveau package java.lang.invoke, qui apporte toutes les notions relatives à l'invocation dynamique. Elle permet principalement de rechercher des éléments du langage (méthodes, constructeurs, attributs) afin de les associer à un handle. Ce dernier permettra d'exécuter le code correspondant. De prime abord cela semble assez proche de l'API de reflection, il en est toutefois assez distinct sur de nombreux points.
Tout d'abord, chaque élément est typé via la classe MethodType, qui décrit à la fois le type de la valeur de retour et le type de chacun de ses paramètres. On dispose de méthode static nous permettant de créer facilement un MethodType :
// Méthode avec deux paramètres (String,int) retournant un int :
// Le premier paramètre correspond au type de retour,
// Les paramètres suivants correspondent aux types des paramètres, dans l'ordre
MethodType type =
MethodType.methodType
(
int
.class
, String.class
, int
.class
);
Cette nouvelle API nous permet donc de créer des MethodHandle, ce qui correspond en quelque sorte à un pointeur de fonction. Pour cela nous devrons utiliser un objet Lookup permettant de rechercher ces divers éléments.
MethodHandles.Lookup lookup =
MethodHandles.lookup
(
);
Contrairement à l'API de reflection, l'API d'invocation dynamique respecte les règles de visibilité du langage. C'est-à -dire que ce Lookup dispose des mêmes règles d'accès que le code qui l'a instancié. Il peut donc retrouver :
- tous les éléments public de n'importe quelle classe visible ;
- tous les éléments protected des classes parentes ;
- tous les éléments protected ou package-view des classes du même package ;
- tous les éléments private de sa propre classe.
Mais il est également possible que le Lookup public nous permette de rechercher uniquement les éléments public depuis n'importe quelle classe :
MethodHandles.Lookup lookup =
MethodHandles.publicLookup
(
);
Les Lookups nous permettent donc de créer des MéthodHandles associés à un élément quelconque d'une classe (méthode, constructeur ou attribut).
Par exemple pour recherche une méthode d'instance, on utilisera findVirtual(), qui attend trois paramètres :
- la classe (Class)Â ;
- le nom de la méthode (String) ;
- le type de la méthode (MethodType).
// boolean contains(CharSequence) de la classe String :
MethodHandle contains =
lookup.findVirtual
(
String.class
, "contains"
,
MethodType.methodType
(
boolean
.class
, CharSequence.class
) );
Puisqu'il s'agit d'une méthode d'instance, le type du MethodHandle correspond à celui de la méthode à laquelle on aura inséré le type de la classe en premier paramètre.
Il est également possible de récupérer un handle sur un constructeur via findConstructor(). Ici le nom du constructeur est logiquement inutile. De même les constructeurs n'ayant pas de type de retour, on utilisera par convention le type void :
// Constructeur Date()
MethodHandle constructor =
lookup.findConstructor
(
java.util.Date.class
,
MethodType.methodType
(
void
.class
));
Dans le cas d'un constructeur, c'est le type de retour qui est modifié afin de correspondre à celui du type créé par le constructeur.
Les attributs peuvent également être la cible d'un MethodHandle, en lecture ou en écriture. On se contentera alors de préciser le nom et le type de l'attribut :
// Attribut Point.x en lecture :
MethodHandle readX =
lookup.findGetter
(
java.awt.Point.class
,
"x"
, int
.class
);
// Attribut Point.x en lecture :
MethodHandle writeX =
lookup.findSetter
(
java.awt.Point.class
,
"x"
, int
.class
);
Attention : les handles ainsi créés accèdent directement à l'attribut sans passer par une éventuelle méthode getter/setter. Si on a besoin de ce comportement il faut passer par findVirtual().
Il est même possible d'utiliser findSpecial() pour retrouver certaines méthodes spéciales, comme les méthodes parentes que l'on a redéfinies (que l'on appellerait généralement via super.method()). On utilise dans ce cas un quatrième paramètre correspondant au type fils appelant :
// Méthode super.toString()
MethodHandle superToString =
lookup.findSpecial
(
Object.class
, "toString"
,
MethodType.methodType
(
String.class
), MyClasse.class
);
On dispose également des méthodes findStatic(), findStaticGetter() et findStaticSetter() afin de retrouver une méthode ou un attribut static. Sans oublier les méthodes unreflect*() permettant d'obtenir un MethodHandle à partir d'un élément de l'API de reflection.
MethodHandle, le pointeur de fonction▲
Les objets de type MethodHandle ainsi créés correspondent à une sorte de pointeur de fonction, et permettent donc d'exécuter le code correspondant. On dispose pour cela de la méthode invokeExact(), qui doit être appelée en utilisant les types exacts associés au handle :
// boolean contains(CharSequence) de la classe String :
MethodHandle contains =
MethodHandles.publicLookup
(
).findVirtual
(
String.class
, "contains"
,
MethodType.methodType
(
boolean
.class
, CharSequence.class
) );
// Équivalent de : "Java7".contains("Java")
boolean
result =
(
boolean
)contains.invokeExact
(
"Java 7"
, (
CharSequence)"Java"
);
La méthode contains() de la classe String retourne un boolean, il est donc impératif de caster vers ce type précis. De même son premier paramètre étant l'interface CharSequence, il est impératif de caster le paramètre vers ce type afin que la signature de l'appel corresponde exactement au type associé au MethodHandle. Si les types déclarés sont différents, il est impératif de les caster vers le type approprié.
Attention : si on ne respecte pas le type exact du MethodHandle, l'utilisation d'invokeExact() engendrera une exception. Et ceci même si les types sont compatibles.
Il est également possible d'utiliser la méthode invoke(), plus souple dans le sens où elle vérifiera la compatibilité des types et qu'elle s'occupera des éventuelles conversions.
// => throws WrongMethodTypeException
boolean
result =
(
boolean
)contains.invokeExact
(
"Java 7"
, "Java"
);
// => OK
boolean
result =
(
boolean
)contains.invoke
(
"Java 7"
, "Java"
);
Attention : les méthodes invokeExact() et invoke() bénéficient d'un traitement particulier de la part du compilateur et de la JVM. De ce fait, il est impératif d'utiliser un compilateur bien qu'elles soient déclarées avec une ellipse d'objet (varargs), à la compilation elle génère une instruction correspondant à la signature des paramètres qu'on lui passe. Ceci permet de vérifier les types à l'exécution.
Et c'est là qu'on retrouve la première grosse différence entre l'API de reflection et l'API d'invocation dynamique. Avec l'API de reflection, les règles de visibilité sont vérifiées à chaque appel de méthode. Avec les MethodHandles, ces vérifications sont effectuées par le Lookup lors de la recherche de l'élément, mais pas lors de son appel. Cela signifie qu'il est possible de partager un MethodHandle sans que cela pose de problème de droit d'accès.
La seconde grosse différence vient du fait que l'on peut associer un MethodHandle à une instance. Le premier paramètre sera alors automatiquement passé à chaque appel, et le type associé au handle sera donc modifié en conséquence (il perdra son premier paramètre) :
// boolean contains(CharSequence) de la classe String :
MethodHandle contains =
MethodHandles.publicLookup
(
).findVirtual
(
String.class
, "contains"
,
MethodType.methodType
(
boolean
.class
, CharSequence.class
) );
System.out.println
(
contains); // MethodHandle(String,CharSequence)boolean
// On associe le handle à une instance :
contains =
contains.bindTo
(
"Java 7"
);
System.out.println
(
contains); // MethodHandle(CharSequence)boolean
// Ce qui nous permet d'appeler le handle directement :
boolean
result =
(
boolean
)contains.invokeExact
((
CharSequence)"Java"
);
// Equivalent de : "Java 7".contains("Java")
Plus intéressant : la classe utilitaire MethodHandles comporte plusieurs méthodes static permettant de modifier les MethodHandles. Quelques exemples :
- insertArguments() permet de spécifier la valeur des arguments avant l'invocation du handle. Ceci est similaire à la méthode bindTo() si ce n'est que l'on précise l'index du paramètre à associer, et que l'on peut en associer plusieurs ;
- dropArguments() permet de rajouter des paramètres fantômes. C'est-à -dire qu'ils seront ajoutés au type du handle, mais qu'ils seront ignorés lors de l'appel ;
- permuteArguments() permet de modifier l'ordre des paramètres, en précisant le nouveau type ainsi que l'ordre des paramètres ;
- filterArguments() et filterReturnValue() permettent de filtrer les valeurs des paramètres (avant l'appel de méthode) ou de la valeur de retour (après l'exécution de la méthode).
- ...
// boolean contains(CharSequence) de la classe String :
MethodHandle contains =
MethodHandles.publicLookup
(
).findVirtual
(
String.class
, "contains"
,
MethodType.methodType
(
boolean
.class
, CharSequence.class
) );
System.out.println
(
contains); // MethodHandle(String,CharSequence)boolean
contains =
MethodHandles.permuteArguments
(
contains,
MethodType.methodType
(
boolean
.class
, CharSequence.class
, String.class
),
1
, 0
// Le nouvel index des paramètres
);
System.out.println
(
contains); // MethodHandle(CharSequence,String)boolean
// Équivalent de : "Java7".contains("Java")
boolean
result =
(
boolean
)contains.invokeExact
((
CharSequence)"Java"
, "Java 7"
);
Toutes ces méthodes permettent d'adapter le type associé à un handle, afin de l'adapter à une signature spécifique. Par exemple il est possible de transformer un MethodHandle en une interface compatible, c'est-à -dire une interface contenant une seule et unique méthode abstraite (SAM - Single Abstract Method), et dont la signature correspond au type du MethodHandle :
MethodHandle handle =
MethodHandles.publicLookup
(
).findVirtual
(
String.class
, "compareToIgnoreCase"
,
MethodType.methodType
(
int
.class
, String.class
));
Comparator<
?>
compareToIgnoreCase =
MethodHandleProxies.asInterfaceInstance
(
Comparator.class
, handle);
Et l'invocation dynamique ?▲
L'invocation dynamique n'est pas supportée par le langage Java, mais uniquement dans le bytecode. C'est-à -dire qu'il n'est pas possible de l'utiliser directement dans une application Java. Il faut que ce soit implémenté par un des divers langages dynamiques basés sur une JVM.
L'instruction invoquedynamic n'est donc pas liée à une méthode existante. Au lieu de cela elle est liée à une méthode dite de bootstrap dont le rôle sera de déterminer le code qui devra être exécuté à l'exécution. Elle dispose pour cela d'informations sur l'appel en question (le Lookup du code appelant, ainsi que le nom et le type de la méthode), ainsi que d'éventuels paramètres spécifiques.
Rappel : le langage Java ne permet toujours pas d'utiliser l'invocation dynamique. Cette section concerne principalement l'implémentation d'un langage dynamique sur une JVM. L'instruction invoquedynamic doit donc être gérée spécifiquement par ces langages dynamiques.
L'instruction invoquedynamic ne peut pas être vérifiée par le compilateur, puisque la cible n'est pas connue au moment de l'exécution. Au lieu de cela, elle comporte un lien vers une méthode de bootstrap. Cette méthode a pour rôle de déterminer le code qui devra être exécuté à l'exécution. Elle dispose pour cela des informations de l'appel : le Lookup du code appelant, le nom et le type de la méthode.
Pour prendre un exemple basique, dans le code ci-dessous, la méthode de bootstrap renvoie un handle vers une méthode static d'une classe utilitaire.
static
CallSite bootstrap
(
MethodHandles.Lookup caller, String name, MethodType type)
throws
ReflectiveOperationException {
return
new
ConstantCallSite
(
caller.findStatic
(
UtilityClass.class
, name, type) );
}
L'intérêt, c'est que cette méthode de bootstrap ne sera appelée qu'une seule fois pour toutes les signatures strictement identiques, et que tous les appels suivants s'effectueront directement sans exécuter la méthode de bootstrap. Mieux : la JVM pourra alors effectuer toutes sortes d'optimisations comme l'inlining, comme c'est déjà le cas pour les appels de méthodes classiques (mais ce qui n'est pas possible avec la reflection).
Ainsi, les langages dynamiques peuvent désormais se contenter d'implémenter des méthodes de bootstrap qui se chargeront de déterminer le code à exécuter lors de l'exécution (bien sûr dans le cas d'un langage dynamique ces méthodes de bootstrap risquent de se révéler plus complexes).
Usage et performance▲
L'API d'invocation dynamique n'a pas vraiment vocation à être utilisée directement par la majorité des développeurs Java, puisque son principal intérêt reste la possibilité d'implémenter plus facilement et plus efficacement un langage dynamique en profitant des avantages de la JVM.
De ce côté-là le pari semble réussi puisque grâce au "bootstrap", la recherche de la méthode à exécuter ne s'effectue plus que lors du premier appel, tout en bénéficiant par la suite de toutes les éventuelles optimisations de la JVM (comme l'inlining entre autres). Mais il faudra quand même du temps avant d'en profiter réellement. En effet si les divers langages dynamiques basés sur une JVM ont commencé à intégrer cela (avec une amélioration des performances à la clef), ils conservent toujours leur précédent fonctionnement afin de rester compatibles avec la majorité des JVM.
Pour le développeur Java, elle ne s'avère pas particulièrement intéressante vis-à -vis de l'API Reflection, même si elle apporte quelques concepts différents. En effet si les MethodHandles permettent en théorie de meilleures performances, leur implémentation actuelle est loin de rivaliser avec les multiples optimisations de l'API de Reflection que l'on a pu avoir au cours des dernières années...
Mais cela pourrait être amené à évoluer rapidement...
JSR 203 - NIO.2 : accès complet aux systèmes de fichiers▲
Java 7 introduit un nouveau package java.nio.file en remplacement de la classe java.io.File.
- Une gestion propre des exceptions.
La plupart des méthodes de la classe File se contentent de renvoyer une valeur nulle en cas d'échec du traitement, et de ce fait il est parfois difficile d'en cibler l'origine. - Un accès complet au système de fichiers, avec le support de fonctionnalités absentes jusque-là comme le support des attributs spécifiques au système (Unix, DOS, droits d'accès ACL...), le support des liens/liens symboliques, etc.
- L'ajout de la notion de FileSystem (le système de fichiers) et du FileStore représentant le support (par exemple une partition du disque). Le tout de manière abstraite et évolutive.
- L'ajout de méthodes utilitaires, afin de ne plus réinventer la roue (déplacement/copie de fichier, lecture/écriture binaire ou texte, parcours d'une arborescence via des visiteurs).
- De meilleures implémentations, comme les DirectoryStream qui permettent de récupérer la liste des fichiers d'un répertoire via un flux, en évitant ainsi de tous les charger en mémoire comme c'est le cas des méthodes File.list() et File.listFiles() (ce qui peut s'avérer problématique si l'on parcourt de gros répertoires).
java.nio.file.Path remplacera java.io.File▲
La classe java.io.File est donc vouée à disparaître, au profit de l'interface java.nio.file.Path. Cette dernière décrit donc un chemin associé à un système de fichiers. Mais contrairement à File, Path comporte principalement des méthodes de manipulation du chemin.
On obtient normalement un objet Path en passant par un objet FileSystem représentant le système de fichiers. Toutefois, dans la plupart des cas on utilisera la méthode Paths.get() pour utiliser directement le système de fichiers par défaut de l'OS hôte...
// Avec java.io.File
File file =
new
File
(
"file.txt"
);
// Avec java.nio.file.Path :
Path path =
FileSystems.getDefault
(
).getPath
(
"file.txt"
);
// Avec java.nio.file.Path :
Path path =
Paths.get
(
"file.txt"
);
Si la java.nio.file.Path devait être privilégiée dans les futures API, la classe java.io.File n'est toutefois pas dépréciée du fait de sa large utilisation. Afin de faciliter la coexistence de code utilisant l'une ou l'autre, nous disposons des méthodes toPath() et toFile() permettant de passer facilement d'un type à l'autre.
À noter que les méthodes de création d'un Path acceptent un nombre d'arguments variables, permettant de juxtaposer plusieurs bouts du chemin final représenté par ce Path. Tout comme java.io.File, on peut utiliser le séparateur / quel que soit le système, mais l'objet Path ainsi créé contiendra toujours le bon séparateur.
Path path =
Paths.get
(
"/chemin/de/base"
, "sous-répertoire"
, "encore/plusieurs/répertoires"
, "file.txt"
);
Files : méthodes à tout faire▲
Path ne comporte aucune méthode d'accès aux informations sur les fichiers, ni aucune des méthodes utilitaires que l'on pouvait trouver dans la classe java.io.File. Toutes ces méthodes se retrouvent déportées dans des méthodes static d'une classe utilitaires Files.
Path path =
Paths.get
(
"file.txt"
);
boolean
exists =
Files.exists
(
path);
boolean
isDirectory =
Files.isDirectory
(
path);
boolean
isExecutable =
Files.isExecutable
(
path);
boolean
isHidden =
Files.isHidden
(
path);
boolean
isReadable =
Files.isReadable
(
path);
boolean
isRegularFile =
Files.isRegularFile
(
path);
boolean
isWritable =
Files.isWritable
(
path);
long
size =
Files.size
(
path);
FileTime time =
Files.getLastModifiedTime
(
path);
Files.delete
(
path);
Et la raison est toute simple : contrairement à java.io.File qui ne peut représenter qu'un fichier sur le système de fichiers de l'OS, le type Path correspond à un chemin sur un système de fichiers quelconque. Bien sûr par défaut (via Paths.get()), cela correspond bien à un fichier sur le système de fichiers de l'OS. Mais l'API offre la possibilité de créer d'autres systèmes de fichiers, et d'y associer des Paths, qui pourraient très bien offrir d'autres possibilités.
L'usage voudrait donc de passer par le provider du système de fichiers pour accéder aux informations (car chaque système de fichiers pourrait proposer ses propres spécificités). La classe Files propose donc des méthodes utilitaires pour simplifier cela.
On notera également l'apparition de plusieurs fonctionnalités bien pratiques, mais absentes jusque-là de l'API standard :
Files.copy
(
Paths.get
(
"source.txt"
), Paths.get
(
"dest.txt"
));
Files.copy
(
Paths.get
(
"source.txt"
), Paths.get
(
"dest.txt"
),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES
);
Files.move
(
Paths.get
(
"source.txt"
), Paths.get
(
"dest.txt"
));
Files.move
(
Paths.get
(
"source.txt"
), Paths.get
(
"dest.txt"
),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES,
StandardCopyOption.ATOMIC_MOVE
);
Path path =
...
byte
[] byteArray =
Files.readAllBytes
(
path);
Files.write
(
path, byteArray);
Path path =
...
Charset charset =
...
List<
String>
lines =
Files.readAllLines
(
path, charset);
Files.write
(
path, lines, charset);
Path link =
...
Path existing =
...
Files.createLink
(
link, existing);
Files.createSymbolicLink
(
link, existing);
Path path =
...
String contentType =
Files.probeContentType
(
path);
// Ouverture en lecture :
try
(
InputStream input =
Files.newInputStream
(
path) ) {
...
}
// Ouverture en écriture :
try
(
OutputStream output =
Files.newOutputStream
(
path) ) {
...
}
// Ouverture d'un Reader en lecture :
try
(
BufferedReader reader =
Files.newBufferedReader
(
path, StandardCharsets.UTF_8) ) {
...
}
// Ouverture d'un Writer en écriture :
try
(
BufferedWriter writer =
Files.newBufferedWriter
(
path, StandardCharsets.UTF_8) ) {
...
}
Les méthodes permettant d'ouvrir un fichier acceptent des paramètres de type OpenOption via une ellipse. Ce type correspond à une interface dont les valeurs possibles sont définies par l'enum StandardOpenOption, qui apporte entre autres des options avancées comme l'utilisation des "sparse file" (fichiers contenant des espaces vides qui ne seront pas écrits sur le disque) ou la possibilité de forcer la synchronisation de l'écriture des données...
Files.write
(
path, data,
StandardOpenOption.APPEND,
StandardOpenOption.SPARSE,
StandardOpenOption.SYNC);
Les méthodes permettant une copie de fichier acceptent des paramètres de type CopyOption via une ellipse. Ce type correspond à une interface dont les valeurs possibles sont définies par l'enum StandardCopyOption permettant de configurer les options de copie.
Files.copy
(
path1, path2,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.COPY_ATTRIBUTES,
StandardCopyOption.REPLACE_EXISTING);
Plusieurs de ces méthodes acceptent un paramètre supplémentaire de type LinkOption. Ce type correspond à une enum ne contenant qu'une seule valeur NOFOLLOW_LINKS qui permet de ne pas suivre les liens symboliques lorsqu'on la passe à une méthode (les liens sont toujours suivis par défaut). Cette enum LinkOption implémente également les interfaces OpenOption et CopyOption, ce qui nous permet également de l'utiliser dans ces cas-là .
// Copie d'un fichier :
Files.copy
(
path1, path2);
// Copie d'un lien symbolique :
Files.copy
(
path1, path2,
StandardCopyOption.REPLACE_EXISTING,
LinkOption.NOFOLLOW_LINKS);
DirectoryStream : lister les éléments d'un dossier▲
La classe Files permet également de lister les fichiers d'un répertoire. Mais à la différence de java.io.File ce listing se fait via un flux/Iterator, ce qui nous évite de charger tous les résultats en mémoire dans un tableau (spécialement utile lors du parcours de répertoires contenant beaucoup de fichiers).
Path dir =
Paths.get
(
); // current directory
try
(
DirectoryStream<
Path>
stream =
Files.newDirectoryStream
(
dir)) {
for
(
Path path : stream) {
System.out.println
(
path);
}
}
On notera l'utilisation du try-with-ressource permettant une libération propre du DirectoryStream.
Il est bien sûr possible d'utiliser un filtre sur n'importe quel critère :
Path dir =
Paths.get
(
); // current directory
DirectoryStream.Filter<
Path>
filter =
new
DirectoryStream.Filter<
Path>(
) {
@Override
public
boolean
accept
(
Path entry) throws
IOException {
return
Files.isRegularFile
(
entry) &&
Files.size
(
entry) >
8192
L;
}
}
;
try
(
DirectoryStream<
Path>
stream =
Files.newDirectoryStream
(
dir, filter)) {
for
(
Path path : stream) {
System.out.println
(
path);
}
}
Plus intéressant, il est possible d'utiliser une syntaxe glob pour filtrer les fichiers selon leur nom en utilisant la syntaxe du shell.
Par exemple en utilisant *.txt pour rechercher tous les fichiers avec l'extension txt :
Path dir =
Paths.get
(
); // current directory
try
(
DirectoryStream<
Path>
stream =
Files.newDirectoryStream
(
dir, "*.txt"
)) {
for
(
Path path : stream) {
System.out.println
(
path);
}
}
FileVisitor : parcours d'une arborescence▲
Autre nouveauté : la possibilité de parcourir très simplement une arborescence de fichiers via la méthode walkFileTree().
Path dir =
Paths.get
(
); // current directory
Files.walkFileTree
(
dir, new
SimpleFileVisitor<
Path>(
) {
@Override
public
FileVisitResult visitFile
(
Path file, BasicFileAttributes attrs)
throws
IOException {
System.out.println
(
file);
return
FileVisitResult.CONTINUE;
}
}
);
Par défaut les liens symboliques ne sont pas suivis. Dans le cas inverse une détection des cycles est intégrée afin d'éviter de tourner en rond inutilement.
Path dir =
Paths.get
(
); // current directory
Files.walkFileTree
(
dir, EnumSet.of
(
FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, new
SimpleFileVisitor<
Path>(
) {
@Override
public
FileVisitResult visitFile
(
Path file, BasicFileAttributes attrs)
throws
IOException {
System.out.println
(
file);
return
FileVisitResult.CONTINUE;
}
}
);
L'interface FileVisitor dispose de quatre méthodes permettant de manipuler le déroulement du parcours. La classe SimpleFileVisitor propose une implémentation basique qui nous permet d'implémenter uniquement ce dont on a besoin.
- visitFile() est exécutée sur chaque fichier trouvé.
- visitFileFailed() ext exécutée lorsqu'une erreur empêche l'accès au fichier.
- preVisitDirectory() est appelée avant le parcours d'un répertoire.
- postVisitDirectory() est appelée une fois le répertoire parcouru.
La valeur de retour de ces méthodes permet de continuer le traitement (CONTINUE), d'ignorer certains éléments (SKIP_SIBLINGS ou SKIP_SUBTREE) ou même d'arrêter le parcours (TERMINATE).
FileAttributeView : accès aux attributs▲
Un des gros points noirs de la classe java.io.File concerne l'accès aux attributs de fichiers, qui reste limité au strict minimum. Désormais nous auront un accès complet aux attributs via un système de vue, qui dépendront bien évidemment du système de fichiers hôte.
Nous disposons pour cela de cinq vues standards, nous permettant d'accéder en lecture/écriture à ces différents attributs :
- BasicFileAttributeView (basic) permet un accès aux propriétés de base, généralement communes à tous les systèmes de fichiers ;
- DosFileAttributeView (dos) étend la première en y apportant le support des attributs MS-DOS (readonly, hidden, system, archive) ;
- PosixFileAttributeView (posix) étend la première en y apportant le support des permissions POSIX du monde Unix ;
- FileOwnerAttributeView permet de manipuler le propriétaire du fichier ;
- AclFileAttributeView (acl) permet de manipuler l'Access Control Lists (ACL), c'est-à -dire les droits d'accès au fichier ;
- UserDefinedFileAttributeView (user) permet enfin de définir des attributs personnalisés.
Le support de ses vues dépend du système de fichiers et de l'emplacement de stockage utilisé. Afin de vérifier le support d'une vue, on peut utiliser la méthode supportsFileAttributeView() du FileStore associé à notre Path.
Path path =
...
// Vérification par classe :
boolean
isSupported =
Files.getFileStore
(
path).supportsFileAttributeView
(
DosFileAttributeView.class
);
// Vérification par nom :
boolean
isSupported =
Files.getFileStore
(
path).supportsFileAttributeView
(
"dos"
);
Lorsqu'une vue est supportée, on peut alors utiliser la méthode Files.getFileAttributeView() pour en récupérer une instance. La vue nous permet alors d'accéder ou de modifier ses attributs :
DosFileAttributeView dosView =
Files.getFileAttributeView
(
path, DosFileAttributeView.class
);
dosView.setArchive
(
false
);
dosView.setHidden
(
false
);
dosView.setReadOnly
(
false
);
dosView.setSystem
(
false
);
dosView.setTimes
(
lastModifiedTime, lastAccessTime, createTime);
DosFileAttributes attrs =
dosView.readAttributes
(
);
boolean
a =
attrs.isArchive
(
);
boolean
h =
attrs.isHidden
(
);
boolean
r =
attrs.isReadOnly
(
);
boolean
s =
attrs.isSystem
(
);
...
La méthode readAttributes(), commune aux trois premières vues, permet de lire tous les attributs du fichier en les retournant dans un objet. Cela permet d'éviter de multiplier les accès au système de fichiers. On peut récupérer ces informations directement sans passer par la vue via la méthode Files.readAttributes() :
DosFileAttributes attrs =
Files.readAttributes
(
path, DosFileAttributes.class
);
...
Il est également possible d'accéder aux attributs via leurs noms, en les référençant sous la forme "view-name:attribute-name", où "view-name" correspond au nom de la vue, et "attribute-name" à celui de l'attribut (voir la javadoc des classes de ces vues pour la liste des noms des attributs, ainsi que le type de données attendu).
// Pour la vue basiqueClaude Leloup2012-03-01T17:08:06Sauf si c'est un terme de syntaxe, le nom de la vue peut être omis :
long
size =
(
Long) Files.getAttribute
(
path, size); // basic:size
FileTime time =
(
FileTime) Files.getAttribute
(
path, "creationTime"
); // "basic:creationTime"
boolean
isReadOnly =
(
Boolean) Files.getAttribute
(
path, "dos:readonly"
);
Files.setAttribute
(
path, "dos:readonly"
, false
);
UserPrincipal owner =
(
UserPrincipal) Files.getAttribute
(
path, "user:owner"
);
...
Il est également possible de lire plusieurs attributs de cette manière via la méthode Files.readAttributes(), qui utilise une notation similaire en permettant de récupérer plusieurs attributs de la même vue (en les séparant par des virgules). Le résultat nous est alors retourné sous forme de Map<String,Object> :
Map<
String,Object>
map =
Files.readAttributes
(
path, "dos:size,creationTime,readonly"
);
System.out.println
(
map);
Il est même possible d'utiliser le métacaractère * pour récupérer tous les attributs d'une vue, par exemple :
Map<
String,Object>
attributes =
Files.readAttributes
(
path, "dos:*"
);
System.out.println
(
attributes);
De ce fait, et grâce à la méthode supportedFileAttributeViews() du FileSystem, il devient très facile d'afficher tous les attributs d'un fichier :
Path path =
Paths.get
(
"file.txt"
);
FileStore fs =
Files.getFileStore
(
path);
for
(
String viewName : path.getFileSystem
(
).supportedFileAttributeViews
(
)) {
if
(
fs.supportsFileAttributeView
(
viewName)) {
for
(
Map.Entry<
String, Object>
attr : Files.readAttributes
(
path, viewName+
":*"
).entrySet
(
)) {
System.out.println
(
viewName+
":"
+
attr.getKey
(
) +
" = "
+
attr.getValue
(
));
}
}
}
WatchService : détecter les changements dans un répertoire▲
Il est désormais possible de surveiller les modifications effectuées sur le contenu d'un répertoire, afin d'être averti (au choix) des créations/suppressions/modifications de fichiers via un WatchService, le tout en tirant avantage du système de notification de fichier du système d'exploitation hôte.
Si le système d'exploitation ne dispose pas de mécanisme de surveillance de fichier, ceci sera émulé en scannant régulièrement le répertoire en question.
En enregistrant des répertoires auprès du WatchService, on pourra obtenir des objets WatchKey regroupant une série d'évènements sur un de ces répertoires. On utilisera généralement un thread séparé pour gérer tout cela.
Path path =
...
// On crée un objet WatchService, chargé de surveiller le dossier :
try
(
WatchService watcher =
path.getFileSystem
(
).newWatchService
(
)) {
// On y enregistre un répertoire, en lui associant certains types d'évènements :
path.register
(
watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);
// (ou plusieurs)
// ...
// Puis on boucle pour récupérer tous les événements :
while
(
true
) {
// On récupère une clef sur un événement (code bloquant)
WatchKey watchKey =
watcher.take
(
);
// On parcourt tous les évènements associés à cette clef :
for
(
WatchEvent<
?>
event: watchKey.pollEvents
(
)) {
if
(
event.king
(
)==
StandardWatchEventKinds.OVERFLOW)
continue
; // évènement perdu
System.out.println
(
event.kind
(
) +
" - "
+
event.context
(
));
}
// On réinitialise la clef (très important pour recevoir les événements suivants)
if
(
watchKey.reset
(
) ==
false
) {
// Le répertoire qu'on surveille n'existe plus ou n'est plus accessible
break
;
}
}
}
Attention : la liste des évènements peut être polluée par des évènements perdus ou rejetés de type OVERFLOW (même si on n'a pas enregistré ce type d'évènement). Il est donc impératif de les ignorer dans le code de traitement.
De même, l'ordre et la quantité d'évènements générés dépendent fortement du système d'exploitation hôte...
Dans l'implémentation actuelle, le WatchService ne permet que de surveiller les modifications apportées à un répertoire. Il est impossible de surveiller uniquement un fichier, ni toute une arborescence (à moins de le faire manuellement bien sûr), du fait des différences importantes entre les divers systèmes de notification des systèmes d'exploitation.
Toutefois, sous Windows, l'implémentation d'Oracle dispose d'un modificateur non standard qui permet cela : com.sun.nio.file.ExtendedWatchEventModifier.FILE_TREE. Il suffit de le passer à la méthode register() pour que toute l'arborescence soit surveillée automatiquement :
Path path =
...
path.register
(
ws, new
WatchEvent.Kind<
?>
[]{
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY}
,
com.sun.nio.file.ExtendedWatchEventModifier.FILE_TREE);
...
Attention : ce modificateur FILE_TREE n'est pas standard et correspond à un élément propriétaire de l'implémentation de la JVM d'Oracle, qui pourrait très bien être supprimée dans une version future. Il est donc préférable de ne pas l'utiliser, et de privilégier des bibliothèques tierces proposant cette fonctionnalité (comme JNA/platform.jarJNA/platform.jar ou JNotifyJNotify).
ZIP FileSystem : gérer les archives Jar/Zip simplement !▲
Si la classe java.io.File représente uniquement un fichier sur le système de fichiers de l'OS, la nouvelle API ne se limite pas à cela : un Path représente un chemin sur un système de fichiers quelconque. Bien sûr dans la majeure partie des cas ce sera sur le système de fichiers de l'OS, mais en théorie cela pourrait être sur n'importe quel système de fichiers, réel ou logique...
En effet, l'API propose même un mécanisme de provider permettant d'utiliser de nouvelle implémentation de système de fichiers quelconque. Et afin de démontrer cela, l'API de base propose un système de fichiers ZIP permettant de traiter une archive jar/zip comme un système de fichiers, de manière totalement transparente.
// Via une référence de Path :
Path path =
Paths.get
(
"file.zip"
);
try
(
FileSystem zipFS =
FileSystems.newFileSystem
(
path, null
) ) {
...
}
// Via une URI :
URI uri =
URI.create
(
"jar:file:/path/file.zip"
);
try
(
FileSystem zipFS =
FileSystems.newFileSystem
(
uri, null
) ) {
...
}
On peut alors utiliser la méthode getPath() du FileSystem pour obtenir un objet Path représentant un fichier à l'intérieur de l'archive, que l'on peut manipuler comme un fichier standard. De ce fait la manipulation des archives devient toute simple...
// Création d'un système de fichiers selon le contenu d'un fichier ZIP
try
(
FileSystem zipFS =
FileSystems.newFileSystem
(
Paths.get
(
"file.zip"
), null
)) {
// Suppression d'un fichier à l'intérieur du ZIP :
Files.deleteIfExists
(
zipFS.getPath
(
"file.txt"
) );
// Création d'un fichier à l'intérieur du ZIP :
Path path =
zipFS.getPath
(
"hello.txt"
);
Files.write
(
path, "Hello World !!!"
.getBytes
(
));
// Parcours des éléments à l'intérieur du ZIP :
try
(
DirectoryStream<
Path>
stream =
Files.newDirectoryStream
(
zipFS.getPath
(/
))) {
for
(
Path entry : stream) {
System.out.println
(
entry);
}
}
// Copie d'un fichier du disque vers l'archive ZIP :
Files.copy
(
Paths.get
(
"on-disk.txt"
), zipFS.getPath
(
"on-zip.txt"
));
}
De la même manière, on pourrait imaginer toutes sortes d'implémentations de FileSystem, basées sur une zone mémoire, un FTP distant, un WebService, etc.
Autre exemple un peu plus complet : la décompression complète d'une archive ZIP dans un répertoire de destination. On utilise pour cela la méthode Fils.walkFileTree() afin de parcourir tous les éléments de l'archive :
public
static
void
unzip
(
Path zipFile, final
Path destDir)
throws
IOException {
// On crée un FileSystem associé à l'archive :
try
(
FileSystem zfs =
FileSystems.newFileSystem
(
zipFile, null
) ) {
// On parcourt tous les éléments root :
for
(
Path root : zfs.getRootDirectories
(
)) {
// Et on parcourt toutes leurs arborescences :
Files.walkFileTree
(
root, new
SimpleFileVisitor<
Path>(
) {
private
Path unzippedPath
(
Path path) {
return
destDir.resolve
(
"./"
+
path.toString
(
));
}
@Override
public
FileVisitResult preVisitDirectory
(
Path dir,
BasicFileAttributes attrs) throws
IOException {
// On crée chaque répertoire intermédiaire :
Files.createDirectories
(
unzippedPath
(
dir));
return
FileVisitResult.CONTINUE;
}
@Override
public
FileVisitResult visitFile
(
Path file,
BasicFileAttributes attrs) throws
IOException {
// Et on copie chaque fichier :
Files.copy
(
file, unzippedPath
(
file),
StandardCopyOption.COPY_ATTRIBUTES,
StandardCopyOption.REPLACE_EXISTING);
return
FileVisitResult.CONTINUE;
}
}
);
}
}
}
JSR 203 - NIO.2 : entrées/sorties asynchrones▲
Le package java.nio.channels s'enrichit d'entrées/sorties asynchrones via les AsynchronousChannel, dont on dispose de trois implémentations :
- AsynchronousFileChannel;
- AsynchronousSocketChannel;
- AsynchronousServerSocketChannel.
Les AsynchronousChannel effectuent leurs opérations en tâche de fond, afin de ne pas bloquer le code appelant. Chaque opération se présente sous deux formes distinctes, permettant de récupérer le résultat de manière active (via un appel explicite) ou passive (via une sorte de listener).
- Future<V> operation(args)
- void operation(args, A attachment, CompletionHandler<V,? super A> handler)
Future<V> operation(args)▲
Contrairement aux autres channels ou flux standards, et puisque les traitements sont asynchrones, les opérations des AsynchronousChannel rendent la main immédiatement. L'instance de Future<V> ainsi retournée permettra donc de surveiller ou arrêter le traitement en tâche de fond, mais surtout de récupérer sa valeur de retour.
byte
[] data =
...
Path path =
Paths.get
(
...);
// On ouvre un AsynchronousFileChannel (dans un try-with-ressource)
try
(
AsynchronousFileChannel file =
AsynchronousFileChannel.open
(
path,
StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
// On lance une opération d'écriture en tâche de fond
Future<
Integer>
task =
file.write
(
ByteBuffer.wrap
(
data), 0
);
// On effectue d'autres traitements
...
...
// On attend la fin de l'opération, et on récupère son code de retour :
Integer result =
task.get
(
);
System.out.println
(
"byte writed : "
+
result);
}
Afin de s'assurer du bon déroulement de l'opération, il est donc impératif d'utiliser la méthode get() de l'AsynchronousChannel.
void operation(args, A attachment, CompletionHandler<V,? super A> handler)▲
L'utilisation d'un CompletionHandler permet une approche différente. Au lieu de surveiller explicitement le résultat de la méthode via un Future, il met en place un mécanisme proche des listeners. En clair, une fois que le traitement de l'opération se sera terminé correctement, la méthode completed() du CompletionHandler sera exécutée avec le résultat en paramètre. En cas d'exception, ce sera la méthode failed() qui sera exécutée avec l'exception en question.
AsynchronousServerSocketChannel server =
AsynchronousServerSocketChannel.open
(
);
server.bind
(
null
);
server.accept
(
null
, new
CompletionHandler<
AsynchronousSocketChannel, Void>(
) {
@Override
public
void
completed
(
AsynchronousSocketChannel client,
Void attachment) {
// Nouvelle connexion sur le server :
...
}
@Override
public
void
failed
(
Throwable exception, Void attachment) {
// Erreur de connexion :
exception.printStackTrace
(
);
}
}
);
L'objet CompletionHandler<V,A> est paramétré avec deux types V et A. Le premier correspond à la valeur de retour de l'opération. Le second est totalement libre et correspond à un objet quelconque que l'on pourra associer à l'opération (par exemple un identifiant).
JSR 166 - API de concurrence▲
Ces dernières années, les architectures multiprocesseurs ou multicores se sont généralisées. Depuis Java 5, la plateforme Java s'est adaptée à cette mouvance via l'API java.util.concurrent qui fournit un ensemble de fonctionnalités facilitant l'utilisation de tout cela.
La cuvée Java 7 nous apporte la notion de TransferQueue (implémentée via LinkedTransferQueue), qui permet de passer des éléments d'un producteur à un consommateur de manière bloquante. Mais à la différence de BlockingQueue dont elle hérite, cette interface permet, via la méthode transfer(), de bloquer le producteur tant qu'aucun consommateur ne vient récupérer les données.
On notera également la classe ThreadLocalRandom, qui permet d'obtenir une instance unique par thread, afin d'éviter toutes dégradations de la qualité des nombres aléatoires. En effet la classe Random n'est pas thread-safe, et son utilisation en parallèle peut poser plusieurs problèmes.
Mais la principale nouveauté de cette version vient du framework Fork/Join.
Fork/Join - diviser pour mieux régner▲
Le gros apport de Java 7 vient du framework Fork/Join, qui consiste à découper une grosse tâche en plusieurs tâches plus petites, afin de les exécuter via différents threads, et ainsi profiter de toutes la puissance de calcul disponible. Le principe est simple : si un gros problème peut être découpé en plusieurs petits problèmes, on peut alors les répartir sur plusieurs processeurs afin d'améliorer le traitement global.
Prenons un exemple tout bête mais consommateur de temps CPU : on va calculer la somme des cosinus et sinus de toutes les valeurs entières comprises entre 0 et une valeur 'n', via la formule suivante :
Basiquement on implémenterait ceci via une simple boucle :
public
static
double
calculate
(
int
max) {
double
result =
0.0
;
for
(
int
i=
0
; i<
max; i++
)
result +=
Math.cos
(
i) +
Math.sin
(
i);
return
result;
}
Ce genre de calcul requiert du temps CPU, surtout en cas de valeurs assez importantes. À titre d'exemple, l'appel de calculate(10_000_000) prend en moyenne un peu plus de 10 secondes sur ma machine de test, doté d'un processeur quadricore. Mais cette boucle n'utilise en tout et pour tout qu'un seul et unique CPU. On va donc utiliser le framework Fork/Join afin de bien mettre à profit tout ce petit monde en parallélisant le travail. Chaque étape de la boucle étant parfaitement indépendante et ne nécessitant pas un ordre précis d'exécution. On peut donc parfaitement découper cette tâche en plusieurs sous-tâches plus petites. Le principe consiste à découper la tâche en sous-tâches plus petites jusqu'à qu'elles soient suffisamment simples à exécuter. À partir de là les différentes tâches pourront être exécutées en parallèle via différents threads.
La première étape consiste à déterminer un seuil au-dessus duquel on va découper la tâche. Ici on va prendre arbitrairement la valeur 10_000, qui peut être calculée dans un temps correct. Il faut ensuite créer une tâche étendant la classe RecursiveTask<T>, qui se chargera d'implémenter notre traitement. C'est un peu plus long, mais il n'y a rien d'insurmontable :
class
CalculateTask extends
RecursiveTask<
Double>
{
private
static
final
int
MAX_VALUES =
10_000
;
private
final
int
start;
private
final
int
stop;
/**
* Constructeur
*/
private
CalculateTask
(
int
start, int
stop) {
this
.start =
start;
this
.stop =
stop;
}
/**
* Méthode interne de calcul entre deux index.
*/
private
static
double
calculate
(
int
start, int
stop) {
double
result =
0.0
;
for
(
int
i=
start; i<
stop; i++
)
result +=
Math.cos
(
i) +
Math.sin
(
i);
return
result;
}
/**
* Traitement de la tâche
*/
@Override
protected
Double compute
(
) {
final
int
count =
this
.stop -
this
.start;
// S'il y a peu d'éléments à calculer :
if
(
count<
MAX_VALUES) {
// On effectue le calcul et on renvoie le résultat :
return
calculate
(
this
.start, this
.stop);
}
// Sinon on coupe la tâche en deux :
final
int
middle =
count/
2
;
// On crée une première tâche qu'on va exécuter en arrière-plan (via fork()) :
CalculateTask task1 =
new
CalculateTask
(
this
.start, this
.start+
middle);
task1.fork
(
);
// On crée une seconde tâche qu'on va exécuter directement (via compute()),
// ce qui nous permet de récupérer son résultat :
CalculateTask task2 =
new
CalculateTask
(
this
.start+
middle, this
.stop);
double
value2 =
task2.compute
(
);
// On attend la fin de la première tâche pour récupérer son résultat :
double
value1 =
task1.join
(
);
// Et on retourne
return
value1 +
value2;
}
/**
* Exécution de la tâche via un ForkJoinPool.
*/
public
static
double
calculate
(
int
max) {
ForkJoinPool pool =
new
ForkJoinPool
(
);
return
pool.invoke
(
new
CalculateTask
(
0
, max));
}
}
La principale difficulté vient de la méthode fork(). En utilisant cette méthode sur une sous-tâche, on l'envoie dans le pool de traitement, afin qu'elle soit exécutée dans un autre thread. On peut alors exécuter directement le code d'une sous-tâche, puis récupérer le résultat via la méthode join().
La méthode static calculate(int) permet d'exécuter la tâche dans un ForkJoinPool. Ce dernier sera chargé de répartir les tâches sur plusieurs threads, afin qu'ils s'exécutent sur les différents processeurs de la machine.
/**
* Exécution de la tâche via un ForkJoinPool.
*/
public
static
double
calculate
(
int
max) {
ForkJoinPool pool =
new
ForkJoinPool
(
);
return
pool.invoke
(
new
CalculateTask
(
0
, max));
}
Sur la même machine, le calcul est alors effectué en moyenne entre 2.5 et 3 secondes, ce qui est approximativement quatre fois plus rapide (normal puisqu'il s'agit d'un quadricore).
Par défaut le ForkJoinPool créera autant de threads qu'il y a de processeurs disponibles sur la machine hôte. Mais il est bien sûr possible de contrôler cela via son constructeur (entre autres).
Attention : la multiplication des tâches n'engendre pas forcément un meilleur résultat. Car si l'exécution en parallèle permet bien de profiter de toute la puissance de calcul, cela ajoute également une synchronisation des données qui pourrait nuire au temps de traitement. Des tâches trop petites ou trop nombreuses pourraient être contre-productives...
Dans le même ordre d'idée, on peut se passer totalement du ForkJoinPool si le problème est simple ou qu'on ne dispose pas de suffisamment de processeurs :
/**
* Exécution de la tâche via un ForkJoinPool.
*/
public
static
double
calculate
(
int
max) {
// Si le problème est complexe, et qu'on dispose de plusieurs processeurs :
if
(
max>
MAX_VALUES &&
Runtime.getRuntime
(
).availableProcessors
(
)>
1
) {
// On exécute le traitement via un ForkJoinPool :
ForkJoinPool pool =
new
ForkJoinPool
(
);
return
pool.invoke
(
new
CalculateTask
(
0
, max));
}
else
{
// Sinon on effectue le calcul directement :
return
calculate
(
0
, max);
}
}
Phaser▲
La classe Phaser permet un nouveau mode de synchronisation de tâches, qui permet de mettre en phase plusieurs parties distinctes.
Les "parties" correspondent aux différentes tâches en exécution. Chaque partie doit s'inscrire auprès du Phaser, puis indiquer son état d'avancement au fur et à mesure. Lorsque toutes les parties sont arrivées à terme, la phase courante s'incrémente et chaque tâche continue ses traitements jusqu'à la prochaine phase...
Prenons un exemple tout simple : on souhaite démarrer cinq threads contenant chacun trois étapes distinctes.
for
(
int
i =
0
; i <
5
; i++
) {
new
Thread
(
) {
public
void
run
(
) {
System.out.println
(
"Étape 1 : "
+
getName
(
));
System.out.println
(
"Étape 2 : "
+
getName
(
));
System.out.println
(
"Étape 3 : "
+
getName
(
));
}
}
.start
(
);
}
Ici nous n'avons aucun contrôle sur l'ordre d'exécution. Le thread 1 peut très bien finir toutes ses étapes avant même que le thread 2 n'ait débuté sa première étape, ou inversement.
Si l'ordre d'exécution est toujours le même au sein d'un même thread, le résultat global pourra beaucoup varier d'une exécution à l'autre, avec des étapes 1, 2 et 3 totalement "mélangées" :
Étape 1
: Thread-0
Étape 2
: Thread-0
Étape 3
: Thread-0
Étape 1
: Thread-2
Étape 1
: Thread-4
Étape 2
: Thread-4
Étape 3
: Thread-4
Étape 1
: Thread-1
Étape 1
: Thread-3
Étape 2
: Thread-3
Étape 3
: Thread-3
Étape 2
: Thread-1
Étape 2
: Thread-2
Étape 3
: Thread-1
Étape 3
: Thread-2
On souhaite désormais que chaque thread soit synchronisé aux autres, afin qu'il ne passe à l'étape suivante qu'à partir du moment où tous les autres threads ont terminé l'étape courante. C'est-à -dire que l'étape 2 ne débute que lorsque tous les threads ont terminé la première étape, etc.
Le Phaser nous permet de faire cela assez simplement, en enregistrant chaque tâche au Phaser. Ce dernier utilise tout simplement un compteur permettant de déterminer le nombre de tâches terminées. Chaque thread doit alors utiliser la méthode arriveAndAwaitAdvance() pour indiquer qu'il a terminé une tâche et qu'il attend le début de la tâche suivante :
final
Phaser phaser =
new
Phaser
(
);
for
(
int
i =
0
; i <
5
; i++
) {
// On enregistre un nouveau thread dans le phaser :
phaser.register
(
);
new
Thread
(
) {
public
void
run
(
) {
System.out.println
(
"Étape 1 : "
+
getName
(
));
phaser.arriveAndAwaitAdvance
(
);
System.out.println
(
"Étape 2 : "
+
getName
(
));
phaser.arriveAndAwaitAdvance
(
);
System.out.println
(
"Étape 3 : "
+
getName
(
));
phaser.arriveAndDeregister
(
);
}
}
.start
(
);
}
Même si l'ordonnancement des threads entre eux n'est toujours pas prévisible, on peut garantir l'ordonnancement des étapes de chaque thread :
Étape 1
: Thread-1
Étape 1
: Thread-3
Étape 1
: Thread-2
Étape 1
: Thread-0
Étape 1
: Thread-4
Étape 2
: Thread-4
Étape 2
: Thread-1
Étape 2
: Thread-0
Étape 2
: Thread-3
Étape 2
: Thread-2
Étape 3
: Thread-1
Étape 3
: Thread-0
Étape 3
: Thread-3
Étape 3
: Thread-2
Étape 3
: Thread-4
La Phaser permet d'appliquer plusieurs types de synchronisation des phases. N'hésitez pas à consulter sa documentation pour d'autres exemples.
AWT/Swing▲
La classe TransferHandler se voit dotée d'une méthode setDragImage() permettant de définir une image à afficher pendant le drag&drop.
Les classes JList et JComboBox utilisent désormais les Generics pour paramétrer le type de leurs données : JList<E> et JComboBox<E>.
Il est désormais possible d'associer un type à une fenêtre, via la méthode setType() et l'énumération Window.Type contenant trois valeurs possibles : NORMAL pour le comportement par défaut, POPUP pour les fenêtres temporaires et UTILITY pour les barres d'outils ou palettes. Selon le système d'exploitation hôte, les caractéristiques de ces fenêtres peuvent varier.
La classe BorderFactory s'enrichit de nouveaux types de bordures, via les méthodes createDashedBorder(), createLineBorder(), createLoweredSoftBevelBorder(), createRaisedSoftBevelBorder(), createSoftBevelBorder() et createStrokeBorder().
Fenêtres transparentes et non rectangulaires▲
Java 6 avait reçu une grosse mise à jour via son update 10, qui a apporté plusieurs nouvelles fonctions et améliorations, ainsi que de nouvelles API internes normalement réservées à Sun/Oracle. Ces API ont été standardisées avec la sortie de Java 7.
TRANSLUCENT - transparence globale▲
Nous disposons désormais d'une méthode setOpacity() permettant de définir le niveau d'opacité globale de la fenêtre, en utilisant des valeurs comprises entre 0.0 (la fenêtre est invisible) et 1.0 (la fenêtre est totalement opaque).
JLabel label =
new
JLabel
(
"Hello World !"
);
label.setForeground
(
Color.WHITE);
label.setFont
(
label.getFont
(
).deriveFont
(
48.
F));
label.setBorder
(
BorderFactory.createEmptyBorder
(
32
, 32
, 32
, 32
));
final
JFrame frame =
new
JFrame
(
);
frame.setUndecorated
(
true
);
frame.add
(
label);
frame.pack
(
);
frame.setLocationRelativeTo
(
null
);
frame.addMouseListener
(
new
MouseAdapter
(
) {
@Override
public
void
mouseClicked
(
MouseEvent e) {
frame.dispose
(
);
}
}
);
//
// On applique à la fenêtre une transparence globale à 75 %
//
frame.setOpacity
(
0.75
F);
frame.getContentPane
(
).setBackground
(
Color.BLACK);
frame.setVisible
(
true
);
Ce qui nous donnera comme résultat quelque chose comme ceci :
PERPIXEL_TRANSLUCENT - transparence pixel par pixel▲
Contrairement à la transparence globale, la transparence pixel par pixel nous permet de définir le niveau de transparence de chaque pixel de la fenêtre. Pour activer la transparence pixel par pixel, il faut appliquer une couleur de fond transparente à la fenêtre en utilisant la méthode setBackground() avec une couleur contenant une composante alpha correspond à son niveau de transparence :
JLabel label =
new
JLabel
(
"Hello World !"
);
label.setForeground
(
Color.WHITE);
label.setFont
(
label.getFont
(
).deriveFont
(
48.
F));
label.setBorder
(
BorderFactory.createEmptyBorder
(
32
, 32
, 32
, 32
));
final
JFrame frame =
new
JFrame
(
);
frame.setUndecorated
(
true
);
frame.add
(
label);
frame.pack
(
);
frame.setLocationRelativeTo
(
null
);
frame.addMouseListener
(
new
MouseAdapter
(
) {
@Override
public
void
mouseClicked
(
MouseEvent e) {
frame.dispose
(
);
}
}
);
//
// On applique à la fenêtre un fond noir transparent à 75 %
//
frame.setBackground
(
new
Color
(
0
F,0
F,0
F,0.75
F));
frame.setVisible
(
true
);
Le résultat peut sembler identique, mais on remarquera ici que le texte de la fenêtre est complètement opaque, car on ne lui a pas affecté de niveau de transparence :
On peut même imaginer des rendus plus complexes en spécifiant une couleur de fond totalement transparente, et en laissant les composants spécifier leur propre niveau de transparence, ou même en utilisant Java2D pour dessiner la fenêtre :
JLabel label =
new
JLabel
(
"Hello World !"
);
label.setForeground
(
Color.WHITE);
label.setFont
(
label.getFont
(
).deriveFont
(
48.
F));
label.setBorder
(
BorderFactory.createEmptyBorder
(
32
, 32
, 32
, 32
));
final
JFrame frame =
new
JFrame
(
);
// On utilise un ContentPane avec un fond en dégradé :
frame.setContentPane
(
new
JPanel
(
) {
@Override
protected
void
paintComponent
(
Graphics g) {
Graphics2D g2d =
(
Graphics2D)g;
g2d.setRenderingHint
(
RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
int
w =
getWidth
(
);
int
h =
getHeight
(
);
// dégradé allant de noir invisible à noir opaque :
g2d.setPaint
(
new
GradientPaint
(
0
,0
,new
Color
(
0
,0
,0
,0
),
w,h,new
Color
(
0
,0
,0
,255
), true
));
g2d.fillRect
(
0
, 0
, w, h);
}
}
);
frame.setUndecorated
(
true
);
frame.add
(
label);
frame.pack
(
);
frame.setLocationRelativeTo
(
null
);
frame.addMouseListener
(
new
MouseAdapter
(
) {
@Override
public
void
mouseClicked
(
MouseEvent e) {
frame.dispose
(
);
}
}
);
//
// On applique à la fenêtre un fond complètement transparent
//
frame.setBackground
(
new
Color
(
0
F,0
F,0
F,0
F));
frame.setVisible
(
true
);
PERPIXEL_TRANSPARENT - forme non rectangulaire▲
Il est désormais possible de créer des fenêtres non rectangulaires, en définissant un masque qui déterminera la visibilité des pixels. On utilisera pour cela la méthode setShape() avec la forme désirée :
JLabel label =
new
JLabel
(
"Hello World !"
);
label.setForeground
(
Color.WHITE);
label.setFont
(
label.getFont
(
).deriveFont
(
48.
F));
label.setBorder
(
BorderFactory.createEmptyBorder
(
32
, 32
, 32
, 32
));
final
JFrame frame =
new
JFrame
(
);
frame.setUndecorated
(
true
);
frame.add
(
label);
frame.pack
(
);
frame.setLocationRelativeTo
(
null
);
frame.addMouseListener
(
new
MouseAdapter
(
) {
@Override
public
void
mouseClicked
(
MouseEvent e) {
frame.dispose
(
);
}
}
);
frame.getContentPane
(
).setBackground
(
Color.BLACK);
//
// On applique un masque sur la fenêtre
//
frame.setShape
(
new
RoundRectangle2D.Double
(
0
,0
,frame.getWidth
(
),frame.getHeight
(
),64
,64
));
frame.setVisible
(
true
);
Ce qui nous donnera comme résultat quelque chose comme ceci :
Contrairement aux modes précédents, il est garanti que les pixels cachés via la méthode setShape() ne soient pas cliquables. Bref c'est exactement comme s'ils n'existaient pas du tout.
Compatibilité et support▲
Toutes ces fonctionnalités nécessitent un support spécifique sur le système hôte, et ne seront pas forcément toujours disponibles. Il faut impérativement récupérer le GraphicsDevice associé à la fenêtre afin de vérifier s'il supporte ces fonctionnalités via la méthode isWindowTranslucencySupported() :
isWindowTranslucencySupported() | Fonctionnalité supportée | Méthode |
---|---|---|
WindowTranslucency.TRANSLUCENT | Transparence globale | setOpacity() |
WindowTranslucency.PERPIXEL_TRANSLUCENT et GraphicsConfiguration().isTranslucencyCapable() |
Transparence pixel par pixel | setBackground() (avec une composante alpha) |
WindowTranslucency.PERPIXEL_TRANSPARENT | Forme non rectangulaire | setShape() |
La transparence pixel par pixel nécessite également un support au niveau de la configuration graphique, ce que l'on peut vérifier via la méthode isTranslucencyCapable() du GraphicsConfiguration :
GraphicsDevice device =
frame.getGraphicsConfiguration
(
).getDevice
(
);
if
(
device.isWindowTranslucencySupported
(
WindowTranslucency.TRANSLUCENT)) {
System.out.println
(
"La transparence globale est supportée !"
);
}
if
(
device.isWindowTranslucencySupported
(
WindowTranslucency.PERPIXEL_TRANSLUCENT)
&&
frame.getGraphicsConfiguration
(
).isTranslucencyCapable
(
)) {
System.out.println
(
"La transparence pixel par pixel est supportée !"
);
}
if
(
device.isWindowTranslucencySupported
(
WindowTranslucency.PERPIXEL_TRANSPARENT)) {
System.out.println
(
"Les formes non rectangulaires sont supportées !"
);
}
Ces fonctionnalités sont intégrées dans la classe java.awt.Window, c'est-à -dire que l'on peut les utiliser sur tout type de fenêtre AWT (Window, Dialog, Frame) ou Swing (JWindow, JDialog, JFrame), à condition de respecter quelques conditions :
- la fenêtre ne doit pas comporter de décoration système (ce que l'on peut forcer via setUndecorated(true)) ;
- la fenêtre ne doit pas utiliser le mode d'affichage en plein écran exclusif.
Secondary Loop▲
Swing est monotâche. C'est-à -dire que toutes les modifications des composants graphiques doivent être effectuées dans un seul et unique thread : l'EDT (Event Dispatch Thread). Ce thread s'occupe d'exécuter tous les traitements relatifs à l'interface graphique (affichage des composants, gestion des évènements, etc.). Pour cela il utilise en fait une boucle infinie qui va traiter toutes les tâches les unes à la suite des autres. C'est pour cette raison que les traitements exécutés dans l'EDT ne doivent pas être trop lents ou bloquants, car cela décalerait l'exécution de toutes les autres tâches.
On utilise donc généralement un thread (via la classe SwingWorker par exemple) pour exécuter nos traitements lents afin de ne pas bloquer l'EDT (ce qui se caractérise par un gel de l'interface, voire des bogues graphiques). Pourtant il peut parfois être utile de bloquer le traitement de l'EDT, par exemple pour attendre une réponse de l'utilisateur. C'est ce qui se produit lorsqu'on utilise une boite de dialogue modale.
Désormais, il est également possible d'utiliser l'objet SecondaryLoop pour bloquer proprement l'EDT en créant une nouvelle boucle de traitement, qui s'occupera de traiter les tâches pendant ce temps. Cela permet de bloquer la boucle principale de l'EDT, qui continuera malgré tout ses traitements via une nouvelle boucle temporaire...
public
static
<
V>
V invokeNow
(
Callable<
V>
code) {
try
{
if
(!
SwingUtilities.isEventDispatchThread
(
)) {
// On n'est pas dans l'EDT, on peut
// appeler le code directement :
return
code.call
(
);
}
// On récupère l'EventQueue pour créer la SecondaryLoop :
EventQueue eventQueue =
Toolkit.getDefaultToolkit
(
).getSystemEventQueue
(
);
final
SecondaryLoop secondaryLoop =
eventQueue.createSecondaryLoop
(
);
// On crée un FutureTask, qui exécutera notre code
// et stoppera la SecondaryLoop à la fin du traitement :
FutureTask<
V>
future =
new
FutureTask<
V>(
code) {
protected
void
done
(
) {
secondaryLoop.exit
(
);
}
}
;
// On démarre notre tâche dans un thread :
new
Thread
(
future).start
(
);
// On rentre dans la SecondaryLoop, ce qui
// est bloquant tant que la méthode exit()
// n'est pas appelée...
secondaryLoop.enter
(
);
// On retourne notre résultat :
return
future.get
(
);
}
catch
(
Exception e) {
throw
new
RuntimeException
(
e);
}
}
En dehors de l'EDT, cette méthode exécutera simplement le code de l'objet Callable en paramètre.
Dans l'EDT, elle exécutera le code dans un thread séparé, en entrant dans la SecondaryLoop via enter(), afin que l'EDT puisse continuer son travail pendant ce temps. Cette nouvelle boucle ne prendra fin qu'après l'appel de exit()) à la fin de la tâche de l'objet Callable.
JLayer<T> : un calque pour vos composants▲
Swing s'enrichit d'un nouveau composant hérité du projet SwingLabs : JLayer. Comme son nom l'indique, ce dernier se comporte comme une couche qui viendrait englober n'importe quel composant, afin de modifier son comportement ou son affichage.
JPanel panel =
...
JLayer<
JPanel>
layer =
new
JLayer<
JPanel>(
panel);
Par défaut le JLayer ne fait rien. Contrairement aux autres composants graphiques qui obtiennent leur UI du Look&Feel, il faut explicitement associer un LayerUI à chaque instance de JLayer. Cela permet de définir la manière dont le JLayer sera dessiné, et donc indirectement de la manière dont sera dessiné le composant qu'il contient.
Par exemple, via la méthode paint() du LayerUI, il est possible de modifier le rendu d'un composant.
JLayer<
JPanel>
layer =
new
JLayer<
JPanel>(
panel);
layer.setUI
(
new
LayerUI<
Component>(
) {
private
final
Color MASK =
new
Color
(
1.0
f, 0
f, 0
f, 0.2
f);
@Override
public
void
paint
(
Graphics g, JComponent c) {
// On dessine le composant normalement :
super
.paint
(
g, c);
// Puis on dessine un carré transparent par-dessus :
g.setColor
(
MASK);
Rectangle r =
g.getClipBounds
(
);
g.fillRect
(
r.x, r.y, r.width, r.height);
}
}
);
Il est également possible d'interagir au niveau des évènements. La classe JLayer permet d'activer certains évènements via la méthode setLayerEventMask(). Cela permet ensuite de les recevoir (avant le composant) afin de les traiter, voire même de les inhiber.
Par exemple, on pourrait utiliser cela pour désactiver n'importe quel composant. La plupart des composants Swing permettent déjà cela via la méthode setEnabled(). Les JButton, JTextField ou autres contrôles seront bien grisés et inaccessibles. Mais on ne peut pas utiliser directement cela sur un JPanel par exemple : le composant sera bien dans l'état 'disabled', mais cela n'aura aucun impact sur les composants qu'il comporte.
Il est possible de simuler ce comportement avec un LayerUI adapté, par exemple en dessinant une couche grise par-dessus le composant, et en inhibant tous ses évènements clavier/souris :
public
class
DisabledLayerUI extends
LayerUI<
Component>
{
private
final
Color MASK =
new
Color
(
0.2
f, 0.2
f, 0.2
f, 0.2
f);
/*
* Installation du LayerUI sur un JLayer.
* On active les évènements souris et clavier.
*/
@Override
public
void
installUI
(
JComponent component) {
JLayer<
?>
layer =
(
JLayer<
?>
) component;
layer.setLayerEventMask
(
AWTEvent.MOUSE_EVENT_MASK
|
AWTEvent.MOUSE_MOTION_EVENT_MASK
|
AWTEvent.KEY_EVENT_MASK);
}
/*
* Désinstallation du LayerUI.
* On désactive tous les évènements.
*/
@Override
public
void
uninstallUI
(
JComponent c) {
JLayer<
?>
layer =
(
JLayer<
?>
) c;
layer.setLayerEventMask
(
0
);
}
/*
* Dessin du composant.
* Si la vue est 'disabled', on dessine un masque gris par-dessus.
*/
public
void
paint
(
Graphics g, JComponent component) {
super
.paint
(
g, component);
JLayer<
?>
layer =
(
JLayer<
?>
) component;
if
(
layer.getView
(
).isEnabled
(
) ==
false
) {
g.setColor
(
MASK);
Rectangle r =
g.getClipBounds
(
);
g.fillRect
(
r.x, r.y, r.width, r.height);
}
}
/*
* Traitement des évènements.
* Si la vue est 'disabled', on consomme tous les évènements clavier/souris.
* Ainsi ils ne seront pas reçus par le composant.
*/
@Override
protected
void
processKeyEvent
(
KeyEvent event, JLayer<
? extends
Component>
layer) {
if
(
layer.getView
(
).isEnabled
(
)==
false
) {
event.consume
(
);
}
}
@Override
protected
void
processMouseEvent
(
MouseEvent event, JLayer<
? extends
Component>
layer) {
if
(
layer.getView
(
).isEnabled
(
)==
false
) {
event.consume
(
);
}
}
@Override
protected
void
processMouseMotionEvent
(
MouseEvent event, JLayer<
? extends
Component>
layer) {
if
(
layer.getView
(
).isEnabled
(
)==
false
) {
event.consume
(
);
}
}
}
Le simple fait d'englober un JPanel dans un JLayer avec cet UI nous permettra donc de désactiver tout le JPanel simplement, ce qui aura pour effet de bloquer tous les évènements clavier/souris sur tous les composants qu'il comporte :
JLayer<
JPanel>
layer =
new
JLayer<
JPanel>(
panel);
layer.setUI
(
new
DisabledUI
(
));
Nimbus, le nouveau LookAndFeel▲
Le Look&Feel Nimbus est également présent depuis l'update 10 de Java 6, de manière propriétaire et spécifique à la JVM d'Oracle/Sun. Avec Java 7 il est enfin officialisé via la classe javax.swing.plaf.nimbus.NimbusLookAndFeel.
UIManager.setLookAndFeel
(
"javax.swing.plaf.nimbus.NimbusLookAndFeel"
);
Toutefois ce code n'est valide qu'avec Java 7. Si notre application cible également Java 6, il est préférable de rechercher la présence de Nimbus via son nom, puisque le nom de la classe est différent :
for
(
LookAndFeelInfo info : UIManager.getInstalledLookAndFeels
(
)) {
if
(
"Nimbus"
.equals
(
info.getName
(
))) {
UIManager.setLookAndFeel
(
info.getClassName
(
));
break
;
}
}
Nimbus apporte donc un nouveau Look&Feel, que chacun appréciera (ou pas) :
Comme la plupart des Look&Feels, les propriétés de l'UIManager restent au c?ur de la personnalisation du rendu des composants. Nimbus n'échappe pas à la règle et propose une tripotée de clefs permettant de modifier les couleurs, polices de caractères, marges et même la manière de dessiner les composants.
Bien entendu, pour des raisons de compatibilité, le Look&Feel par défaut restera Metal.
Les couleurs (primaires et secondaires)▲
Nimbus se base sur un jeu de 14 couleurs primaires, associées à une clef. Ces couleurs primaires permettent de générer 21 couleurs secondaires par dérivation. Il est bien sûr possible de modifier chacune de ces couleurs indépendamment via l'UIManager, ce qui aura un impact direct sur tous les composants de l'interface graphique.
On peut retrouver la liste de ces couleurs et de leur valeur par défaut à cette adresse :
http://download.oracle.com/javase/7/docs/api/javax/swing/plaf/nimbus/doc-files/properties.htmlPrimary & Secondary Colors.
On peut également retrouver la liste des toutes les propriétés de l'UIManager qui sont définies par défaut par le Look&Feel Nimbus à cette adresse :
http://download.oracle.com/javase/tutorial/uiswing/lookandfeel/_nimbusDefaults.htmlNimbus Defaults.
Par défaut (lorsqu'on ne les définit pas), les couleurs secondaires sont automatiquement créées en dérivant les couleurs primaires. Il est ainsi possible de se contenter de redéfinir les couleurs primaires pour modifier l'interface globale de manière cohérente. Il est même possible de ne modifier que certaines d'entre elles pour avoir un résultat cohérent, puisque la modification d'une couleur primaire aura des impacts sur la valeur par défaut des couleurs secondaires :
UIManager.put
(
"nimbusBase"
, new
ColorUIResource
(
51
, 140
, 98
));
UIManager.setLookAndFeel
(
"javax.swing.plaf.nimbus.NimbusLookAndFeel"
);
Attention à bien redéfinir les couleurs avant d'appliquer le Look&Feel Nimbus, sinon certaines couleurs pourraient ne pas être prises en compte...
Les états (states)▲
Nimbus introduit la notion d'états au niveau des propriétés de l'UIManager, via sept états standards :
- Enabled ne s'applique que si le composant est activé ;
- Disabled ne s'applique que si le composant est désactivé ;
- MouseOver ne s'applique que lorsque la souris survole le composant ;
- Focused ne s'applique que lorsque le composant possède le focus ;
- Selected ne s'applique que lorsque le composant est sélectionné ;
- Pressed ne s'applique qu'à l'instant où l'on clique sur le composant ;
- Default s'applique au bouton par défaut de la fenêtre (défini via JRootPane.setDefaultButton()).
Ces différents états sont utilisables avec les propriétés de l'UIManager, afin de modifier le rendu du composant de manière conditionnelle. On utilise pour cela une notation entre crochets après le type du composant. Ainsi, si Button.textForeground permet de modifier la couleur du texte de tous les boutons, Button[MouseOver].textForeground permet d'effectuer cela uniquement lors du passage de la souris, tandis que Button[Focused].textForeground ne modifiera que le bouton possédant le focus...
Il est ainsi possible d'apporter un côté dynamique en modifiant le rendu des éléments selon leur état, sans forcément avoir recours à un listener :
UIManager.put
(
"Button[MouseOver].textForeground"
, Color.RED);
Il est possible de préciser plusieurs états en même temps, en les séparant par le signe + :
UIManager.put
(
"Button[Default+MouseOver].textForeground"
, Color.RED);
Attention, lorsqu'on définit une clef de base (sans état), elle prend le dessus sur toutes les autres clefs. Pour éviter ce comportement, il faut utiliser les UIResources (par exemple en utilisant new ColorUIResource(Color.BLACK) au lieu d'utiliser directement Color.BLACK).
À noter également l'existence de la clef Composant.States permettant de redéfinir les états à prendre en considération. Lorsqu'elle est utilisée, cette clef doit contenir la liste de tous les états supportés, séparés par des virgules.
// 'MouseOver' ne fait pas partie de cette liste d'états :
UIManager.put
(
"Button.States"
, "Enabled,Disabled,Focused,Selected,Pressed,Default"
);
Cette propriété permet surtout de définir de nouveaux états plus spécifiques, en y incluant le nom des nouveaux états que l'on veut mettre en place. Puis, pour chaque nouvel état, il faut définir une propriété Composant.Etat pointer vers une instance de la classe javax.swing.plaf.nimbus.State qui permettra au Look&Feel de déterminer si le composant appartient à un état ou pas via la méthode isInState().
Par exemple, pour rajouter un état Empty aux JTextField, on utiliserait le code suivant :
// 1. On définit les états que l'on veut prendre en compte :
UIManager.put
(
"TextField.States"
, "Enabled,Disabled,MouseOver,Focused,Selected,Pressed,Empty"
);
// 2. On associe notre état à la clef du même nom :
UIManager.put
(
"TextField.Empty"
, new
State<
JTextField>(
"Empty"
) {
@Override
protected
boolean
isInState
(
JTextField c) {
Document doc =
c.getDocument
(
);
// On considère le JTextField vide lorsqu'il
// n'y a pas de Document ou qu'il est vide :
return
doc==
null
||
doc.getLength
(
)==
0
;
}
}
);
Attention à bien redéfinir les états standards dans la propriété Composant.States. Sinon il ne seront plus pris en compte. De même la méthode isInState() risque d'être exécutée un grand nombre de fois, il faut donc éviter d'y faire des traitements trop complexes...
On notera que certains composants comportent déjà des états personnalisés. Par exemple les JComboBox peuvent être Editable, les JProgressBar peuvent être Indeterminate ou Finished...
Les Painters▲
Nimbus dessine tous les composants graphiques à partir de Painters, via l'interface javax.swing.Painter qui définit une seule et unique méthode, de la manière suivante :
public
interface
Painter<
T>
{
public
void
paint
(
Graphics2D g, T object, int
width, int
height);
}
L'unique objectif de cette interface consiste à dessiner un objet (un composant ou une partie du composant). Nimbus l'utilise de manière assez intense pour chaque état ou partie des composants. Chaque Painter étant défini via une propriété de l'UIManager, il est alors possible de modifier le rendu d'un composant via Java 2D.
UIManager.put
(
"Button[Enabled].backgroundPainter"
, new
Painter<
JButton>(
) {
@Override
public
void
paint
(
Graphics2D g, JButton button, int
width, int
height) {
g.setColor
(
button.getBackground
(
));
g.fill3DRect
(
0
, 0
, width, height, true
);
}
}
);
// Note : il faut bien entendu faire la même chose pour tous les états pour avoir un résultat final cohérent.
Cette technique peut permettre de configurer grandement le rendu du composant. Mais elle possède plusieurs limitations. D'une part elle requiert une bonne maitrise de Java 2D afin d'obtenir un résultat agréable à l??il, mais cela oblige également à implémenter plusieurs painters selon l'état du composant. Enfin, il est malheureusement impossible de réutiliser les painters de Nimbus, puisque ces derniers ne sont pas publics...
Tailles des composants▲
Nimbus gère également quatre tailles de composant via les propriétés client des composants Swing. En plus de la taille "standard" par défaut, on dispose donc des tailles "mini", "small" et "large", que l'on applique via putClientProperty() avec la clef JComponent.sizeVariant :
component.putClientProperty
(
"JComponent.sizeVariant"
, "small"
);
À noter toutefois que le composant doit être mis à jour via updateUI() afin que le changement soit bien pris en compte (cela permet de réinitialiser les caractéristiques du composant).
On peut également utiliser SwingUtilities.updateComponentTreeUI() pour mettre à jour tous les sous-composants d'un composant ou d'une fenêtre.
UIManager.setLookAndFeel
(
"javax.swing.plaf.nimbus.NimbusLookAndFeel"
);
JPanel panel =
new
JPanel
(
);
for
(
String size : "mini,small,normal,large"
.split
(
","
)) {
JButton button =
new
JButton
(
size);
button.putClientProperty
(
"JComponent.sizeVariant"
, size);
panel.add
(
button);
}
SwingUtilities.updateComponentTreeUI
(
panel);
JOptionPane.showMessageDialog
(
null
, panel);
Propriétés propres à un composant▲
Enfin, Nimbus nous offre la possibilité de redéfinir des propriétés de l'UIManager pour chaque composant en lui associant un objet UIDefault contenant les propriétés qui lui sont propres :
JButton button =
...
UIDefaults overrides =
new
UIDefaults
(
);
overrides.put
(
"Button[Enabled].backgroundPainter"
, new
Painter<
JButton>(
) {
@Override
public
void
paint
(
Graphics2D g, JButton button, int
width, int
height) {
g.setColor
(
button.getBackground
(
));
g.fill3DRect
(
0
, 0
, width, height, true
);
}
}
);
button.putClientProperty
(
"Nimbus.Overrides"
, overrides);
À noter également la propriété cliente Nimbus.Overrides.InheritDefaults qui permet d'indiquer si l'on souhaite hériter des propriétés de base de l'UIManager.
UIDefaults overrides =
...
button.putClientProperty
(
"Nimbus.Overrides"
, overrides);
button.putClientProperty
(
"Nimbus.Overrides.InheritDefaults"
, false
);
Améliorations de la machine virtuelle▲
Contrairement à l'API Java qui reste généralement "figée" entre deux versions, la machine virtuelle est en perpétuelle évolution. Son compilateur HotSpot est constamment amélioré et optimisé afin d'offrir les meilleures performances.
Les nouveautés présentées ne le sont pas forcément dans le sens où elles ont déjà été introduites dans les différentes updates de Java 6 (ou rétroportées). Elles correspondent généralement à des options expérimentales qui sont désormais activées par défaut.
Tiered Compilation▲
La JVM de Sun/Oracle dispose de deux compilateurs JIT : "client" et "server".
Si le compilateur "client" se révèle très rapide au démarrage, le compilateur "server" propose quant à lui de bien meilleures performances grâce à un profilage et une recompilation native du code bien plus poussés...
Le concept de "tiered compilation" (que l'on pourrait traduire par "compilation à plusieurs niveaux") consiste à cumuler le meilleur des deux mondes, c'est-à -dire de bénéficier d'un temps de démarrage rapide tout en permettant le meilleur profilage du code, et donc les meilleures optimisations possible. Pour cela, il s'applique à lui-même les mêmes règles de profilage et d'optimisation...
Pour activer cela, il faut lancer la JVM avec les options -server -XX:+TieredCompilation afin d'activer le mode "server" et la compilation à plusieurs niveaux.
java -server -XX:+TieredCompilation ...
Compressed Oops▲
Dans le dialecte de la JVM, un Oop (Ordinary object pointer) correspond tout simplement à un pointeur vers un objet Java, dont la taille correspond à la taille des pointeurs sur la machine hôte. Ainsi, sur une architecture 32 bits un pointeur occupera 32 bits, tandis qu'il en occupera le double sur une architecture 64 bits.
Les architectures 64 bits ont plusieurs avantages, dont celui permettant de dépasser allègrement la limite des 4 Go de mémoire maximum pour le heap (paramètre -Xmx). En fait il n'y a plus vraiment de limite. Mais en contrepartie cela implique également en moyenne une utilisation mémoire plus importante pour un même programme du fait que toutes les références occuperont un espace mémoire plus grand...
La notion de "Compressed Oops" consiste donc tout logiquement à compresser la majeure partie des pointeurs 64 bits afin qu'ils n'occupent pas plus de place qu'un pointeur 32 bits. La valeur des pointeurs est évaluée selon sa valeur "compressée" et l'adresse mémoire du début de l'espace mémoire heap. Cela permettra donc à la majorité des programmes de fonctionner en mode 64 bits sans avoir forcément besoin de beaucoup plus de mémoire qu'en mode 32 bits. En contrepartie cela implique quand même une nouvelle limite pour le heap de 32 Go (ce qui est malgré tout 8 fois plus important que la limite des 4 Go du 32 bits).
Avec Java 7, les "compressed oops" sont activés par défaut sur toutes les JVM 64 bits dont la taille max du heap est inférieure à 32 Go. Ils sont automatiquement désactivés au-delà de cette limite puisqu'il est impératif d'utiliser des pointeurs 64 bits pour représenter un tel espace de stockage.
Cette option est également activée par défaut depuis l'update 23 de Java 6, et il est possible de la forcer sur les versions précédentes de Java 6 via l'option -XX:+UseCompressedOops.
Zero-Based Compressed Oops▲
Lorsqu'on utilise les "Compressed Oops" dans une JVM 64 bits, cette dernière demande au système d'exploitation hôte de lui fournir un espace mémoire qui commence à une adresse virtuelle de "zéro" pour le heap.
Cela permet de simplifier l'encodage/décodage des pointeurs 64 bits en 32 bits puisqu'il n'y plus à gérer l'adresse de base du heap, ce qui est bien plus efficace pour les heaps inférieurs à 4 Go (ce qui doit quand même correspondre à une grosse majorité des applications).
L'allocation du heap avec une adresse virtuelle "zéro" est généralement possible jusqu'à 26 Go sur les principaux systèmes d'exploitation (Windows, Linux et Solaris).
Escape Analysis▲
Lors de l'analyse du code d'une méthode, le compilateur JIT déterminera pour chaque objet créé localement un statut d'échappement, qui peut prendre trois valeurs possibles :
- GlobalEscape lorsque l'objet est partagé en dehors de la méthode ou du thread. Par exemple s'il est stocké dans un attribut static, dans un attribut d'instance d'un autre objet, ou tout simplement s'il est utilisé comme valeur de retour de la méthode ;
- ArgEscape lorsque l'objet provient d'un paramètre de la méthode ou qu'il est passé à un paramètre d'une autre méthode, à condition qu'il n'entre jamais dans la première catégorie. Bref que l'objet soit partagé en dehors de la méthode tout en restant inaccessible depuis un autre thread ;
- enfin, NoEscape pour les objets utilisés uniquement dans le corps de la méthode.
À partir de ces informations, le compilateur JIT peut donc effectuer un certain nombre d'optimisations plus poussées, selon le cas.
Les éléments non-GlobalEscape restent limités au thread courant, ce qui rend toute synchronisation inutile. Le compilateur peut ainsi "supprimer" les éventuels locks associés à ces variables (que ce soit directement ou via l'appel d'une méthode synchronized). L'utilisation d'objet synchrone comme Vector ou StringBuffer s'effectuera donc sans synchronisation dans ce cas-là .
Mais il y a plus intéressant : le compilateur peut également directement remplacer des objets locaux par leurs valeurs, en supprimant toute allocation inutile. Cela permet en particulier de passer outre toutes les copies d'objets par protection. Prenons l'exemple suivant permettant de calculer l'aire d'un composant graphique :
public
static
int
getArea
(
Component component) {
Dimension dim =
component.getSize
(
);
return
dim.width *
dim.height;
}
La méthode getSize() se contentant de retourner new Dimension(width, height), le compilateur peut déjà optimiser cela via de l'inlining de méthode en supprimant l'appel de méthode, comme ceci :
public
static
int
getArea
(
Component component) {
Dimension dim =
new
Dimension
(
component.width, component.height);
return
dim.width *
dim.height;
}
C'est là que l'Escape Analysis rentre en jeu. Puisque la variable "dim" reste locale à la méthode, son utilisation peut être détaillée et donc optimisée. Ici l'objet Dimension est créé uniquement pour accéder à ses valeurs width et height sans les modifier. L'allocation de l'objet est complètement inutile et peut donc être ignorée. Pour cela le compilateur JIT va remplacer les éventuelles méthodes par de l'inlining, et ses attributs d'instance par des variables locales (qui pourront donc être stockées dans le registre).
public
static
int
getArea
(
Component component) {
int
width =
component.width;
int
height =
component.height;
return
dim.width *
dim.height;
}
L'allocation inutile a donc été supprimée au profit de l'accès direct aux attributs en les remplaçant par des variables locales, malgré que ceux-ci ne soient pas directement visibles...
À titre d'information, sur ma machine il faut environ 900 millisecondes pour exécuter cette méthode 100 millions de fois. Avec l'Escape Analysis on tombe à 40 millisecondes...
L'Escape Analysis est activé par défaut depuis Java 6 update 23. Il reste toutefois possible de la désactiver avec l'option -XX:-DoEscapeAnalysis.
NUMA Collector Enhancements▲
Le garbage collector "Parallel" (ParallelGC) a été amélioré afin de prendre en compte les architectures NUMA (Non Uniform Memory Access), qui offrent un temps d'accès différent à la mémoire selon son emplacement. En fait chaque processeur dispose d'une mémoire locale plus rapide, qui est couplée à une mémoire distante plus lente mais plus importante.
Le ParallelGC positionne donc les objets dans différents emplacements mémoire selon le thread qui les a instanciés et selon leurs utilisations, afin d'offrir de meilleures performances globales.
À noter que le support des architectures NUMA n'est disponible que sur les systèmes Solaris 9 12/02 ou Linux kernel 2.6.19 (glibc 2.6.1) ou supérieurs. Pour l'activer il faut utiliser le flag -XX:+UseNUMA couplés au flag -XX:+UseParallelGC (pour activer le ParallelGC).
D'après Oracle, le support des architectures NUMA apporterait un gain de performance de 30 % sur le benchmark SPEC JBB 2005 avec une machine Opteron 32 bits à 8 processeurs. En 64 bits le gain de performance atteindrait les 40 %...
Les tout petits plus...▲
Comme chaque nouvelle version, Java 7 apporte également son lot de modifications mineures, mais pas forcément dénuées d'intérêt pour autant.
En voici une petite liste non exhaustive agrémentée d'exemples, c'est toujours plus parlant !
La JSR 203 en profite également pour améliorer le support des sockets dans l'API standard. On notera par exemple l'ajout de plusieurs interfaces intermédiaires (NetworkChannel, MulticastChannel, etc.) et une API d'option de socket plus évolutive que les simples constantes numériques utilisées jusqu'à maintenant.
SocketAddress remote =
...
SocketChannel channel =
SocketChannel.open
(
remote)
.setOption
(
StandardSocketOptions.SO_KEEPALIVE, true
)
.setOption
(
StandardSocketOptions.SO_SNDBUF, 8192
),
La classe Random ne doit pas être partagée par plusieurs threads, car cela peut engendrer une mauvaise qualité des nombres aléatoires. La classe ThreadLocalRandom vient pallier cela en proposant une implémentation spécifique à chaque thread que l'on récupère via la méthode ThreadLocalRandom.current()
int
value =
ThreadLocalRandom.current
(
).nextInt
(
);
L'autoboxing du compilateur accepte désormais les casts d'un Object directement vers un type primitif, sans avoir à utiliser le type wrapper correspondant.
Object object =
...
// Java 6 et Java 5 :
int
value =
(
Integer) object;
// Java 7 :
int
value =
(
int
) object;
Afin de faciliter l'écriture de Comparateur, les classes Boolean, Byte, Character, Short, Integer et Long se voient dotées d'une méthode static compare(x,y) permettant de comparer deux valeurs primitives de la même manière que la méthode d'instance compareTo(). Les types Float et Double ne sont pas concernés puisqu'ils disposaient déjà d'une telle méthode. Bien sûr ces implémentations sont relativement basiques, mais leur mise à disposition permet d'éviter les petites erreurs en proposant une implémentation de base pour tous les types primitifs.
int
diff =
Boolean.compare
(
true
, false
);
int
diff =
Byte.compare
((
byte
)0
, (
byte
)1
);
int
diff =
Character.compare
(
'a'
, 'b'
);
int
diff =
Short.compare
((
short
)0
, (
short
)1
);
int
diff =
Integer.compare
(
0
, 1
);
int
diff =
Long.compare
(
0
L, 1
L);
int
diff =
Float.compare
(
0.0
f, 1.0
f);
int
diff =
Double.compare
(
0.0
, 1.0
);
De même, les méthodes parseXXX() des types Byte, Short, Integer et Long acceptent désormais le caractère '+' devant les nombres positifs (jusqu'à présent cela générait une exception). Ceci permet de s'aligner sur le comportement de leurs équivalents pour les nombres réels parseFloat() et parseDouble().
int
value =
Integer.parseInt
(
"+1"
); // OK
ReflectiveOperationException est une nouvelle exception parente aux exceptions générées via l'API de Reflection (ClassNotFoundException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException, NoSuchMethodException), ce qui permet d'intercepter toutes ces exceptions dans un seul bloc catch facilement.
// Java 6 et antérieures :
try
{
String methodName =
"toUpperCase"
;
Object instance =
"a String"
;
Method method =
instance.getClass
(
).getDeclaredMethod
(
methodName);
method.invoke
(
instance);
}
catch
(
IllegalAccessException e) {
e.printStackTrace
(
);
}
catch
(
IllegalArgumentException e) {
e.printStackTrace
(
);
}
catch
(
InvocationTargetException e) {
e.printStackTrace
(
);
}
catch
(
NoSuchMethodException e) {
e.printStackTrace
(
);
}
// Java 7 :
try
{
String methodName =
"toUpperCase"
;
Object instance =
"a String"
;
Method method =
instance.getClass
(
).getDeclaredMethod
(
methodName);
method.invoke
(
instance);
}
catch
(
ReflectiveOperationException e) {
e.printStackTrace
(
);
}
System.lineSeparator() remplacera avantageusement System.getProperty(line.separator).
// Java 6 et antérieures :
String endl =
System.getProperty
(
"line.separator"
);
// Java 7 :
String endl =
System.lineSeparator
(
);
Les flux d'entrées/sorties des processus externes ont été améliorés. La classe ProcessBuilder permet désormais de les rediriger automatiquement vers un fichier, ou d'hériter directement des mêmes flux d'entrées/sorties que le processus Java (les flux seront alors directement associés aux flux correspondants du process Java, sans que l'on ait besoin de gérer quoi que ce soit).
// Exécution d'un processus externe en héritant des flux d'entrées/sorties
// (il n'y a donc pas besoin de traiter les flux du process)
Process process =
new
ProcessBuilder
(
"programme"
, "arg1"
, "arg2"
)
.inheritIO
(
).start
(
);
// Exécution d'un processus externe en héritant des flux d'entrées/sorties
// ET en redirigeant la sortie standard dans un fichier :
Process process =
new
ProcessBuilder
(
ls)
.inheritIO
(
).redirectOutput
(
new
File
(
"out.txt"
)).start
(
);
java.util.Objects propose un ensemble de méthode static facilitant certains traitements sur les objets, comme par exemple des méthodes compare(), equals(), hashCode() ou toString() gérant automatiquement les valeurs nulles, hash() qui permet de calculer facilement un hashcode, ou encore requireNonNull() qui vérifiera la validité d'une référence.
class
MyObject {
private
final
String name;
private
final
Date date;
private
final
Date expir;
public
MyObject
(
String name, Date date, Date expir) {
// Vérification automatique des valeurs nulles
// Ceci générera une NullPointerException
// si le paramètre est null.
this
.name =
Objects.requireNonNull
(
name);
// On peut définir un message perso en cas d'exception :
this
.date =
Objects.requireNonNull
(
date, date is null
);
// Ce paramètre peut être null :
this
.expir =
expir;
}
@Override
public
int
hashCode
(
) {
// Calcul du hashCode selon ces attributs, en gérant les null éventuels :
return
Objects.hash
(
this
.name, this
.date, this
.expir);
}
@Override
public
boolean
equals
(
Object obj) {
if
(
this
==
obj)
return
true
;
if
(
obj instanceof
MyObject) {
MyObject other =
(
MyObject)obj;
// Vérification de l'égalité des attributs
// (en gérant proprement les valeurs nulles) :
return
Objects.equals
(
this
.name, other.name)
&&
Objects.equals
(
this
.date, other.date)
&&
Objects.equals
(
this
.expir, other.expir);
}
return
false
;
}
@Override
public
String toString
(
) {
// Objects.toString() nous permet de définir une valeur
// dans le cas où la valeur de this.expir serait nulle
return
this
.name +
" "
+
this
.date +
" "
+
Objects.toString
(
this
.expir, "(absent)"
);
}
}
Le compilateur javac accepte désormais l'option -Werror, qui permet de considérer tous les warnings comme des erreurs et donc d'interrompre la compilation. À utiliser conjointement avec -Xlint pour activer tous les warnings, et donc s'assurer d'un code plus robuste.
Date date =
new
Date
(
111
, 1
, 1
);
>
javac Code.java
Note: Code.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for
details.
>
javac -Xlint Code.java
Main.java:7
: warning: [deprecation] Date
(
int,int,int) in
Date has been deprecated
Date date =
new Date
(
111
, 1
, 1
);
^
1
warning
>
javac -Xlint -Werror Code.java
Main.java:7
: warning: [deprecation] Date
(
int,int,int) in
Date has been deprecated
Date date =
new Date
(
111
, 1
, 1
);
^
error: warnings found and -Werror specified
1
error
1
warning
Swing utilise désormais les Generics. Outre les nouveaux éléments comme Painter<T> ou JLayer<V extends Component>, on peut noter l'évolution des classes JList et JComboBox respectivement en JList<E> et JComboBox<E>...
JList<
Date>
listDate =
new
JList<
Date>(
);
JComboBox<
String>
combo =
new
JComboBox<
String>(
);
La classe URLClassLoader dispose désormais d'une méthode close(), permettant de libérer les ressources liées pour éventuellement les modifier ou les recharger...
La classe StandardCharsets définit des attributs static permettant un accès direct aux codages de caractères les plus courants, qui sont forcément présents dans toutes les plateformes Java, soit : US_ASCII, ISO_8859_1, UTF8, UTF_16, UTF16_LE et UTF16_BE.
Path path =
...
List<
String>
lines =
Files.readAllLines
(
path, StandardCharsets.UTF_8);
La classe Locale supporte désormais la notation internationale IETF BCP 47, qui apporte la notion de script et d'extension. La création de Locale peut donc désormais s'effectuer via la méthode static Locale.forLanguageTag() ou via la classe Locale.Builder().
Dans le même ordre, il est désormais possible de définir deux catégories de locales par défaut via l'enum Locale.Category, afin de distinguer la langue de l'interface utilisateur de celle utilisée pour le formatage des données.
// Création d'une locale complexe à partir d'un tag IETF BCP 47
Locale japanese =
Locale.forLanguageTag
(
"ja-JP-u-ca-japanese-x-lvariant-JP"
);
// Utilisation de deux locales par défaut :
// L'interface utilisateur sera en français :
Locale.setDefault
(
Category.DISPLAY, Locale.FRENCH);
// Tandis que le formatage des nombres/dates sera en anglais US :
Locale.setDefault
(
Category.FORMAT, Locale.US);
Java 7 inclut également le support de l'Unicode version 6.0. Pour le développeur, c'est totalement transparent, mises à part quelques nouvelles méthodes utilitaires au sein de la classe Character...
Vers Java 8 et au-delà ...▲
J'ai essayé de faire une présentation globale des nouveautés apportées par Java SE 7, plus ou moins complète selon les sections. Bien entendu pour approfondir tout cela il reste la javadoc, qui est source d'exemples et de détails intéressants dont il serait bête de se priver.
Enfin il ne faut pas oublier non plus que Java SE 7 est intimement lié à son successeur Java SE 8. En effet le fameux "Plan B" y a reporté plusieurs des fonctionnalités initialement prévues pour Java 7.
On pense au projet Jigsaw, un système de modularisation de la plateforme Java, à l'usage étendu des annotations, au support d'expressions littérales pour les collections (List, Set et Map), et surtout aux expressions Lambdas et leurs lots de nouveaux concepts (type "SAM", référence de méthode, defender's methods, etc.) qui permettront un nouveau genre d'API tout en améliorant grandement l'utilisation des API actuelles.
Mais c'est une autre histoire...