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.
Catégorie Architecture & qualité
Temps de lecture 23 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 démonstration est donnée sous forme de programme complet. Tu peux l'enregistrer dans un fichier et le lancer directement avec `java RaceDemo.java` (Java 11 ou plus récent, sans compilation séparée). Les sorties affichées ont été obtenues 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 les appels suivants ne recevront jamais. 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, le programme ci-dessous ralentit le constructeur de 50 millisecondes, comme le ferait l'ouverture d'un fichier. Les deux threads attendent derrière un `CountDownLatch`, une barrière qui les libère au même instant quand on appelle `countDown()`. Le `join()` final attend la fin des deux threads avant de comparer ce qu'ils ont obtenu. import java.util.concurrent.CountDownLatch;
public class RaceDemo {
static class Logger { private static Logger logger;
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(); } }
static Logger getInstance() { if (logger == null) { logger = new Logger(); } return logger; } }
public static void main(String[] args) throws InterruptedException { CountDownLatch start = new CountDownLatch(1); Logger[] seen = new Logger[2];
Thread a = new Thread(() -> seen[0] = getInstanceAfter(start), "thread-A"); Thread b = new Thread(() -> seen[1] = getInstanceAfter(start), "thread-B"); a.start(); b.start();
start.countDown(); // releases both threads at the same moment a.join(); b.join();
System.out.println("Même instance ? " + (seen[0] == seen[1])); }
private static Logger getInstanceAfter(CountDownLatch start) { try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return Logger.getInstance(); } } Résultat de l'exécution : Création d'un Logger par thread-A Création d'un Logger par thread-B Même instance ? false Deux constructeurs ont été appelés, et les deux threads ne tiennent pas le même objet. L'ordre des deux premières lignes change d'une exécution à l'autre, le résultat final non. 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 verrou apporte aussi une seconde garantie, moins visible, sur laquelle on reviendra avec la méthode 5 : tout ce qu'un thread a écrit avant de relâcher le verrou est visible par le thread qui le prend ensuite. 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 verrou explicite. 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 on ne le paie plus une fois la classe 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. Le programme suivant ajoute un champ statique `version` à deux loggers, l'un eager, l'autre construit avec la méthode 4 présentée juste après, et lit ce champ sans jamais demander l'instance : public class InitDemo {
static class EagerLogger { private static final EagerLogger logger = new EagerLogger(); static String version = "1.0";
private EagerLogger() { System.out.println(" -> EagerLogger créé"); }
static EagerLogger getInstance() { return logger; } }
static class HolderLogger { static String version = "1.0";
private HolderLogger() { System.out.println(" -> HolderLogger créé"); }
private static class Holder { private static final HolderLogger INSTANCE = new HolderLogger(); }
static HolderLogger getInstance() { return Holder.INSTANCE; } }
public static void main(String[] args) { System.out.println("Lecture de EagerLogger.version :"); System.out.println(" version = " + EagerLogger.version); System.out.println("Lecture de HolderLogger.version :"); System.out.println(" version = " + HolderLogger.version); System.out.println("Premier appel à HolderLogger.getInstance() :"); HolderLogger.getInstance(); } } Lecture de EagerLogger.version : -> EagerLogger créé version = 1.0 Lecture de HolderLogger.version : version = 1.0 Premier appel à HolderLogger.getInstance() : -> HolderLogger créé Lire `EagerLogger.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. La JDK elle-même utilise cette version : `Runtime.getRuntime()` renvoie un champ `private static final Runtime currentRuntime`, créé à l'initialisation de la classe, et le constructeur de `Runtime` est privé. 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. C'est la garantie de la méthode 3, appliquée à `Holder` au lieu de `Logger`. Et comme seul `getInstance()` touche à `Holder`, rien d'autre ne peut déclencher la création : la sortie de `InitDemo` le montre, lire `HolderLogger.version` n'a rien créé. 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. Pour comprendre pourquoi `volatile` est indispensable ici, il faut séparer quatre notions que l'on regroupe souvent sous le mot « thread-safe ». Quatre notions à ne pas confondre **L'atomicité** : une opération se fait d'un bloc, sans qu'un autre thread puisse s'intercaler au milieu. Le *check-then-act* de la méthode 1 n'est pas atomique. **La visibilité** : une écriture faite par un thread n'est pas forcément vue tout de suite par les autres. Chaque cœur de processeur a ses caches, et la JVM peut garder une valeur dans un registre. Sans règle particulière, un thread peut lire une valeur périmée. **L'ordonnancement** : pour aller plus vite, le compilateur et le processeur peuvent réordonner des instructions, tant que le résultat reste identique **pour le thread qui les exécute**. Un autre thread, lui, peut observer ces opérations dans un ordre différent. **La publication sûre** : rendre un objet accessible à d'autres threads de façon qu'ils le voient entièrement construit. Un champ initialisé dans un initialiseur statique, un champ `final`, un champ `volatile` ou un accès protégé par un verrou sont des moyens de publication sûre. Le modèle mémoire de Java relie ces notions par une relation appelée **happens-before** : si une action A *happens-before* une action B, alors tout ce que A a écrit est visible par B, dans l'ordre. La spécification fixe les cas où cette relation existe. Les deux plus utiles ici : relâcher un verrou *happens-before* la prochaine prise de ce même verrou, et l'écriture d'un champ `volatile` *happens-before* toute lecture ultérieure qui voit cette valeur. L'initialisation des classes offre une garantie équivalente : la procédure décrite au §12.4.2 de la spécification synchronise chaque thread qui utilise la classe avec celui qui l'a initialisée. C'est ce qui rend les méthodes 3 et 4 sûres sans qu'on écrive le moindre verrou. Pourquoi volatile est indispensable La ligne `logger = result = new Logger()` n'est pas une seule opération : il faut réserver la mémoire, exécuter le constructeur, puis écrire la référence dans le champ. Le thread qui crée l'objet est protégé par le verrou. Le problème vient des threads qui passent par la **première vérification, sans verrou** : rien ne relie leur lecture de `logger` à l'écriture faite par le créateur. Sans `volatile`, ils peuvent voir la référence avant les écritures du constructeur, et utiliser un objet à moitié construit. Déclarer le champ `volatile` crée la relation qui manquait. L'écriture de la référence dans le champ `volatile` a lieu après la construction. Un thread qui lit ce champ et y trouve la référence voit donc aussi toutes les écritures faites avant, constructeur compris : l'objet est publié de façon sûre. Cette garantie date de Java 5 et du nouveau modèle mémoire défini par la JSR-133. Avant, même `volatile` ne suffisait pas, ce qui a valu à cette technique sa réputation de piège. 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, ou quand on veut contrôler précisément le moment de la création. Pour un Singleton classique, le Holder obtient le même résultat 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 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. Deux mécanismes de Java permettent de fabriquer une seconde instance d'un Singleton apparemment correct, ici la version eager de la méthode 3, rendue `Serializable`. 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(); `copy` est un second logger, distinct de celui que renvoie `getInstance()`. Une défense partielle consiste à lever une exception dans le constructeur quand le champ `logger` est déjà rempli. L'enum, elle, est protégée par la JVM, qui refuse de créer une constante d'enum par réflexion. 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` à la classe : Java l'appelle après la lecture pour savoir quel objet renvoyer réellement. Dans notre version eager, le champ s'appelle `logger` : public class Logger implements Serializable {
private static final Logger logger = new Logger();
private Logger() {}
public static Logger getInstance() { return logger; }
private Object readResolve() { return logger; } } L'objet tout juste désérialisé est alors abandonné au profit de l'instance existante. L'enum n'a besoin de rien : Java sérialise une constante d'enum par son nom et retrouve la constante existante à la lecture. On le prouve Le programme suivant attaque trois versions : une eager sans protection, la même avec `readResolve`, et une enum. import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.lang.reflect.Constructor;
public class BreakDemo {
static class Logger implements Serializable { private static final Logger logger = new Logger();
private Logger() {}
static Logger getInstance() { return logger; } }
static class SafeLogger implements Serializable { private static final SafeLogger logger = new SafeLogger();
private SafeLogger() {}
static SafeLogger getInstance() { return logger; }
private Object readResolve() { return logger; } }
enum EnumLogger { INSTANCE }
public static void main(String[] args) throws Exception { Constructor<Logger> constructor = Logger.class.getDeclaredConstructor(); constructor.setAccessible(true); System.out.println("Réflexion, même instance ? " + (constructor.newInstance() == Logger.getInstance()));
System.out.println("Sérialisation, même instance ? " + (copy(Logger.getInstance()) == Logger.getInstance())); System.out.println("Avec readResolve, même instance ? " + (copy(SafeLogger.getInstance()) == SafeLogger.getInstance())); System.out.println("Enum sérialisée, même instance ? " + (copy(EnumLogger.INSTANCE) == EnumLogger.INSTANCE));
try { Constructor<EnumLogger> enumConstructor = EnumLogger.class.getDeclaredConstructor(String.class, int.class); enumConstructor.setAccessible(true); enumConstructor.newInstance("COPY", 1); } catch (IllegalArgumentException e) { System.out.println("Réflexion sur l'enum : " + e.getMessage()); } }
// Writes the object to bytes and reads it back, as a file or a distributed cache would. @SuppressWarnings("unchecked") static <T> T copy(T value) throws IOException, ClassNotFoundException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { out.writeObject(value); } try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { return (T) in.readObject(); } } } Réflexion, même instance ? false Sérialisation, même instance ? false Avec readResolve, même instance ? true Enum sérialisée, même instance ? true Réflexion sur l'enum : Cannot reflectively create enum objects Le constructeur d'une enum prend deux paramètres cachés, le nom et la position de la constante, d'où `getDeclaredConstructor(String.class, int.class)`. Même en le trouvant, la JVM refuse de l'appeler. Le clonage et les class loaders Le clonage est un risque plus théorique. `Object.clone()` est protégé et refuse de copier un objet qui n'implémente pas `Cloneable`. Un Singleton classique n'est donc pas clonable par défaut. Il le devient si la classe implémente `Cloneable` et expose une méthode `clone()`, par exemple héritée d'une classe parente qui l'a rendue publique. Dans ce cas, `clone()` renvoie une copie superficielle, et donc une seconde instance. La protection consiste à redéfinir `clone()` pour lever une `CloneNotSupportedException`, ou à renvoyer l'instance existante. 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 à notre logger eager un compteur de messages, sous deux formes : un `int` ordinaire et un `AtomicInteger`. Huit threads appellent `log` 100 000 fois au total, et on lit les compteurs une fois que tous ont terminé. import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger;
public class CounterDemo {
static class Logger { private static final Logger logger = new Logger();
private int count; private final AtomicInteger safeCount = new AtomicInteger();
private Logger() {}
static Logger getInstance() { return logger; }
void log(String message) { count++; safeCount.incrementAndGet(); } }
public static void main(String[] args) throws InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(8); for (int i = 0; i < 100_000; i++) { pool.submit(() -> Logger.getInstance().log("requête")); } pool.shutdown(); pool.awaitTermination(1, TimeUnit.MINUTES);
Logger logger = Logger.getInstance(); System.out.println("int count = " + logger.count); System.out.println("AtomicInteger = " + logger.safeCount.get()); } } `shutdown()` refuse les nouvelles tâches, et `awaitTermination` attend que toutes celles déjà soumises soient finies avant de lire les compteurs. Trois exécutions successives donnent : $ java CounterDemo.java int count = 99954 AtomicInteger = 100000 $ java CounterDemo.java int count = 99939 AtomicInteger = 100000 $ java CounterDemo.java int count = 99845 AtomicInteger = 100000 Il manque des messages, et jamais le même nombre. `count++` n'est pas atomique : c'est un *read-modify-write*, qui lit la valeur, ajoute un, puis réécrit. Deux threads qui lisent la même valeur écrivent le même résultat, et un incrément est perdu. `AtomicInteger` fait la même opération de façon atomique, grâce à une instruction spéciale du processeur, et 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 sérialisation, les méthodes 1 à 5 deviennent correctes avec `readResolve`. Contre la réflexion, seule l'enum est protégée sans code supplémentaire. Quelle méthode choisir ? Les six méthodes ne se valent pas pour un nouveau projet. La méthode 1 n'est utile que pour comprendre le problème, et la méthode 2 a été remplacée par des solutions sans verrou explicite. Pour le reste : **Singleton classique avec création différée** : le Holder (méthode 4). **Instance légère, dont la création anticipée ne coûte rien** : l'eager (méthode 3). **Besoin d'une protection native contre la réflexion et la sérialisation** : l'enum (méthode 6). **Création qui dépend d'informations connues seulement à l'exécution** : le double-checked locking (méthode 5), avec `volatile`. **Application Spring ou tout code avec injection de dépendances** : pas de Singleton écrit à la main, mais un bean de portée singleton, comme on va le voir. Et dans un vrai projet ? Le Singleton classique garde sa place dans du code sans framework : une bibliothèque, un outil en ligne de commande, ou la JDK elle-même avec `Runtime.getRuntime()`. Dans une application Spring, on en écrit rarement un. 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. public interface AuditLog { void log(String message); }
@Component public class FileAuditLog implements AuditLog {
@Override public void log(String message) { // writes the message to the audit file } }
@Service public class PaymentService {
private final AuditLog audit;
public PaymentService(AuditLog audit) { this.audit = audit; }
public void pay(String orderId) { // ... payment logic audit.log("Paiement de la commande " + orderId); } } Spring crée un seul `FileAuditLog` et le passe au constructeur de `PaymentService`. La différence avec le pattern classique est importante. L'unicité est assurée **par le conteneur**, pas imposée par la classe. Et surtout, `PaymentService` ne va pas chercher son logger avec un `getInstance()` caché dans son code : il le reçoit par son constructeur, et dépend d'une interface plutôt que d'une classe précise. Le bénéfice apparaît dans les tests. Pour vérifier que chaque paiement est bien tracé, il suffit de passer au service une fausse implémentation qui enregistre les messages dans une liste, sans fichier, sans Spring et sans modifier `PaymentService` : class PaymentServiceTest {
@Test void recordsEveryPayment() { List<String> messages = new ArrayList<>(); PaymentService service = new PaymentService(messages::add);
service.pay("A-42");
assertEquals(List.of("Paiement de la commande A-42"), messages); } } `messages::add` suffit comme implémentation, parce qu'`AuditLog` n'a qu'une seule méthode. Avec un `Logger.getInstance()` appelé à l'intérieur de `pay`, ce test serait impossible sans toucher au code du service : il écrirait dans le vrai fichier, et l'état du logger passerait d'un test à l'autre. C'est le principal reproche fait au Singleton classique : c'est un état global, dont chaque classe dépend sans le dire. 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` corrige le problème, au prix d'un verrou à chaque appel. Les versions eager et Holder s'appuient sur l'initialisation des classes, que la JVM effectue une seule fois et rend visible à tous les threads. La version eager crée l'instance à la première utilisation de la classe, pas au démarrage du programme. Atomicité, visibilité, ordonnancement et publication sûre sont quatre problèmes distincts. Le double-checked locking a besoin de `volatile` pour la publication sûre, grâce à la relation *happens-before*. L'enum est la seule version qui résiste d'elle-même à la réflexion et à la sérialisation. Les autres ont besoin de `readResolve` pour la seconde. Thread-safe à la création ne veut pas dire thread-safe à l'usage : l'état modifiable d'un Singleton doit être protégé. Pour un nouveau projet : le Holder par défaut, l'enum pour la robustesse, et l'injection de dépendances dès qu'un framework la propose. 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