Le pattern de conception Singleton garantit qu'une classe n'ait qu'une seule instance et fournit un point d'accès global à celle-ci. Sa mise en œuvre repose sur deux mécanismes clés :
- Rendre le constructeur de la classe privé, empêchant ainsi toute instanciation externe via l'opérateur
new. - Exposer une méthode statique qui retourne l'instance unique. Si l'instance n'existe pas encore, elle est créée ; sinon, la référence existante est retournée.
Considérations sur la Concurrence
L'utilisation du Singleton dans un environnement multithread nécessite une attention particulière. Si deux threads accèdent simultanément à la méthode de création alors que l'instance n'est pas encore initialisée, ils risquent de créer deux objets distincts, violant ainsi le principe d'unicité. Pour pallier ce problème, des mécanismes de synchronisation ou de verrouillage mutuel doivent être employés, bien qu'ils puissent impacter les performances.
Différentes Approches d'Implémentation
1. Initialisation Hâtive (Champ Statique)
public class AppConfig {
private static final AppConfig configInstance = new AppConfig();
private AppConfig() {}
public static AppConfig getConfig() {
return configInstance;
}
}
Analyse : Cette approche est simple et intrinsèquement thread-safe car l'instanciation se fait lors du chargement de la classe. Cependant, elle ne permet pas le chargement paresseux (Lazy Loading), ce qui peut entraîner une consommation mémoire inutile si l'instance n'est jamais utilisée.
2. Initialisation Hâtive (Bloc Statique)
public class LoggerService {
private static LoggerService loggerObj;
static {
try {
loggerObj = new LoggerService();
} catch (Exception e) {
throw new RuntimeException("Échec de l'initialisation du Logger");
}
}
private LoggerService() {}
public static LoggerService getLogger() {
return loggerObj;
}
}
Analyse : Similaire à la première méthode, l'instanciation est déléguée à un bloc statique. Cela permet de gérer d'éventuelles exceptions lors de la création. Les avantages et inconvénients concernant le chargement hâtif restent identiques.
3. Initialisation Paresseuse (Non Thread-Safe)
public class TaskScheduler {
private static TaskScheduler scheduler;
private TaskScheduler() {}
public static TaskScheduler getScheduler() {
if (scheduler == null) {
scheduler = new TaskScheduler();
}
return scheduler;
}
}
Analyse : Bien que cette méthode implémente le chargement paresseux, elle est totalement inadaptée aux environnements multithreads. Plusieurs threads peuvent franchir la condition if simultanément, résultant en la création de multiples instances.
4. Initialisation Paresseuse (Méthode Synchronisée)
public class EventManager {
private static EventManager manager;
private EventManager() {}
public static synchronized EventManager getManager() {
if (manager == null) {
manager = new EventManager();
}
return manager;
}
}
Analyse : L'ajout du mot-clé synchronized résout le problème de concurrence. Néanmoins, cette solution est très coûteuse en performance, car chaque appel à la méthode nécessite l'acquisition d'un verrou, alors que la synchronisation n'est utile que lors de la toute première instanciation.
5. Initialisation Paresseuse (Bloc Synchronisé - Défectueux)
public class DataPool {
private static DataPool pool;
private DataPool() {}
public static DataPool getPool() {
if (pool == null) {
synchronized (DataPool.class) {
pool = new DataPool();
}
}
return pool;
}
}
Analyse : Cette tentative d'optimisation de la méthode précédente est incorrecte. Le verrou est acquis après la vérification de nullité. Si deux threads évaluent la condition avant que le premier n'entre dans le bloc synchronisé, ils créeront tous deux une instance. Cette approche ne garantit pas l'unicité.
6. Verrouillage à Double Vérification (Double-Checked Locking)
public class NetworkClient {
private static volatile NetworkClient clientInstance;
private NetworkClient() {}
public static NetworkClient getClient() {
if (clientInstance == null) {
synchronized (NetworkClient.class) {
if (clientInstance == null) {
clientInstance = new NetworkClient();
}
}
}
return clientInstance;
}
}
Analyse : Cette technique effectue deux vérificatoins de nullité, encadrant la création par un bloc synchronisé. L'utilisation du mot-clé volatile est cruciale pour empêcher la réorganisation des instructions par le compilateur. Elle offre un excellent compromis entre sécurité des threads, chargement paresseux et performance.
7. Classe Interne Statique
public class SecurityContext {
private SecurityContext() {}
private static class ContextHolder {
private static final SecurityContext context = new SecurityContext();
}
public static SecurityContext getContext() {
return ContextHolder.context;
}
}
Analyse : Cette approche exploite le mécanisme de chargement des classes de la JVM. La classe interne ContextHolder n'est chargée que lors de l'appel à getContext(), assurant ainsi un chargement paresseux. La JVM garantit naturellement que l'initialisation de la classe est thread-safe, rendant cette méthode à la fois élégante et performante.
8. Énumération
public enum PaymentGateway {
GATEWAY;
public void processTransaction() {
// Logique de traitement
}
}
Analyse : Introduite pour simplifier la création de Singletons, l'énumération gère nativement la sérialisation et offre une garantie absolue contre les instanciations multiples, même en présence de réflexion ou de désérialisation. C'est la méthode la plus robuste, bien que moins conventionnelle dans certains codebases.
Bilan et Cas d'Usage
Avantages : Réduction de l'empreinte mémoire en limitant le nombre d'objets, amélioration des performances pour les ressources coûteuses à initialiser, et contrôle centralisé de l'accès.
Inconvénients : Peut compliquer les tests unitaires en introduisant un état global. L'obtention de l'instance via une méthode spécifique plutôt que par instanciation classique peut rendre le code moins intuitif pour les nouveaux développeurs.
Cas d'Usage Recommandés :
- Gestionnaires de connexions aux bases de données ou pools de threads.
- Services de journalisation (Logging) ou de configuration globale.
- Classes utilitaires sans état (Stateless) nécessitant un point d'accès unique.
- Objets lourds à créer mais fréquemment sollicités au cours du cycle de vie de l'application.