📋 Раздел 20 · Вопрос #5

Какие методы автоматически генерируются для Record

Когда вы объявляете record, компилятор Java автоматически создает 5 типов методов:


🟢 Junior Level

Когда вы объявляете record, компилятор Java автоматически создает 5 типов методов:

  1. Канонический конструктор: принимает все компоненты в порядке их объявления в заголовке и присваивает их соответствующим private final полям.
  2. Публичные методы-аксессоры (геттеры): генерируются для каждого компонента с именем, совпадающим с именем компонента (например, user.name(), user.age() — без приставки get!).
  3. equals(Object o): возвращает true, если сравниваемый объект принадлежит тому же типу записи и все его компоненты эквивалентны.
  4. hashCode(): вычисляет хеш-код на основе значений всех компонентов записи.
  5. toString(): возвращает понятное строковое представление вида RecordName[field1=val1, field2=val2].
public record User(String name, int age) {}

// Использование всех автогенерированных методов:
User user1 = new User("Alice", 25); // 1. Канонический конструктор
User user2 = new User("Alice", 25);

System.out.println(user1.name());   // 2. Аксессор: "Alice"
System.out.println(user1.age());    // 2. Аксессор: 25

System.out.println(user1.equals(user2)); // 3. true (сравнение по полям)
System.out.println(user1.hashCode() == user2.hashCode()); // 4. true (идентичные хеши)
System.out.println(user1);          // 5. "User[name=Alice, age=25]"

🟡 Middle Level

1. Как работают сгенерированные методы сравнения

  • equals(Object o):
    • Сначала проверяет ссылочное равенство (this == o).
    • Проверяет тип: o != null && this.getClass() == o.getClass().
    • Сравнивает примитивные поля через оператор == (для float/double используются Float.compare и Double.compare во избежание проблем с NaN и -0.0).
    • Сравнивает ссылочные поля через Objects.equals(this.field, other.field).
  • hashCode():
    • Комбинирует хеш-коды всех компонентов по формуле умножения на нечетное простое число ($31$), обеспечивая корректный контракт с equals() для работы в HashMap и HashSet.

2. Критическая ловушка: Record с массивами (byte[], int[])

[!CAUTION] Автогенерированные методы equals(), hashCode() и toString() не умеют работать с массивами по содержимому! Для массива метод Objects.equals(a, b) сравнивает ссылки, а не элементы!

public record BinaryPayload(String id, byte[] data) {}

BinaryPayload p1 = new BinaryPayload("1", new byte[]{1, 2, 3});
BinaryPayload p2 = new BinaryPayload("1", new byte[]{1, 2, 3});

// ❌ Проблема: разные массивы в куче, ссылки не равны!
System.out.println(p1.equals(p2)); // FALSE!
System.out.println(p1);            // BinaryPayload[id=1, data=[B@6bdf28bb] (адрес массива!)

Решение: Явное переопределение через Arrays

Если в Record есть массив, вы обязаны вручную переопределить все три метода:

public record BinaryPayload(String id, byte[] data) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof BinaryPayload that)) return false;
        return Objects.equals(id, that.id) && Arrays.equals(data, that.data);
    }

    @Override
    public int hashCode() {
        int result = Objects.hashCode(id);
        return 31 * result + Arrays.hashCode(data);
    }

    @Override
    public String toString() {
        return "BinaryPayload[id=" + id + ", data=" + Arrays.toString(data) + "]";
    }
}

🔴 Senior Level

Внутреннее устройство в байткоде: invokedynamic и ObjectMethods.bootstrap

Вопреки распространенному заблуждению, компилятор javac не генерирует длинные простыни байткода для каждого метода equals, hashCode и toString внутри каждого файла .class.

Вместо этого компилятор генерирует вызов инструкции invokedynamic (indy), указывающей на системный бутстрап-метод:

// Декомпиляция через javap -v:
public final boolean equals(java.lang.Object);
  descriptor: (Ljava/lang/Object;)Z
  flags: (0x0011) ACC_PUBLIC, ACC_FINAL
  Code:
    0: aload_0
    1: aload_1
    2: invokedynamic #17, 0 // BootstrapMethods #0:equals:(LUser;Ljava/lang/Object;)Z
    7: ireturn

Бутстрап-метод: java.lang.runtime.ObjectMethods.bootstrap(...)

При первом выполнении JVM вызывает:

public static Object bootstrap(
    MethodHandles.Lookup lookup,
    String methodName,
    TypeDescriptor type,
    Class<?> recordClass,
    String names,
    MethodHandle... getters
)
  1. JVM передает список MethodHandle для каждого компонента записи.
  2. Метод ObjectMethods на лету генерирует специализированную цепочку вызовов MethodHandle под конкретный тип записи.
  3. JIT-компилятор (C2) идеально оптимизирует и инлайнит сгенерированные дескрипторы прямо в машинный код.

