Як реалізувати шаблон Singleton за допомогою Record
Технічно реалізувати шаблон Singleton (Одинак) за допомогою Record можна, оголосивши константу екземпляра в public static final полі:
🟢 Junior Level
Технічно реалізувати шаблон Singleton (Одинак) за допомогою Record можна, оголосивши константу екземпляра в public static final полі:
public record AppConfig(String environment, int timeoutSeconds) {
// Єдиний глобальний екземпляр
public static final AppConfig INSTANCE = new AppConfig("production", 30);
}
// Використання в коді:
AppConfig config = AppConfig.INSTANCE;
System.out.println(config.environment()); // production
Переваги Record для конфігураційних синглтонів:
- Незмінність (Immutability): Усі поля Record неявно
final, що виключає випадкову модифікацію налаштувань з паралельних потоків. - Потокобезпечна ініціалізація: Статичне поле ініціалізується під час завантаження класу відповідно до гарантій Java Memory Model (JMM).
- Компактність: Автоматично генеруються геттери, методи
equals(),hashCode()таtoString().
Однак на практиці Record не є найкращим інструментом для створення синглтонів. Для класичних Singleton в Java стандартом де-факто залишається enum (за рекомендацією Джошуа Блоха).
🟡 Middle Level
Обмеження Record при реалізації Singleton
Спроба зробити з Record суворий класичний Singleton стикається з низкою архітектурних обмежень специфікації Java:
1. Заборона на приватний конструктор у public record (JLS §8.10.4)
У звичайному класі для захисту від створення додаткових екземплярів конструктор роблять приватним (private MySingleton()).
У public record це заборонено специфікацією:
public record AppConfig(String url) {
// ❌ Помилка компіляції:
// Access modifier must not be less accessible than the record class
// private AppConfig { ... }
}
Канонічний конструктор public record зобов’язаний мати модифікатор доступу не слабший за сам клас (тобто public). Це означає, що будь-який розробник може викликати new AppConfig("other"), порушивши єдиність екземпляра.
2. Захист через прапорець у компактному конструкторі
Щоб припинити створення повторних екземплярів, доводиться впроваджувати захист часу виконання:
public record AppConfig(String url) {
public static final AppConfig INSTANCE = new AppConfig("https://api.prod.com");
private static boolean initialized = false;
public AppConfig {
synchronized (AppConfig.class) {
if (initialized) {
throw new IllegalStateException("Singleton already initialized!");
}
initialized = true;
}
}
}
3. Серіалізація та десеріалізація
На відміну від звичайних класів, де десеріалізація обходить конструктори через механізм Unsafe, при десеріалізації Record завжди викликається канонічний конструктор (JEP 395).
Без методу readResolve() процес десеріалізації або викине виняток через прапорець initialized, або створить дублікат екземпляра:
public record AppConfig(String url) implements Serializable {
public static final AppConfig INSTANCE = new AppConfig("https://api.prod.com");
// Обов'язковий для збереження ідентичності Singleton при десеріалізації:
private Object readResolve() {
return INSTANCE;
}
}
🔴 Senior Level
Порівняльний аналіз: Enum vs Record vs Classic Class
| Критерій | Enum Singleton |
Record Singleton |
Звичайний Class (Holder / DCL) |
|---|---|---|---|
| Гарантія єдиності JVM | ✅ Повна (на рівні специфікації) | ❌ Часткова (вимагає ручних перевірок) | ⚠️ Ручна (приватний конструктор) |
| Захист від Reflection | ✅ Абсолютний (JVM блокує newInstance) |
❌ Вразливий (вимагає guard-прапорців) | ❌ Вразливий |
| Серіалізація | ✅ Вбудована за специфікацією (за ім’ям константи) | ⚠️ Вимагає явного readResolve() |
⚠️ Вимагає явного readResolve() |
| Ліниве завантаження (Lazy) | ❌ Немає (ініціалізується при першому зверненні до enum) | ✅ Так (через вкладений Holder клас) |
✅ Так (Holder Idiom) |
| Істинне призначення | Поодинокі сервіси та константи | Незмінні структури даних (DTO/Value Objects) | Будь-які складні сервіси зі станом |
Лінива ініціалізація Record (Bill Pugh Singleton / Holder Idiom)
Якщо створення Record ресурсомістке і вимагає відкладеної ініціалізації:
public record HeavyweightService(String host, int port) {
// Вкладений статичний клас ініціалізується тільки при першому виклику getInstance()
private static class Holder {
private static final HeavyweightService INSTANCE =
new HeavyweightService("10.0.0.1", 8080);
}
public static HeavyweightService getInstance() {
return Holder.INSTANCE;
}
}
Фаза ініціалізації класу Holder захищена механізмом блокувань завантажувача класів (Class Loader Initialization Lock, JLS §12.4.2), гарантуючи потокобезпеку без використання synchronized або volatile.
Архітектурний вердикт: Чому Record не повинен бути сервісом-синглтоном
У чистій архітектурі програмного забезпечення існує поділ на:
- Objects with Identity & Behavior (Сервіси/Агенти): Керують процесами, утримують ресурси (пули з’єднань, сокети, потоки виконання).
- Value Objects / Data Carriers (Дані): Описують факти, не мають унікальної поведінки, порівнюються за значенням.
Record задуманий творцями Java саме як прозорий носій даних (transparent carrier of shallowly immutable data). Використання Record як сервісу-одинака порушує принцип єдиної відповідальності (SRP) та концептуальне призначення мови. Якщо вам потрібен синглтон — використовуйте enum або довірте керування життєвим циклом компонентів Inversion of Control (IoC) контейнеру (Spring, Micronaut).
4 Tricky Questions
1. Чому не можна зробити канонічний конструктор приватним у public record?
Відповідь:
Специфікація JLS §8.10.4 встановлює фундаментальний принцип прозорості Record: компоненти запису та їхній канонічний конструктор повинні мати доступність не нижчу, ніж сам запис. Запис декларує свій API через список компонентів у заголовку, тому клієнти, які мають доступ до класу запису, зобов’язані мати можливість вільно конструювати його екземпляри. Приватний конструктор можливий, тільки якщо весь Record оголошений як private всередині зовнішнього класу.
2. Як Reflection API поводиться при спробі зламати Enum Singleton та Record Singleton?
Відповідь:
- Для
Enum: МетодConstructor.newInstance()при спробі інстанціювати enum викидаєIllegalArgumentException: Cannot reflectively create enum objects. Цей захист зашитий безпосередньо в байткод ядра OpenJDK (java.lang.reflect.Constructor). - Для
Record: Конструктор Record є звичайним Java-конструктором. Рефлексія спокійно створить другий екземпляр черезrecordClass.getDeclaredConstructor(...).newInstance(...), якщо в тілі конструктора немає спеціальної захисної логіки з викиданням винятків.
3. Чи обов’язковий readResolve() для Record, якщо він реалізує Serializable та використовується як Singleton?
Відповідь:
Так, абсолютно обов’язковий.
Специфікація десеріалізації Record (JEP 395) стверджує, що потік байтів зчитується і передається як аргументи в канонічний конструктор запису. В результаті ObjectInputStream.readObject() завжди створює новий екземпляр у купі. Єдиний спосіб повернути вихідну константу — оголосити в Record метод:
private Object readResolve() {
return INSTANCE;
}
JVM викличе readResolve() одразу після створення тимчасового об’єкта і підставить посилання на INSTANCE.
4. Чи може Record Singleton містити змінюваний стан?
Відповідь:
Поля самого Record є суворо final. Однак незмінність Record є неглибокою (shallow immutability). Якщо компонентом Record є змінюваний об’єкт (наприклад, AtomicInteger або Map<String, Object>), синглтон зможе мутувати свій внутрішній стан. Тим не менш, мутабельний синглтон на базі Record вважається грубим антипатерном.
🎯 Шпаргалка для інтерв’ю
- Як зробити: Оголосити
public static final MyRecord INSTANCE = new MyRecord(...). - Головна проблема: У
public recordконструктор зобов’язаний бутиpublic(JLS §8.10.4). Не можна зробити конструктор приватним, тому будь-який клієнт може створити другий екземпляр черезnew MyRecord(). - Серіалізація: Вимагає явної реалізації
readResolve(), інакше десеріалізація створить новий об’єкт через виклик канонічного конструктора. - Reflection: Рефлексія здатна легко створити дублікат Record-синглтона (на відміну від
enum, де рефлексія заблокована JVM). - Найкраща альтернатива:
enum MySingleton { INSTANCE; }гарантує 100% потокобезпеку, єдиність екземпляра, захист від рефлексії та коректну серіалізацію “з коробки”. - Де Record доречний: У ролі незмінного об’єкта конфігурації (
ConfigSnapshot), що поширюється як синглтон-константа.