🔒 Раздел 13 · Вопрос #17

Что произойдёт, если переопределить геттер в подклассе иммутабельного класса

Если класс, задуманный как иммутабельный, не защищён от наследования модификатором final, класс-наследник может переопределить методы-геттеры и возвращать динамически изменяемые...


🟢 Junior Level

30-секундный ответ

Если класс, задуманный как иммутабельный, не защищён от наследования модификатором final, класс-наследник может переопределить методы-геттеры и возвращать динамически изменяемые, случайные или поддельные значения.

В результате гарантия иммутабельности полностью уничтожается:

  1. Механизм динамического полиморфизма в Java (invokevirtual) направит все вызовы геттеров к переопределённой реализации подкласса.
  2. Любой клиентский код, работающий по ссылке базового класса, будет считать объект неизменяемым, но в реальности получать разные значения при каждом вызове.
  3. Потокобезопасность будет сломана, хэш-таблицы потеряют ключи, а проверки безопасности окажутся скомпрометированными.

Наглядный пример: Взлом неизменяемости через переопределение геттера

// ❌ ОШИБКА ДИЗАЙНА: класс не объявлен как 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
  1. В момент выполнения процессор JVM читает ссылку на таблицу виртуальных методов (vtable) из заголовка объекта (Mark Word / Klass Word).
  2. Поскольку фактический экземпляр в куче принадлежит классу FraudulentBalance, слот метода указывает на переопределённую функцию подкласса.
  3. Вывод: Модель памяти 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?

Ответ: Это предотвратит подмену возвращаемых значений существующих геттеров, но не обеспечит полной иммутабельности:

  1. Подкласс всё ещё может добавить новые методы-мутаторы и собственное изменяемое состояние.
  2. Подкласс может переопределить методы класса Object: toString(), hashCode(), equals(), сломав контракты ассоциативных коллекций.
  3. Клиентский код, использующий рефлексию (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.

Способы защиты

  1. final class — полное и абсолютное закрытие наследования (лучшая практика).
  2. final методы — защита конкретных геттеров от переопределения.
  3. record — компактные классы данных, неявно финальные.
  4. sealed class ... permits — закрытая иерархия доверенных типов.

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Если поле объявлено как final в суперклассе, переопределённый геттер не сможет вернуть другое значение» — переопределённый метод может вернуть всё что угодно (другое поле, результат генератора случайных чисел).
  • ❌ «Полиморфизм не работает для методов-геттеров» — геттеры являются обычными виртуальными методами и подчиняются общим правилам полиморфизма.
  • ❌ «Для защиты достаточно сделать поля final, класс защищать не нужно» — именно переопределение геттеров в наследниках и обходит защиту final-полей суперкласса.

Связанные вопросы