Привет, Хабр!

Паттерн Singleton гарантирует существование лишь одного экземпляра класса и предоставляет к нему глобальную точку доступа. Этот паттерн стал почти синонимом чистоты кода в многих сценариях работы с Java, где требуется строго один экземпляр объекта. Но не менее интересный и гибкий паттерн - это Multiton. Менее известный, но не менее мощный, он позволяет создавать множество экземпляров класса и контролировать их число и жизненный цикл через предопределенные ключи.

В этой статье мы рассмотрим эти паттерны и их различия.

Singleton

Суть Singleton заключается не просто в ограничении инстанцирования класса одним объектом, но и в предоставлении универсальной точки доступа к этому экземпляру.

Пн помогает контролировать доступ к ресурсам, которые по своей природе должны быть уникальными. Т.е: доступ к БД, файловой системе или какому-либо общему ресурсу, который требует строгого контроля над своим состоянием и доступностью.

Singleton работает по принципу отложенной инициализации — экземпляр класса создается только тогда, когда он впервые нужен. Т.е так ресурсы используются экономно и эффективно, поскольку инициализация происходит только по требованию.

С точки зрения реализации, основными моментами Singleton являются:

  • Приватный конструктор, который предотвращает прямое создание объекта класса.

  • Статическая переменная, которая хранит экземпляр Singleton класса.

  • Публичный статический метод, который предоставляет глобальный доступ к этому экземпляру. Если экземпляр еще не создан, метод его инициализирует; если уже создан - возвращает ссылку на существующий.

Тем не менее, использование Singleton не всегда оправдано. В частности, его применение может затруднить тестирование кода из-за сложностей с мокированием зависимостей и может привести к нежелательной связанности компонентов системы. Также в многопоточных средах требуется доп. осторожность, чтобы обеспечить потокобезопасность Singleton, что часто достигается за счет синхронизации, что, в свою очередь, может негативно сказаться на производительности.

Примеры кода

Рассмотрим пять классических вариантов:

Самый базовый вариант Singleton включает в себя приватный конструктор и статический метод для получения экземпляра:

public class ClassicSingleton {
    private static ClassicSingleton instance;

    private ClassicSingleton() {}

    public static ClassicSingleton getInstance() {
        if (instance == null) {
            instance = new ClassicSingleton();
        }
        return instance;
    }
}

Для обеспечения потокобезопасности в многопоточной среде используется синхронизация:

public class ThreadSafeSingleton {
    private static ThreadSafeSingleton instance;

    private ThreadSafeSingleton() {}

    public static synchronized ThreadSafeSingleton getInstance() {
        if (instance == null) {
            instance = new ThreadSafeSingleton();
        }
        return instance;
    }
}

Lazy Holder использует вложенный статический класс для отложенной инициализации экземпляра:

public class LazyHolderSingleton {
    private LazyHolderSingleton() {}

    private static class LazyHolder {
        static final LazyHolderSingleton INSTANCE = new LazyHolderSingleton();
    }

    public static LazyHolderSingleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

Использование перечислений для реализации Singleton гарантирует противодействие проблемам сериализации:

public enum EnumSingleton {
    INSTANCE;

    public void someMethod() {
        // Реализация метода
        System.out.println("Log message: " + message);
    }
}

Double-checked locking для ленивой инициализации уменьшает затраты на синхронизацию, проверяя экземпляр дважды:

public class DoubleCheckedLockingSingleton {
    private static volatile DoubleCheckedLockingSingleton instance;

    private DoubleCheckedLockingSingleton() {}

    public static DoubleCheckedLockingSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

Реализуем логирование и управление подключением к БД

Системы логирования - это классический пример использования Singleton, поскольку обычно требуется единственный экземпляр логгера на всё приложение. Т.е с паттерном все части приложения используют один и тот же экземпляр логгера:

public class LoggerSingleton {
    private static LoggerSingleton instance;
    private LoggerSingleton() {}

    public static synchronized LoggerSingleton getInstance() {
        if (instance == null) {
            instance = new LoggerSingleton();
        }
        return instance;
    }

    public void log(String message) {
        // примитивная реализация записи сообщения в лог
        System.out.println(System.currentTimeMillis() + ": " + message);
    }
}

Singleton также часто используется для управления подключениями к БД, гарантируя, что вся система использует единственное подключение, или управляет пулом подключений через олин экземпляр:

public class DatabaseConnectionSingleton {
    private static DatabaseConnectionSingleton instance;
    private Connection connection;

    private DatabaseConnectionSingleton() {
        try {
            // инициализация подключения к БД
            this.connection = DriverManager.getConnection("jdbc:example:database:url", "user", "password");
        } catch (SQLException e) {
            // обработка исключения
        }
    }

    public static DatabaseConnectionSingleton getInstance() {
        if (instance == null) {
            synchronized (DatabaseConnectionSingleton.class) {
                if (instance == null) {
                    instance = new DatabaseConnectionSingleton();
                }
            }
        }
        return instance;
    }

    public Connection getConnection() {
        return connection;
    }
}

Multiton

Multiton это порождающий паттерн проектирования, который обеспечивает контролируемое создание объектов класса с использованием ассоциативного массива для хранения и доступа к экземплярам по уникальным ключам.

В основе паттерна лежит идея о том, что для некоторых классов может потребоваться не один, а несколько экземпляров, каждый из которых связан с определенным ключом. Это позволяет избегать глобального состояния, связанного с Singleton.

Примеры

Пул подключения к БД:

public class DatabaseConnection {
    private static final Map<String, DatabaseConnection> instances = new HashMap<>();

    private DatabaseConnection() {
        // инициализация подключения к базе данных
    }