Преимущества архитектуры на invokedynamic:

  • Минимальный размер .class файлов (нет сотен байт однотипных инструкций).
  • Возможность улучшения алгоритмов хеширования и сравнения в будущих версиях JDK без перекомпиляции пользовательского кода.

Почему Record не кэширует свой hashCode?

Класс java.lang.String кэширует свой хеш-код в приватном мутабельном поле private int hash. При первом вызове hashCode() он рассчитывается, а при последующих возвращается мгновенно за $O(1)$.

В Record такой трюк невозможен:

  • В теле record запрещены любые нестатические поля экземпляра (instance fields).
  • Объявить скрытое поле private int cachedHashCode = 0; компилятор не позволит.
  • Всякий раз при вызове record.hashCode() расчет производится заново по всем полям. Если в записи лежат тяжелые вложенные структуры (например, строки на миллион символов или глубокие списки), частый вызов hashCode() в цикле может стать бутылочным горлышком CPU.

4 Tricky Questions

1. Как компилятор реализует equals, hashCode и toString в байткоде: генерирует классический код или использует invokedynamic?

Ответ: Компилятор использует инструкцию invokedynamic с бутстрап-методом java.lang.runtime.ObjectMethods.bootstrap.

В байткоде класса записи нет жестко закодированных цепочек if-else или вычислений хеша. При первой загрузке бутстрап-метод динамически компонует цепочку дескрипторов MethodHandle для чтения полей конкретной записи и связывает их с CallSite. Это кардинально уменьшает размер байткода и позволяет виртуальной машине на лету применять внутренние оптимизации JIT-компилятора.

2. Что произойдет с автогенерированными equals(), hashCode() и toString(), если одним из компонентов Record является массив (например, byte[] payload)?

Ответ: Произойдет логическая ошибка сравнения:

  • Автогенерированный equals() использует Objects.equals(), который для массивов сравнивает их ссылки на область памяти (==), а не байты. Два разных объекта Record с идентичным содержимым массивов вернут false при вызове .equals().
  • hashCode() вернет identity-хешкод массива (по адресу в памяти), а не хеш содержимого элементов.
  • toString() напечатает тип и системный хеш (например, [B@4f023edb), а не читаемый массив. Вывод: Если компонент является массивом, разработчик обязан вручную переопределить все три метода с использованием методов класса java.util.Arrays.

3. Можно ли реализовать ленивое кэширование hashCode внутри Record, как это сделано в классе String?

Ответ: Напрямую в полях объекта — нельзя.

Спецификация языка Java (JLS §8.10) категорически запрещает объявление любых нестатических полей внутри тела record, кроме канонических компонентов, объявленных в заголовке. Переменную private int hash; для кэша создать невозможно. Хеш-код вычисляется заново при каждом обращении.

Если кэширование критично для производительности, разработчики вынуждены либо использовать внешний кэш (например, ConcurrentHashMap), либо отказаться от Record в пользу классического final class с ручным кэшированием.

4. Генерирует ли Record метод clone() или специальные методы для Java Serialization (readObject / writeObject)?

Ответ: Нет, не генерирует.

  • Метод clone() не генерируется и не поддерживается. Поскольку Record неизменяем, клонирование объектов бессмысленно (поверхностная копия ссылки достаточна, а для изменения компонентов используется паттерн Wither).
  • Методы readObject и writeObject не генерируются, а если их объявить вручную, JVM их проигнорирует. Механизм сериализации для записей жестко фиксирован: десериализация выполняется исключительно через вызов канонического конструктора.

🎯 Шпаргалка для интервью

5 автоматически генерируемых методов Record

Метод Сигнатура Особенности реализации
Канонический конструктор User(String name, int age) Инициализирует private final поля в порядке объявления.
Аксессоры name(), age() Имена строго без get. Возвращают значения полей.
equals(Object o) boolean equals(Object o) Реализован через invokedynamic (ObjectMethods.bootstrap).
hashCode() int hashCode() Хеш комбинации всех компонентов. Не кэшируется!
toString() String toString() Выводит: RecordName[comp1=val1, comp2=val2].

Памятка разработчика

  1. Массивы в Record: Автогенерация ломается (сравнивает ссылки). Всегда переопределяйте equals, hashCode и toString через Arrays.*.
  2. Байткод под капотом: invokedynamic + ObjectMethods.bootstrap — признак глубокого знания JVM на интервью.
  3. Кэширование: Полей для кэша добавить нельзя, расчет хеша происходит при каждом вызове.

Связанные темы