Singleton — Modèle de conception (Design Pattern)
Prérequis
Maîtriser les classes, les objets, les modificateurs d'accès (private, static) et les constructeurs. Comprendre la gestion de la concurrence en Java (thread-safety).
Objectifs
Comprendre le pattern Singleton et ses cas d'usage. Maîtriser les différentes implémentations (basique, thread-safe, avec holder, enum). Connaître les avantages, les inconvénients et les alternatives modernes.
1. Qu'est-ce que le pattern Singleton ?
Singleton est un modèle de conception (Design Pattern) qui garantit qu'une seule instance d'une classe est créée dans toute l'application. Il fournit un point d'accès global à cette instance unique.
Un modèle de conception est une solution réutilisable et éprouvée à un problème récurrent en génie logiciel. Les patterns sont comme une bibliothèque de techniques partagées par les programmeurs du monde entier.
- Configuration d'application — paramètres globaux, propriétés système
- Gestion de logs — un seul logger pour toute l'application
- Pool de connexions — gestion centralisée des connexions à une ressource
- Caches — stockage global de données en mémoire
- Factories — point d'accès unique à des fabriques d'objets
2. Implémentation de base (non thread-safe)
Voici les trois étapes pour implémenter un Singleton basique :
- Déclarer un constructeur
private— empêche l'instanciation directe avecnew - Déclarer un attribut
private staticqui référence l'instance unique - Déclarer une méthode
public staticqui crée et retourne l'instance unique (lazy initialization)
Exemple 1 — Singleton basique (non thread-safe)
class Singleton {
// Attribut privé statique — référence l'unique instance
private static Singleton instance;
// Constructeur privé — empêche l'instanciation externe
private Singleton() {
// Initialisation éventuelle
System.out.println("Création de l'instance unique");
}
// Méthode statique publique — point d'accès global
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
public void afficherMessage() {
System.out.println("Singleton en action !");
}
}
public class TestSingleton {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println("s1 == s2 : " + (s1 == s2)); // true
s1.afficherMessage();
}
}
Création de l'instance unique s1 == s2 : true Singleton en action !
Cette implémentation n'est pas thread-safe. Dans un environnement multi-thread, deux threads peuvent entrer simultanément dans getInstance(), tous deux voir instance == null et créer deux instances différentes. Ce problème doit être résolu pour les applications concurrentes.
3. Implémentations thread-safe
3.1 Méthode synchronisée (simple mais coûteuse)
Exemple 2 — Singleton avec méthode synchronisée
class SingletonSynchronized {
private static SingletonSynchronized instance;
private SingletonSynchronized() {
System.out.println("Création thread-safe");
}
// La synchronisation garantit l'unicité mais dégrade les performances
public static synchronized SingletonSynchronized getInstance() {
if (instance == null) {
instance = new SingletonSynchronized();
}
return instance;
}
}
La synchronisation sur la méthode entière est coûteuse après l'initialisation. Chaque appel à getInstance() subit le verrouillage, même si l'instance existe déjà.
3.2 Double-checked locking (optimisé)
Exemple 3 — Double-checked locking (Java 5+)
class SingletonDoubleChecked {
// Le mot-clé volatile est essentiel (Java 5+)
private static volatile SingletonDoubleChecked instance;
private SingletonDoubleChecked() {
System.out.println("Création avec double-checked locking");
}
public static SingletonDoubleChecked getInstance() {
if (instance == null) { // Premier test (sans verrou)
synchronized (SingletonDoubleChecked.class) {
if (instance == null) { // Deuxième test (avec verrou)
instance = new SingletonDoubleChecked();
}
}
}
return instance;
}
}
volatile ?
Le mot-clé volatile empêche les réordonnancements d'instructions par le compilateur ou la JVM, garantissant que l'initialisation de l'objet est complète avant que la référence ne soit publiée.
3.3 Initialisation statique (eager loading)
Exemple 4 — Initialisation statique (thread-safe par défaut)
class SingletonEager {
// Instance créée au chargement de la classe (thread-safe)
private static final SingletonEager INSTANCE = new SingletonEager();
private SingletonEager() {
System.out.println("Création eager (au chargement de la classe)");
}
public static SingletonEager getInstance() {
return INSTANCE;
}
}
L'instance est créée même si elle n'est jamais utilisée. Cela peut être problématique si l'initialisation est lourde (connexion à une base de données, lecture de fichiers).
3.4 Initialisation holder (recommandé)
Exemple 5 — Singleton avec holder class (thread-safe, lazy)
class SingletonHolder {
// Classe interne statique — pas chargée avant d'être référencée
private static class Holder {
private static final SingletonHolder INSTANCE = new SingletonHolder();
}
private SingletonHolder() {
System.out.println("Création via holder (lazy et thread-safe)");
}
public static SingletonHolder getInstance() {
return Holder.INSTANCE; // Holder n'est chargé qu'ici
}
}
Cette technique est thread-safe sans synchronisation (le chargement des classes en Java est thread-safe), lazy (la classe interne n'est chargée qu'à l'appel de getInstance()), et performante. C'est la méthode recommandée pour la plupart des cas.
3.5 Singleton avec enum (Java 5+)
Exemple 6 — Singleton avec enum (plus simple et plus sûr)
enum SingletonEnum {
INSTANCE;
private SingletonEnum() {
System.out.println("Création via enum");
}
public void executer() {
System.out.println("Singleton enum en action");
}
// Méthodes métier
public void connecterBaseDonnees() {
System.out.println("Connexion à la base de données");
}
}
public class TestEnumSingleton {
public static void main(String[] args) {
SingletonEnum s1 = SingletonEnum.INSTANCE;
SingletonEnum s2 = SingletonEnum.INSTANCE;
System.out.println("s1 == s2 : " + (s1 == s2)); // true
s1.executer();
s1.connecterBaseDonnees();
// Protection contre la réflexion et la sérialisation
}
}
Création via enum s1 == s2 : true Singleton enum en action Connexion à la base de données
- Thread-safe par construction
- Protection contre la sérialisation — pas de problème de désérialisation créant une nouvelle instance
- Protection contre la réflexion — les
enumne peuvent pas être instanciés par réflexion - Syntaxe simple et concise
4. Exemple concret — Gestion de connexion à une base de données
Exemple 7 — Singleton pour pool de connexions (Java 21)
import java.sql.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Singleton pour la gestion des connexions à une base de données
* Version thread-safe avec holder class
*/
class DatabaseConnection {
private Connection connection;
private final AtomicInteger requetesCount = new AtomicInteger(0);
private final String url;
private final String utilisateur;
private final String motDePasse;
// Constructeur privé
private DatabaseConnection() {
this.url = "jdbc:mysql://localhost:3306/ma_base";
this.utilisateur = "admin";
this.motDePasse = "secret";
initialiserConnexion();
}
private void initialiserConnexion() {
try {
System.out.println("=== Initialisation de la connexion à la base de données ===");
// Dans un vrai projet : DriverManager.getConnection(url, utilisateur, motDePasse)
// Simulons une connexion
System.out.println("Connexion établie avec succès");
} catch (Exception e) {
System.err.println("Erreur de connexion : " + e.getMessage());
}
}
// Holder class — chargée uniquement au premier appel
private static class Holder {
private static final DatabaseConnection INSTANCE = new DatabaseConnection();
}
public static DatabaseConnection getInstance() {
return Holder.INSTANCE;
}
public void executerRequete(String sql) {
int numRequete = requetesCount.incrementAndGet();
System.out.printf("Requête #%d : %s%n", numRequete, sql);
// Exécution de la requête...
}
public int getNombreRequetes() {
return requetesCount.get();
}
public void fermerConnexion() {
System.out.println("Fermeture de la connexion");
// Dans un vrai projet : connection.close()
}
}
public class TestDatabase {
public static void main(String[] args) {
// Plusieurs appels — une seule instance
DatabaseConnection db1 = DatabaseConnection.getInstance();
DatabaseConnection db2 = DatabaseConnection.getInstance();
DatabaseConnection db3 = DatabaseConnection.getInstance();
System.out.println("db1 == db2 : " + (db1 == db2)); // true
System.out.println("db2 == db3 : " + (db2 == db3)); // true
db1.executerRequete("SELECT * FROM utilisateurs");
db2.executerRequete("INSERT INTO logs VALUES ('action')");
db3.executerRequete("UPDATE comptes SET solde = solde + 100");
System.out.println("Total requêtes exécutées : " + db1.getNombreRequetes());
db1.fermerConnexion();
}
}
=== Initialisation de la connexion à la base de données ===
Connexion établie avec succès
db1 == db2 : true
db2 == db3 : true
Requête #1 : SELECT * FROM utilisateurs
Requête #2 : INSERT INTO logs VALUES ('action')
Requête #3 : UPDATE comptes SET solde = solde + 100
Total requêtes exécutées : 3
Fermeture de la connexion
5. Tableau comparatif des implémentations
| Implémentation | Thread-safe | Lazy | Performance | Protection réflexion |
|---|---|---|---|---|
| Basique (non synchronisée) | Non | Oui | Élevée | Non |
| Méthode synchronisée | Oui | Oui | Faible (verrouillage systématique) | Non |
| Double-checked locking | Oui | Oui | Bonne | Non |
| Eager loading (static final) | Oui | Non (création au chargement) | Très bonne | Non |
| Holder class | Oui | Oui | Très bonne | Non |
| Enum | Oui | Non (création au chargement) | Très bonne | Oui |
6. Avantages et inconvénients
- Accès global contrôlé — point d'accès unique à une ressource partagée
- Réduction de la mémoire — une seule instance, pas de duplication
- Initialisation à la demande (lazy loading) possible
- Simplifie la configuration pour les ressources partagées (logger, cache, pool)
- Test unitaire difficile — l'état global persiste entre les tests
- Couplage implicite — les classes qui utilisent le singleton deviennent dépendantes
- Violation du principe de responsabilité unique — gère sa propre création ET sa logique métier
- Problèmes en environnement multi-classloader — plusieurs instances possibles
- Difficile à étendre — héritage compliqué (constructeur privé)
Il est essentiel de noter qu'il n'y a que peu de scénarios où les singletons ont vraiment du sens (logging, configuration). Même une connexion à une base de données ne devrait généralement pas être un singleton. Les singletons ne sont rien de plus qu'un état global, et l'état global permet à vos objets de récupérer secrètement des éléments non déclarés dans leurs API, ce qui rend le code moins prévisible et plus difficile à tester.
7. Alternatives modernes au Singleton
Dans les applications modernes, plusieurs alternatives au pattern Singleton sont préférables :
- Injection de dépendances (DI) — Spring, Jakarta EE, Dagger gèrent la portée unique sans état global
- Service Locator — registre centralisé sans verrouiller l'unicité
- Factory avec cache — la fabrique peut gérer le partage sans imposer l'unicité
Exemple 8 — Alternative avec injection de dépendances (style)
// Au lieu d'un singleton, une classe avec une portée unique gérée par un conteneur
class DatabaseService {
private final Connection connection;
// Injecté par le conteneur (Spring, etc.)
public DatabaseService(Connection connection) {
this.connection = connection;
}
public void executerRequete(String sql) {
// Utiliser connection
}
}
// Le conteneur garantit une instance unique par application
// Sans forcer la classe elle-même à gérer l'unicité
8. Exercice
Gestionnaire de configuration
Implémenter un gestionnaire de configuration global pour une application.
Travail demandé
- Créer une classe
ConfigurationManager(Singleton thread-safe avec holder class) contenant :- Une
Map<String, String>pour stocker les paires clé/valeur - Une méthode
void setProperty(String key, String value) - Une méthode
String getProperty(String key)(retourne null si absent) - Une méthode
String getProperty(String key, String defaultValue) - Une méthode
int getIntProperty(String key, int defaultValue) - Une méthode
boolean getBooleanProperty(String key, boolean defaultValue) - Une méthode
void loadFromFile(String chemin)pour charger des propriétés depuis un fichier (simuler)
- Une
- Ajouter une méthode
void reset()pour réinitialiser (utile pour les tests) - Tester dans un
mainen chargeant plusieurs propriétés et en les lisant depuis différents endroits.
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Gestionnaire de configuration global — Singleton thread-safe
* Utilise le pattern Holder pour une initialisation lazy et thread-safe
*/
public class ConfigurationManager {
// Stockage thread-safe
private final Map<String, String> proprietes;
private boolean initialise;
// Constructeur privé
private ConfigurationManager() {
this.proprietes = new ConcurrentHashMap<>();
this.initialise = false;
chargerConfigurationParDefaut();
}
// Holder class — chargée uniquement au premier appel
private static class Holder {
private static final ConfigurationManager INSTANCE = new ConfigurationManager();
}
public static ConfigurationManager getInstance() {
return Holder.INSTANCE;
}
private void chargerConfigurationParDefaut() {
// Valeurs par défaut
proprietes.put("app.nom", "MonApplication");
proprietes.put("app.version", "1.0.0");
proprietes.put("app.environnement", "development");
proprietes.put("db.host", "localhost");
proprietes.put("db.port", "3306");
proprietes.put("logging.niveau", "INFO");
proprietes.put("cache.active", "true");
initialise = true;
System.out.println("=== ConfigurationManager initialisé avec valeurs par défaut ===");
}
// ─────────────────────────────────────────────────────────────────
// Méthodes publiques
// ─────────────────────────────────────────────────────────────────
public void setProperty(String key, String value) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("La clé ne peut pas être vide");
}
proprietes.put(key, value);
System.out.printf("Configuration mise à jour : %s = %s%n", key, value);
}
public String getProperty(String key) {
return proprietes.get(key);
}
public String getProperty(String key, String defaultValue) {
return proprietes.getOrDefault(key, defaultValue);
}
public int getIntProperty(String key, int defaultValue) {
String value = proprietes.get(key);
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
System.err.printf("Valeur invalide pour %s : %s (utilisation de %d)%n", key, value, defaultValue);
return defaultValue;
}
}
public boolean getBooleanProperty(String key, boolean defaultValue) {
String value = proprietes.get(key);
if (value == null) {
return defaultValue;
}
return "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) || "1".equals(value);
}
public void loadFromFile(String chemin) {
System.out.printf("Chargement de la configuration depuis %s%n", chemin);
// Simulation du chargement depuis un fichier
Map<String, String> proprietesFichier = Map.of(
"app.environnement", "production",
"db.host", "192.168.1.100",
"db.port", "5432",
"logging.niveau", "WARN"
);
proprietes.putAll(proprietesFichier);
System.out.println("Configuration chargée depuis le fichier");
}
public void reset() {
proprietes.clear();
chargerConfigurationParDefaut();
System.out.println("=== ConfigurationManager réinitialisé ===");
}
public void afficherToutesLesProprietes() {
System.out.println("\n=== Configuration actuelle ===");
proprietes.forEach((k, v) -> System.out.printf(" %s = %s%n", k, v));
System.out.println();
}
public boolean isInitialise() {
return initialise;
}
}
/**
* Exemple d'utilisation — service qui dépend de la configuration
*/
class DatabaseService {
public void connecter() {
ConfigurationManager config = ConfigurationManager.getInstance();
String host = config.getProperty("db.host");
int port = config.getIntProperty("db.port", 3306);
String env = config.getProperty("app.environnement", "unknown");
System.out.printf("Connexion à la base %s:%d (environnement: %s)%n", host, port, env);
}
}
class LoggingService {
public void log(String message) {
ConfigurationManager config = ConfigurationManager.getInstance();
String niveau = config.getProperty("logging.niveau", "INFO");
boolean cacheActif = config.getBooleanProperty("cache.active", false);
System.out.printf("[%s] %s (cache actif: %b)%n", niveau, message, cacheActif);
}
}
public class TestConfiguration {
public static void main(String[] args) {
// Récupération du singleton à plusieurs endroits
ConfigurationManager config1 = ConfigurationManager.getInstance();
ConfigurationManager config2 = ConfigurationManager.getInstance();
System.out.println("Même instance : " + (config1 == config2));
// Lecture des propriétés
System.out.println("\n--- Lecture des propriétés ---");
System.out.println("Nom app : " + config1.getProperty("app.nom"));
System.out.println("Version : " + config1.getProperty("app.version"));
System.out.println("Port DB (int) : " + config1.getIntProperty("db.port", 3306));
System.out.println("Cache actif (boolean) : " + config1.getBooleanProperty("cache.active", false));
// Modification
config1.setProperty("app.nom", "SuperApplication");
config1.setProperty("app.theme", "dark");
// Utilisation par d'autres services
System.out.println("\n--- Services utilisant la configuration ---");
DatabaseService db = new DatabaseService();
LoggingService logger = new LoggingService();
db.connecter();
logger.log("Application démarrée");
// Réinitialisation (utile pour les tests)
System.out.println("\n--- Réinitialisation ---");
config1.reset();
db.connecter();
logger.log("Nouveau démarrage");
// Affichage complet
config1.afficherToutesLesProprietes();
}
}
=== ConfigurationManager initialisé avec valeurs par défaut === Même instance : true --- Lecture des propriétés --- Nom app : MonApplication Version : 1.0.0 Port DB (int) : 3306 Cache actif (boolean) : true Configuration mise à jour : app.nom = SuperApplication Configuration mise à jour : app.theme = dark --- Services utilisant la configuration --- Connexion à la base localhost:3306 (environnement: development) [INFO] Application démarrée (cache actif: true) --- Réinitialisation --- === ConfigurationManager réinitialisé === === ConfigurationManager initialisé avec valeurs par défaut === Connexion à la base localhost:3306 (environnement: development) [INFO] Nouveau démarrage (cache actif: true) === Configuration actuelle === app.nom = MonApplication cache.active = true db.host = localhost app.environnement = development db.port = 3306 app.version = 1.0.0 logging.niveau = INFO
- Holder class : initialisation lazy et thread-safe sans synchronisation
- ConcurrentHashMap : accès thread-safe sans verrouillage global
- Méthodes utilitaires :
getIntProperty(),getBooleanProperty()avec valeurs par défaut - reset() : permet de réinitialiser l'état pour les tests unitaires
- Séparation des préoccupations : les services (
DatabaseService,LoggingService) utilisent le singleton sans connaître son implémentation interne
L'essentiel en bref
- Le Singleton garantit qu'une seule instance d'une classe existe dans l'application.
- Implémentation : constructeur
private, attributprivate static, méthodepublic static getInstance(). - En environnement multi-thread, il faut assurer la thread-safety (holder class ou enum).
- La holder class est la méthode recommandée (lazy, thread-safe, performante).
- L'enum est la méthode la plus simple et la plus sûre (protégée contre réflexion et sérialisation).
- À utiliser avec parcimonie — le singleton est un état global, difficile à tester et crée un couplage implicite.
- Les alternatives modernes (injection de dépendances) sont souvent préférables.
Le pattern Singleton a été popularisé par le livre "Design Patterns: Elements of Reusable Object-Oriented Software" (GoF, 1994) — connu sous le nom de "Gang of Four". En Java, l'implémentation a évolué avec les versions : le double-checked locking n'était pas fiable avant Java 5 (problème de mémoire volatile). L'approche par enum a été introduite par Joshua Bloch dans "Effective Java" (2008) comme la meilleure façon d'implémenter un singleton. Aujourd'hui, avec les frameworks d'injection de dépendances (Spring, Micronaut, Quarkus), l'utilisation directe du pattern Singleton est de moins en moins recommandée.
Discussion (0)
Soyez le premier à laisser un commentaire !
Laisser un commentaire
Votre commentaire sera visible après modération.