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

Що таке незмінний (ім'ютабельний) об'єкт

Якщо над незмінним об'єктом потрібно виконати операцію модифікації (наприклад, змінити ім'я, додати елемент або змістити дату), метод не змінює вихідний об'єкт, а конструює та п...


🟢 Junior Level

Ім’ютабельний (незмінний / Immutable) об’єкт — це об’єкт, стан якого (значення всіх полів) не може бути змінений жодним способом після завершення його створення в конструкторі.

Якщо над незмінним об’єктом потрібно виконати операцію модифікації (наприклад, змінити ім’я, додати елемент або змістити дату), метод не змінює вихідний об’єкт, а конструює та повертає новий екземпляр з оновленими даними.

Суть у 30 секундах

  • Вихідний об’єкт завжди залишається незмінним і потокобезпечним.
  • Класичні приклади в JDK: String, класи-обгортки примітивів (Integer, Double, Boolean), класи дати й часу Java 8 (LocalDate, LocalTime, Instant), BigDecimal, UUID, а також типи даних record (Java 14+).
// Найпростіший незмінний клас (Java 21):
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    // Операція "модифікації" повертає НОВИЙ об'єкт:
    public Point move(int dx, int dy) {
        return new Point(this.x + dx, this.y + dy);
    }
}

Основні ознаки незмінного класу

  1. Відсутні будь-які методи-модифікатори (сетери setX(), методи додавання/видалення).
  2. Усі поля оголошені як private final.
  3. Клас оголошений як final (або має лише приватні конструктори), щоб заборонити перевизначення методів у підкласах.
  4. Внутрішні змінні об’єкти захищені за допомогою захисного копіювання (Defensive Copy).

🟡 Middle Level

П’ять правил створення незмінного класу (Effective Java, Item 17)

Джошуа Блох сформулював 5 обов’язкових правил створення надійних незмінних класів:

  1. Не надавати методів-мутаторів: Жодних сетерів або методів очищення.
  2. Запобігти перевизначенню методів: Оголосити клас final або зробити всі конструктори private зі статичними фабричними методами (static of(...)).
  3. Зробити всі поля final: Це гарантує дотримання моделі пам’яті Java Memory Model (JMM) і безпечну публікацію об’єкта між потоками без явної синхронізації.
  4. Зробити всі поля private: Захищає від прямого звернення до стану із зовнішнього коду.
  5. Забезпечити виключний доступ до будь-яких змінних компонентів: Якщо клас містить посилання на змінні об’єкти (Date, List, int[]), вони ніколи не повинні повертатися назовні напряму або зберігатися без захисної копії.

Shallow Immutability проти Deep Immutability

  • Shallow Immutability (поверхнева незмінність): Усі посилання самого класу є final, але об’єкти, на які вони вказують, можна змінити ззовні.
  • Deep Immutability (глибока / транзитивна незмінність): Незмінним є весь граф об’єктів, досяжних із цього класу.
// ПОМИЛКА: Shallow Immutability (клас НЕ є по-справжньому незмінним!)
public final class Order {
    private final List<String> items; // final забороняє змінювати саме посилання items

    public Order(List<String> items) {
        this.items = items; // ПОМИЛКА: зовнішнє посилання збережено напряму!
    }

    public List<String> getItems() {
        return items; // ПОМИЛКА: посилання витекло назовні!
    }
}

// Зовнішній код ламає інкапсуляцію Order:
List<String> list = new ArrayList<>();
list.add("Book");
Order order = new Order(list);
list.add("Car"); // Стан Order змінився ззовні!
order.getItems().clear(); // Стан Order очищено!

Правильна реалізація із захисним копіюванням:

public final class Order {
    private final List<String> items;

    public Order(List<String> items) {
        // Захисна копія на вході (List.copyOf повертає незмінний список)
        this.items = List.copyOf(items); 
    }

    public List<String> getItems() {
        return items; // Безпечно, оскільки результат List.copyOf неможливо мутувати
    }
}

Сучасні рекорди (record, Java 16+)

