La garantie découle de la règle happens-before du Modèle de Mémoire Java (JMM) associée à l'initialisation de classe. Lorsque la JVM accède pour la première fois à un champ ou à une méthode static d'une classe, elle doit d'abord terminer la phase d'initialisation de la classe. Cette phase exécute les blocs d'initialisation static et les affectations de champ sous un verrou interne unique à cet objet de classe. Par conséquent, toute écriture effectuée dans l'initialiseur statique — telle que la construction de l'instance singleton — forme un lien happens-before avec toute lecture ultérieure de ce champ par des threads accédant à la classe, garantissant une visibilité complète de l'état construit sans nécessiter des mots-clés synchronized ou des déclarations volatile.
public class ConnectionPool { private ConnectionPool() { // poignée TCP coûteuse et création de threads } private static class Holder { static final ConnectionPool INSTANCE = new ConnectionPool(); } public static ConnectionPool getInstance() { return Holder.INSTANCE; // Déclenche l'initialisation de la classe Holder } }
Problème : Une application de trading financier nécessitait un singleton ConnectionPool qui était coûteux à construire en raison des poignées TCP initiales et de la création de threads, mais qui pourrait ne pas être nécessaire dans certains modes de diagnostic légers. L'initialisation hâtive gaspillerait des centaines de millisecondes au démarrage même lorsque le pool restait inutilisé, tandis que le Double-checked locking nécessitait un traitement minutieux de la sémantique volatile et des barrières d'ordre pour éviter le réarrangement des instructions.
Solution 1 : Initialisation Hâtive : Cette approche initialise le champ static lorsque la classe se charge, ce qui est trivial à mettre en œuvre et garanti thread-safe par la JVM. Cependant, elle échoue à exiger d'éviter le coût de construction lorsque le pool n'est jamais accédé, gaspillant des ressources significatives dans les modes de diagnostic et augmentant inutilement le temps de démarrage du déploiement.
Solution 2 : Accesseur Synchronisé : Enveloppant le getter dans synchronized assure la sécurité entre tous les threads et est simple à coder. Malheureusement, cela force chaque appelant à acquérir un moniteur même après que l'instance existe, créant un goulet d'étranglement sévère sous une charge de trading à haute fréquence où les microsecondes comptent et les threads se disputent le même verrou.
Solution 3 : Détenteur d'Initialisation à la Demande : Cela définit une classe statique privée ConnectionPoolHolder contenant une instance static final ConnectionPool, où getInstance renvoie simplement ConnectionPoolHolder.INSTANCE. Cela tire parti du chargement paresseux de classes de la JVM : la classe de détenteur n'est initialisée que lorsque getInstance est invoquée, et le verrou d'initialisation de classe garantit une publication sûre sans synchronisation explicite ou surcharge volatile.
Solution Choisie : L'équipe a sélectionné l'idiome de détenteur pour sa performance sans surcharge après initialisation et sa sécurité garantie sous le Modèle de Mémoire Java, car il équilibré parfaitement l'initialisation paresseuse avec l'efficacité à l'exécution.
Résultat : L'application a atteint une latence d'accès inférieure à la microseconde pour la référence du pool sous une charge concurrente tout en différant l'initialisation lourde jusqu'à la première utilisation, éliminant la surcharge au démarrage dans les modes de diagnostic et demeurant exempte de conditions de concurrence pendant les sessions de trading à fort volume.
Que se passe-t-il pour les threads suivants si le constructeur singleton déclenche une exception lors de l'initialisation de la classe de détenteur ?
Si l'initialiseur statique déclenche une exception, la JVM marque la classe comme ayant échoué à s'initialiser et lance une ExceptionInInitializerError (enveloppant la cause). Il est crucial de noter que tout thread suivant tentant d'accéder à ConnectionPoolHolder recevra une NoClassDefFoundError, même si la cause racine était transitoire (comme une indisponibilité temporaire du réseau). Contrairement au Double-Checked Locking, qui pourrait potentiellement réessayer la construction dans des blocs catch, l'idiome de détenteur nécessite une logique de récupération externe car la classe reste dans un état d'initialisation échouée pour toute la durée de vie du ClassLoader définissant.
Le modèle de détenteur d'initialisation à la demande peut-il être adapté pour des singletons à portée d'instance au sein d'un conteneur multi-locataires ?
Non. Le modèle repose strictement sur des champs static et des verrous d'initialisation au niveau de la classe. Pour des singletons à portée d'instance ou par locataire, le détenteur devrait être une classe interne du contexte du locataire, mais les verrous d'initialisation de classe sont par ClassLoader, pas par instance de conteneur. Cela conduit soit à partager des instances entre les locataires (un risque de sécurité et d'isolation) soit à nécessiter une synchronisation explicite au sein de l'instance du locataire, ce qui annule le but du modèle de permettre un accès sans verrou. Les candidats confondent souvent le chargement paresseux au niveau de la classe avec le chargement paresseux au niveau de l'objet.
Comment cet idiome se comporte-t-il lorsque plusieurs hiérarchies ClassLoader sont impliquées dans des environnements de serveurs d'applications ?
Chaque ClassLoader initialise sa propre copie de la classe de détenteur indépendamment. Dans Tomcat ou WildFly, si la classe singleton est présente à la fois dans l'application web et le chargeur parent partagé, ou si l'application web est redéployée (créant un nouveau ClassLoader), des instances distinctes existeront. Cela viole le contrat singleton à travers le processus de la JVM. Le modèle garantit la sécurité des threads au sein d'un seul espace de chargement de classe mais ne fournit pas de JVM singleton global, une distinction cruciale dans des environnements modulaires où l'isolation du chargeur de classes est appliquée.