Le Singleton en Java : six implémentations, leurs pièges et leurs garanties

Du Singleton naïf qui casse sous plusieurs threads jusqu'au Holder et à l'enum : comment garantir une seule instance en Java, ce que veut dire thread-safe, et les pièges de la réflexion et de la sérialisation.

Type Article

Catégorie Design Patterns

Temps de lecture 16 min

Un logger, une configuration, un pool de connexions : certains objets n'ont de sens qu'en un seul exemplaire. Si chaque partie du programme crée son propre logger, les messages partent dans des fichiers différents, les réglages divergent et la mémoire se remplit de copies inutiles. Le **Singleton** répond à ce problème : il garantit qu'une classe n'a qu'une seule instance et offre un point d'accès unique à cette instance. Sur le papier, c'est le plus simple des design patterns. En pratique, c'est l'un des plus piégeux : la version qu'on écrit naturellement casse dès que plusieurs threads s'en mêlent, et même les versions correctes peuvent être contournées par la réflexion ou la sérialisation. Cet article part de la version la plus naïve et avance pas à pas jusqu'aux implémentations robustes, en expliquant à chaque étape ce qui casse, pourquoi, et comment on le répare. Chaque comportement décrit a été vérifié en exécutant le code sur Java 21. Le problème de départ : une seule instance, partout Le Singleton fait partie des patterns de création décrits en 1994 par le « Gang of Four » dans *Design Patterns*. Son contrat tient en deux exigences : **Une seule instance.** Personne ne doit pouvoir créer un deuxième objet de la classe. **Un point d'accès global.** N'importe quelle partie du programme doit pouvoir récupérer cette instance, sans se la faire passer de main en main. Toutes les implémentations Java reposent sur les trois mêmes ingrédients : **un constructeur privé**, pour que `new Logger()` soit impossible en dehors de la classe ; **un champ `static`**, qui appartient à la classe et non à un objet, et qui garde l'instance ; **une méthode `static`**, souvent appelée `getInstance()`, qui renvoie cette instance. Ce qui distingue les variantes, c'est le **moment** où l'instance est créée et la façon dont on protège cette création. C'est là que tout se joue. Méthode 1 : l'initialisation paresseuse (lazy) La première idée est de créer l'instance seulement quand quelqu'un la demande : public class Logger {

private static Logger logger;

private Logger() {}

public static Logger getInstance() { if (logger == null) { logger = new Logger(); } return logger; } } Au premier appel, `logger` vaut `null` : Java crée un objet avec `new Logger()`, le range dans le champ et le renvoie. Aux appels suivants, `logger` n'est plus `null`, aucun objet n'est créé et l'instance existante est renvoyée. Logger logger1 = Logger.getInstance(); Logger logger2 = Logger.getInstance();

System.out.println(logger1 == logger2); // true L'opérateur `==` compare ici les références : `true` signifie que les deux variables désignent le même objet en mémoire. L'avantage de cette version est évident : si personne n'appelle jamais `getInstance()`, aucun logger n'est créé. Son défaut l'est beaucoup moins, et il faut d'abord comprendre ce qu'est un thread pour le voir. Ce que veut dire « thread-safe » Un **thread** est un fil d'exécution. Un programme Java peut en faire tourner plusieurs en même temps : c'est ce que fait un serveur HTTP, où chaque requête est traitée par son propre thread. Thread A traite la requête du client 1. Thread B traite la requête du client 2. Si les deux requêtes ont besoin de journaliser quelque chose, les deux threads appellent `Logger.getInstance()` au même moment. Un composant est dit **thread-safe** lorsqu'il reste correct quand plusieurs threads l'utilisent simultanément, selon les garanties qu'il annonce. Pour un Singleton, la garantie annoncée est simple : une seule instance, quoi qu'il arrive. Pourquoi la méthode 1 n'est pas thread-safe Le problème se cache dans ces trois lignes : if (logger == null) { logger = new Logger(); } Elles ressemblent à une seule action, mais ce sont deux étapes distinctes : **vérifier**, puis **créer**. Entre les deux, un autre thread peut passer. Voici le scénario qui casse tout : Diagramme : Thread A et Thread B voient tous deux logger == null et créent chacun un objet Les deux threads vérifient avant que l'un d'eux ait fini de créer l'objet. Thread A vérifie `logger == null` : le résultat est `true`. Thread B vérifie `logger == null` : le résultat est encore `true`, car A n'a pas fini. Thread A crée l'objet A, Thread B crée l'objet B. A range l'objet A dans `logger`, puis B l'écrase avec l'objet B. Deux objets ont été créés, et Thread A continue de travailler avec un logger que plus personne d'autre n'utilise. Le problème vient du fait que la vérification et la création ne forment pas une **opération atomique**, c'est-à-dire indivisible. Ce motif porte un nom : *check-then-act*, vérifier puis agir. On le prouve Ce scénario n'a rien de théorique. Pour le rendre reproductible, on ralentit volontairement le constructeur de 50 millisecondes, comme le ferait l'ouverture d'un fichier, et on lance deux threads exactement en même temps grâce à un `CountDownLatch` : private Logger() { System.out.println("Création d'un Logger par " + Thread.currentThread().getName()); try { Thread.sleep(50); // simulates opening a log file } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } Résultat de l'exécution : Création d'un Logger par thread-B Création d'un Logger par thread-A Même instance ? false Deux constructeurs ont été appelés, et les deux threads ne tiennent pas le même objet. Sans le ralentissement, le bug se produit plus rarement, ce qui le rend pire : il passe les tests et apparaît en production, sous la charge. Méthode 2 : synchroniser getInstance La réparation la plus directe consiste à empêcher deux threads d'exécuter `getInstance()` en même temps, avec le mot-clé `synchronized` : public class Logger {

