Java Records. Не музыкальный лейбл, а расширение возможностей языка

Java Records. Не музыкальный лейбл, а расширение возможностей языка

Посмотрим на сгенерированный байт-код с помощью javap :

Интересно, что записи, как и Enum-ы, являются обычными классами Java с несколькими фундаментальными свойствами:

  • Записи объявлены как final , поэтому мы не можем наследоваться от них.
  • Записи уже наследуются от другого класса под названием java.lang.Record . Поэтому записи не могут расширить какой-либо другой класс, поскольку Java не допускает множественного наследования.
  • Записи могут имплементить другие интерфейсы.
  • Для каждого компонента существует свой метод доступа, например, max и min .
  • Существуют автоматически генерируемые реализации для toString , equals и hashCode и автоматически сгенерированный конструктор, который принимает все компоненты в качестве своих аргументов.
  • Кроме того, java.lang.Record – это абстрактный класс с защищенным конструктором no-arg и несколькими базовыми абстрактными методами:

Давайте рассмотрим класс данных Kotlin, эквивалентный описанному выше Range:

Подобно записям, компилятор Kotlin генерирует методы доступа, стандартные реализации toString , equals и hashCode и еще несколько функций, основанных на этой однострочной схеме.

Посмотрим, как компилятор Kotlin генерирует код, например, для toString :

Для генерации вывода мы применили javap -c -v Range . Kotlin использует StringBuilder для генерации строкового представления вместо нескольких конкатенаций строк (как и любой приличный Java-разработчик). Что мы имеем:

  • сначала создается новый экземпляр StringBuilder (индекс 0, 3, 4);
  • добавляется литерал Range ;
  • добавляется фактическое значение min (индекс 12, 13, 16);
  • добавляется литерал max= (индекс 19, 21);
  • добавляется фактическое значение max (индекс 24, 25, 28);
  • скобки закрываются и добавляется литерал) (индекс 31, 33);
  • создается и возвращается экземпляр StringBuilder (индекс 36, 39).

Чем больше свойств в нашем классе данных, тем длиннее байт-код и больше время запуска.

Кажется, что Scala генерирует более простую реализацию toString :

toString вызывает статический метод scala.runtime.ScalaRunTime._toString , а он, в свою очередь, метод productIterator для итерации по продуктам. Итератор вызывает метод productElement , выглядящий примерно так:

Это перебор свойств Case-класса: если productIterator хочет получить первое свойство, он получает значение min . Если хочет второй элемент – значение max . Иначе возникнет исключение IndexOutOfBoundsException .

Все, как и в Data-классах Kotlin: чем больше свойств в case-классе, тем объемнее будет перебор, а длина байт-кода пропорциональна количеству свойств.

Вернемся и внимательнее рассмотрим байт-код, сгенерированный для записей Java:

Invoke Dynamic (также известный как Indy) был частью JSR 292 и предназначался для улучшения поддержки JVM в динамических языках. После первого релиза в Java 7, invokedynamic opcode вместе с java.lang.invoke широко используется в динамических JVM-based языках, например, JRuby.

Хотя Indy был специально разработан для улучшения языковой поддержки, он предлагает гораздо больший функционал и подходит для использования везде, где требуется динамичность. Например, лямбда-выражения Java 8 реализуются с помощью invokedynamic , хотя Java – это статически типизированный язык.

В течение длительного времени JVM поддерживал четыре типа вызова методов:

  1. invokestatic для вызова статических методов,
  2. invokeinterface для вызова интерфейсных методов,
  3. invokespecial для вызова конструкторов,
  4. super() или privatemethods и invokevirtual для вызова методов экземпляра.

Несмотря на их различия, эти типы вызовов имеют одну общую черту: мы не можем снабдить их собственной логикой. А еще, invokedynamic позволяет нам загружать процесс вызова любым желаемым способом.

Когда JVM видит вызываемую динамическую инструкцию, он вызывает специальный статический Bootstrap Method . Данный метод – это фрагмент кода Java, используемый для подготовки фактической логики к вызову.