Починаючи з Java 16, ключове слово record генерує незмінні структури даних без шаблонного коду:

public record User(String id, String email) {}

Компілятор автоматично робить клас final, усі поля private final, генерує канонічний конструктор, методи доступу (id(), email()), а також equals(), hashCode() та toString().


🔴 Senior Level

Семантика Final-полів у Java Memory Model (JLS §17.5)

Незмінні об’єкти в Java мають гарантію безпечної публікації (Safe Publication) без використання synchronized або volatile.

Згідно зі специфікацією JMM:

  1. Запис у final-поле в конструкторі пов’язується з операцією завершення конструктора відношенням Freeze Action.
  2. Компілятор і процесор вставляють бар’єр пам’яті (StoreStore-бар’єр) перед виходом із конструктора.
  3. Потік, що отримав посилання на незмінний об’єкт після завершення конструктора, гарантовано бачить коректно ініціалізовані значення всіх його final-полів, навіть якщо посилання було передано через гонитву даних (Data Race) без синхронізації!

Катастрофа витоку посилання this із конструктора (This Escape)

Гарантія JMM §17.5 діє суворо за однієї критичної умови: посилання this не повинно витікати під час виконання конструктора:

public final class BrokenImmutable {
    private final int value;
    public static BrokenImmutable instance;

    public BrokenImmutable(int value) {
        this.value = value;
        // КАТАСТРОФА: витік посилання this до завершення конструктора!
        instance = this; 
        // Або передача у слухач: eventBus.register(this);
    }
}

Якщо інший потік прочитає instance до того, як конструктор завершить фазу Freeze, він побачить об’єкт із value = 0 (неініціалізований стан за замовчуванням), зруйнувавши гарантію потокобезпеки.

Вплив на Garbage Collector: Card Table Write Barriers

Незмінність надає колосальну архітектурну перевагу для збирача сміття (HotSpot GC):

  1. Поколінна гіпотеза: Більшість об’єктів швидко помирає. Коли довгоживучий об’єкт в Old Generation мутує і починає посилатися на молодий об’єкт в Eden, JVM змушена фіксувати це міжпоколінне посилання.
  2. Card Marking Write Barrier: Під час кожного перезапису посилання процесором виконується нативний бар’єр запису: байт у таблиці карток (Card Table) позначається як «брудний» (Dirty Card). Це забирає такти CPU та шини пам’яті.
  3. Незмінні об’єкти ніколи не оновлюють поля: Потрапивши в Old Generation, незмінний об’єкт гарантовано ніколи не генерує брудних карток (Zero Card Table Overhead). Це скорочує час фаз сканування в G1, ZGC та Parallel GC.

Персистентні структури даних (Persistent Data Structures)

У разі глибокої незмінності повне копіювання великих колекцій ($O(N)$) стає неефективним. Для цього застосовуються персистентні структури даних зі структурним розділенням пам’яті (Structural Sharing), наприклад Hash Array Mapped Trie (HAMT) у бібліотеці Vavr. Під час додавання елемента перестворюється лише шлях від кореня дерева до нового листка ($O(\log N)$ вузлів), а всі інші 99% дерева спільно перевикористовуються старою та новою версіями без копіювання.


4 Tricky Questions

1. Чи є клас, оголошений через ключове слово record, гарантовано глибоко незмінним?

Відповідь: Ні, не є. Ключове слово record гарантує лише поверхневу (Shallow) незмінність:

  • Компілятор робить поля компонента private final і не генерує сетерів.
  • Проте якщо компонент рекорда є змінним об’єктом (наприклад, record Order(String id, List<Item> items)), зовнішній код може безперешкодно мутувати список: order.items().add(newItem).
  • Для забезпечення глибокої незмінності розробник зобов’язаний вручну перевизначити компактний конструктор рекорда і виконати захисне копіювання:
    public record Order(String id, List<Item> items) {
        public Order {
            items = List.copyOf(items); // Захист від мутацій
        }
    }
    

2. Чи обов’язково позначати незмінний клас ключовим словом final? Як реалізувати незмінність без final?

