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

Чому незмінний клас повинен бути final

Незмінний клас зобов'язаний бути оголошений з модифікатором final, щоб на рівні компілятора та специфікації мови повністю заблокувати успадкування.


🟢 Junior Level

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

Незмінний клас зобов’язаний бути оголошений з модифікатором final, щоб на рівні компілятора та специфікації мови повністю заблокувати успадкування.

Якщо клас не позначити як final:

  1. Сторонній розробник або зловмисник може створити підклас (class Child extends ImmutableClass).
  2. Додати в цей підклас змінні поля та сетери.
  3. Перевизначити методи-гетери, змусивши їх повертати динамічно змінювані або фальшиві значення.
  4. У результаті клієнти, які приймають об’єкт за посиланням базового «незмінного» типу, отримають змінний підклас, що зруйнує потокобезпечність, пошкодить кеші та призведе до непередбачуваних збоїв у продакшені.

Наочний приклад: Злам класу без модифікатора final

// ❌ ПОМИЛКА ПРОЄКТУВАННЯ: клас не оголошено як final
public class Coordinates {
    private final int x;
    private final int y;

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

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

// 💥 Підступний підклас ламає незмінність суперкласу:
public class MutableCoordinates extends Coordinates {
    private int mutableX;

    public MutableCoordinates(int x, int y) {
        super(x, y);
        this.mutableX = x;
    }

    public void setX(int newX) { 
        this.mutableX = newX; // Додано метод-мутатор!
    }

    @Override
    public int getX() { 
        return mutableX; // Перевизначений гетер повертає змінюване значення!
    }
}

Наочна аналогія з життя

Уявіть запечатаний сургучною печаткою конверт:

  • Модифікатор final у класу — це сургучна печатка автора. Конверт не можна розкрити, дописати туди інший текст або непомітно підмінити аркуш паперу.
  • Якщо сургучної печатки немає, будь-який зловмисник може вкласти всередину підроблений аркуш із тим самим заголовком і надіслати його далі від вашого імені.

🟡 Middle Level

3 критичні ризики відсутності final у Production

Вектор ризику Механізм виникнення проблеми Наслідки в Production
Уразливість TOCTOU (Безпека) Метод валідації перевіряє об’єкт, а паралельний потік змінює поля підкласу між перевіркою та виконанням операції Обхід перевірок доступу, виконання неавторизованих фінансових транзакцій
Пошкодження асоціативних структур (HashMap, HashSet) Підклас перевизначає hashCode() та equals() зі спиранням на свої мутабельні поля Втрата елементів у бакетах HashMap, об’єкти, які неможливо видалити, витік пам’яті в Heap
Руйнування багатопотокової безпеки (Thread Safety) Поля батька захищені бар’єрами JMM §17.5, а додані поля підкласу — ні Стан перегонів (Data Race), читання дефолтних значень (0, null) або розірваних даних

Приклади з архітектури стандартної бібліотеки JDK

Розробники платформи Java зробили суворо final абсолютно всі фундаментальні класи значень:

  • java.lang.String
  • Усі класи-обгортки примітивів: Integer, Long, Double, Boolean тощо
  • Числа довільної точності: BigDecimal, BigInteger
  • Усі класи сучасної дати та часу пакета java.time: LocalDate, LocalDateTime, Instant, Duration, Period
  • Записи (record, Java 16+) у байткоді неявно компілюються зі специфікатором final.

Взаємодія з фреймворками: Spring AOP та Mockito

Історично модифікатор final викликав складнощі у бібліотек генерації коду:

  1. Spring AOP / CGLIB: CGLIB генерує проксі-клас на льоту через створення підкласу (Proxy extends TargetClass). Для final класів CGLIB викидав виняток. Рішення в сучасному Spring Boot 3 — використання стандартних JDK Dynamic Proxies через інтерфейси.
  2. Mockito 2/3: не вмів створювати моки для final класів та методів без ручного підключення додаткових модулів.
  3. Mockito 5+ (Spring Boot 3+): повністю підтримує мокування final класів та методів «з коробки» завдяки плагіну mock-maker-inline (на базі Java Agent ByteBuddy), що динамічно трансформує байткод у пам’яті.

🔴 Senior Level

JIT C2-оптимізації: Devirtualization та Monomorphic Inlining

Модифікатор final на рівні класу — це найсильніша директива для оптимізатора HotSpot JIT (компілятора C2):

  1. Девіртуалізація викликів (Devirtualization):
    • Для звичайного класу компілятор генерує інструкцію invokevirtual, яка потребує звернення до таблиці віртуальних методів (vtable) у заголовку об’єкта.
    • Якщо клас оголошений як final, JVM на етапі компіляції знає, що перевизначення методів неможливе в принципі. Інструкція invokevirtual перетворюється на прямий машинний виклик (direct call), усуваючи пошук адреси в пам’яті.
  2. Агресивний інлайнінг (Monomorphic Inlining):
    • Виклики простих гетерів (getX()) повністю інлайняться в машинний код методу, що викликає: виклик функції замінюється прямим читанням зі зміщенням у пам’яті або значенням у регістрі CPU за 0 тактів.
  3. Усунення точок деоптимізації (Uncommon Traps):
    • Якщо клас не final, JIT використовує аналіз ієрархії класів (Class Hierarchy Analysis — CHA). Якщо завантажується новий підклас, скомпільований машинний код скидається (деоптимізація). Для final класів цей ризик виключено.

Архітектурні способи заборони успадкування без слова final

Якщо з архітектурних міркувань слово final не можна використати напряму (наприклад, потрібне закрите сімейство класів усередині пакета):

