Что произойдёт, если переопределить геттер в подклассе иммутабельного класса
Если класс, задуманный как иммутабельный, не защищён от наследования модификатором 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-полей суперкласса.