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

Что такое Record в Java и с какой версии они доступны

До появления Records создание простейшего неизменяемого объекта передачи данных (DTO / Value Object) требовало написания десятков строк шаблонного кода (boilerplate):


🟢 Junior Level

Record (запись) — это специальный тип класса в Java, спроектированный как прозрачный и неизменяемый носитель данных (data carrier).

Хронология версий Java

  • Java 14 (март 2020): Первое превью (JEP 359).
  • Java 15 (сентябрь 2020): Второе превью (JEP 384).
  • Java 16 (март 2021): Полноценный релиз (General Availability) (JEP 395). Начиная с Java 16, Records доступны из коробки без флагов --enable-preview.

Проблема, которую решает Record

До появления Records создание простейшего неизменяемого объекта передачи данных (DTO / Value Object) требовало написания десятков строк шаблонного кода (boilerplate):

// Классический Java-класс (POJO) требовал:
// 1. Объявления private final полей
// 2. Конструктора со всеми аргументами
// 3. Геттеров
// 4. Методов equals(), hashCode() и toString()
// Это 40-50 строк громоздкого кода (или зависимость от Lombok).

В современном синтаксисе Java всё это объявляется в одну строку:

public record User(String name, int age, String email) {}

Компилятор автоматически генерирует:

  1. private final поля для каждого компонента (name, age, email).
  2. Публичный канонический конструктор со всеми параметрами.
  3. Публичные методы чтения (аксессоры), имена которых совпадают с именами компонентов: user.name(), user.age(), user.email() (без приставки get!).
  4. Реализацию equals() и hashCode(), сравнивающую все компоненты по значению.
  5. Человекочитаемый toString() в формате User[name=Alice, age=30, email=alice@example.com].
User user = new User("Alice", 30, "alice@example.com");
System.out.println(user.name()); // "Alice"
System.out.println(user);        // User[name=Alice, age=30, email=alice@example.com]

🟡 Middle Level

1. Архитектурные ограничения Record

Record — это не просто синтаксический сахар для генерации кода. Это особый вид типа с жесткими инвариантами языка:

  1. Неявный final класс: record не может быть расширен другим классом (final class Point extends Record), и от него невозможно наследоваться.
  2. Фиксированный суперкласс: Все записи неявно наследуются от java.lang.Record. Прямое наследование (extends AnyClass) запрещено компилятором.
  3. Реализация интерфейсов разрешена: Запись может реализовывать любое количество интерфейсов:
    public record Point(int x, int y) implements Serializable, Comparable<Point> {
        @Override
        public int compareTo(Point o) {
            return Integer.compare(this.x, o.x);
        }
    }
    
  4. Запрет на дополнительные поля экземпляра: Внутри тела record нельзя объявлять новые нестатические поля:
    public record Point(int x, int y) {
        // private int z; // ❌ Ошибка компиляции: non-static field not allowed in record
        public static final Point ORIGIN = new Point(0, 0); // ✅ Статические поля разрешены
    }
    

2. Компактный конструктор (Compact Constructor)

Одной из самых мощных фич Records является компактный конструктор. Он объявляется без скобок списка аргументов и используется для валидации и нормализации данных:

public record Money(BigDecimal amount, Currency currency) {
    // Компактный конструктор: параметры amount и currency неявно передаются сюда
    public Money {
        Objects.requireNonNull(amount, "Amount must not be null");
        Objects.requireNonNull(currency, "Currency must not be null");
        if (amount.scale() > 2) {
            // Нормализация значения: присваивание обновляет неявный локальный параметр!
            amount = amount.setScale(2, RoundingMode.HALF_UP);
        }
        // Компилятор сам выполнит: this.amount = amount; this.currency = currency;
    }
}

3. Shallow Immutability (Поверхностная неизменяемость)

[!WARNING] Record гарантирует неизменяемость только ссылок, но не содержимого объектов по ссылкам! Если компонент записи — мутабельный объект (например, int[], Date или ArrayList), состояние записи может быть изменено снаружи:

public record BadRecord(List<String> items) {}

List<String> mutableList = new ArrayList<>(List.of("A", "B"));
BadRecord record = new BadRecord(mutableList);
mutableList.add("C"); // Мутирует данные внутри record!
record.items().clear(); // Тоже мутирует!

// ✅ Решение: Защитное копирование (Defensive Copy) в компактном конструкторе:
public record SafeRecord(List<String> items) {
    public SafeRecord {
        items = List.copyOf(items); // Создает глубоко неизменяемый список
    }
}

🔴 Senior Level

Внутреннее устройство в JVM и структуре байткода

Компилятор помечает класс записи специальным модификатором в заголовке .class файла:

  • Флаг доступа ACC_RECORD (0x10000).
  • Суперкласс в пуле констант: java/lang/Record.
  • Специальный байткод-атрибут Record:
    Record_attribute {
        u2 attribute_name_index;
        u4 attribute_length;
        u2 components_count;
        record_component_info components[components_count];
    }
    

Наличие RecordComponent в метаданных класса позволяет Java Reflection API предоставлять прямой метод инспекции:

RecordComponent[] components = User.class.getRecordComponents();
for (RecordComponent rc : components) {
    String name = rc.getName();          // "name", "age"
    Class<?> type = rc.getType();        // String.class, int.class
    Method accessor = rc.getAccessor();  // public String User.name()
}

Это кардинально ускорило работу JSON-сериализаторов (Jackson 2.12+) и ORM, исключив необходимость эвристического сопоставления полей и геттеров.

Безопасная сериализация (Serialization Revolution)