private static Logger logger;

private Logger() {}

public static synchronized Logger getInstance() { if (logger == null) { logger = new Logger(); } return logger; } } Une méthode `static synchronized` prend un verrou sur la classe `Logger` : tant qu'un thread est à l'intérieur, les autres attendent à l'entrée. La vérification et la création redeviennent indivisibles, et le scénario précédent devient impossible. Le prix à payer est que **chaque appel** prend le verrou, y compris les millions d'appels qui suivent la création, alors que le danger n'existait qu'au tout premier. Les JVM modernes rendent un verrou non disputé peu coûteux, mais sous forte concurrence, tous les threads font la queue devant une méthode qui ne fait plus que renvoyer une référence. La méthode est correcte, simple, et souvent suffisante. On peut cependant obtenir la même sécurité sans aucun verrou. Méthode 3 : l'initialisation anticipée (eager) Plutôt que de créer l'instance à la demande, on peut la créer avec la classe elle-même : public class Logger {

private static final Logger logger = new Logger();

private Logger() {}

public static Logger getInstance() { return logger; } } Chaque mot compte : `static` : la variable appartient à la classe, il n'en existe qu'une ; `final` : une fois initialisée, elle ne peut plus désigner un autre objet ; `new Logger()` : l'instance est créée pendant l'**initialisation de la classe**. `getInstance()` se contente ensuite de renvoyer la référence, sans condition ni verrou. Pourquoi c'est thread-safe La sécurité vient de la JVM elle-même. La spécification du langage Java garantit qu'une classe n'est initialisée **qu'une seule fois**, et que cette initialisation est correctement synchronisée : si deux threads déclenchent l'initialisation en même temps, l'un d'eux l'exécute pendant que l'autre attend, puis voit l'objet entièrement construit. Le verrou existe toujours, mais c'est la JVM qui le gère, et il disparaît dès que la classe est initialisée. Quand l'instance est-elle vraiment créée ? On lit souvent que la version eager crée l'instance « au démarrage du programme ». C'est inexact. Une classe est initialisée lors de sa **première utilisation active** : l'appel d'une de ses méthodes statiques, la lecture d'un de ses champs statiques (hors constantes de compilation), ou la création d'un objet. Dans la plupart des programmes, cette première utilisation est justement l'appel à `getInstance()`. La nuance apparaît quand la classe expose autre chose. Ajoutons un champ statique `version` et lisons-le sans jamais demander le logger : Démarrage du programme Lecture de EagerLogger.version : -> EagerLogger créé version = 1.0 Simplement lire `version` a initialisé la classe, et donc créé le logger, alors que personne ne l'a demandé. Si le constructeur est coûteux (ouverture de fichier, connexion réseau), c'est du travail fait pour rien. Le vrai inconvénient de la version eager n'est donc pas « l'instance est créée au démarrage », mais « l'instance est créée dès que la classe sert à quoi que ce soit ». Pour une classe qui ne fait qu'une chose, la différence est souvent nulle. Méthode 4 : le Holder (initialization-on-demand holder) Cette variante combine les deux qualités qu'on cherche : création différée et sécurité garantie par la JVM, sans aucun `synchronized`. public class Logger {

private Logger() {}

private static class Holder { private static final Logger INSTANCE = new Logger(); }

public static Logger getInstance() { return Holder.INSTANCE; } } L'astuce repose sur une classe interne statique, `Holder`, qui ne contient que l'instance. Une classe interne est une classe à part entière pour la JVM : elle n'est pas initialisée en même temps que `Logger`, mais lors de sa propre première utilisation. Le déroulement est le suivant : Java initialise la classe `Logger`. L'instance n'existe pas encore. Un composant appelle `getInstance()`. Pour lire `Holder.INSTANCE`, Java doit initialiser la classe `Holder`. L'initialisation de `Holder` crée l'instance de `Logger`. Les appels suivants lisent directement `Holder.INSTANCE`, déjà prêt. On retrouve la garantie de la méthode 3, appliquée à `Holder` : la JVM initialise cette classe une seule fois, même si plusieurs threads y accèdent en même temps. Et puisque seul `getInstance()` touche à `Holder`, rien d'autre ne peut déclencher la création. Avec le même champ `version` que tout à l'heure, la différence est nette : Lecture de HolderLogger.version : version = 1.0 Premier appel à HolderLogger.getInstance() : -> HolderLogger créé Lire `version` n'a rien créé. L'instance n'apparaît qu'au premier `getInstance()`. Pour un Singleton Java classique, le Holder est un excellent choix par défaut : création différée, thread-safe, aucun verrou à gérer, et un code qui reste court. Méthode 5 : le double-checked locking Avant que le Holder ne se répande, une autre technique cherchait à réduire le coût de la méthode 2 : ne prendre le verrou que tant que l'instance n'existe pas. public class Logger {

