domaine : règles java
Un chemin optimisé sur les entrées d'hier se déoptimise lorsque celles d'aujourd'hui arrivent [R:a-speculated-path-deoptimises-when-the-input-changes]
Écrit d'après Aleksey Shipilev, "JVM Anatomy Quarks", à propos de la pollution de profil, de l'inlining et de la déoptimisation ; le mécanisme d'uncommon-trap de HotSpot.
Un JIT ne compile pas votre code. Il compile un pari sur votre code, et le pari est réglé au moment où le pari cesse d'être vrai.
HotSpot compile sur un profil : ce site d'appel n'a jamais vu qu'un seul type de récepteur, donc inline-le et protège-le ; cette branche n'a jamais été prise, donc ne l'émet pas ; ce champ n'a jamais été null, donc saute la vérification ; cette classe n'a jamais été chargée, donc suppose qu'aucune sous-classe n'existe. Chaque hypothèse reçoit une garde, et le chemin compilé est rapide précisément parce que les gardes sont peu coûteuses et les corps sont absents. Lorsqu'une garde échoue, la frame est déoptimisée : un piège peu commun, les frames de l'interpréteur sont reconstruites à partir des frames compilées, l'exécution continue en mode interprété, et on attend d'être recompilé avec le nouveau profil.
Le timing est ce qui en fait une règle de latence plutôt que de débit. Les hypothèses se brisent lorsque l'entrée change -- le type d'ordre inhabituel, la première exception de la journée, le format de message d'un nouveau lieu, la deuxième implémentation d'une interface chargée à la sixième heure. Ainsi, la pire latence que le chemin produira jamais est délivrée lors de l'événement le moins ordinaire, qui est généralement l'événement qui comptait le plus. Les tests de charge avec un trafic représentatif de la moyenne n'en reproduisent rien.
C'est aussi silencieux par défaut. Rien n'est enregistré, aucune métrique ne bouge, et la seule trace est un
point aberrant de latence impossible à distinguer d'une pause de collecte ou d'un délai d'ordonnancement. -XX:+PrintCompilation
et -XX:+TraceDeoptimization disent ce qui s'est passé ; JFR transporte les événements avec moins de surcharge et est
celui qui vaut la peine d'être laissé actif.
Alors, réchauffez le chemin avec les cas inhabituels ainsi que les cas courants -- c'est là que cela
rencontre [R:a-measurement-matches-the-regime-it-reports] : un échauffement construit uniquement à partir d'un trafic typique
produit un profil que le trafic typique confirme et que le trafic atypique détruit. Gardez les sites d'appel chauds
délibérément monomorphes ; un appel d'interface mégamorphique sur le chemin chaud est une barrière d'inlining permanente
plutôt qu'un piège ponctuel. Chargez les classes dont le chemin aura besoin avant que le chemin ne soit
en direct. Et traitez une lambda ou un site d'appel dynamique ajouté à un chemin chaud comme un nouveau profil qui
doit être à nouveau gagné.
Vérification du mode d'échec : qu'est-ce que le JIT a supposé à propos de ce chemin, et que se passe-t-il la première fois que l'une de ces hypothèses est fausse ? Si la réponse honnête est que personne ne sait ce qui a été supposé, c'est la conclusion -- les hypothèses sont lisibles, et les lire est le travail.
Une vue de collection est une fenêtre sur les données de quelqu'un d'autre [R:a-view-is-not-a-copy]
Sachez si vous détenez des données ou une fenêtre sur des données. Le type statique est List<T>
dans les deux cas et ne vous le dira jamais.
La bibliothèque retourne des vues bien plus souvent que la plupart des codes ne le supposent, et chacune a sa propre façon de surprendre :
Arrays.asList(a)est un wrapper de taille fixe sur le tableau.setécrit à travers vers le tableau ;addetremovelancentUnsupportedOperationException.List.of(...)est immuable et indépendant, et est généralement ce qui était voulu.list.subList(from, to)est une fenêtre en direct. Les écritures passent à travers, et tout changement structurel du parent fait que la sous-liste lanceConcurrentModificationExceptionlors de sa prochaine utilisation -- y compris l'idiome courant de prendre une sous-liste puis de vider le parent.map.keySet(),values(),entrySet()sont des vues.keySet().remove(k)supprime l'entrée de la carte. C'est parfois ce que vous voulez et vaut toujours la peine d'être fait délibérément.Collections.unmodifiableList(l)encapsule sans copier : le wrapper refuse les écritures et l'original fonctionne toujours, donc il défend contre l'appelant et non contre vous. Ce n'est pas une copie défensive, c'est pourquoi[R:no-reference-to-internals-escapes]utiliseList.copyOfà la place.Stream.toList()est non modifiable ;collect(Collectors.toList())n'offre aucune telle garantie et peut ou non l'être. Ne vous fiez pas à la mutabilité de l'un ou l'autre.
Alors, indiquez ce que vous voulez au moment où vous le créez. List.copyOf lorsque le récepteur doit être
indépendant, la vue lorsque le partage est l'intention et la durée de vie est courte. Une vue stockée
dans un champ est la forme à examiner le plus attentivement : elle survit à l'expression qui l'a créée, et
la collection sous-jacente peut changer en dessous à tout moment par la suite.
La même distinction régit l'itération. Modifier une collection tout en l'itérant lance
ConcurrentModificationException même en mono-thread, car l'itérateur est aussi une vue
— utilisez removeIf, ou itérez une copie, ou collectez les suppressions et appliquez-les après.
Vérification du mode d'échec, pour toute collection reçue ou retournée : si quelqu'un écrit là-dedans, qu'est-ce qui change d'autre ? Si vous ne pouvez pas répondre sans ouvrir le Javadoc de la méthode d'usine, copiez-la.
Un type wrapper coûte un objet jusqu'à ce que l'analyse d'échappement le supprime [R:a-wrapper-type-is-not-free-here]
Donnez aux concepts du domaine leurs propres types ici aussi — un OrderId qui ne peut pas être passé là où
un AccountId appartient vaut autant en Java qu'ailleurs. Mais ne transportez pas l'argument de coût
avec l'argument de conception, car un seul d'entre eux voyage.
En Rust, un newtype est une construction au moment de la compilation : struct OrderId(u64) est un u64 au moment
de l'exécution et le compilateur le prouve. En Java, record OrderId(long value) est un objet —
un en-tête, un champ, une référence à poursuivre, et une ligne de cache séparée de ce qui pointait
vers lui. Le JIT peut le supprimer : l'analyse d'échappement plus le remplacement scalaire peut aplatir un
wrapper qui ne s'échappe probablement pas de son unité de compilation, ce qui couvre une grande partie du code
ordinaire. Ce qu'il ne couvre pas, c'est le cas qui vous intéresse — un wrapper stocké dans un champ, mis dans
une collection, passé à travers une méthode que le JIT a refusé d'inliner, ou dans un tableau, où OrderId[]
est un tableau de pointeurs et long[] est un tableau de nombres.
Ainsi, la discipline se divise selon l'endroit où la valeur vit :
- À une limite d'API, et partout où l'exactitude est la préoccupation, utilisez le wrapper. Le bogue d'argument échangé qu'il prévaut vaut une allocation que le JIT supprimera généralement de toute façon.
- À l'intérieur d'une boucle chaude, dans un grand tableau, ou dans une structure par message, mesurez avant
de supposer que le wrapper a disparu. C'est
[R:verify-the-abstraction-compiled-away]arrivant dans un langage où la décision de l'optimiseur est dynamique :-XX:+PrintInlining, un profil d'allocation JFR, ou un dump d'analyse d'échappement dit ce qui s'est réellement passé, et la réponse peut différer entre les exécutions du même binaire. - Là où il ne disparaît pas et où le budget est réel, gardez le primitif et mettez la sécurité là où elle ne coûte rien — un paramètre nommé, un builder, une fabrique statique dont la signature ne peut pas être appelée de manière incorrecte.
La version de cette règle qui arrive vaut la peine d'être connue plutôt que d'être attendue : les classes de valeur de Project Valhalla sont conçues pour aplatir exactement ce wrapper par spécification plutôt que par la discrétion de l'optimiseur. Jusqu'à ce que le code s'exécute sur une JVM où c'est vrai, le coût est dynamique et doit être mesuré plutôt que supposé.
Vérification du mode d'échec, avant d'envelopper un primitif sur un chemin avec un budget : cet objet existe-t-il toujours après que le JIT a terminé, et qu'est-ce qui me l'a dit ? "C'est juste un wrapper" est la réponse Rust, et c'est celle qui ne se transfère pas.
Une ressource est fermée par la construction qui l'a ouverte, sur chaque chemin [R:close-what-you-open]
Ouvrez une ressource dans un en-tête try-with-resources et nulle part ailleurs. La construction ferme sur
chaque sortie, dans l'ordre inverse, et — la partie qu'un finally écrit à la main ne peut pas faire — elle conserve
l'exception d'origine lorsque la fermeture échoue également.
try (var conn = pool.take(); var stmt = conn.prepare(sql)) {
return stmt.execute();
}
L'échec qu'un bloc finally introduit mérite d'être énoncé clairement car il semble
correct. Si le corps lance et que close() lance également, l'exception du finally remplace
celle du corps : le programme signale un échec de fermeture d'une connexion et ne dit rien sur la
requête qui a échoué en premier. Try-with-resources à la place supprime l'échec de fermeture
et l'attache à l'original, donc les deux survivent et la cause est celle que vous vouliez. Une fuite vous
coûte un handle ; une cause perdue vous coûte l'investigation.
Trois cas où la construction ne s'applique pas et la discipline le fait toujours :
- Une ressource dont la durée de vie est un champ, pas un bloc — un pool, un client, un exécuteur.
L'objet propriétaire devient
AutoCloseableet la discipline monte d'un niveau ; la chose à refuser est un champ qui est ouvert et jamais fermé par personne en particulier. - Un
ExecutorServicen'est pas fermé parshutdown()seul.shutdown()puisawaitTermination()puisshutdownNow()est la séquence, et la sauter laisse des threads non démons retenant la JVM. - Un
Streamsur un fichier (Files.lines,Files.walk) tient un handle et doit être fermé. C'est le seul flux qui fuit, et il ressemble exactement à ceux qui ne le font pas.
N'utilisez pas un finaliseur ou Cleaner comme mécanisme principal. Ils s'exécutent à un
moment non spécifié ou jamais, et ils existent pour attraper le cas où la discipline a déjà
échoué.
Vérification du mode d'échec, pour chaque ressource : quelle construction ferme cela si la ligne suivante
lance ? Si la réponse est un appel à close() plus loin dans la méthode, rien ne le fait.
Une classe est finale, ou son héritage est conçu et documenté [R:design-for-inheritance-or-forbid-it]
Rendez la classe final sauf si vous avez conçu pour une sous-classe, et si c'est le cas, dites
exactement ce qu'une sous-classe peut surcharger et ce qu'elle ne doit pas.
L'héritage n'est pas le mécanisme de réutilisation qu'il semble être. Une sous-classe dépend de la façon dont
les méthodes de la superclasse s'appellent les unes les autres — addAll appelant add est l'exemple
canonique — et c'est un détail d'implémentation que la superclasse a le droit de modifier dans une version
corrective. Ainsi, une sous-classe fonctionnelle se brise lorsque le parent est refactorisé, sans que l'un ou l'autre
auteur fasse rien de mal. La composition n'a pas cette propriété : un wrapper dépend du contrat
public et de rien d'autre.
Le cas du constructeur est pire que fragile, il est cassé dès le départ. Un constructeur
qui appelle une méthode surchargeable exécute la surcharge de la sous-classe avant que les propres champs de la sous-classe
ne soient assignés, donc la surcharge voit null et 0 dans des champs qu'elle a déclarés final
et initialisés. Rien n'avertit. C'est le même risque de publication
que [R:no-reference-to-internals-escapes] nomme comme this s'échappant, atteint par une route qui
ressemble à une répartition de méthode ordinaire — et [R:publish-safely-or-not-at-all] est pourquoi cela
compte même lorsqu'il n'y a qu'un seul thread.
Alors :
finalpar défaut sur les classes, et sur toute méthode qu'une sous-classe n'a pas à remplacer. Le scellement ([R:seal-the-alternatives]) est la forme plus forte où l'ensemble des sous-types est fermé et connu.- N'appelez jamais une méthode surchargeable depuis un constructeur, un initialiseur, ou
clone()oureadObject(), qui sont des constructeurs portant d'autres noms. - Si une classe est conçue pour l'héritage, documentez l'auto-utilisation : quelles méthodes appellent
lesquelles, lesquelles sont sûres à surcharger, ce qu'une sous-classe doit appeler. Cette documentation fait partie
du contrat, et son coût est la raison honnête pour laquelle la plupart des classes devraient être
finalà la place. - Préférez la composition. Un wrapper qui contient l'instance et la transmet est plus long à écrire, immunisé contre les internes du parent, et c'est ce qu'il faut utiliser lorsque la motivation était la réutilisation plutôt que la substituabilité.
Vérification du mode d'échec, pour chaque classe non finale : lesquelles de mes méthodes s'appellent les unes les autres, et une sous-classe surchargeant l'une d'elles serait-elle encore correcte après que je les ai réorganisées ? Si cela ne peut pas être répondu, la classe était extensible par accident.
equals et hashCode sont une seule décision, et une clé mutable brise les deux [R:equality-is-one-contract]
Décidez de l'égalité une fois, implémentez les deux moitiés dans le même changement, et dérivez-les de champs qui ne peuvent pas changer tant que l'objet est accessible depuis une collection.
Le contrat est mécanique : des objets égaux doivent avoir des codes de hachage égaux. Brisez-le et un
HashMap regarde dans le mauvais compartiment, donc get retourne null pour une clé qu'il contient
et remove ne peut pas trouver d'entrée à supprimer -- une fuite dont la taille croît avec le trafic. Rien ne
détecte cela. La collection se comporte exactement comme spécifié ; la spécification a été
remise à un objet qui a menti.
La moitié de la clé mutable est celle qui survit à la revue, car la classe est correcte lorsqu'elle
est écrite et devient incorrecte lorsque quelqu'un ajoute un setter. Un objet dont le hashCode
dépend d'un champ qui est ensuite muté a changé de compartiment sans que la carte ne soit informée, et
est maintenant inaccessible via la clé même sous laquelle il est classé. Donc un type utilisé comme clé
est immuable, ou il n'est pas utilisé comme clé. Un record vous donne les deux moitiés générées à partir
des composants et des champs finaux par construction, c'est pourquoi c'est la forme par défaut pour
un type de valeur et pourquoi écrire ces deux méthodes à la main devrait sembler une décision plutôt qu'un
code passe-partout.
Deux autres qui découlent du même contrat et qui valent la peine d'être énoncées car chacune a sa propre façon de mal tourner :
compareTodoit être cohérent avecequalspartout où le type atteint unTreeMapou un ensemble trié, qui utilisent l'ordre et n'appellent jamaisequalsdu tout. Un comparateur disant que deux objets sont équivalents alors queequalsdit qu'ils diffèrent produit un ensemble qui contient silencieusement l'un d'eux.- L'héritage et l'égalité ne composent pas. Un
equalsécrit avecinstanceofaccepte une sous-classe et est alors asymétrique ; un écrit avecgetClass()est symétrique et rejette toute sous-classe. Il n'y a pas de troisième option, ce qui est l'une des raisons pour lesquelles[R:design-for-inheritance-or-forbid-it]préfèrefinal.
Vérification du mode d'échec, avant qu'un type ne soit utilisé comme clé ou mis dans un ensemble : **sur quels champs son égalité
...
[suite tronquée]
... repose-t-elle, et l'un d'eux peut-il changer après la construction ?** Si la réponse n'est pas "aucun", le type n'est pas adapté comme clé de la manière dont il est actuellement écrit.
Les exceptions nomment ce qui a échoué, pas la chose qui a été tentée [R:exceptions-name-what-failed]
Le nom d'une exception doit dire ce qui n'a pas fonctionné, pas l'opération qui échouait. Le type définit la catégorie de l'échec ; le message doit permettre de le retrouver sans le message, si possible.
Les exceptions sont la partie de l'API qui est invisible jusqu'à ce qu'elle soit vue, et c'est une
raison de s'arrêter plutôt qu'un simple signal. IllegalArgumentException ne signifie pas "l'argument n'est pas valide" —
toute exception signifie cela — mais plutôt "l'argument viole une contrainte que javac n'a pas appliquée, et que le
code appelant a choisi d'ignorer". NullPointerException lancée par une déréférence est une
conséquence, pas une cause ; une IllegalArgumentException lancée délibérément parce qu'un paramètre
était null lorsqu'il n'aurait pas dû l'être est un point d'arrêt où le programme s'est arrêté pour éviter
d'agir sur des données invalides.
Les pratiques :
- Utilisez les exceptions standard quand elles correspondent.
IllegalArgumentExceptionpour un paramètre invalide,IllegalStateExceptionpour un objet dans un état invalide,UnsupportedOperationExceptionpour une méthode qui ne peut pas être implémentée du tout,NullPointerException(Java 8+ peut l'inclure dans le message) pour un paramètre null qui était interdit —requireNonNullfait exactement cela et donne le nom du champ dans le message. - Donnez un message qui rend le point d'arrêt identifiable. Le nom de la méthode et le nom du paramètre, ou un résumé de l'état qui a échoué. "expected non null" ne dit rien à personne ; "accountId must not be null in getAccount" dit tout.
- Ne lancez pas depuis un constructeur une exception qui ne soit pas initialisable. Un constructeur qui lance
laisse l'objet partiellement construit.
ExceptionInInitializerErrorvient implicitement des initialiseurs statiques qui échouent, et ce n'est jamais une exception métier. - Considérez une exception comme un point d'arrêt, pas un signal de flux de contrôle. Une exception lancée
et attrapée dans le même module est généralement une utilisation incorrecte —
[R:a-speculated-path-deoptimises-when-the-input-changes]explique le coût, et la lisibilité est également négative.
Vérification du mode d'échec, pour chaque exception lancée : si quelqu'un lit le message de cette exception dans un journal, peut-il dire exactement ce qui n'a pas fonctionné sans connaître le code ? Si le message est "An error occurred", la réponse est non.
L'identité n'est pas l'égalité pour les types boxés [R:identity-is-not-equality-for-boxes]
Utilisez .equals() pour comparer des objets ; utilisez == pour comparer des primitives, ou pour tester
l'identité de référence lorsque vous voulez dire exactement cela. Les types boxés (Integer, Long, Boolean,
et leurs frères) sont des objets, pas des valeurs, même lorsqu'ils se comportent comme tels sur les petits
nombres à cause de caches internes.
// Peut être vrai, selon la JVM et le nombre.
Integer a = 100; Integer b = 100; System.out.println(a == b); // Very likely true
Integer x = 200; Integer y = 200; System.out.println(x == y); // Very likely false
Ce n'est pas aléatoire : Integer.valueOf met en cache les valeurs de -128 à 127 par défaut. Mais elle met en cache
seulement par commodité, pas par contrat. Le résultat de == sur des types boxés est donc
un comportement indéfini de la JVM — il fonctionne pendant les tests et échoue en production, ou l'inverse, selon
le nombre. Long, Short, Byte ont leur propre cache ; Boolean.TRUE et Boolean.FALSE
sont garantis d'être les mêmes références, donc == fonctionne toujours pour les booléens mais c'est une habitude
qui ne se transfère pas.
// Comparaison correcte avec des types boxés
Integer a = 100; Integer b = 100;
if (a.equals(b)) { ... } // Toujours correct
// Ou déboîter d'abord
if (a.intValue() == b.intValue()) { ... } // Toujours correct
La règle est simple : les types boxés obtiennent .equals(). Les seules exceptions sont
Boolean garanti par cache, et les types énumérés où == est sûr et préféré. Mais les énumérations
ne sont pas boxées, donc elles sont couvertes par [R:identity-is-not-equality-for-boxes] seulement indirectement.
Ce qui est en jeu ici, c'est l'habitude de copier == depuis les primitives. int == int est sémantiquement
correct ; Integer == Integer peut toujours être vrai pendant les tests et toujours faux en production, ce qui
constitue un bogue qui dépend de l'heure de la journée. [R:measurement-matches-the-regime-it-reports] s'applique
au profil de performance, mais ici, == est un bogue qui attend, et aucune mesure ne le
révélera avant que les nombres dépassent 127.
Vérification du mode d'échec, pour toute comparaison impliquant un type boxé : est-ce que == est utilisé ou
.equals() ? Si c'est ==, et que l'intention n'était pas l'identité de référence, échouez.
Aucune référence aux internes ne s'échappe [R:no-reference-to-internals-escapes]
Ne retournez pas une référence à un état mutable que vous possédez. Copiez-le, rendez-le immuable, ou refusez l'accès.
Une classe qui retourne son tableau interne est livrée à chaque appelant : getItems() retournant
un List<T> que le champ sous-jacent a également créé signifie que l'appelant mute l'état interne
de l'objet, et l'objet ne le sait pas. C'est une violation de l'encapsulation. Pire, c'est silencieux :
la classe est correcte lorsqu'elle est seule et incorrecte en composition, le bogue venant de
la classe propriétaire, pas de l'appelant.
// Mauvais : la référence interne s'échappe
public class Inventory {
private final List<Item> items = new ArrayList<>();
public List<Item> getItems() {
return items; // L'appelant peut maintenant muter la liste interne
}
}
// Correct : retourner une copie
public List<Item> getItems() {
return new ArrayList<>(items); // Indépendant
}
// Correct : retourner une vue non modifiable
public List<Item> getItems() {
return Collections.unmodifiableList(items); // Protège contre l'appelant
}
// Correct : retourner un flux (si seul l'accès en lecture est nécessaire)
public Stream<Item> items() {
return items.stream(); // Stream est consommable, pas une référence persistante
}
Collections.unmodifiableList protège contre l'appelant mais pas contre le propriétaire. List.copyOf
protège des deux côtés, et c'est typiquement ce que vous voulez après la construction. Si la collection interne
doit être modifiable et que vous voulez toujours protéger les appelants, retournez une copie de la totalité ou une
vue qui ne peut pas écrire.
La forme la plus subtile est this s'échappant via un constructeur : passer this à une autre méthode
avant que le constructeur ne termine, ou enregistrer un auditeur qui a this comme état. C'est
traité dans [R:design-for-inheritance-or-forbid-it] et [R:publish-safely-or-not-at-all], mais
le principe est le même : ne partagez pas une référence à un objet qui n'est pas encore complètement
construit, et ne partagez pas une référence à un état mutable que vous possédez.
Vérification du mode d'échec, pour chaque méthode retournant un objet : est-ce une copie défensive, une vue immuable, ou une référence directe ? Si c'est une référence directe à un état mutable, c'est un bogue.
null n'est pas une valeur, c'est l'absence de valeur [R:null-is-not-a-value]
Traitez null comme un état non valide pour les paramètres et les valeurs de retour, sauf indication contraire
explicite. null n'est pas un signal de valeur spécial ; c'est l'absence de valeur.
Utilisez Optional<T> pour signaler l'absence d'une valeur de retour. Optional est une
déclaration de type explicite : cet appel peut ne rien retourner, et l'appelant doit décider quoi faire.
Le type de retour, Optional<Account>, dit : le compte peut ne pas exister. Un type de retour Account
qui peut être null n'est pas détectable par le type, donc le code appelant doit vérifier, mais le langage ne
l'exige pas, donc il ne le fait pas.
// Bon : Optional déclare l'absence
public Optional<Account> findAccount(String id) {
return Optional.ofNullable(repo.get(id));
}
// Mauvais : null retourné sans avertissement
public Account findAccount(String id) {
return repo.get(id); // pourraie être null, mais le type ne le montre pas
}
Pour les paramètres, Optional n'est pas un type de paramètre idiomatique ; au lieu de cela, utilisez
requireNonNull pour vérifier que les paramètres qui ne devraient pas être null ne le sont pas, et documentez
quand null est autorisé. null n'est jamais un signal de valeur spécial car il ne peut pas être destructuré ;
il faut le vérifier manuellement, et la vérification peut être oubliée. Utilisez des classes de valeur ([R:a-wrapper-type-is-not-free-here])
lorsqu'un signal de valeur spécial est vraiment nécessaire (par exemple, -1 pour "non trouvé" dans une méthode de
recherche où la valeur zéro est valide).
La règle s'applique également aux collections : Map.get(key) retourne null si la clé n'existe pas.
Ne comptez jamais sur une implémentation de Map qui ne permet pas null (comme ConcurrentHashMap),
car cela signifie que le contrat d'interface promet une chose et l'implémentation en livre une autre.
Utilisez containsKey ou gérez l'absence via computeIfAbsent ou une valeur par défaut.
Vérification du mode d'échec, à chaque fois qu'un paramètre ou une valeur de retour peut être null : si
cela peut être null, est-ce documenté et l'appelant est-il obligé de le vérifier ? Si la réponse est non,
la conception est peu fiable.
Publiez des objets en toute sécurité ou pas du tout [R:publish-safely-or-not-at-all]
Une référence à un objet mutable publiée sans synchronisation appropriée signifie que
l'état que vous voyez peut être obsolète ou incohérent. La sécurité du thread ne s'applique pas
seulement aux variables partagées ; elle s'applique également aux champs final qui ne sont pas encore,
à la construction d'objets et à la publication.
Un objet publié en toute sécurité garantit que tous les threads voient le même état. Les mécanismes :
- Champs
final: un constructeur qui assigne des champsfinalet qui ne laisse pasthiss'échapper garantit que tout thread voyant l'objet voit correctement ces champs. Ceci est garanti par le JMM pour les types correctement construits. volatile: lire un champvolatilegarantit que tous les threads voient la dernière écriture, mais implique une barrière mémoire à chaque accès.AtomicReference: fournit une publication de référence sécurisée avec des méthodes commesetetget.- Synchronisation : les blocs ou méthodes
synchronizedfournissent une relation "se produit avant" pour tous les accès dans le bloc. final+ publication : un constructeur qui assigne des champsfinalet ne s'échappe pas (par exemple, ne passe pasthisà un autre thread) garantit que l'objet est publié en toute sécurité. L'objet n'a pas besoin d'être verrouillé si tous les accès se font après la construction.
// Pas de publication sécurisée : un autre thread peut voir un état obsolète
public class Holder {
private int value;
public Holder(int v) { this.value = v; }
public int get() { return value; }
}
// Publication sécurisée via final
public class Holder {
private final int value;
public Holder(int v) { this.value = v; }
public int get() { return value; }
}
// Publication sécurisée via volatile
public class Holder {
private volatile int value;
public void set(int v) { this.value = v; }
public int get() { return value; }
}
La distinction entre publication sécurisée et non sécurisée est cruciale pour l'immutabilité\ : un objet immuable (tous les champs sont final, aucun this ne s'échappe pendant la construction)
est publié en toute sécurité par défaut. Un objet "effectivement immuable" (tous les champs sont
final mais l'objet référence un état mutable) n'est pas en sécurité s'il n'est pas publié avec une
barrière appropriée.
Vérification du mode d'échec, pour tout objet qui sera partagé entre threads : cet objet est-il publié en toute sécurité, et comment puis-je le savoir ? Si aucun mécanisme (final, volatile, synchronisation) n'est en place, c'est une course de données.
Scellez les alternatives [R:seal-the-alternatives]
Lorsqu'un ensemble de sous-types est fixe et connu au moment de la compilation, déclarez-le dans le type
lui-même avec sealed. Cela rend le modèle de données complet et le compilateur le vérifie.
sealed est l'opposé de open pour les cas où l'ensemble des possibilités ne devrait pas
changer : un type de statut, un format de message, un résultat. Il remplace les énumérations
lorsque chaque alternative a un état différent, et il remplace le code qui fait des instanceof et
jette quand aucun cas ne correspond.
// Sans sealed : le compilateur ne sait pas que l'ensemble est complet
public abstract class Notification { }
public class EmailNotification extends Notification { }
public class SmsNotification extends Notification { }
// Avec sealed : le compilateur connaît l'ensemble, et peut se plaindre des branches manquantes
public abstract sealed class Notification
permits EmailNotification, SmsNotification { }
public final class EmailNotification extends Notification { }
public final class SmsNotification extends Notification { }
// Utilisation du switch (Java 17+)
Notification n = ...;
return switch (n) {
case EmailNotification e -> sendEmail(e);
case SmsNotification s -> sendSms(s);
};
sealed garantit que switch couvre tous les cas, ce qui élimine les chemins "par défaut" qui
cachent des bogues. Il rend également explicite la sémantique de l'héritage : la classe a exactement autant
de sous-classes que déclarées, toutes connues au moment de la compilation. Ceci est différent de final, qui
interdit toute sous-classe ; sealed en autorise un ensemble limité.
Lorsque l'ensemble des alternatives est connu de l'auteur de l'API mais pas des implémenteurs ultérieurs,
sealed est le mécanisme correct. Il rend le code robuste face aux modifications du modèle de données sans
recourir à l'énumération de toutes les possibilités dans une énumération. Et il s'associe bien avec
[R:design-for-inheritance-or-forbid-it] : la classe de base est scellée, les sous-classes sont conçues
comme des types de valeur avec peu d'état mutable.
Vérification du mode d'échec, pour un type avec un ensemble fixe de possibilités : est-ce que sealed est utilisé,
ou le code dépend-il de instanceof et de default ? Si le code utilise default, il a des
branches manquantes non vérifiées.
Serializable est un second constructeur [R:serializable-is-a-second-constructor]
Serializable rend un objet instance vulnérable à la désérialisation, ce qui contourne complètement le
constructeur. Méfiez-vous-en.
L'interface Serializable elle-même n'a pas de méthodes, mais elle transforme chaque objet de la classe en une
cible potentielle pour un flux de sérialisation. La désérialisation construit l'objet sans
appeler aucun constructeur, ce qui signifie que les invariants établis dans le constructeur ne sont pas
exécutés. Les champs sont lus directement à partir du flux, ou via readObject s'il est présent. Cela fait
de Serializable un second constructeur, qui peut être appelé avec des données arbitraires.
// Une classe qui semble sûre...
public class User implements Serializable {
private final String name;
private final int age;
public User(String name, int age) {
this.name = Objects.requireNonNull(name);
if (age < 0) throw new IllegalArgumentException("negative age");
this.age = age;
}
// ... mais la désérialisation contourne le constructeur, donc l'âge négatif ou name null est possible.
}
Pour se défendre :
- Utilisez
readObjectpour valider l'état après désérialisation. Appelez des vérifications similaires à celles du constructeur. - Utilisez
writeReplacepour remplacer l'objet sérialisé par un objet proxy ("proxy pattern"), transformant la désérialisation en un appel au constructeur du proxy. - Utilisez
readResolvepour remplacer l'objet désérialisé par une instance canonique (couramment utilisé pour les singletons). - Ne rendez pas une classe
Serializablepour la commodité de la sérialisation automatique. La sérialisation est un détail d'implémentation. Si vous le faites, traitez-la comme une API publique.
java.io.Serializable est un talon d'Achille : chaque classe qui l'implémente devient vulnérable aux
attaques par désérialisation si elle ne vérifie pas l'état. C'est pourquoi les classes de la bibliothèque standard
comme String, Integer et ArrayList le sont ; elles ont toutes été conçues en tenant compte de cela. Une
classe de domaine ne devrait l'être que si elle est explicitement destinée à être sérialisée via
ObjectOutputStream, et alors elle reçoit la même attention de conception que le constructeur public.
Vérification du mode d'échec, pour chaque classe implémentant Serializable : puis-je créer une instance
non valide via la désérialisation (un objet avec un invariant violé, ou un état non vérifié) ? Si oui,
readObject doit être implémenté pour le valider.