🔒 Розділ 13 · Питання #3

Як створити незмінний клас в Java

Для створення ім'ютабельного (незмінного) класу в Java необхідно гарантувати, що стан об'єкта не може бути змінений після його конструювання. Класичний рецепт складається з 5 пр...


🟢 Junior Level

30-секундна відповідь

Для створення ім’ютабельного (незмінного) класу в Java необхідно гарантувати, що стан об’єкта не може бути змінений після його конструювання. Класичний рецепт складається з 5 правил Джошуа Блоха (Effective Java):

  1. Не надавати методи-мутатори (сетери).
  2. Запобігти спадкуванню — оголосити клас як final (або зробити конструктори private, надавши статичні фабричні методи).
  3. Зробити всі поля final — це гарантує однозначну ініціалізацію та задіює бар’єри пам’яті JMM для безпечної публікації.
  4. Зробити всі поля private — захистити прямий доступ до стану.
  5. Забезпечити ексклюзивний доступ до змінних компонентів — виконувати глибоке або захисне копіювання (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");

Проте:

  1. JEP 403 (Java 16+ / Strong Encapsulation): Прямий виклик setAccessible(true) до нутрощів java.base викидає InaccessibleObjectException.
  2. C2 JIT Constant Folding: Якщо HotSpot C2-компілятор бачить звернення до final-поля перевіреного незмінного класу, він оптимізує читання, виконуючи constant folding (підставляючи константу безпосередньо в асемблерний код). Навіть якщо поле буде фізично перезаписане через Unsafe.putObject, скомпільований код продовжить читати старе значення!

4 Tricky Questions

1. Чому Джошуа Блох вважає альтернативою оголошенню класу final закриття конструкторів (private) та статичні фабричні методи? У чому архітектурна перевага?

Відповідь: Оголошення всіх конструкторів private або package-private робить клас фактично закритим для розширення зовнішніми клієнтами, оскільки підклас не може викликати super(). Архітектурні переваги:

  1. Гнучкість поліморфізму: Пакет може містити кілька закритих підкласів (наприклад, RegularImmutableMap та EmptyImmutableMap), прихованих від публічного API.
  2. Кешування та пулінг: Фабричний метод (valueOf(), of()) на відміну від конструктора не зобов’язаний створювати новий об’єкт під час кожного виклику (яскравий приклад — Boolean.valueOf(boolean) або BigDecimal.ZERO).
  3. Іменування: Фабричні методи мають осмислені імена (Complex.fromPolar(r, theta) vs Complex.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:

  1. Компілятор HotSpot вважає поля, оголошені як final, незмінними константами (Compile-Time Constants або Trust Final Fields).
  2. Під час оптимізації циклів або агресивного інлайнінгу гетерів компілятор зберігає значення поля в регістрі процесора або взагалі замінює звернення до пам’яті прямою константою (constant folding / dead code elimination).
  3. У разі зміни значення в купі через Unsafe.putAddress() значення в оперативній пам’яті зміниться, але JIT-скомпільований код, що працює в сусідньому циклі чи методі, ніколи не перечитає його з пам’яті і продовжить використовувати старе закешоване значення. У результаті програма опиниться в стані розсинхронізації.

4. Як саме витік посилання this із конструктора ламає семантику final-полів за специфікацією JMM §17.5? Наведіть конкретний приклад гонитви даних.

Відповідь: Специфікація JMM §17.5 гарантує коректну видимість final-полів лише за умови, що конструктор завершився нормально без витоку посилання this. У разі витоку (наприклад, передача this у слухач подій, реєстрація в статичному списку або запуск нового Thread(this).start() всередині конструктора) відбувається наступне:

  1. Дія Freeze Action ще не виконана компілятором, і бар’єр пам’яті StoreStore не встановлений.
  2. Процесор і компілятор мають право здійснити запис адреси посилання на об’єкт до завершення запису значень полів об’єкта в кеш.
  3. Сторонній потік, що перехопив посилання, звернеться до final-поля і прочитає значення за замовчуванням (0 для примітивів, null для посилань). Через кілька наносекунд у тому ж потоці це ж поле поверне вже реальне значення, що порушує фундаментальний інваріант незмінності.

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

5 кроків створення незмінного класу

  1. Заборонити спадкування: модифікатор final у класу або private конструктори + фабричні методи.
  2. Усі поля private final: інкапсуляція + запуск Freeze Action JMM.
  3. Жодних сетерів: стан задається один раз у конструкторі.
  4. Захисні копії на вході: List.copyOf(), clone() для масивів, new Date(date.getTime()).
  5. Захисні копії на виході: повернення unmodifiable-колекцій або клонів масивів.
  6. Жодних витоків 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() допустимий.

Пов’язані запитання