Що станеться, якщо перевизначити геттер в підкласі незмінного класу
Якщо клас, задуманий як незмінний, не захищений від успадкування модифікатором final, клас-нащадок може перевизначити методи-гетери та повертати динамічно змінювані, випадкові а...
🟢 Junior Level
30-секундна відповідь
Якщо клас, задуманий як незмінний, не захищений від успадкування модифікатором final, клас-нащадок може перевизначити методи-гетери та повертати динамічно змінювані, випадкові або підроблені значення.
У результаті гарантія незмінності повністю знищується:
- Механізм динамічного поліморфізму в Java (
invokevirtual) спрямує всі виклики гетерів до перевизначеної реалізації підкласу. - Будь-який клієнтський код, що працює за посиланням базового класу, вважатиме об’єкт незмінним, але в реальності отримуватиме різні значення при кожному виклику.
- Потокобезпечність буде зруйнована, хеш-таблиці втратять ключі, а перевірки безпеки виявляться скомпрометованими.
Наочний приклад: Злам незмінності через перевизначення гетера
// ❌ ПОМИЛКА ДИЗАЙНУ: клас не оголошено як final
public class BankAccountBalance {
private final long cents; // Поле незмінне і безпечне в пам'яті
public BankAccountBalance(long cents) {
this.cents = cents;
}
public long getCents() {
return cents;
}
}
// 💥 Зловмисний змінний підклас:
public class FraudulentBalance extends BankAccountBalance {
private long mutableCents;
public FraudulentBalance(long initialCents) {
super(initialCents);
this.mutableCents = initialCents;
}
public void setCents(long newCents) {
this.mutableCents = newCents; // Метод-мутатор
}
@Override
public long getCents() {
// 💥 ПІДМІНА: замість базового final-поля повертається змінне значення!
return this.mutableCents;
}
}
Що відбувається в коді програми:
// Клієнт приймає базовий незмінний тип:
BankAccountBalance balance = new FraudulentBalance(100_00);
System.out.println(balance.getCents()); // 10000
// Хтось змінює стан підкласу:
((FraudulentBalance) balance).setCents(999_999_00);
// Базовий "незмінний" контракт видає змінене значення!
System.out.println(balance.getCents()); // 99999900! Інваріант зламано.
🟡 Middle Level
Порушення принципу підстановки Барбари Лісков (LSP)
Принцип підстановки Лісков (Liskov Substitution Principle — LSP) вимагає, щоб поведінка підкласу не порушувала контрактів суперкласу. Базовий тип декларує:
- Детермінованість: виклик
getCents()гарантує повернення одного й того самого значення протягом усього життєвого циклу об’єкта. - Потокобезпечність: екземпляр можна безпечно кешувати та передавати між потоками без синхронізації.
Підклас
FraudulentBalanceпорушує ці фундаментальні очікування, перетворюючи систему на міну сповільненої дії.
Наслідки для інфраструктури застосунку
1. Руйнування структури HashMap та HashSet
Якщо базовий клас перевизначав equals() та hashCode() на основі своїх гетерів (Objects.hash(getCents())):
- При зміні стану підкласу через
setCents()його хеш-код зміниться. - Якщо об’єкт уже був збережений у
HashMap, він опиниться в неправильній корзині (bucket). - Метод
map.get(balance)повернеnull, а об’єкт назавжди зависне в пам’яті купи, спричиняючи прихований витік пам’яті (Memory Leak).
2. Руйнування багатопотокової безпеки (Thread Safety)
- Значення базового поля
centsбуло захищене дією Freeze Action за специфікацією JMM §17.5. - Додане поле
mutableCentsпідкласу не має жодних гарантій JMM: при модифікації одним потоком інші потоки на різних ядрах CPU можуть нескінченно бачити старе закешоване значення з кешу L1 або прочитати неініціалізований0.
🔴 Senior Level
Байткод і динамічна диспетчеризація: чому JMM не рятує
На рівні байткоду Java виклик гетера транслюється в інструкцію invokevirtual:
invokevirtual #7 // Method BankAccountBalance.getCents:()J
- У момент виконання процесор JVM читає посилання на таблицю віртуальних методів (vtable) із заголовка об’єкта (Mark Word / Klass Word).
- Оскільки фактичний екземпляр у купі належить класу
FraudulentBalance, слот методу вказує на перевизначену функцію підкласу. - Висновок: Модель пам’яті Java (JMM) гарантує надійність та бар’єри запису для полів у купі, але жодним чином не може скасувати поліморфізм віртуальних викликів. Якщо метод перевизначено, JVM зобов’язана виконати код нащадка.
Деградація продуктивності в JIT C2 (Inline Cache Deoptimization)
Коли клас не є final і його гетер перевизначено:
- Компілятор HotSpot C2 не може виконати пряму девіртуалізацію методу в безумовний машинний перехід.
- Виклик перетворюється на структуру Polymorphic Inline Cache (PIC) або звернення до таблиці
vtable. - Інлайнінг тіла методу скасовується, що збільшує час виклику гетера в 3–5 разів і перешкоджає векторній оптимізації навколишнього циклу.
3 архітектурні способи запобігти перевизначенню
1. Оголосити клас final (Класичний підхід)
public final class BankAccountBalance { ... } // Успадкування заборонено компілятором
2. Оголосити всі методи-гетери як final
Якщо з якоїсь причини клас зобов’язаний залишатися не-final:
public class BankAccountBalance {
private final long cents;
// final метод неможливо перевизначити в жодному підкласі!
public final long getCents() { return cents; }
}
3. Використовувати record (Java 16+) або sealed class (Java 17+)
Записи (record) компілюються зі специфікатором final, а їхні компоненти доступу неможливо перевизначити через успадкування.
4 Tricky Questions
1. Якщо базовий клас усередині своїх методів звертається до поля через гетер (this.getCents()) замість прямого звернення до поля (this.cents), до чого це призведе при успадкуванні?
Відповідь:
Це катастрофічно посилить уразливість!
Якщо методи базового класу (наприклад, calculateTax(), equals() або compareTo()) звертаються до стану через перевизначуваний гетер getCents(), підклас отримає можливість спотворити не лише зовнішнє читання, а й внутрішню бізнес-логіку самого суперкласу!
Саме тому у внутрішніх методах класів значень завжди слід звертатися до приватних полів напряму (this.cents), або закривати гетери модифікатором final.
2. Чи захистить оголошення всіх гетерів як final від порушення незмінності, якщо сам клас залишився не-final?
Відповідь: Це запобіжить підміні значень, що повертаються існуючими гетерами, але не забезпечить повної незмінності:
- Підклас усе ще може додати нові методи-мутатори та власний змінний стан.
- Підклас може перевизначити методи класу
Object:toString(),hashCode(),equals(), зламавши контракти асоціативних колекцій. - Клієнтський код, що використовує рефлексію (
obj.getClass() == Base.class) або сувору перевірку типів, зіткнеться з неочікуваним типом часу виконання. Тому єдиний надійний захист — закриття від успадкування всього класу цілком (final class).
3. Якщо перевизначений гетер викликається паралельно із сетером підкласу, які гарантії видимості діють за JMM?
Відповідь:
Якщо мутабельне поле підкласу не оголошене як volatile і до нього немає синхронізації (synchronized), за специфікацією JMM §17.4 між записом у сетері та читанням у гетері немає відношення Happens-Before.
Потік, що читає, може:
- Годинами бачити старе значення з кешу L1/L2 процесора.
- Для 64-бітних примітивів (
long,doubleна 32-бітних JVM) прочитати спотворене розірване значення (Torn Read). - Отримати значення за замовчуванням (
0абоnull), якщо конструктор підкласу ще не завершив ініціалізацію.
4. У чому різниця у вирішенні виклику перевизначеного гетера через інструкцію invokevirtual та через invokeinterface?
Відповідь:
invokevirtual: використовується, коли змінна має тип конкретного класу (BankAccountBalance). JVM знаходить адресу методу за фіксованим зміщенням у таблиці віртуальних методів (vtable) класу, що відбувається дуже швидко.invokeinterface: використовується, якщо звернення йде через інтерфейс (BalanceProvider). Оскільки різні класи можуть реалізовувати інтерфейс у різному порядку, зміщення методу в таблиці інтерфейсів (itable) не є фіксованим. JVM доводиться виконувати пошук за stub-таблицями інтерфейсів або використовувати динамічні таблиці диспетчеризації (Inline Cache), що створює додатковий оверхед при виклику.
🎯 Шпаргалка для інтерв’ю
Що відбувається при перевизначенні гетера
- Поліморфізм (
invokevirtual): викликається метод підкласу, а не базового класу. - Інваріант зруйновано: незмінний тип починає віддавати змінні дані.
- LSP порушено: підклас не може коректно замінити базовий клас.
- Багатопотоковість зламана: поля підкласу позбавлені гарантій JMM §17.5 Freeze Action.
- Хеш-таблиці пошкоджені: зміна значення, що повертається, змінює
hashCode(), гублячи об’єкт уHashMap.
Способи захисту
final class— повне й абсолютне закриття успадкування (найкраща практика).finalметоди — захист конкретних гетерів від перевизначення.record— компактні класи даних, неявно фінальні.sealed class ... permits— закрита ієрархія довірених типів.
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Якщо поле оголошено як final у суперкласі, перевизначений гетер не зможе повернути інше значення» — перевизначений метод може повернути будь-що (інше поле, результат генератора випадкових чисел).
- ❌ «Поліморфізм не працює для методів-гетерів» — гетери є звичайними віртуальними методами й підпорядковуються загальним правилам поліморфізму.
- ❌ «Для захисту достатньо зробити поля final, клас захищати не потрібно» — саме перевизначення гетерів у нащадках і обходить захист
final-полів суперкласу.