Питання 4 · Розділ 2

Як реалізувати потокобезпечний Singleton?

Якщо два потоки одночасно перевірять instance == null, обидва створять свій об'єкт — вийде два екземпляри замість одного. Щоб Singleton працював у багатопотоковому середовищі, п...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Якщо два потоки одночасно перевірять instance == null, обидва створять свій об’єкт — вийде два екземпляри замість одного. Щоб Singleton працював у багатопотоковому середовищі, потрібно захистити створення екземпляра.

Проста проблема:

// ❌ Два потоки можуть одночасно створити два екземпляри!
if (instance == null) {
    instance = new Singleton();  // Потік 1 і Потік 2 одночасно!
}

Найпростіший спосіб — Enum:

public enum Singleton {
    INSTANCE;  // JVM гарантує один екземпляр

    public void doWork() {
        System.out.println("Working...");
    }
}

// Використання
Singleton.INSTANCE.doWork();
// ✅ Потокобезпечно, захист від рефлексії і серіалізації

Чому Enum безпечний:

  • Java забороняє створення enum через new
  • Серіалізація працює коректно
  • Багатопотоковість забезпечена JVM

Коли НЕ реалізовувати потокобезпечний Singleton вручну

У Spring-додатках: @Component + singleton scope (за замовчуванням) вже керує цим. У CDI: @ApplicationScoped. Не винаходьте велосипед.


🟡 Middle Level

Спосіб 1: Enum Singleton (Рекомендований)

public enum Singleton {
    INSTANCE;

    private final String config;

    Singleton() {
        this.config = loadConfig();
    }

    public String getConfig() {
        return config;
    }
}

Переваги:

  • ✅ Потокобезпечний (JVM)
  • ✅ Захист від reflection
  • ✅ Захист від серіалізації
  • ✅ Простий код

Недоліки:

  • ❌ Не ледачий (створюється при завантаженні класу)

Спосіб 2: Bill Pugh (Static Holder)

public class BillPughSingleton {
    private BillPughSingleton() {}

    // Вкладений клас завантажиться лише при першому зверненні
    private static class SingletonHolder {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

    // Захист від серіалізації
    protected Object readResolve() {
        return getInstance();
    }
}

Чому працює:

  • JVM гарантує атомарність ініціалізації статичних полів
  • Клас SingletonHolder завантажиться лише при першому виклику getInstance()
  • Ледача ініціалізація без явного synchronized — потокобезпечність забезпечується ClassLoader locking (JLS §12.4.2).

Спосіб 3: Double-Checked Locking

public class DclSingleton {
    private static volatile DclSingleton instance;

    private DclSingleton() {
        if (instance != null) {
            throw new IllegalStateException("Already created");
        }
    }

    public static DclSingleton getInstance() {
        DclSingleton local = instance;  // Оптимізація
        if (local == null) {
            synchronized (DclSingleton.class) {
                local = instance;
                if (local == null) {
                    instance = local = new DclSingleton();
                }
            }
        }
        return local;
    }
}

Навіщо volatile:

  • Без volatile можливий reordering
  • Потік може отримати посилання на НЕІНІЦІАЛІЗОВАНИЙ об’єкт
  • volatile забороняє переупорядкування

До Java 5 (JSR-133) Memory Model не гарантувала happens-before для volatile, і DCL був зламаний.

Порівняння

Спосіб Ледачість Потокобезпечність Складність
Enum ❌ Ні ✅ Повна Мінімальна
Bill Pugh ✅ Так ✅ Повна Низька
DCL + volatile ✅ Так ✅ Повна Висока

У Spring

// НЕ потрібно писати Singleton вручну!
@Component  // Singleton scope за замовчуванням
public class UserService { }

// Spring сам гарантує один екземпляр
// + тестованість через DI

🔴 Senior Level

Java Memory Model Deep Dive

Проблема без volatile:

// instance = new Singleton() складається з 3 кроків:
// 1. Allocate memory
// 2. Invoke constructor
// 3. Assign reference to instance

// Без volatile можливий reorder: 1 → 3 → 2
// Потік B бачить instance != null на кроці 3
// Але конструктор (крок 2) ще не виконаний!
// → Потік B отримує об'єкт з дефолтними полями!

volatile вирішує:

// volatile вставляє Memory Barriers:
// StoreStore перед записом у volatile
// LoadLoad після читання з volatile

// Це гарантує:
// Усі записи у конструкторі завершені ДО публікації посилання

Local Variable Optimization:

public static DclSingleton getInstance() {
    DclSingleton local = instance;  // Читаємо volatile ОДИН раз
    if (local == null) {
        synchronized (DclSingleton.class) {
            local = instance;
            if (local == null) {
                instance = local = new DclSingleton();
            }
        }
    }
    return local;  // Використовуємо локальну змінну
}

// Без оптимізації: 2 читання volatile (повільно)
// З оптимізацією: 1 читання volatile (швидше на ~25%)

VarHandle (Java 9+)

// Для екстремальної продуктивності
public class VarHandleSingleton {
    private static final VarHandle HANDLE = MethodHandles.lookup()
        .findStaticVarHandle(VarHandleSingleton.class, "instance", VarHandleSingleton.class);