Відповідь: Ключове слово final на самому класі не є суворо обов’язковим, якщо запобігти спадкуванню іншим архітектурним шляхом:

  • Зробити всі конструктори класу приватними (private).
  • Надати можливість створення екземплярів виключно через статичні фабричні методи:
    public class ComplexNumber {
        private final double re;
        private final double im;
    
        private ComplexNumber(double re, double im) {
            this.re = re;
            this.im = im;
        }
    
        public static ComplexNumber of(double re, double im) {
            return new ComplexNumber(re, im);
        }
    }
    

    Оскільки жоден підклас не зможе викликати super() конструктор, спадкування від такого класу стає фізично неможливим, що повністю гарантує захист від підміни логіки мутабельними нащадками.

3. Чому конструктор незмінного класу повинен виконувати захисне копіювання змінного аргументу ДО перевірки його валідності (Time-of-Check to Time-of-Use Attack)?

Відповідь: Це класичне правило безпеки (TOCTOU / Defect Prevention):

// ПОМИЛКА: вразливо до атаки TOCTOU
public Period(Date start, Date end) {
    if (start.after(end)) throw new IllegalArgumentException();
    this.start = new Date(start.getTime()); // Копіювання ПІСЛЯ перевірки!
}

Якщо об’єкт Date розділяється між потоками, то в багатопотоковому середовищі зловмисник може змінити значення об’єкта start у проміжку між перевіркою if і моментом копіювання. Правило: Захисна копія зобов’язана створюватися найпершою, і всі перевірки інваріантів повинні виконуватися над створеною локальною копією.

4. Чи може незмінний об’єкт мати змінювані (мутабельні) приватні поля для внутрішніх оптимізацій?

Відповідь: Так, може, якщо це не порушує стан, що спостерігається ззовні (Observable Immutability). Класичний приклад із JDK — клас java.lang.String:

  • Поле private int hash не є final.
  • Під час створення рядка воно дорівнює 0. Геш обчислюється ліниво під час першого виклику методу hashCode() і зберігається в це поле.
  • Оскільки обчислення є детермінованим, і результат для зовнішнього спостерігача завжди ідентичний, об’єкт залишається абсолютно незмінним з погляду контракту. Важливо: У багатопотоковому середовищі не-final поля лінивої ініціалізації вимагають або атомарності/ідемпотентності (як у String.hash), або використання volatile.

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

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

«Незмінний об’єкт — це об’єкт, стан якого неможливо змінити після конструювання. Будь-яка модифікація повертає новий екземпляр. Для його створення клас оголошують final, поля роблять private final, виключають сетери, ізолюють змінні поля захисними копіями та запобігають витоку this із конструктора. Головні переваги — абсолютна потокобезпека без блокувань, безпека використання як ключів HashMap і зниження навантаження на GC завдяки відсутності Write Barriers в Old Generation».

Ключові тези для Senior-інтерв’ю

  1. 5 правил Блоха: final клас, private final поля, відсутність мутаторів, defensive copy на вході та виході, безпечна публікація.
  2. JMM §17.5 Final Freeze: Гарантія коректної видимості final полів усіма потоками без бар’єрів синхронізації.
  3. This Escape: Витік this із конструктора руйнує гарантії безпеки JMM.
  4. Records (Java 16+): Надають лише shallow immutability; колекції вимагають List.copyOf у компактному конструкторі.
  5. GC Card Table: Відсутність мутацій в Old Gen виключає Card Marking, розвантажуючи збирач сміття.
  6. Observable Immutability: Допускає мутабельні поля для лінивого кешування (String.hash).

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Вважати, що клас незмінний просто тому, що в ньому немає сетерів.
  • ❌ Стверджувати, що record завжди автоматично гарантує глибоку незмінність (він не захищає вкладені змінні об’єкти).
  • ❌ Забувати робити захисну копію колекцій у гетерах і конструкторах.
  • ❌ Вважати, що незмінність завжди знижує продуктивність (у багатопотоковому коді відсутність блокувань дає величезний приріст).

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