Объявление класса
Начнем с простого примера:
public record Range(int min, int max) {}
Скомпилируем посредством javac
:
javac --enable-preview -source 14 Range.java
Посмотрим на
сгенерированный байт-код с помощью javap
:
javap Range
Выведется следующее:
Compiled from "Range.java"
public final class Range extends java.lang.Record {
public Range(int, int);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int min();
public int max();
}
Интересно, что записи, как и Enum-ы, являются обычными классами Java с несколькими фундаментальными свойствами:
- Записи объявлены как
final
, поэтому мы не можем наследоваться от них. - Записи уже наследуются от другого класса под названием
java.lang.Record
. Поэтому записи не могут расширить какой-либо другой класс, поскольку Java не допускает множественного наследования. - Записи могут имплементить другие интерфейсы.
- Для каждого компонента существует свой метод доступа, например,
max
иmin
. - Существуют автоматически генерируемые реализации для
toString
,equals
иhashCode
и автоматически сгенерированный конструктор, который принимает все компоненты в качестве своих аргументов. - Кроме того,
java.lang.Record
– это абстрактный класс с защищенным конструктором no-arg и несколькими базовыми абстрактными методами:
public abstract class Record {
protected Record() {}
@Override
public abstract boolean equals(Object obj);
@Override
public abstract int hashCode();
@Override
public abstract String toString();
}
Классы данных Kotlin
Давайте рассмотрим класс данных Kotlin, эквивалентный описанному выше Range:
data class Range(val min: Int, val max: Int)
Подобно записям,
компилятор Kotlin генерирует методы доступа, стандартные реализации toString
,
equals
и hashCode
и еще несколько функций, основанных на этой однострочной
схеме.
Посмотрим, как
компилятор Kotlin генерирует код, например, для toString
:
Compiled from "Range.kt"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: new #36 // class StringBuilder
3: dup
4: invokespecial #37 // Method StringBuilder."<init>":()V
7: ldc #39 // String Range(min=
9: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
12: aload_0
13: getfield #10 // Field min:I
16: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
19: ldc #48 // String , max=
21: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
24: aload_0
25: getfield #16 // Field max:I
28: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
31: ldc #50 // String )
33: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
36: invokevirtual #52 // Method StringBuilder.toString:()LString;
39: areturn
Для генерации вывода мы применили 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
Напишем эквивалент записи в Scala:
case class Range(min: Int, max: Int)
Кажется, что Scala
генерирует более простую реализацию toString
:
Compiled from "Range.scala"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #89 // Field ScalaRunTime$.MODULE$:LScalaRunTime$;
3: aload_0
4: invokevirtual #111 // Method ScalaRunTime$._toString:(LProduct;)LString;
7: areturn
toString
вызывает статический метод
scala.runtime.ScalaRunTime._toString
,
а он, в свою очередь, метод productIterator
для итерации по продуктам. Итератор
вызывает метод productElement
, выглядящий примерно так:
public java.lang.Object productElement(int);
descriptor: (I)Ljava/lang/Object;
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=3, args_size=2
0: iload_1
1: istore_2
2: iload_2
3: tableswitch { // 0 to 1
0: 24
1: 34
default: 44
}
24: aload_0
25: invokevirtual #55 // Method min:()I
28: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
31: goto 59
34: aload_0
35: invokevirtual #58 // Method max:()I
38: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
41: goto 59
44: new #73 // class IndexOutOfBoundsException
47: dup
48: iload_1
49: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
52: invokevirtual #76 // Method Object.toString:()LString;
55: invokespecial #79 // Method IndexOutOfBoundsException."<init>":(LString;)V
58: athrow
59: areturn
Это перебор свойств Case-класса: если
productIterator
хочет получить первое свойство, он получает значение min
. Если хочет второй элемент – значение max
. Иначе возникнет исключение IndexOutOfBoundsException
.
Все, как и в Data-классах Kotlin: чем больше свойств в case-классе, тем объемнее будет перебор, а длина байт-кода пропорциональна количеству свойств.
Знакомство с Indy
Вернемся и внимательнее рассмотрим байт-код, сгенерированный для записей Java:
Compiled from "Range.java"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
6: areturn
Invoke Dynamic (также
известный как Indy) был частью JSR 292 и предназначался для улучшения поддержки
JVM в динамических языках. После первого релиза в Java 7, invokedynamic
opcode вместе с java.lang.invoke
широко используется в динамических JVM-based
языках, например, JRuby.
Хотя Indy был
специально разработан для улучшения языковой поддержки, он предлагает гораздо
больший функционал и подходит для использования везде, где требуется динамичность. Например, лямбда-выражения Java 8 реализуются с помощью
invokedynamic
, хотя Java – это статически типизированный язык.
Пользовательский байт-код
В течение длительного времени JVM поддерживал четыре типа вызова методов:
invokestatic
для вызова статических методов,invokeinterface
для вызова интерфейсных методов,invokespecial
для вызова конструкторов,super()
илиprivatemethods
иinvokevirtual
для вызова методов экземпляра.
Несмотря на их
различия, эти типы вызовов имеют одну общую черту: мы не можем снабдить их
собственной логикой. А еще, invokedynamic
позволяет нам загружать процесс
вызова любым желаемым способом.
Как работает Indy
Когда JVM видит
вызываемую динамическую инструкцию, он вызывает специальный статический Bootstrap
Method
. Данный метод – это фрагмент кода Java, используемый для подготовки
фактической логики к вызову.

