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

Чи достатньо зробити всі поля final для незмінності

Модифікатор final на рівні поля гарантує лише одне: саме посилання не можна перенаправити на інший об'єкт після ініціалізації. Проте final ніяк не захищає внутрішній стан об'єкт...


🟢 Junior Level

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

Категорично ні, недостатньо! Це одна з найпоширеніших помилок на співбесідах.

Модифікатор final на рівні поля гарантує лише одне: саме посилання не можна перенаправити на інший об’єкт після ініціалізації. Проте final ніяк не захищає внутрішній стан об’єкта, на який вказує це посилання:

  • Якщо поле посилається на мутабельний об’єкт (наприклад, ArrayList, HashMap, масив int[] або java.util.Date), дані всередині цього об’єкта можна вільно змінювати через методи-мутатори.
  • Якщо клас не оголошено як final, клас-нащадок може перевизначити гетери або додати мутабельний стан.
  • Якщо посилання this витікає з конструктора, інші потоки побачать неініціалізовані поля.

Наочний приклад уразливості

// Клас виглядає "незмінним", але насправді він МУТАБЕЛЬНИЙ!
public final class VulnerableUser {
    private final String name;
    private final List<String> roles; // final посилання!

    public VulnerableUser(String name, List<String> roles) {
        this.name = name;
        this.roles = roles; // ПОМИЛКА 1: збережено пряме посилання на зовнішній список
    }

    public String getName() { return name; }

    public List<String> getRoles() {
        return roles; // ПОМИЛКА 2: повернуто пряме посилання на внутрішній список
    }
}

Як зовнішній код легко ламає цей клас:

List<String> myRoles = new ArrayList<>();
myRoles.add("USER");

VulnerableUser user = new VulnerableUser("Alice", myRoles);

// Злам 1: модифікація через вихідний список
myRoles.add("ADMIN"); 
System.out.println(user.getRoles()); // [USER, ADMIN] — стан змінився ззовні!

// Злам 2: модифікація через гетер
user.getRoles().clear();
System.out.println(user.getRoles()); // [] — стан очищено!

🟡 Middle Level

Поверхнева (Shallow) vs Глибока (Deep) незмінність

  1. Shallow Immutability (Поверхнева): поля об’єкта оголошені як final, але об’єкти, на які вони посилаються, мутабельні. Приклад: record User(String name, List<String> roles).
  2. Deep Immutability (Глибока): стан усього транзитивного графа об’єктів незмінний від кореня до листкових вузлів.
Поверхнева незмінність:
[User Object] (final ref) ───► [ArrayList] (МУТАБЕЛЬНИЙ: size, elementData[])

Глибока незмінність:
[User Object] (final ref) ───► [ImmutableCollections.ListN] (незмінний) ───► ["ADMIN"] (String, незмінний)

Коли модифікатора final ДІЙСНО достатньо

Оголошення всіх полів private final у зв’язці з final класом робить об’єкт істинно незмінним лише тоді, коли всі поля належать до таких категорій:

  1. Примітивні типи: int, long, boolean, double тощо.
  2. Відомі незмінні класи стандартної бібліотеки:
    • Рядки: java.lang.String.
    • Обгортки примітивів: Integer, Long, Double тощо.
    • Числа довільної точності: BigDecimal, BigInteger.
    • Дата і час (Java 8+): LocalDate, LocalTime, Instant, Duration.
    • Ідентифікатори: UUID.
    • Інші глибоко незмінні користувацькі класи.

Коли модифікатора final КАТЕГОРИЧНО недостатньо

Якщо клас містить:

  • Масиви: будь-які масиви в Java (int[], String[]) завжди мутабельні.
  • Колекції: List, Set, Map (навіть якщо поле final, методи add, remove, put змінюють дані).
  • Застарілі класи дати/часу: java.util.Date, java.util.Calendar (містять методи setTime(), setYear()).
  • Будь-які мутабельні доменні об’єкти: класи з сетерами або змінюваним внутрішнім станом.

🔴 Senior Level

Повна формула істинної незмінності

Істинна незмінність — це властивість усього графа об’єктів та процесу їх ініціалізації: \(\text{True Immutability} = \text{final class} + \text{private final fields} + \text{Defensive Copy (In/Out)} + \text{No This-Escape}\)

Кожен компонент формули закриває окремий вектор атаки:

  1. final class: закриває вектор підміни поведінки через поліморфізм та перевизначення гетерів.
  2. private final fields: закриває прямий доступ до пам’яті та запускає Freeze Action JMM.
  3. Defensive Copy: ізолює внутрішній стан від зовнішніх мутацій через вхідні аргументи або повернені гетери.
  4. No This-Escape: гарантує, що посилання на об’єкт не буде прочитане іншим потоком до завершення дії Freeze Action.

Межі гарантій JMM §17.5: Транзитивність Freeze Action

Критичний нюанс моделі пам’яті Java, про який часто забувають навіть Senior-розробники:

  • Дія Freeze Action наприкінці конструктора «заморожує» тільки саме посилальне поле.
  • JMM не гарантує транзитивне заморожування внутрішнього стану об’єкта, на який посилається final-поле, якщо цей об’єкт був створений поза конструктором!
  • Якщо ви передали в конструктор уже наявний мутабельний ArrayList і зберегли його у final-поле:
    • Посилання на ArrayList опубліковано безпечно.
    • Проте елементи всередині ArrayList можуть модифікуватися паралельним потоком без синхронізації, породжуючи класичну гонитву даних (Data Race) за правилами JMM.