    private static VarHandleSingleton instance;

    public static VarHandleSingleton getInstance() {
        VarHandleSingleton local = (VarHandleSingleton) HANDLE.getAcquire();
        if (local == null) {
            synchronized (VarHandleSingleton.class) {
                local = (VarHandleSingleton) HANDLE.getAcquire();
                if (local == null) {
                    local = new VarHandleSingleton();
                    HANDLE.setRelease(null, local);
                }
            }
        }
        return local;
    }
}

// getAcquire/setRelease дешевше volatile
// Але достатньо для DCL семантики

Initialization-on-demand Holder: Internal Mechanics

// JVM Class Loading механізм:
// 1. Клас завантажується при першому зверненні
// 2. Ініціалізація статичних полів = атомарна
// 3. ClassLoader内部的 locking гарантує thread-safety

// Це НЕ "магія" — це JLS (Java Language Specification) §12.4.1:
// "Initialization of a class consists of executing its static initializers"
// "The Java programming language implements multiple-threaded initialization correctly"

Protection from All Attacks

public class SecureSingleton implements Serializable {
    private static final SecureSingleton INSTANCE = new SecureSingleton();

    private SecureSingleton() {
        if (INSTANCE != null) {
            throw new IllegalStateException("Singleton violation");
        }
    }

    public static SecureSingleton getInstance() {
        return INSTANCE;
    }

    // Захист від серіалізації
    private Object readResolve() throws ObjectStreamException {
        return INSTANCE;
    }

    // Захист від клонування
    @Override
    protected Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException("Singleton cannot be cloned");
    }
}

Performance Comparison

Benchmark (10M calls):
  Enum:              10ms  (static final, JVM оптимізує)
  Bill Pugh:         12ms  (клас loading один раз)
  DCL + volatile:    15ms  (volatile read overhead)
  VarHandle:         11ms  (weaker barriers)
  synchronized:      150ms (lock overhead кожен раз!)

Production Experience

Реальний сценарій: DCL без volatile у production

  • Додаток працював 2 роки без проблем
  • Після міграції на новий JDK → випадкові NPE
  • Причина: JDK змінив стратегію JIT оптимізації
  • Рішення: додали volatile → проблема пішла

Best Practices

  1. Enum — для 99% випадків (безпека > ледачість)
  2. Bill Pugh — коли критична ледачість
  3. DI Container — у Spring-додатках (НЕ пишіть вручну!)
  4. volatile ОБОВ’ЯЗКОВИЙ у DCL
  5. Local variable optimization у DCL
  6. readResolve() для захисту від серіалізації
  7. Перевірка у конструкторі для захисту від reflection
  8. VarHandle (Java 9+) для екстремальної продуктивності

Резюме для Senior

  • JMM: volatile забороняє reordering через Memory Barriers
  • Bill Pugh: relies on JLS §12.4.1 guarantees
  • Enum: нативний захист від reflection/serialization
  • Local variable: знижує volatile reads на 25%
  • VarHandle: weaker barriers → faster than volatile
  • Spring: НЕ пишіть Singleton — використовуйте @Component
  • DCL без volatile = бомба сповільненої дії

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Enum Singleton — рекомендований спосіб: потокобезпечний, захищений від reflection і серіалізації
  • Bill Pugh (Static Holder) — ледача ініціалізація без synchronized, через static inner class (JLS 12.4.1)
  • Double-Checked Locking вимагає volatile для заборони instruction reordering
  • Local variable optimization у DCL знижує volatile reads на 25%
  • У Spring-додатках НЕ пишіть Singleton вручну — @Component з singleton scope
  • VarHandle (Java 9+) надає getAcquire/setRelease — дешевше volatile
  • Без volatile: reordering 1→3→2 (memory → publish → constructor) → неініціалізований об’єкт

Часті уточнювальні запитання:

  • Чому volatile обов’язковий у DCL? — Без нього JIT/процесор може переупорядкувати: виділити пам’ять → опублікувати посилання → викликати конструктор
  • Як працює Bill Pugh? — Вкладений клас завантажується лише при першому зверненні, JLS гарантує атомарність ініціалізації
  • Enum vs Bill Pugh — що обрати? — Enum якщо не потрібна ледачість, Bill Pugh якщо ледачість критична
  • Чому Spring не потребує ручного Singleton? — @Component з singleton scope, + тестованість через DI

Червоні прапорці (НЕ говорити):

  • “У Spring я пишу Singleton вручну через synchronized” — @Component вже керує цим
  • “Volatile не потрібен, у мене працює” — reordering проявляється лише на певних CPU/JDK
  • “DCL без volatile безпечний” — undefined behavior, особливо на ARM процесорах
  • “Enum не підходить для Singleton” — це рекомендований Joshua Bloch спосіб

Пов’язані теми:

  • [[3. Що таке Singleton]] — загальний опис патерну
  • [[5. Що таке double-checked locking]] — докладніше про DCL
  • [[6. Які проблеми є у Singleton]] — проблеми Singleton
  • [[2. Які категорії патернів існують]] — Creational патерни
  • [[16. Які антипатерни ви знаєте]] — коли Singleton стає антипатерном