private static volatile Logger logger;

private Logger() {}

public static Logger getInstance() { Logger result = logger; if (result == null) { synchronized (Logger.class) { result = logger; if (result == null) { logger = result = new Logger(); } } } return result; } } Le principe se lit en deux vérifications. La première, sans verrou, laisse passer immédiatement tous les appels une fois l'instance créée. La seconde, à l'intérieur du bloc `synchronized`, revérifie parce qu'un autre thread a pu créer l'instance entre le premier test et la prise du verrou. La variable locale `result` évite de relire le champ `volatile` plus que nécessaire. Pourquoi volatile est indispensable Sans `volatile`, ce code est faux, et pendant des années il l'a été dans de nombreux projets. La ligne `logger = new Logger()` n'est pas une seule opération : il faut réserver la mémoire, exécuter le constructeur, puis publier la référence dans le champ. Le compilateur et le processeur ont le droit de **réordonner** ces étapes. Un thread peut alors voir `logger` différent de `null` avant que le constructeur ait fini, passer la première vérification sans verrou, et utiliser un objet à moitié construit. Le mot-clé `volatile` interdit ce réordonnancement : depuis Java 5 et le nouveau modèle mémoire (JSR-133), l'écriture dans un champ `volatile` garantit que tout ce qui la précède, y compris le constructeur, est visible par le thread qui lit ce champ. Un double-checked locking sans `volatile` compile, passe les tests et casse de façon aléatoire en production. Si tu croises cette version dans du code existant, c'est un vrai bug. Cette méthode a sa place quand l'instance dépend d'informations disponibles seulement à l'exécution. Pour un Singleton classique, le Holder fait la même chose en plus court et sans risque d'oubli. Méthode 6 : l'enum La dernière variante est la plus courte de toutes : public enum Logger { INSTANCE;

public void log(String message) { System.out.println(message); } } On l'utilise avec `Logger.INSTANCE.log("Démarrage")`. Une `enum` Java est une classe dont les instances sont fixées par le langage : `INSTANCE` est créée une seule fois, à l'initialisation de l'enum, avec les mêmes garanties de thread-safety que la méthode 3. Joshua Bloch la recommande dans *Effective Java* comme la meilleure façon d'écrire un Singleton, pour une raison qu'on va voir dans la section suivante : elle résiste aux attaques que les autres versions ne couvrent pas. Ses limites sont réelles. Une enum ne peut pas hériter d'une autre classe, ce qui gêne quand le Singleton doit étendre une classe existante. Et l'écriture `Logger.INSTANCE` surprend parfois dans une base de code qui n'utilise les enums que pour des listes de valeurs. Les failles que le constructeur privé ne bloque pas Le constructeur privé empêche d'écrire `new Logger()`. Il n'empêche pas tout. Trois mécanismes de Java permettent de fabriquer une seconde instance d'un Singleton apparemment correct, ici la version eager. La réflexion L'API de réflexion permet d'accéder aux membres privés d'une classe, constructeur compris : Constructor<Logger> constructor = Logger.class.getDeclaredConstructor(); constructor.setAccessible(true); Logger copy = constructor.newInstance(); Résultat : `Réflexion, même instance ? false`. Une défense partielle consiste à lever une exception dans le constructeur si l'instance existe déjà. L'enum, elle, est protégée par la JVM : Réflexion sur l'enum : IllegalArgumentException: Cannot reflectively create enum objects La sérialisation Si le Singleton implémente `Serializable`, l'enregistrer puis le relire crée un nouvel objet, car la désérialisation ne passe pas par `getInstance()`. La parade consiste à ajouter une méthode `readResolve`, que Java appelle après la lecture pour savoir quel objet renvoyer réellement : private Object readResolve() { return INSTANCE; } Les résultats mesurés sont sans ambiguïté : Sérialisation, même instance ? false Avec readResolve, même instance ? true Enum sérialisé, même instance ? true L'enum n'a besoin de rien : Java sérialise une constante d'enum par son nom et retrouve la constante existante à la lecture. Le clonage et les class loaders Un Singleton ne doit pas implémenter `Cloneable`, sinon `clone()` crée une copie. Enfin, l'unicité est garantie **par class loader** : dans un serveur d'applications qui charge la même classe dans deux class loaders différents, chacun aura son instance. C'est rare dans une application Spring Boot classique, mais c'est la raison de comportements étranges dans certains environnements à plugins. Thread-safe à la création ne veut pas dire thread-safe à l'usage Toutes les garanties précédentes concernent un seul moment : la **création** de l'instance. Elles ne disent rien de ce qui se passe ensuite. Ajoutons un compteur de messages à notre logger, et faisons-le appeler 100 000 fois par 8 threads : private int count;