    public static synchronized DatabaseConnection getInstance(String dbName) {
        if (!instances.containsKey(dbName)) {
            instances.put(dbName, new DatabaseConnection());
        }
        return instances.get(dbName);
    }
}

Кэширование объекта:

public class ObjectCache {
    private static final Map<String, Object> cache = new HashMap<>();

    private ObjectCache() {
        // инициализация кэша
    }

    public static synchronized Object getInstance(String key) {
        if (!cache.containsKey(key)) {
            cache.put(key, new Object());
        }
        return cache.get(key);
    }
}

Лог разных модулей приложения:

public class Logger {
    private static final Map<String, Logger> loggers = new HashMap<>();
    private String moduleName;

    private Logger(String moduleName) {
        this.moduleName = moduleName;
        // инициализация логгера
    }

    public static synchronized Logger getInstance(String moduleName) {
        if (!loggers.containsKey(moduleName)) {
            loggers.put(moduleName, new Logger(moduleName));
        }
        return loggers.get(moduleName);
    }

    public void log(String message) {
        System.out.println("[" + moduleName + "] " + message);
    }

Сравним эти паттерны

Singleton:

  1. Описание: гарантирует, что для класса существует только один экземпляр, и предоставляет глобальную точку доступа к нему.

  2. Управление экземпляром: один экземпляр класса.

  3. Ограничение: нет возможности создавать несколько экземпляров класса.

  4. Идентификация: единственный экземпляр идентифицируется по статическому методу или переменной.

  5. Применение: используется для доступа к общим ресурсам, кэширования объектов, логирования и т.д.

Multiton:

  1. Описание: похож на Singleton, но позволяет создавать и управлять множеством экземпляров класса с уникальными ключами.

  2. Управление экземпляром: множество экземпляров класса, каждый из которых идентифицируется уникальным ключом.

  3. Ограничение: ограниченное кол-воэкземпляров, определенных по ключам.

  4. Идентификация: каждый экземпляр идентифицируется уникальным ключом.

  5. Применение: спользуется для управления пулами ресурсов, кэширования с ограниченным размером, управления соединениями к БД и т.д.

Для наглядности сделал табличку:

Параметр

Singleton

Multiton

Управление

Один экземпляр класса

Множество экземпляров по ключам

Ограничение

Один экземпляр

Ограниченное количество

Идентификация

По статическому методу или переменной

По уникальному ключу

Применение

Общие ресурсы, кэширование, логирование

Пулы ресурсов, кэширование с ограничением, управление соединениями к БД и т.д.


Итак, если задача требует существования только одного экземпляра класса в приложении, например, для доступа к общим ресурсам или управления глобальными настройками, то Singleton - хороший выбор. Если нужно иметь несколько экземпляров класса с различными характеристиками или параметрами, например, для управления пулами ресурсов с ограниченным размером или для работы с разными источниками данных, то Multiton конечно будет намного лучше.

В завершение хочу пригласить вас на бесплатный вебинар, где вы узнаете, что такое дамп памяти, как его собрать и какие инструменты существуют для этих целей. Далее вы познакомитесь с инструментом Eclipse Memory Analyzer, с помощью которого можно исследовать дампы памяти, особенно, если у вас возникает OutOfMemory.

Комментарии (9)


  1. rmrfchik
    18.03.2024 18:45

    Double Checking не работает. После первой проверки на null, мы можем получить ссылку на недоинициализированный объект.


    1. aleksandy
      18.03.2024 18:45

      Именно поэтому, чтобы такого не произошло, там стоит синхронизация на классе. После входа этот блок, все параллельные потоки не смогут получить доступ к полю instance, до завершения этого блока.


      1. rmrfchik
        18.03.2024 18:45

        Могут.

        Часть метода выглядит под капотом работает так:

        instance = malloc();
        instance.ctor();

        Второй поток сделает первую проверку на null и получит не до конца сконструированный объект.


    1. kurandx
      18.03.2024 18:45

      Работает. После первой проверки, если объект ещё не создан, мы попадаем в блок с синхронизацией, которая гарантированно будет выполняться одновременно только в одном потоке. Те даже если два потока одновременно запросят экземпляр объекта, любой первый кто дошел до синхронизации его создаст и инициализирует, затем положит его в переменную, а у второго потока в переменной уже не будет null, из за чего он пропустит его создание, и просто вернёт уже созданный объект.


      1. rmrfchik
        18.03.2024 18:45

        Синхронизация не даст два раза сконструтировать объект, но не запретит получить ссылку на не до конца сконструированный объект.


        1. rmrfchik
          18.03.2024 18:45

          Дополню: The Java memory model allows the publication of partially initialized objects and this may lead in turn to subtle bugs.


        1. kurandx
          18.03.2024 18:45

          В таком случае при создании любого объекта, при попытке его использования мы можем получить не до конца сконструированный объект?

          В теории да, можно создать не до конца инициализированный объект... через байт-код, если не вызвать его конструктор, но через сам код на java такие приколы сделать не получится.

          Double Checking существует уже очень давно, и я думаю не просто так


          1. rmrfchik
            18.03.2024 18:45

            И не просто так в каждой нормальной статье говорят, что им не надо пользоваться.

            Ссылки:


    1. madmax_001
      18.03.2024 18:45

      Согласен с rmrfchik: DCL является антипаттерном. Об этом есть отдельный раздел 16.2.4 в изветсной книге с поездами. Тут как раз второй поток может наблюдать либо неинициаоизированный объект, либо объект в неконсистентном состоянии, т.е. часть полей инициализировано. И чтобы все работало нужно еще volatile добавить!!! Плюсом java > 5.