Як реалізувати потокобезпечний Singleton?
Якщо два потоки одночасно перевірять instance == null, обидва створять свій об'єкт — вийде два екземпляри замість одного. Щоб Singleton працював у багатопотоковому середовищі, п...
🟢 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
- Enum — для 99% випадків (безпека > ледачість)
- Bill Pugh — коли критична ледачість
- DI Container — у Spring-додатках (НЕ пишіть вручну!)
- volatile ОБОВ’ЯЗКОВИЙ у DCL
- Local variable optimization у DCL
- readResolve() для захисту від серіалізації
- Перевірка у конструкторі для захисту від reflection
- 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 стає антипатерном