void log(String message) { count++; } Une exécution typique donne : int count = 99979 AtomicInteger = 100000 Il manque des messages. `count++` est lui aussi un *check-then-act* déguisé : lire la valeur, ajouter un, réécrire. Deux threads qui lisent la même valeur écrivent le même résultat, et un incrément est perdu. La valeur exacte change d'une exécution à l'autre. La même opération avec un `AtomicInteger`, conçu pour être modifié par plusieurs threads, tombe juste à chaque fois. Un Singleton est par définition partagé par tous les threads. Tout état modifiable qu'il contient doit être protégé : types atomiques, collections concurrentes, ou `synchronized` sur les méthodes concernées. Le mieux reste un Singleton sans état modifiable. Comparatif des six méthodes Méthode Création Thread-safe Différée Réflexion et sérialisation 1. Lazy premier appel non oui non 2. synchronized premier appel oui oui non 3. Eager init. de la classe oui partielle non 4. Holder premier getInstance() oui oui non 5. Double-checked premier appel oui, volatile oui non 6. Enum init. de l'enum oui partielle oui « Partielle » signifie que la création arrive à la première utilisation de la classe, pas forcément à la première demande de l'instance. Pour la résistance à la sérialisation, les méthodes 1 à 5 deviennent correctes avec `readResolve`. Et dans un vrai projet ? Dans une application Spring, on écrit rarement un Singleton à la main. Par défaut, chaque bean déclaré avec `@Component`, `@Service` ou `@Repository` a la portée **singleton** : le conteneur crée une seule instance et la fournit à tous ceux qui en ont besoin. @Component public class AuditLogger {

public void log(String message) { // ... } }

@Service public class PaymentService {

private final AuditLogger audit;

public PaymentService(AuditLogger audit) { this.audit = audit; } } La différence avec le pattern classique est importante. Le singleton de Spring est unique **par conteneur**, pas imposé par la classe : `AuditLogger` a un constructeur public, et rien n'empêche un test d'en créer un autre. Et surtout, `PaymentService` ne va pas chercher son logger avec un `getInstance()` caché dans son code : il le reçoit par son constructeur. Sa dépendance est visible, et un test peut lui passer une fausse implémentation. C'est le principal reproche fait au Singleton classique : `Logger.getInstance()` est un état global. Chaque classe qui l'appelle dépend de lui sans le dire, et les tests ne peuvent ni le remplacer ni repartir d'un état propre. Le pattern reste utile pour du code sans framework, une bibliothèque, ou un outil en ligne de commande. Dans une application avec injection de dépendances, on obtient l'unicité sans ces inconvénients. Ce qu'il faut retenir Un Singleton garantit une seule instance et un point d'accès unique, avec un constructeur privé, un champ statique et une méthode d'accès. La version lazy naïve n'est pas thread-safe : vérifier puis créer n'est pas une opération atomique. `synchronized` sur `getInstance()` corrige le problème, au prix d'un verrou à chaque appel. Les versions eager et Holder s'appuient sur la garantie de la JVM : une classe est initialisée une seule fois, de façon thread-safe. La version eager crée l'instance à la première utilisation de la classe, pas au démarrage du programme. Le Holder offre une création différée et thread-safe sans aucun verrou : c'est le bon choix par défaut. Le double-checked locking exige `volatile`, sans quoi un thread peut voir un objet à moitié construit. L'enum est la seule version qui résiste d'elle-même à la réflexion et à la sérialisation. Thread-safe à la création ne veut pas dire thread-safe à l'usage : l'état modifiable du Singleton doit être protégé. Dans une application Spring, l'injection de dépendances fournit l'unicité sans l'état global. Pour aller plus loin Le Singleton sur Refactoring.Guru Java Language Specification, §12.4.2 : l'initialisation d'une classe « The Double-Checked Locking is Broken » Declaration JSR-133 FAQ : le modèle mémoire de Java Spring : les portées des beans