Singleton — Modèle de conception (Design Pattern)

11 Sep 2019 11 Sep 2019 6283 vues ESSADDOUKI Mostafa 17 min de lecture
Introduction
1 Nouveautés de Java 11 2 Différences entre JDK, JRE et JVM 3 Structure d'un programme Java - Hello World 4 Mots clés et conventions de dénomination en Java 5 Types de données intégrés en Java 6 Les variables en Java 7 Classes enveloppe - Number, Integer, Double ... 8 Lire les entrées clavier en Java
Structures de contrôle
9 Les opérateurs en Java 10 Les structures conditionnelles en Java 11 Les boucles en Java 12 Instructions de contrôle de boucle - break, continue
Chaines de caractères
13 Les chaines en Java - API String 14 Les chaines en Java - StringBuffer et StringBuilder 15 Les expressions régulières en Java
Programmation OO
16 Objets et classes en Java 17 Modificateurs d'accès Java - public, private, protected et package 18 Méthodes et surcharge des méthodes en Java 19 les constructeurs en Java 20 L'héritage en Java 21 Classes abstraites en Java 22 Interfaces et héritage multiple en Java 23 Les classes imbriquées en Java 24 Les singletons en Java 25 Classes et méthodes génériques 26 Interface fonctionnelle et expressions Lambda en Java
Tableaux et collections
27 Les tableaux en Java 28 Classe Arrays - java.util.Arrays 29 Les listes dynamiques - java.util.ArrayList 30 Les listes chaînées en Java - java.util.LinkedList 31 HashSet en Java - java.util.HashSet 32 HashMap en Java - java.util.HashMap
Gestion des fichiers
33 Comprendre les fichiers informatiques 34 Utilisation des classes Path et Files en Java 35 Lecture et écriture dans un fichier en Java 36 Fichiers à accès aléatoire en Java
Gestion d'exceptions
37 Gestion d'exceptions en Java 38 Créez vos propres classes d'exception en Java
Programmation concurrente
39 Introduction à la programmation concurrente en Java - Multi-threads 40 classe java.lang.Thread 41 Synchronisation des threads en Java
Cours Java pour les débutants — Étape 24 sur 41

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

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.

Quand utiliser Singleton ?
  • 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 :

  1. Déclarer un constructeur private — empêche l'instanciation directe avec new
  2. Déclarer un attribut private static qui référence l'instance unique
  3. Déclarer une méthode public static qui 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();
    }
}
Sortie
Création de l'instance unique
s1 == s2 : true
Singleton en action !
Problème de thread-safety

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;
    }
}
Inconvénient

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;
    }
}
Pourquoi 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;
    }
}
Inconvénient de l'eager loading

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
    }
}
Meilleure pratique

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
    }
}
Sortie
Création via enum
s1 == s2 : true
Singleton enum en action
Connexion à la base de données
Avantages de l'approche enum
  • 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 enum ne 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();
    }
}
Sortie
=== 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

Avantages
  • 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)
Inconvénients
  • 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é)
Remarque importante

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

Niveau : Intermédiaire

Implémenter un gestionnaire de configuration global pour une application.

Travail demandé

  1. 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)
  2. Ajouter une méthode void reset() pour réinitialiser (utile pour les tests)
  3. Tester dans un main en chargeant plusieurs propriétés et en les lisant depuis différents endroits.

  L'essentiel en bref

Synthèse — Pattern Singleton
  • Le Singleton garantit qu'une seule instance d'une classe existe dans l'application.
  • Implémentation : constructeur private, attribut private static, méthode public 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.
Un peu d'histoire

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.

Sortie
// La sortie apparaîtra ici…
Prêt · Ctrl+Entrée pour exécuter

Discussion (0)

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Votre commentaire sera visible après modération.