Классическая Java Serialization (implements Serializable) исторически была главным источником уязвимостей в экосистеме Java. Для обычных классов десериализация полностью обходит конструкторы, создавая экземпляр через ReflectionFactory.newConstructorForSerialization и напрямую записывая байты в память через Unsafe.putObject. Это позволяло злоумышленникам создавать объекты с нарушенными инвариантами безопасности.

В Records эта дыра закрыта на уровне спецификации JVM:

  1. При десериализации записи поток ObjectInputStream считывает значения полей из байтов и в обязательном порядке передает их в канонический конструктор!
  2. Все проверки безопасности и валидаторы в компактном конструкторе отрабатывают гарантированно, даже если сериализованный поток данных был подделан.
  3. Методы readObject, writeObject, readObjectNoData у записей запрещены и игнорируются.

Защита от Reflection-модификаций (JEP 395)

В обычных классах поле private final можно изменить в рантайме через рефлексию:

Field field = OldClass.class.getDeclaredField("value");
field.setAccessible(true);
field.set(instance, newValue); // Работало в старых версиях Java

Для компонентов Record в JVM введена жесткая блокировка: вызов field.set(...) на поле записи безусловно выбрасывает IllegalAccessException, даже если был вызван field.setAccessible(true). Поля Record физически иммутабельны.

Проекция в будущее: Project Valhalla

Record спроектирован так, чтобы в будущем естественным образом стать Value Object (Identity-free class) в рамках Project Valhalla. Поскольку записи уже декларируют неизменяемость и прозрачность компонентов, в будущем JVM сможет размещать их на стеке процессора или упаковывать в непрерывные плоские массивы без указателей и заголовков объектов (flattening in memory).


4 Tricky Questions

1. Как десериализация Records отличается от обычной десериализации в Java (через Serializable), и почему это устраняет критические уязвимости?

Ответ: При стандартной десериализации обычного Java-класса JVM создает объект в обход любых конструкторов (через native-механизмы Unsafe), после чего напрямую перезаписывает поля байтами из потока. Это позволяет внедрить недопустимое состояние (например, отрицательный возраст или null в поле с аннотацией @NonNull).

Для Record спецификация Java жестко предписывает:

  1. ObjectInputStream считывает данные компонентов.
  2. Десериализатор находит канонический конструктор записи и передает считанные аргументы в него.
  3. Вся валидация в компактном или каноническом конструкторе выполняется штатно. Невозможно создать поврежденный или взломанный Record через десериализацию.

2. Можно ли изменить значение final поля внутри Record с помощью Reflection (field.setAccessible(true); field.set(...))?

Ответ: Нет, нельзя. Начиная с момента стандартизации Records (Java 16) и усиления инкапсуляции JVM (JEP 396/403), попытка вызвать field.set() на компоненте Record завершится исключением IllegalAccessException: Can not set final field of a record class.

JVM жестко проверяет флаг clazz.isRecord() внутри реализации Field.set() и блокирует любую мутацию полей, делая записи по-настоящему неизменяемыми на уровне среды исполнения.

3. Почему Record не генерирует классические геттеры с префиксом get (например, getName()), и как это влияет на интеграцию со старыми библиотеками?

Ответ: Создатели Java сознательно отказались от стандарта JavaBeans (1996 года) с его префиксами get/set, так как Record — это не «бин», а математический кортеж (носитель данных). Имя метода чтения совпадает с именем компонента: user.name().

Это вызывало сложности со старыми библиотеками (старые мапперы типа Dozer, Apache Commons BeanUtils, старые версии Spring/Hibernate), которые искали методы исключительно по шаблону get[A-Z].*. Современные фреймворки (Jackson 2.12+, MapStruct 1.4+, Spring 5.3+, Hibernate 6+) полностью адаптированы и понимают сигнатуру аксессоров Record нативно.

4. Можно ли объявить локальный Record внутри метода, и имеет ли он доступ к локальным переменным метода?

Ответ: Да, объявление локальных записей (Local Records) внутри методов разрешено начиная с Java 15/16. Это очень удобно для промежуточных вычислений в Stream API.

Однако ключевая тонкость: локальный record является неявно статическим (implicitly static). Это означает, что он не имеет неявной ссылки на экземпляр внешнего класса и не может захватывать (capture) нестатические локальные переменные объемлющего метода, в отличие от локальных анонимных классов. Он может оперировать только теми данными, которые были явно переданы в его компоненты.


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

Главное о Records за 1 минуту

  • Версии: Preview — Java 14/15, Production (GA) — Java 16.
  • Суть: Неизменяемый носитель данных (immutable data carrier). Все поля private final, класс final extends java.lang.Record.
  • Что генерируется: Канонический конструктор, аксессоры field() (без get), equals(), hashCode(), toString().
  • Конструкторы: Бывают каноническими, компактными (для валидации и нормализации) и кастомными (обязаны делегировать через this(...)).
  • Чего нельзя: Наследоваться от классов, наследоваться от других записей, объявлять нестатические поля экземпляра.
  • Сериализация: Всегда вызывает канонический конструктор (безопасна к инъекциям).
  • Рефлексия: Поля защищены от изменения через Field.set(). Доступна инспекция через Class.getRecordComponents().

Антипаттерны при использовании Records

  • ❌ Использование мутабельных коллекций без List.copyOf: ломает иммутабельность.
  • ❌ Попытка использовать Record как JPA Entity: JPA/Hibernate требует пустой конструктор и мутабельные свойства для lazy-loading прокси (в JPA Record допустим только как проекция в @Query DTO).
  • ❌ Добавление бизнес-логики со сложным состоянием: Record предназначен для хранения данных, а не для тяжелых сервисных операций.

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