Avant Java 5, le modèle mémoire Java (JMM) souffrait de garanties de visibilité mémoire faibles qui rendaient de nombreux idiomes de concurrence populaires non sûrs. Le modèle de Double Vérification de Verrouillage a émergé à la fin des années 1990 comme une optimisation de performance prétendue pour l'initialisation paresseuse, mais il contenait une faille mortelle concernant le réarrangement des instructions. JSR-133 a redéfini la sémantique du mot-clé volatile en 2004 pour fournir un ordre de mémoire d'acquisition-relâchement, spécifiquement pour résoudre ces problèmes de visibilité sans le coût de la synchronisation complète.
Sans volatile, la JVM et les architectures CPU sous-jacentes sont autorisées à réorganiser les instructions de telle sorte que l'assignation d'une référence à une variable se produise avant la fin de l'exécution du constructeur. Cela crée une fenêtre où un autre thread peut observer une référence non nulle à un objet dont les champs contiennent des valeurs par défaut ou non initialisées, entraînant un comportement imprévisible ou un NullPointerException. Le danger de concurrence est particulièrement insidieux car il se manifeste uniquement sous des conditions de timing spécifiques et des modèles mémoire matériels, rendant la reproduction difficile lors des tests.
Déclarer le champ d'instance comme volatile insère une barrière mémoire qui établit une relation de « se passe avant » entre l'écriture dans le constructeur et toutes les lectures ultérieures par d'autres threads. Cela empêche le compilateur et le processeur de réorganiser l'écriture vers le champ volatile avec les écritures précédentes dans le constructeur, garantissant que l'objet est entièrement construit avant que sa référence ne devienne visible. Le modèle permet aux threads de vérifier la référence sans verrouillage après l'initialisation, offrant à la fois la sécurité des threads et de hautes performances.
public class ConnectionPool { private static volatile ConnectionPool instance; private ConnectionPool() { // Initialisation lourde } public static ConnectionPool getInstance() { if (instance == null) { synchronized (ConnectionPool.class) { if (instance == null) { instance = new ConnectionPool(); } } } return instance; } }
Un microservice à haut débit traitant les paiements nécessitait un singleton ConnectionPool pour gérer les connexions JDBC à un cluster PostgreSQL. Pendant les périodes de fort trafic, des milliers de threads invoquaient simultanément getInstance() lorsque le service a commencé, nécessitant une stratégie d'initialisation sûre pour les threads qui minimisait la contention de verrou. La séquence d'initialisation impliquait l'établissement de sockets TCP, l'allocation de tampons d'octets directs et l'exécution de requêtes de validation de schéma, rendant l'instanciation impatiente prohibitive pour les scénarios de mise à l'échelle automatique.
L'initialisation impatiente consistait à créer le pool dans un bloc d'initialisation statique. Cette approche garantissait la sécurité des threads grâce aux mécanismes de chargement de classe et éliminait complètement le besoin de blocs synchronized. Cependant, l'établissement de la connexion nécessitait trois secondes de manipulations TCP et d'échanges de clés, ce qui violait l'accord de niveau de service pour les temps de démarrage à froid lors des événements de mise à l'échelle automatique.
La méthode synchronisée enveloppait la méthode getInstance() avec le mot-clé synchronized. Bien que cela corrige le problème de concurrence en sérialisant tous les accès, cela introduisait une dégradation sévère des performances sous charge. Le profilage a révélé qu'après l'initialisation, les threads passaient des cycles inutiles à acquérir le verrou de moniteur malgré la nature immuable du pool entièrement construit, ajoutant environ 18 millisecondes de latence par appel.
Double vérification de verrouillage avec volatile a été choisie comme la solution optimale. Cette solution utilisait un chemin rapide non synchronisé pour vérifier la nullité, suivi d'un bloc synchronized pour la section critique, avec une seconde vérification de nullité à l'intérieur pour éviter les multiples instanciation. Le modificateur volatile garantissait que l'état du pool entièrement initialisé était visible par tous les cœurs de CPU immédiatement après publication, équilibrant l'initialisation paresseuse avec un surcoût zéro en verrou après le démarrage.
La solution choisie a abouti à une initialisation paresseuse réussie sans blocage, permettant au service de gérer 50 000 requêtes par seconde avec des temps de réponse inférieurs à une milliseconde après la création initiale du pool. L'implémentation a éliminé les problèmes de concurrence lors du démarrage tout en maintenant un accès sans verrou pendant les opérations en état stable, empêchant les instances de NullPointerException observées auparavant dans des scénarios à forte concurrence. La surveillance a confirmé que la JVM gérait correctement la visibilité mémoire à travers tous les 64 cœurs sans synchronisation explicite après l'établissement du singleton.
Pourquoi le modèle de double vérification de verrouillage nécessite-t-il deux vérifications nulles distinctes plutôt qu'une seule vérification synchronisée ?
La première vérification opère en dehors du bloc synchronized pour fournir un chemin rapide sans verrou pour le cas commun où l'instance existe déjà. La seconde vérification à l'intérieur du bloc synchronized est essentielle car plusieurs threads peuvent simultanément passer la première vérification nulle lorsque l'instance est encore non initialisée. Sans cette seconde vérification, chaque thread acquérirait séquentiellement le verrou et créerait des instances séparées, violant la propriété singleton. La vérification intérieure garantit que seul le premier thread à entrer dans la section critique effectue la construction, tandis que les threads suivants découvrent que l'instance est déjà initialisée et évitent la création.
Comment le modèle mémoire Java distingue-t-il les garanties de visibilité d'un écriture volatile et d'une sortie de bloc synchronisé ?
Les deux constructions établissent des relations de se passe avant, mais elles opèrent sur des granularités et caractéristiques de performance différentes. Une sortie de bloc synchronized purge toutes les variables modifiées dans la mémoire de travail du thread vers la mémoire principale, agissant comme une barrière mémoire globale. En revanche, une écriture volatile empêche spécifiquement le réarrangement de cette variable particulière avec les instructions environnantes et garantit que l'écriture est immédiatement visible. Avant Java 5, volatile manquait de ces garanties, ce qui le rendait insuffisant pour une publication sûre ; le JMM moderne traite les écritures volatile de manière similaire aux opérations de libération de C++ et les lectures comme des opérations d'acquisition, offrant une visibilité ciblée sans le coût complet du verrouillage de moniteur.
Les objets immuables peuvent-ils supprimer le besoin de volatile dans le modèle de double vérification de verrouillage ?
Non, car les champs final garantissent l'immuabilité uniquement après l'achèvement du constructeur, pas pendant la publication de la référence elle-même. Sans volatile, le réarrangement des instructions peut entraîner l'écriture de la référence dans la mémoire principale avant la fin de l'exécution du constructeur, permettant à un autre thread d'observer une référence non nulle à un objet partiellement construit. Bien que les champs final garantissent que les valeurs ne peuvent pas changer après construction, ils ne préviennent pas la visibilité des valeurs par défaut ou non initialisées si la référence s'échappe tôt. Une publication sûre nécessite soit volatile, soit synchronized pour garantir la relation se passe avant entre construction et visibilité, indépendamment de l'immuabilité interne de l'objet.