Як створити незмінний клас в Java
Для створення ім'ютабельного (незмінного) класу в Java необхідно гарантувати, що стан об'єкта не може бути змінений після його конструювання. Класичний рецепт складається з 5 пр...
🟢 Junior Level
30-секундна відповідь
Для створення ім’ютабельного (незмінного) класу в Java необхідно гарантувати, що стан об’єкта не може бути змінений після його конструювання. Класичний рецепт складається з 5 правил Джошуа Блоха (Effective Java):
- Не надавати методи-мутатори (сетери).
- Запобігти спадкуванню — оголосити клас як
final(або зробити конструкториprivate, надавши статичні фабричні методи). - Зробити всі поля
final— це гарантує однозначну ініціалізацію та задіює бар’єри пам’яті JMM для безпечної публікації. - Зробити всі поля
private— захистити прямий доступ до стану. - Забезпечити ексклюзивний доступ до змінних компонентів — виконувати глибоке або захисне копіювання (defensive copy) на вході (в конструкторі) та на виході (в гетерах).
Класичний приклад: Користувацький клас
Розглянемо клас UserProfile, що містить як примітивні та незмінні типи (long, String), так і мутабельні об’єкти (List<String>):
package com.example.model;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
// 1. Клас оголошено final — спадкування заборонено
public final class UserProfile {
// 2. Усі поля private final
private final long id;
private final String username;
private final List<String> roles;
// 3. Ініціалізація всіх полів через конструктор із захисним копіюванням
public UserProfile(long id, String username, List<String> roles) {
this.id = id;
this.username = Objects.requireNonNull(username, "Username must not be null");
// Захисна копія на вході: запобігає модифікації вихідного списку ззовні
this.roles = (roles == null) ? List.of() : List.copyOf(roles);
}
// 4. Тільки методи читання (гетери), жодних сетерів
public long getId() {
return id;
}
public String getUsername() {
return username;
}
// 5. Повернення незмінного представлення (List.copyOf вже повертає unmodifiable list)
public List<String> getRoles() {
return roles;
}
}
🟡 Middle Level
Робота зі змінними полями та захисне копіювання
Однією з найпоширеніших помилок під час проєктування незмінних класів є збереження прямого посилання на змінний об’єкт.
1. Колекції
У Java 10+ стандартним і найпродуктивнішим способом створення захисної копії є метод List.copyOf(), Set.copyOf(), Map.copyOf(). Якщо передана колекція вже є незмінною колекцією платформи, copyOf повертає її ж без виділення додаткової пам’яті.
До Java 10 застосовувався патерн:
this.roles = Collections.unmodifiableList(new ArrayList<>(roles));
Зверніть увагу: просто обгорнути Collections.unmodifiableList(roles) без new ArrayList<>(roles) недостатньо, оскільки обгортка є лише «представленням» (view), і модифікація вихідного списку з викликаючого коду відразу позначиться на внутрішньому стані класу!
2. Масиви
Масиви в Java завжди мутабельні, навіть якщо посилання на масив оголошено як final. Під час збереження або повернення масиву необхідно завжди викликати .clone() або Arrays.copyOf():
public final class CryptographicKey {
private final byte[] keyBytes;
public CryptographicKey(byte[] keyBytes) {
Objects.requireNonNull(keyBytes, "Key bytes must not be null");
// Захисна копія на вході
this.keyBytes = keyBytes.clone();
}
public byte[] getKeyBytes() {
// Захисна копія на виході
return keyBytes.clone();
}
}
3. Дати та час (Java 8+)
Використання старого класу java.util.Date вимагає обов’язкового створення копії через new Date(date.getTime()). У сучасному коді слід використовувати незмінні класи пакета java.time: LocalDate, LocalDateTime, Instant, Duration.
Створення через Record (Java 14/16+)
Записи (record) у сучасній Java надають лаконічний синтаксис для створення незмінних класів даних:
public record UserAccount(long id, String email, List<String> permissions) {
// Компактний конструктор для валідації та захисного копіювання
public UserAccount {
Objects.requireNonNull(email, "Email required");
// Record типово зберігає посилання as-is, тому необхідне захисне копіювання:
permissions = (permissions == null) ? List.of() : List.copyOf(permissions);
}
}
Важливий нюанс: record гарантує лише «поверхневу» незмінність (shallow immutability). Поля в байткоді генеруються як private final, гетери не мають префікса get, а сам клас неявно успадковується від java.lang.Record і є final. Проте якщо компонент запису є змінним об’єктом (наприклад, ArrayList або масив), захисне копіювання в компактному конструкторі залишається обов’язковим!
Патерн Builder для складних незмінних класів
Коли в незмінного класу багато полів, телескопічні конструктори стають незручними. Стандартне рішення — статичний внутрішній клас Builder:
public final class ServerConfig {
private final String host;
private final int port;
private final int timeoutMs;
private final boolean tlsEnabled;
private ServerConfig(Builder builder) {
this.host = builder.host;
this.port = builder.port;
this.timeoutMs = builder.timeoutMs;
this.tlsEnabled = builder.tlsEnabled;
}
public String getHost() { return host; }
public int getPort() { return port; }
public int getTimeoutMs() { return timeoutMs; }
public boolean isTlsEnabled() { return tlsEnabled; }
public static class Builder {
private String host = "localhost";
private int port = 8080;
private int timeoutMs = 5000;
private boolean tlsEnabled = true;
public Builder host(String host) {
this.host = Objects.requireNonNull(host);
return this;
}
public Builder port(int port) {
if (port < 1 || port > 65535) throw new IllegalArgumentException("Invalid port");
this.port = port;
return this;
}
public Builder timeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
return this;
}
public Builder tlsEnabled(boolean tlsEnabled) {
this.tlsEnabled = tlsEnabled;
return this;
}
public ServerConfig build() {
return new ServerConfig(this);
}
}
}
🔴 Senior Level
Семантика final полів у JMM (§17.5) та бар’єри пам’яті
Java Memory Model (JSR-133) надає спеціальні гарантії для final полів, відомі як Final Field Semantics:
- Під час створення об’єкта середовище виконання HotSpot виконує так звану дію Freeze Action наприкінці конструктора.
- JIT-компілятор вставляє бар’єр пам’яті
StoreStoreміж операцією ініціалізаціїfinalполів і записом посилання на створений об’єкт у спільну змінну (публікацією). - Це гарантує, що будь-який потік, який отримав посилання на об’єкт після коректного завершення конструктора, гарантовано побачить проініціалізовані значення всіх
finalполів без необхідності синхронізації (synchronized,volatileабоLock).
[Потік 1: Конструктор]
1. Запис field1 = val1
2. Запис field2 = val2
[Freeze Action: StoreStore Barrier]
3. Публікація посилання: sharedRef = this;
│
▼ (Безпечна передача через пам'ять)
[Потік 2: Читач]
1. Читання sharedRef
[LoadLoad Barrier]
2. Читання sharedRef.field1 -> Гарантовано val1 (не дефолтні 0 чи null!)
Критична небезпека: Витік посилання this (Constructor Escape)
Якщо посилання на створюваний об’єкт витікає з конструктора до завершення Freeze Action, гарантії JMM повністю анулюються:
public final class LeakingPublisher {
private final int value;
public LeakingPublisher(EventBus bus) {
this.value = 42;
// ПОМИЛКА: витік 'this' до завершення конструктора!
bus.registerListener(this);
}
public int getValue() {
return value;
}
}
У чому загроза на рівні CPU:
Без завершення конструктора компілятор або процесор мають право перевпорядкувати інструкції: посилання this може бути передане в EventBus до того, як запис this.value = 42 завершиться в кеші L1. Інший робочий потік в EventBus негайно звернеться до listener.getValue() і прочитає 0 (значення за замовчуванням)!
Апаратна когерентність кешів (MESI) та продуктивність
У багатопроцесорних архітектурах постійна зміна полів об’єкта викликає протокол MESI/MOESI:
- Коли ядро процесора змінює змінну в рядку кешу (cache line), воно надсилає сигнал RFO (Request For Ownership) іншим ядрам, переводячи їхні кеш-лінії у стан Invalid (I).
- Це викликає феномен Cache Bouncing та інвалідацію конвеєра команд.
- Навпаки, незмінні об’єкти гарантують, що рядки кешу процесорів залишаються у стані Shared (S) на всіх ядрах. Читання даних відбувається паралельно на максимальній швидкості кешу L1/L2 без між’ядерної синхронізації та ризику хибного розділення кеш-ліній (False Sharing).
Альтернатива final class: Приватні конструктори та фабрики
Оголошення класу як final — найпростіший, але не єдиний спосіб заборонити неконтрольоване спадкування. Другий підхід — приватний конструктор і публічні статичні фабричні методи:
public class ComplexNumber {
private final double real;
private final double imaginary;
// Приватний конструктор: забороняє спадкування із зовнішніх пакетів
private ComplexNumber(double real, double imaginary) {
this.real = real;
this.imaginary = imaginary;
}
public static ComplexNumber of(double real, double imaginary) {
return new ComplexNumber(real, imaginary);
}
public static ComplexNumber ofReal(double real) {
return new ComplexNumber(real, 0.0);
}
}
Переваги перед final class:
- Дозволяє мати внутрішні або пакетно-приватні реалізації/підкласи (наприклад, спеціалізовані оптимізовані варіанти для нуля чи одиниці).
- Дає можливість кешувати екземпляри (Flyweight / пулінг) всередині фабричного методу, чого не дозволяє звичайний оператор
new.
Злам незмінності через Reflection та Unsafe
Історично поля private final можна було перезаписати через рефлексію:
Field field = UserProfile.class.getDeclaredField("username");
field.setAccessible(true);
field.set(profile, "hacked");
Проте:
- JEP 403 (Java 16+ / Strong Encapsulation): Прямий виклик
setAccessible(true)до нутрощівjava.baseвикидаєInaccessibleObjectException. - C2 JIT Constant Folding: Якщо HotSpot C2-компілятор бачить звернення до
final-поля перевіреного незмінного класу, він оптимізує читання, виконуючи constant folding (підставляючи константу безпосередньо в асемблерний код). Навіть якщо поле буде фізично перезаписане черезUnsafe.putObject, скомпільований код продовжить читати старе значення!
4 Tricky Questions
1. Чому Джошуа Блох вважає альтернативою оголошенню класу final закриття конструкторів (private) та статичні фабричні методи? У чому архітектурна перевага?
Відповідь:
Оголошення всіх конструкторів private або package-private робить клас фактично закритим для розширення зовнішніми клієнтами, оскільки підклас не може викликати super().
Архітектурні переваги:
- Гнучкість поліморфізму: Пакет може містити кілька закритих підкласів (наприклад,
RegularImmutableMapтаEmptyImmutableMap), прихованих від публічного API. - Кешування та пулінг: Фабричний метод (
valueOf(),of()) на відміну від конструктора не зобов’язаний створювати новий об’єкт під час кожного виклику (яскравий приклад —Boolean.valueOf(boolean)абоBigDecimal.ZERO). - Іменування: Фабричні методи мають осмислені імена (
Complex.fromPolar(r, theta)vsComplex.fromCartesian(x, y)), запобігаючи двозначності перевантажень конструктора.
2. Припустимо, клас оголошено як final, усі поля private final, гетери не віддають прямих посилань на змінні об’єкти, але один із методів кешує обчислений hashCode у не-final полі int hash. Чи є клас незмінним і потокобезпечним?
Відповідь:
Так, такий клас залишається незмінним з погляду викликаючого коду (логічна незмінність, observational immutability). Саме так влаштований клас java.lang.String.
Поле hash оголошується як private int hash; (без final і навіть без volatile).
Потокобезпека забезпечується тим, що:
- Обчислення геш-коду є ідемпотентною чистою функцією (один і той самий алгоритм завжди дає однакове 32-бітне число).
- У разі одночасного виклику
hashCode()з кількох потоків може статися гонитва обчислень (benign race condition), але всі потоки запишуть одне й те саме значення. - У Java запис 32-бітного примітива
intє атомарним за специфікацією JMM (§17.7), тому поява спотворених половинчастих даних (torn read/write) виключена.
3. Що станеться на рівні JIT-компілятора C2, якщо спробувати змінити final-поле незмінного об’єкта через sun.misc.Unsafe або рефлексію? Чи буде нове значення видно в усіх частинах програми?
Відповідь: Поведінка буде непередбачуваною через оптимізації JIT-компілятора C2:
- Компілятор HotSpot вважає поля, оголошені як
final, незмінними константами (Compile-Time Constants або Trust Final Fields). - Під час оптимізації циклів або агресивного інлайнінгу гетерів компілятор зберігає значення поля в регістрі процесора або взагалі замінює звернення до пам’яті прямою константою (constant folding / dead code elimination).
- У разі зміни значення в купі через
Unsafe.putAddress()значення в оперативній пам’яті зміниться, але JIT-скомпільований код, що працює в сусідньому циклі чи методі, ніколи не перечитає його з пам’яті і продовжить використовувати старе закешоване значення. У результаті програма опиниться в стані розсинхронізації.
4. Як саме витік посилання this із конструктора ламає семантику final-полів за специфікацією JMM §17.5? Наведіть конкретний приклад гонитви даних.
Відповідь:
Специфікація JMM §17.5 гарантує коректну видимість final-полів лише за умови, що конструктор завершився нормально без витоку посилання this.
У разі витоку (наприклад, передача this у слухач подій, реєстрація в статичному списку або запуск нового Thread(this).start() всередині конструктора) відбувається наступне:
- Дія Freeze Action ще не виконана компілятором, і бар’єр пам’яті
StoreStoreне встановлений. - Процесор і компілятор мають право здійснити запис адреси посилання на об’єкт до завершення запису значень полів об’єкта в кеш.
- Сторонній потік, що перехопив посилання, звернеться до
final-поля і прочитає значення за замовчуванням (0для примітивів,nullдля посилань). Через кілька наносекунд у тому ж потоці це ж поле поверне вже реальне значення, що порушує фундаментальний інваріант незмінності.
🎯 Шпаргалка для інтерв’ю
5 кроків створення незмінного класу
- Заборонити спадкування: модифікатор
finalу класу абоprivateконструктори + фабричні методи. - Усі поля
private final: інкапсуляція + запуск Freeze Action JMM. - Жодних сетерів: стан задається один раз у конструкторі.
- Захисні копії на вході:
List.copyOf(),clone()для масивів,new Date(date.getTime()). - Захисні копії на виході: повернення unmodifiable-колекцій або клонів масивів.
- Жодних витоків
this: ніколи не викликати зовнішні методи й не запускати потоки в конструкторі.
Порівняння підходів
| Підхід | Плюси | Мінуси / Особливості |
| :— | :— | :— |
| Звичайний final class | Повний контроль, звичний код, можливість кастомних гетерів/валідації | Багатослівність (boilerplate), вимагає ручної реалізації equals/hashCode |
| record (Java 16+) | Максимально компактно, генерує equals, hashCode, toString, final поля | Не підтримує спадкування класів; колекції все одно вимагають ручного List.copyOf |
| Builder патерн | Зручно при 5+ полях, гнучка валідація, читабельний код конструювання | Додатковий допоміжний клас, витрати на проміжний об’єкт будівника |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Щоб клас був незмінним, достатньо оголосити всі поля final» — груба помилка! Якщо поле посилається на змінний
ArrayList, будь-який клієнт може викликатиgetList().add()і змінити стан об’єкта. - ❌ «Колекції в Record автоматично стають незмінними» — неправильно.
recordлише робить поле посиланняfinal, сам переданий список залишається змінним, якщо не зробитиList.copyOf()у компактному конструкторі. - ❌ «Масиви з модифікатором final незмінні» — неправильно.
finalзаморожує лише посилання на масив, елементи масиву за індексами можна вільно перезаписувати. - ❌ «Захисну копію можна зробити через clone()» — небезпечно для нестандартних класів: метод
clone()в Java архітектурно зламаний (Effective Java Item 13). Для колекцій використовуватиcopyOf(), для масивів —clone()допустимий.
Пов’язані запитання
- Що таке незмінний (ім’ютабельний) об’єкт
- Які переваги дає використання незмінних об’єктів
- Що таке ключове слово final і як воно допомагає у створенні незмінних класів
- Чи достатньо зробити всі поля final для незмінності
- Що робити, якщо поле класу посилається на змінний об’єкт
- Що таке Record і як він допомагає створювати незмінні класи