  • Патерн приватного конструктора (Effective Java, Стаття 17):
    public class SecureConfig {
        private final String apiKey;
    
        // Приватний конструктор забороняє виклик super() ззовні класу
        private SecureConfig(String apiKey) {
            this.apiKey = apiKey;
        }
    
        public static SecureConfig of(String key) {
            return new SecureConfig(key);
        }
    }
    
  • Пакетно-приватний конструктор (package-private): дозволяє підкласи тільки всередині свого пакета, захищаючи від зовнішніх клієнтів.
  • sealed класи (Java 17+): public sealed class MyClass permits SubA, SubB — фіксує суворий вичерпний перелік дозволених нащадків.

4 Tricky Questions

1. Як оголошення класу final допомагає компілятору C2 уникнути спекулятивного інлайнінгу та деоптимізацій (Deoptimization Traps)?

Відповідь: Якщо клас не оголошений як final, компілятор C2 виконує оптимізацію спекулятивно за допомогою Class Hierarchy Analysis (CHA): він перевіряє, скільки підкласів завантажено в поточний момент. Якщо підклас один, C2 інлайнить метод, але вставляє в машинний код приховану перевірку залежності (class dependency check / uncommon trap). Якщо в рантаймі динамічно завантажиться новий JAR або спрацює ClassLoader.loadClass(), додавши підклас, JVM визнає скомпільований код невалідним, виконає деоптимізацію, скине код у режим інтерпретатора і буде заново профілювати метод. Модифікатор final дає гарантію на рівні байткоду: поява підкласів фізично неможлива. Компілятор C2 назавжди компілює метод за прямою адресою без накладних перевірок і без ризику деоптимізації.


2. Якщо у нефінального класу всі методи оголошені як final, чи дає це таку саму гарантію безпеки, як оголошення всього класу final?

Відповідь: Ні, це не дає еквівалентної гарантії безпеки:

  1. Підклас усе ще може додати нові методи-мутатори та власний змінний стан.
  2. Підклас може перевизначити методи класу java.lang.Object (такі як equals(), hashCode(), toString()), якщо розробник забув оголосити їх final у базовому класі.
  3. Клієнтський код, що перевіряє точний тип через рефлексію (obj.getClass() == Base.class), зламається при отриманні екземпляра підкласу.
  4. JIT-компілятор C2 не зможе виконати глобальні оптимізації компонування структури об’єкта, які можливі лише для повністю закритого типу.

3. Чому JPA-сутності (Hibernate) часто не можуть бути final, і в чому фундаментальна різниця між незмінністю сутності JPA та доменного об’єкта?

Відповідь: Hibernate використовує динамічне проксіювання на основі генерації байткоду (ByteBuddy / CGLIB) для реалізації механізму лінивого завантаження (Lazy Loading). Проксі-об’єкт лінивого зв’язку успадковується від класу сутності (EntityProxy extends Entity). Якщо клас сутності оголосити як final, Hibernate не зможе створити проксі-клас і викине помилку при старті SessionFactory. Крім того, сутність JPA за своєю природою мутабельна й управляється контекстом персистентності (Persistence Context / Dirty Checking). Для моделювання незмінності в БД Hibernate надає анотацію @Immutable на рівні сутності, але на рівні Java-коду клас зазвичай залишають не-final заради проксіювання.


4. Яким чином сучасний Mockito 5+ мокає final класи, якщо JVM на рівні байткоду забороняє створення підкласів?

Відповідь: Mockito 5+ використовує механізм ByteBuddy Mock Maker Inline на базі Java Instrumentation API (java.lang.instrument):

  1. Mockito реєструє віртуальний Java Agent під час запуску JVM.
  2. При виклику mock(FinalClass.class) Mockito не створює підклас.
  3. Натомість агент переозначає байткод самого цільового FinalClass прямо в оперативній пам’яті JVM, впроваджуючи на початок кожного методу хук-диспетчер (Method Dispatcher).
  4. Якщо виклик виконується в межах налаштованого мока (when(...).thenReturn(...)), диспетчер повертає стаб, інакше передає керування оригінальному тілу методу.

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

Головне про те, чому незмінний клас повинен бути final

  • Захист контракту: підклас не зможе додати сетери, перевизначити гетери або спотворити логіку hashCode/equals.
  • Захист безпеки: блокує атаки підміни даних (TOCTOU) у валідаторах і контролерах.
  • Продуктивність: дозволяє C2-компілятору виконувати девіртуалізацію викликів (Devirtualization) та мономорфний інлайнінг методів без перевірок vtable.
  • Стандарти JDK: усі ключові типи значень (String, Integer, BigDecimal, LocalDate, Record) суворо закриті модифікатором final.

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

  • ❌ «Якщо всі поля позначені private final, клас final робити не обов’язково» — груба помилка: підклас перевизначить гетери й зламає бізнес-інваріанти.
  • ❌ «Модифікатор final у класів шкідливий для тестів, оскільки Mockito не може їх мокати» — застаріло ще з Mockito 2.x, у сучасному Mockito 5+ (Spring Boot 3) мокування final працює за замовчуванням.
  • ❌ «Зробити всі методи final — те саме, що зробити клас final» — ні, підклас зможе додати свої змінні методи та перевизначити equals/hashCode.

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