Затем метод bootstrap
возвращает экземпляр java.invoke.CallSite
. CallSite
содержит ссылку на
фактический метод, т. е. MethodHandle
. С этого момента каждый раз, когда JVM
снова видит invokedynamicinstruction
, он пропускает «медленный путь» и применяет
прямой вызов, пока что-то не изменится.
Почему Indy?
В отличие от Reflection
API
, java.lang.invoke API
более эффективен, так как JVM может полностью видеть
все вызовы. Поэтому JVM может применять всевозможные оптимизации, пока мы
избегаем «медленного пути», насколько это возможно.
Cгенерированный байт-код для записей Java не зависит от количества свойств. Таким образом, меньше байт-кода – быстрее время запуска.
Наконец, предположим,
что новая версия Java включает в себя новую реализацию метода bootstrap
. С
инструкциями invokedynamic
наше приложение может воспользоваться
преимуществами этого улучшения без перекомпиляции. Итак, у нас есть своего рода
прямая бинарная совместимость.
Методы объекта
Давайте
разберемся в байт-коде invokedynamic
:
invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
Вот что в байт-коде:
BootstrapMethods:
0: #41 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
Method arguments:
#8 Range
#48 min;max
#50 REF_getField Range.min:I
#51 REF_getField Range.max:I
Таким образом, метод 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
:
jshell> var r = new Range(0, 42)
r ==> Range[min=0, max=42]
jshell> r.getClass().isRecord()
$5 ==> true
Очевидно, что он
возвращает false
для типов без записей:
jshell> "Not a record".getClass().isRecord()
$6 ==> false
Существует также метод
getRecordComponents
, возвращающий массив RecordComponent
в том же порядке, в котором
они определены в исходной записи. Каждый java.lang.reflect.RecordComponent
представляет
собой компонент записи или переменную текущего типа записи. Например, RecordComponent.getName
возвращает имя компонента:
jshell> public record User(long id, String username, String fullName) {}
| created record User
jshell> var me = new User(1L, "johndo", "John Dou")
me ==> User[id=1, username=johndo, fullName=John Dou]
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getName).
...> forEach(System.out::println)
id
username
fullName
Похожим образом метод
getType
возвращает type-token
для каждого компонента:
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getType).
...> forEach(System.out::println)
long
class java.lang.String
class java.lang.String
Можно даже получить
дескриптор для методов доступа через getAccessor
:
jshell> var nameComponent = me.getClass().getRecordComponents()[2].getAccessor()
nameComponent ==> public java.lang.String User.fullName()
jshell> nameComponent.setAccessible(true)
jshell> nameComponent.invoke(me)
$21 ==> "John Dou"
Аннотации записей
Java позволяет добавлять аннотации к записи, если они применимы к записи или ее членам. Аннотации могут использоваться только в компонентах записи:
@Target(ElementType.RECORD_COMPONENT)
2
public @interface Param {}
Сериализация
Любая новая функция
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. Так что теперь есть всё, чтобы попробовать записи в деле.
Комментарии