Логічна (Observational) vs Бітова (Bitwise) незмінність

В архітектурі високопродуктивних систем розрізняють:

  • Бітова незмінність (Bitwise): жоден біт оперативної пам’яті об’єкта не змінюється після виходу з конструктора.
  • Логічна незмінність (Observational): з погляду зовнішнього спостерігача стан і поведінка об’єкта незмінні, але внутрішні службові поля можуть мутувати.

Класичний приклад логічної незмінності — клас java.lang.String:

public final class String {
    private final byte[] value; // Бітово незмінний
    private int hash;           // НЕ final! Мутує при першому виклику hashCode()
}

Рядок не є бітово незмінним, але залишається абсолютно потокобезпечним і логічно незмінним.


4 Tricky Questions

1. Чи поширюється дія JMM §17.5 Freeze Action транзитивно на об’єкти, доступні за ланцюжком посилань із final-поля?

Відповідь: Специфікація JMM §17.5 явно визначає: гарантії безпечної публікації поширюються на об’єкт і транзитивно на всі об’єкти, досяжні через його final-поля, тільки якщо ці залежні об’єкти були створені та ініціалізовані в межах конструювання основного об’єкта до моменту Freeze Action. Якщо ж у final-полі зберігається посилання на об’єкт, створений десь зовні (наприклад, переданий параметром у конструктор), то жодної транзитивної гарантії на його поля немає! Якщо зовнішній код продовжить змінювати переданий об’єкт, читачі побачать стан гонитви. Саме тому захисне копіювання (List.copyOf(), clone()) всередині конструктора є абсолютно обов’язковим для забезпечення гарантій багатопотоковості JMM.


2. Чи є Java record глибоко незмінним «з коробки», якщо один із його компонентів — масив байтів byte[]?

Відповідь: Ні. Java record забезпечує виключно поверхневу незмінність (Shallow Immutability):

  1. Компілятор оголошує клас як final і всі компоненти як private final.
  2. Проте для компонента byte[] згенерований канонічний конструктор просто скопіює посилання на масив, а гетер componentName() поверне пряме посилання на цей масив.
  3. Будь-який клієнт зможе написати recordInstance.data()[0] = 0xFF; і змінити стан запису. Щоб зробити Record глибоко незмінним, розробник зобов’язаний вручну перевизначити компактний конструктор із викликом .clone() та гетер із поверненням .clone().

3. Якщо клас оголошено як final, усі поля private final, але гетер повертає Collections.unmodifiableList(this.internalList), чи може стан об’єкта змінитися?

Відповідь: Так, може, якщо internalList був збережений у конструкторі напряму без захисної копії! Collections.unmodifiableList() — це не копія, а всього лише представлення (view) поверх наявного списку. Обгортка захищає від виклику методів мутації через сам гетер (list.add() викине UnsupportedOperationException). Але якщо той, хто створював об’єкт, зберіг посилання на вихідний ArrayList, він може в будь-який момент викликати originalList.add("Boom"), і ця зміна миттєво відобразиться всередині «незмінного» об’єкта та в поверненому unmodifiableList.


4. У чому небезпека використання Cloneable / clone() для створення захисної копії полів у незмінному класі?

Відповідь: Метод clone() у Java архітектурно зламаний (Effective Java, Item 13):

  1. Якщо поле має нефінальний тип (наприклад, CustomClass), то переданий у конструктор об’єкт може виявитися шкідливим підкласом MaliciousCustomClass extends CustomClass.
  2. Виклик parameter.clone() викличе перевизначений метод підкласу, який може повернути зовсім не незалежну копію, а зберегти посилання на об’єкт усередині хакерського реєстру.
  3. Крім того, Object.clone() виконує лише поверхневе копіювання (shallow copy). Висновок: Використовувати clone() безпечно тільки для масивів (оскільки тип масиву точний і не має підкласів: array.clone()). Для всіх інших класів необхідно використовувати конструктори копіювання або статичні фабричні методи.

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

Порівняння: Тільки final поля vs Істинно незмінний клас

| Критерій | Тільки final поля | Істинно незмінний клас | | :— | :— | :— | | Переприсвоєння посилання | Заборонено компілятором | Заборонено компілятором | | Мутація вмісту полів | ❌ Можливо (колекції, масиви) | ✅ Виключено (defensive copy) | | Захист від підкласів | ❌ Немає (якщо клас не final) | ✅ Заборонено (final class) | | Safe Publication в JMM | Частково (тільки самі посилання) | Повна транзитивна безпека | | Модифікація через гетери | ❌ Можливо, якщо повернути мутабель | ✅ Повернення unmodifiable / копій |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Так, модифікатора final на полях абсолютно достатньо для незмінності» — фатальна відповідь, що дискваліфікує кандидата.
  • ❌ «Java Record завжди глибоко незмінний» — Records забезпечують лише shallow immutability; колекції та масиви вимагають ручного захисту.
  • ❌ «Collections.unmodifiableList() створює незалежну копію списку» — це обгортка-view, вихідний список залишається зв’язаним.
  • ❌ «Для глибокої незмінності достатньо скопіювати колекцію через new ArrayList<>(input)» — це скопіює тільки посилання; якщо елементи списку мутабельні, їх теж потрібно клонувати.

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