Java Records. Не музыкальный лейбл, а расширение возможностей языка

Затем метод bootstrap возвращает экземпляр java.invoke.CallSite . CallSite содержит ссылку на фактический метод, т. е. MethodHandle . С этого момента каждый раз, когда JVM снова видит invokedynamicinstruction , он пропускает «медленный путь» и применяет прямой вызов, пока что-то не изменится.

В отличие от Reflection API , java.lang.invoke API более эффективен, так как JVM может полностью видеть все вызовы. Поэтому JVM может применять всевозможные оптимизации, пока мы избегаем «медленного пути», насколько это возможно.

Cгенерированный байт-код для записей Java не зависит от количества свойств. Таким образом, меньше байт-кода – быстрее время запуска.

Наконец, предположим, что новая версия Java включает в себя новую реализацию метода bootstrap . С инструкциями invokedynamic наше приложение может воспользоваться преимуществами этого улучшения без перекомпиляции. Итак, у нас есть своего рода прямая бинарная совместимость.

Давайте разберемся в байт-коде invokedynamic :

Вот что в байт-коде:

Таким образом, метод bootstrap для записей находится в классе java.lang.runtime.ObjectMethods . Метод может принимать следующие аргументы:

  • Экземпляр MethodHandles.Lookup , описывающий контекст поиска ( Ljava/lang/invoke/MethodHandles$Lookup ).
  • Имя метода: toString , equals , hashCode и т. д. Например, если значение равно toString , bootstrap вернет ConstantCallSite – указатель на фактическую реализацию toString для этой конкретной записи.
  • TypeDescriptor для метода ( Ljava/lang/invoke/TypeDescriptor ).
  • Токен Class<?> , описывающий тип класса записи. Здесь это Class<Range> .
  • Список всех имен компонентов, разделенных “ ; ”, т. е. min;max .
  • Один MethodHandle для каждого компонента.

Инструкция invokedynamic передает аргументы в метод bootstrap , а он, в свою очередь, возвращает экземпляр ConstantCallSite , содержащий ссылку на запрошенную реализацию метода.

Для поддержки записей java.lang.Class API был переписан, а чтобы проверить, например, является ли Class<?> записью, можно использовать метод isRecord :

Очевидно, что он возвращает false для типов без записей:

Существует также метод getRecordComponents , возвращающий массив RecordComponent в том же порядке, в котором они определены в исходной записи. Каждый java.lang.reflect.RecordComponent представляет собой компонент записи или переменную текущего типа записи. Например, RecordComponent.getName возвращает имя компонента:

Похожим образом метод getType возвращает type-token для каждого компонента:

Можно даже получить дескриптор для методов доступа через getAccessor :

Java позволяет добавлять аннотации к записи, если они применимы к записи или ее членам. Аннотации могут использоваться только в компонентах записи:

Любая новая функция Java без сериализации была бы неполной. Хотя записи по умолчанию не являются сериализуемыми, их можно сделать таковыми, просто реализовав интерфейс java.io.Serializable.

Сериализуемые записи сериализуются и десериализуются иначе, нежели обычные сериализуемые объекты. Вот, что говорит на эту тему javadoc:

  • Сериализованная запись – это последовательность значений, полученных из компонентов записи.
  • Процесс, с помощью которого сериализуются объекты записи, не может быть кастомизирован. Любые методы writeObject , readObject , readObjectNoData , writeExternal и readExternal , определенные классами записей, игнорируются во время сериализации и десериализации.
  • Если SerialVersionUID класса не объявлен явно, он равен 0L .

Записи Java предоставят новый способ инкапсуляции данных. Несмотря на то, что в настоящее время они ограничены в плане функциональности (по сравнению с тем, что предлагают Kotlin или Scala), реализация является надежной.

В данной статье использовалась сборка openjdk 14-ea 2020-03-17, когда Java 14 еще не была выпущена. В день, когда мы опубликовали эту статью, вышел официальный релиз Java SE 14. Так что теперь есть всё, чтобы попробовать записи в деле.