JEP 401: Value Objects (Preview)
Value Objects (объекты-значения), версия Preview (предварительная версия)
| Ответственный | Dan Smith |
| Тип | Feature |
| Область | SE |
| Статус | Integrated |
| Выпуск | 28 |
| Обсуждение | valhalla dash dev at openjdk dot org |
| Трудоёмкость | XL |
| Длительность | XL |
| Связан с | JEP 539: Strict Field Initialization in the JVM (Preview) |
| Рецензенты | Alex Buckley, Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2020/08/13 19:31 |
| Обновлён | 2026/08/07 18:02 |
| Задача | 8251554 |
Аннотация
Мы вводим Value Objects. Это неизменяемые объекты без Identity (идентичность объекта). Value Objects различаются только значениями своих полей, и виртуальные машины Java могут представлять их так, чтобы повысить производительность. Это Preview-возможность языка и VM.
Цели
-
Дать разработчикам возможность выбрать модель программирования для неизменяемых данных, в которой оператор
==и все остальные операции различают объекты по значениям их полей, а не по их Identity. -
Поддержать совместимый перевод на эту модель существующих классов, которые представляют неизменяемые данные. Перевести подходящие существующие классы Java Platform API, такие как
IntegerиLocalDate, чтобы их экземпляры были Value Objects. -
Не требовать от разработчиков изучать новую семантику управления памятью или хранения переменных. Язык Java должен по-прежнему работать всего с двумя видами данных: примитивами и ссылками на объекты.
-
Дать разработчикам реализаций JVM как можно больше свободы в выборе представления неизменяемых данных, чтобы уменьшить потребление памяти, улучшить локальность и повысить эффективность сборки мусора.
Что не является целью
-
Целью не является автоматически считать экземпляры существующих классов Value Objects. Value Objects работают не во всём так же, как другие объекты, поэтому авторы классов должны явно решить, что экземпляры будут Value Objects.
-
Целью не является переработать оператор
==так, чтобы его можно было использовать вместо методаequals. Мы переопределяем==лишь настолько, насколько необходимо для работы с новым видом объектов без Identity. Обычная рекомендация в большинстве случаев сравнивать объекты методомequalsостаётся в силе. -
Целью не является ввести возможность
struct, как в языках C и C#. -
Целью не является изменить работу с примитивными типами. Во многом примитивы ведут себя как Value Objects, но это отдельное понятие.
-
Целью не является гарантировать какую-либо конкретную стратегию оптимизации или размещения в памяти. Это предложение делает возможными многие оптимизации, но сначала мы реализуем лишь некоторые из них. Некоторые оптимизации, например размещения, исключающие
null, станут возможны только после будущих улучшений языка и JVM.
Мотивация
Многие виды простых значений данных неизменяемы: комплексные числа, цвета пикселей, время, даты и т. д. Обычно мы моделируем такие значения классами, которые содержат ровно столько логики, сколько нужно для создания, проверки и преобразования экземпляров, и определяют методы equals, hashCode и toString, чтобы эквивалентные экземпляры были взаимозаменяемы.
Например, класс LocalDate из Platform API моделирует даты:
jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
d1 ==> 1996-01-23
jshell> LocalDate d2 = d1.plusYears(30)
d2 ==> 2026-01-23
jshell> LocalDate d3 = d2.minusYears(30)
d3 ==> 1996-01-23
jshell> d1.equals(d3)
$4 ==> true
Интуитивно суть объекта LocalDate заключается в значениях года, месяца и дня. Но в языке Java суть любого объекта — его Identity. Каждый раз, когда метод LocalDate.of вызывает new LocalDate(...), JVM создаёт новый объект с уникальной Identity, отличимый от любого другого объекта в системе.
Проще всего увидеть Identity объекта с помощью оператора ==:
jshell> d1 == d3
$6 ==> false
Хотя d1 и d3 представляют одну и ту же тройку «год-месяц-день» (то есть d1.equals(d3) даёт true), это два объекта с разными Identity.
Неизменяемым данным Identity не нужна
Для изменяемых объектов Identity важна: с её помощью можно различить два объекта, у которых сейчас одинаковое состояние, но в будущем оно может стать разным. Возьмём текстовый редактор, в котором строки текста представлены экземплярами класса Line. Единственное поле класса — список символов, который изменяется, когда пользователь редактирует строку. Два объекта Line могут содержать эквивалентные списки символов и, следовательно, тоже быть эквивалентными, но это будет совпадением: когда пользователь изменит одну из строк, приложение изменит список символов этого объекта, но не другого, и нужный объект оно найдёт по Identity.
Иначе говоря, изменяемые объекты не могут быть взаимозаменяемыми — а большинство неизменяемых значений данных взаимозаменяемы. Между двумя объектами LocalDate, представляющими 1996-01-23, нет практической разницы, потому что их состояние зафиксировано и не меняется. Они представляют одно и то же значение и сейчас, и в будущем. Различать эти два объекта по Identity не нужно.
На самом деле, когда объекты неизменяемы и взаимозаменяемы, Identity объектов только сбивает с толку. Многие из нас помнят, как по невнимательности сравнивали объекты с помощью ==, как в d1 == d3 выше, и недоумевали, получив false, хотя состояние и поведение объектов кажутся одинаковыми.
Хуже того, Identity объектов может выставлять напоказ случайные решения реализации, которые приводят к неожиданному поведению. Например, класс Integer использует кэш, чтобы не создавать лишних объектов Integer с уникальными Identity. Так, обычно существует всего один объект Integer, представляющий значение 1. Однако кэш имеет фиксированный размер и не охватывает бо́льшие значения int, такие как 1996:
jshell> Integer i = 1, j = 1;
i ==> 1
j ==> 1
jshell> i == j
$3 ==> true
jshell> Integer x = 1996, y = 1996;
x ==> 1996
y ==> 1996
jshell> x == y
$6 ==> false
Такого неожиданного результата можно было бы избежать, если бы объекты, которые по состоянию и поведению взаимозаменяемы, можно было освободить от унаследованного требования иметь разные Identity.
Identity объектов дорого обходится во время выполнения
Язык Java требует, чтобы каждый объект имел Identity, нужна она или нет, и это требование мешает производительности. Из-за него JVM выделяет память под каждый новый объект, тем самым отличая его от всех остальных объектов в системе, и обращается к этой памяти при каждом использовании объекта.
Например, пусть программа создаёт массивы значений int и ссылок LocalDate:
jshell> int[] ints = { 1996, 2006, 1996, 1, 23 }
ints ==> int[5] { 1996, 2006, 1996, 1, 23 }
jshell> LocalDate[] dates = { d1, d1, d2, null, d3 }
dates ==> LocalDate[5] { 1996-01-23, 1996-01-23, 2026-01-23,
null, 1996-01-23 }
Массив int можно представить одним блоком памяти, содержащим значения int:
+----------+
| int[5] |
+----------+
| 1996 |
| 2006 |
| 1996 |
| 1 |
| 23 |
+----------+
Массив LocalDate, напротив, приходится представлять блоком памяти с последовательностью указателей, каждый из которых ссылается на другой блок памяти, представляющий объект LocalDate:
+--------------+
| LocalDate[5] |
+--------------+
| 87fa1a09 | -----------------------> +-----------+
| 87fa1a09 | -----------------------> | LocalDate |
| 87fb4ad2 | ------> +-----------+ +-----------+
| 00000000 | | LocalDate | | y=1996 |
| 87fb5366 | --- +-----------+ | m=1 |
+--------------+ | | y=2026 | | d=23 |
v | m=1 | +-----------+
+-----------+ | d=23 |
| LocalDate | +-----------+
+-----------+
| y=1996 |
| m=1 |
| d=23 |
+-----------+
Данные, представленные массивом LocalDate, ненамного сложнее массива int (тройка «год-месяц-день» фактически занимает 48 бит примитивных данных), и всё же занимают гораздо больше памяти из-за указателей и выделенных объектов.
Хуже того, когда программа перебирает массив LocalDate, она может разыменовывать каждый указатель. Современные процессоры повышают производительность, кэшируя небольшие фрагменты памяти, которые называются строками кэша. Разные объекты LocalDate могут оказаться по далеко отстоящим друг от друга адресам памяти, например если они были созданы в разное время или их переместил сборщик мусора. Получившаяся плохая локальность ссылок может снизить производительность, поскольку при каждом разыменовании придётся загружать из памяти другую строку кэша.
От безысходности мы можем попытаться повысить производительность и писать код, который создаёт как можно меньше объектов. Так снижается нагрузка на сборщик мусора и улучшается локальность ссылок. Например, вместо объектов LocalDate можно было бы моделировать даты значениями int, в которых хранится число дней с 1970-01-01. К сожалению, при таком подходе мы теряем те возможности классов, которые делают код на Java таким удобным в сопровождении: осмысленные имена, закрытое состояние, проверку данных в конструкторах, вспомогательные методы и т. д. Слишком легко забыть (а коллега может просто не знать), что даты int отсчитываются от 1970-01-01, а не от какой-то другой даты, и это приводит к ошибкам, которые трудно диагностировать.
Программирование без Identity
Каждый день создаются триллионы объектов Java, и у каждого есть уникальная Identity. Мы должны дать разработчикам возможность выбирать, каким объектам в программе нужна Identity, а каким нет. Автор класса вроде LocalDate, который представляет простые неизменяемые данные, должен иметь возможность отказаться от Identity. Два объекта LocalDate, представляющие дату 1996-01-23, должны быть неразличимы, так же как неразличимы два значения int, представляющие число 4.
Отказываясь от Identity, разработчики выбирают модель программирования, которая объединяет лучшее из двух миров: абстракцию классов, а также простоту и производительность примитивов.
В будущем на этой модели программирования будут построены новые API платформы Java, например классы, кодирующие разные виды целых чисел и чисел с плавающей точкой, и новые возможности языка Java, например пользовательские преобразования и математические операторы для неизменяемых данных.
Описание
Для моделирования простых неизменяемых данных мы вводим Value Objects. Value Objects — это экземпляры Value Classes (классы-значения), которые объявляются с модификатором value. Классы без модификатора value — это identity-классы, а их экземпляры — identity-объекты.
Программы на Java работают с объектами через ссылки. Ссылка на объект хранится в переменной, и по ней можно найти поля объекта. Традиционно ссылки в JVM представлены указателями на области памяти и поэтому кодируют уникальную Identity каждого объекта. Каждый вызов оператора new выделяет новый объект в новом блоке памяти и возвращает уникальную ссылку. Кроме того, традиционно оператор == сравнивает ссылки, сравнивая указатели, поэтому разные ссылки на два объекта не равны по ==, даже если сами объекты взаимозаменяемы.
С Value Objects всё иначе. Ссылка на объект value-класса хранится в переменной, и по ней можно найти поля объекта. Однако в JVM она может быть представлена не указателем и поэтому не кодирует уникальную Identity объекта. Для value-класса вызов оператора new может не выделять новый объект: вместо этого он может вернуть ссылку на существующий объект или даже ссылку, которая непосредственно содержит сам объект. Оператор == сравнивает ссылки на Value Objects, сравнивая значения полей объектов, поэтому ссылки на два объекта равны по ==, если значения полей у объектов одинаковые.
Если использовать Value Objects для неизменяемых данных, можно сэкономить память и повысить производительность. Программа не может различить два объекта value-класса с одинаковыми значениями полей, даже с помощью оператора ==, поэтому JVM может менять размещение таких объектов в памяти, не влияя на программу. Например, JVM может хранить поля объекта value-класса на стеке или даже в регистрах процессора, а не в куче.
Value Objects — это Preview-возможность языка и VM, по умолчанию она отключена
Чтобы использовать эту возможность в JDK 28, необходимо включить Preview-возможности:
-
Скомпилируйте программу с
javac --release 28 --enable-preview Main.javaи запускайте её сjava --enable-preview Main; или -
При использовании средства запуска исходного кода запускайте программу с
java --enable-preview Main.java; или -
При использовании jshell запускайте его с
jshell --enable-preview.
Некоторые классы Java Platform API становятся value-классами только при включённых Preview-возможностях; в противном случае они ведут себя так же, как в JDK 27.
Например, если ваш код обращается к классу LocalDate и вы компилируете его с отключёнными Preview-возможностями, компилятор использует существующую версию LocalDate с identity-объектами, и запускать программу с включёнными Preview-возможностями не нужно. Если же вы компилируете с включёнными Preview-возможностями, компилятор использует новую версию LocalDate с Value Objects, и программу нужно запускать с включёнными Preview-возможностями. При включённых Preview-возможностях версию LocalDate с identity-объектами использовать нельзя.
Содержание
Программирование с Value Objects
Объявление value-классов
Сравнение Value Objects
Value-классы и наследование
Безопасное конструирование Value Objects
Другие улучшения конструирования
Унаследованные методыjava.lang.Object
Переход на value-классы
Value-классы в Java Platform
Оптимизации Value Objects во время выполнения
Flattening (плоское размещение) ссылок
Скаляризация ссылок
Когда возможны Flattening и скаляризация?
Программирование с Value Objects
В Java Platform API 30 классов теперь объявлены как value-классы. Примеры:
| Пакет | Классы |
|---|---|
| java.lang | Integer,
Long,
Float,
Double,
Byte,
Short,
Character,
Boolean
|
| java.util | Optional,
OptionalInt,
OptionalLong,
OptionalDouble
|
| java.time | LocalDate,
LocalTime,
LocalDateTime,
ZonedDateTime,
Duration
|
Все экземпляры этих классов являются Value Objects. Это относится и к упакованным примитивам — экземплярам Integer, Long и так далее. Оператор == сравнивает Value Objects по значениям их полей, поэтому, например, два объекта Integer дают ==, если представляют одно и то же примитивное значение:
$ jshell --enable-preview
| Welcome to JShell -- Version 28-internal
| For an introduction type: /help intro
jshell> Integer x = 1996, y = 1996;
x ==> 1996
y ==> 1996
jshell> x == y
$3 ==> true
Аналогично два объекта LocalDate дают ==, если у них одинаковые значения года, месяца и дня:
jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
d1 ==> 1996-01-23
jshell> LocalDate d2 = d1.plusYears(30)
d2 ==> 2026-01-23
jshell> LocalDate d3 = d2.minusYears(30)
d3 ==> 1996-01-23
jshell> d1 == d3
$7 ==> true
С помощью нового метода Objects.hasIdentity можно узнать, является ли объект identity-объектом:
jshell> Objects.hasIdentity(d1)
$8 ==> false
Класс String из-за того, что его API и реализация в некоторых местах зависят от Identity объектов, не является value-классом, поэтому экземпляры String всегда являются identity-объектами:
jshell> String s = "abcd"
s ==> "abcd"
jshell> Objects.hasIdentity(s)
$10 ==> true
jshell> String t = "aabcd".substring(1)
t ==> "abcd"
jshell> s == t
$12 ==> false
В большинстве отношений Value Objects работают так же, как объекты в языке работали всегда: у них есть поля и методы, работа с ними идёт через ссылки, и их ссылки могут быть равны null.
Однако некоторые операции, зависящие от Identity, для Value Objects не поддерживаются, в том числе синхронизация:
jshell> synchronized (d1) { d1.notify(); }
| Error:
| unexpected type
| required: a type with identity
| found: java.time.LocalDate
| synchronized (d1) { d1.notify(); }
| ^--------------------------------^
jshell> Object o = d1
o ==> 1996-01-23
jshell> synchronized (o) { o.notify(); }
| Exception java.lang.IdentityException: Cannot synchronize on
an instance of value class java.time.LocalDate
| at (#19:1)
Разработчики реализаций JVM вправе кодировать ссылки на Value Objects во время выполнения так, чтобы оптимизировать расход памяти, локальность и эффективность сборки мусора. Например, выше мы видели следующий массив, реализованный с помощью указателей на объекты в куче:
jshell> LocalDate[] dates = { d1, d1, d2, null, d3 }
dates ==> LocalDate[5] { 1996-01-23, 1996-01-23, 2026-01-23,
null, 1996-01-23 }
Теперь, когда у объектов LocalDate нет Identity, JVM не обязана представлять ссылки на них указателями; вместо этого она может кодировать поля объектов LocalDate прямо в ссылках. Каждый элемент массива dates можно представить 64-битным словом, которое указывает, равна ли ссылка null, а если нет, то непосредственно хранит значения полей года, месяца и дня этого экземпляра value-класса:
+--------------+
| LocalDate[5] |
+--------------+
| 1|1996|01|23 |
| 1|1996|01|23 |
| 1|2026|01|23 |
| 0|0000|00|00 |
| 1|1996|01|23 |
+--------------+
Характеристики производительности такого массива LocalDate могут быть близки к характеристикам обычного массива int: меньший расход памяти и лучшая локальность ссылок:
+----------+
| int[5] |
+----------+
| 1996 |
| 2006 |
| 1996 |
| 1 |
| 23 |
+----------+
Эта оптимизация — лишь один пример; некоторые value-классы, например LocalDateTime, слишком велики, чтобы воспользоваться именно этим приёмом. Тем не менее отсутствие Identity позволяет разработчикам реализаций JVM оптимизировать ссылки на Value Objects многими способами.
Объявление value-классов
Вы можете объявлять собственные value-классы, применяя модификатор value к любому классу, экземпляры которого должны быть неизменяемыми и взаимозаменяемыми:
-
Неизменяемые — все поля экземпляра класса являются
final, и значение, представленное экземпляром, со временем не меняется; и -
Взаимозаменяемые — нет необходимости различать два отдельно созданных экземпляра, представляющих одно и то же значение.
Если к классу применён модификатор value, поля класса неявно являются final. Сам класс тоже неявно является final, поэтому его нельзя расширить. Поскольку класс является final, его методы нельзя переопределить.
Ограничений на типы полей в value-классе нет. Поля могут хранить ссылки на другие Value Objects или на identity-объекты, например строки.
Классы Records (записи) являются final, и все их поля являются final, поэтому они часто хорошо подходят на роль value-классов:
jshell> value record Point(int x, int y) {}
| created record Point
jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]
jshell> Objects.hasIdentity(p)
$3 ==> false
jshell> new Point(17, 3) == p
$4 ==> true
У многих классов экземпляры неизменяемые и взаимозаменяемые, но эти классы не могут быть классами Records, потому что их поля не соответствуют в точности аргументам конструктора, то есть они не являются прозрачными. Такие классы могут использовать закрытые поля внутри более эффективно, чем это видно снаружи через открытые методы. Например, класс может представлять сумму в евро и центах одним полем long ради экономии памяти; он не может быть value-record, но всё равно может быть value-классом:
value class EURCurrency {
private long cs; // implicitly final
private EURCurrency(long cs) { this.cs = cs; }
public EURCurrency(long e, int c, boolean neg) {
this(neg ? -e * 100 - c : e * 100 + c);
}
public EURCurrency(long e, int c) { this(e, c, false); }
public long euros() { return Math.abs(cs) / 100; }
public int cents() { return (int) Math.abs(cs) % 100; }
public boolean negative() { return cs < 0; }
public String toString() {
var prefix = negative() ? "-€" : "€";
return "%s%d,%d".formatted(prefix, euros(), cents());
}
}
Сравнение Value Objects
Традиционно оператор == служил для проверки того, имеют ли два объекта, на которые указывают ссылки, одну и ту же Identity.
После появления Value Objects оператор == служит для проверки того, неразличимы ли два объекта, на которые указывают ссылки. Поскольку identity-объекты по определению различаются своей Identity, это означает, что для identity-объектов оператор == работает в Java 28 так же, как со времён Java 1.0: он проверяет, указывают ли ссылки на один и тот же объект — в одном и том же месте памяти — или обе ссылки равны null.
При сравнении двух Value Objects оператор == проверяет, указывают ли ссылки на экземпляры одного и того же класса с одинаковыми значениями полей. То есть два Value Objects неразличимы, если:
-
они являются экземплярами одного и того же value-класса,
-
их поля примитивных типов хранят одинаковые битовые последовательности и
-
их поля ссылочных типов неразличимы при рекурсивном применении оператора
==.
В этом случае JVM может свободно заменить одну ссылку другой, и никакой код не сможет заметить разницу.
Оператор == и метод equals для Value Objects часто дают одинаковые результаты. Однако у некоторых value-классов экземпляры могут быть взаимозаменяемыми (то есть equals), даже если значения их полей различаются (то есть не ==). Чтобы проверить, представляют ли два Value Objects одно и то же значение, используйте метод equals. При объявлении класса определяйте equals так, чтобы для взаимозаменяемых экземпляров он всегда возвращал true.
Приведённый ниже value-класс Substring показывает, как == и equals могут расходиться для некоторых Value Objects. Этот класс представляет подстроку строки, не выделяя в памяти новый char[]. Внутреннее состояние экземпляра Substring — исходная строка и две координаты, а значение, представленное экземпляром, — последовательность символов, которую возвращает toString. Поэтому два экземпляра могут представлять одну и ту же последовательность символов (то есть быть equals), даже если их внутреннее состояние различается (то есть не ==).
value class Substring {
private String str;
private int start, end;
public Substring(String s, int i, int j) {
str = s; start = i; end = j;
}
public String toString() {
return str.substring(start, end);
}
public boolean equals(Object o) {
return o instanceof Substring && toString().equals(o.toString());
}
public int hashCode() {
return Objects.hash(Substring.class, toString());
}
}
jshell> Substring sub1 = new Substring("ringing", 1, 4);
sub1 ==> ing
jshell> Substring sub2 = new Substring("ringing", 4, 7);
sub2 ==> ing
jshell> sub1.equals(sub2)
$3 ==> true
jshell> sub1 == sub2
$4 ==> false
Результаты оператора == и метода equals также могут различаться, если поля двух Value Objects ссылаются на разные identity-объекты, взаимозаменяемые с точки зрения equals:
jshell> String r = "bringing".substring(1);
r ==> ringing
jshell> r == "ringing"
$6 ==> false
jshell> Substring sub3 = new Substring(r, 1, 4);
sub3 ==> ing
jshell> sub1.equals(sub3)
$8 ==> true
jshell> sub1 == sub3 // tests sub1.str == sub3.str
$9 ==> false
Ещё одна ситуация, в которой оператор == и equals могут расходиться, — когда у Value Objects есть поля float или double. Примитивные типы с плавающей точкой поддерживают несколько значений NaN. Большинство операций с плавающей точкой считают эти значения NaN взаимозаменяемыми, но поскольку каждое значение отличается, Value Objects, оборачивающие разные значения NaN, различимы оператором ==. При объявлении value-класса вы должны решить, имеет ли это различие значение для метода equals. Например, в value-record поведение equals по умолчанию считает все значения NaN взаимозаменяемыми:
jshell> value record Length(float val) {}
| created record Length
jshell> Length l1 = new Length(Float.intBitsToFloat(0x7ff80000))
l1 ==> Length[val=NaN]
jshell> Length l2 = new Length(Float.intBitsToFloat(0x7ff80001))
l2 ==> Length[val=NaN]
jshell> l1.equals(l2)
$4 ==> true
jshell> l1 == l2
$5 ==> false
jshell> Integer.toHexString(Float.floatToRawIntBits(l1.val()))
$6 ==> "0x7ff80000"
jshell> Integer.toHexString(Float.floatToRawIntBits(l2.val()))
$7 ==> "0x7ff80001"
(Подробнее о разных видах эквивалентности значений с плавающей точкой см. спецификацию класса Double.)
Поскольку оператор == рекурсивно сравнивает поля ссылочных типов у Value Objects, его применение к двум Value Objects может потребовать неограниченного числа сравнений. В следующем примере, чтобы определить, неразличимы ли объекты, нужно полностью обойти две глубоко вложенные структуры объектов Box:
jshell> value record Box(Object val) {}
| created record Box
jshell> var b1 = new Box(new Box(new Box(new Box(l1))))
b1 ==> Box[val=Box[val=Box[val=Box[val=Length[val=NaN]]]]]
jshell> var b2 = new Box(new Box(new Box(new Box(l2))))
b2 ==> Box[val=Box[val=Box[val=Box[val=Length[val=NaN]]]]]
jshell> b1.equals(b2)
$11 ==> true
jshell> b1 == b2
$12 ==> false
Конструкторы value-классов ограничены, как описано ниже, так что рекурсивное применение оператора == к Value Objects никогда не приведёт к бесконечному циклу. Но глубокие сравнения могут занимать много времени и даже вызвать StackOverflowError.
Value-классы и наследование
Каждый value-класс, как и каждый identity-класс, принадлежит иерархии классов с корнем java.lang.Object. Общего суперкласса java.lang.Value для всех value-классов нет.
Value-классы могут реализовывать интерфейсы. Поэтому переменные, объявленные с типом интерфейса или с типом Object, могут хранить ссылки как на Value Objects, так и на identity-объекты:
jshell> Comparable<?> comp = LocalDate.of(1996, 1, 23)
comp ==> 1996-01-23
jshell> Objects.hasIdentity(comp)
$2 ==> false
jshell> comp = "abc"
comp ==> "abc"
jshell> Objects.hasIdentity(comp)
$4 ==> true
По умолчанию value-класс неявно является final и не может быть расширен. Однако value-класс можно объявить abstract, и тогда его можно расширять другими классами и переопределять его методы. Поля абстрактного value-класса неявно являются final, как и в конкретном value-классе. Методы абстрактного value-класса могут быть помечены abstract, как и в абстрактном identity-классе.
Объявление абстрактного value-класса означает, что самому классу Identity не нужна. Его подклассы могут быть value-классами или identity-классами. (Модификатор value у абстрактного value-класса можно понимать как совместимый с value.)
Value-класс может расширять либо java.lang.Object, либо абстрактный value-класс, но не identity-класс. (Класс Object в этом отношении уникален: он не является ни абстрактным, ни value-классом, и экземпляры, создаваемые new Object(), имеют Identity, но при этом он допускает расширение value-классами.)
Многие существующие абстрактные классы, если они рассчитаны на открытое расширение, хорошо подходят на роль абстрактных value-классов. Например, у абстрактного класса Number нет полей и нет кода, зависящего от возможностей, связанных с Identity, поэтому его можно безопасно перевести в абстрактный value-класс:
abstract value class Number implements Serializable {
public abstract int intValue();
public abstract long longValue();
public byte byteValue() { return (byte) intValue(); }
...
}
Integer, который является value-классом, и java.math.BigInteger, который является identity-классом, оба расширяют Number:
jshell> Number num = 123
num ==> 123
jshell> Objects.hasIdentity(num)
$2 ==> false
jshell> num = BigInteger.valueOf(123)
num ==> 123
jshell> Objects.hasIdentity(num)
$4 ==> true
Абстрактный value-класс можно объявить sealed, чтобы ограничить классы, которые могут его расширять:
sealed abstract value class UserID
permits EmailID, PhoneID, UsernameID
{
...
}
value class EmailID extends UserID {
private String name, domain; ...
}
value class PhoneID extends UserID {
private String digits; ...
}
value class UsernameID extends UserID {
private String name; ...
}
Безопасное конструирование Value Objects
Конструкторы инициализируют только что созданные объекты, задавая значения их полей. Поскольку у Value Objects нет Identity, другой код должен видеть их только с полностью инициализированными значениями полей.
Чтобы это гарантировать, поля экземпляра value-класса должны быть заданы до того, как объект станет доступен другому коду. Это также не позволяет двум Value Objects ссылаться друг на друга прямо или косвенно и тем самым гарантирует, что вызов оператора == никогда не приведёт к бесконечному циклу.
Объект в процессе конструирования является личиночным — он уже создан, но ещё не полностью сформирован. С личиночными объектами всегда нужно обращаться осторожно: если личиночный объект становится доступен коду за пределами конструктора, инварианты объекта могут ещё не выполняться, а этот код может даже увидеть изменение полей final.
Традиционно конструктор начинает процесс инициализации с вызова конструктора суперкласса, super(...). Если конструктор не делает этого явно, компилятор Java вставляет вызов super() в начало тела конструктора. После возврата из конструктора суперкласса конструктор подкласса задаёт объявленные в нём поля экземпляра и выполняет другие задачи инициализации. При такой схеме объект с полностью неинициализированными полями подкласса подвержен утечке личиночного объекта в любом конструкторе суперкласса.
Flexible Constructor Bodies (гибкие тела конструкторов), появившиеся в Java 25, делают инициализацию безопаснее: они позволяют задавать поля и выполнять другой код до вызова super(...). С этой возможностью процесс инициализации объекта состоит из двух фаз: раннее конструирование до вызова super(...) и позднее конструирование после него.
В фазе раннего конструирования утечка larval-объекта (объекта, конструирование которого ещё не завершено) невозможна: конструктор может задавать поля larval-объекта, но не может вызывать методы экземпляра или как-либо иначе использовать this. Поэтому поля, инициализированные в фазе раннего конструирования, получают значения раньше, чем их можно прочитать, даже если суперкласс допускает утечку larval-объекта. В частности, изменение final-полей никогда не может быть замечено.
В value-классе код конструктора всегда выполняется в фазе раннего конструирования. Компилятор Java вставляет вызов super() в конец тела конструктора, а не в начало. Попытки вызвать методы экземпляра или как-либо иначе использовать this завершатся ошибкой:
value class Name {
String name;
int length;
private int strLength() {
return name.length();
}
Name(String n) {
name = n;
length = strLength(); // Error, invokes this.strLength()
}
}
Поля экземпляра, объявленные с выражениями-инициализаторами, получают значения в начале конструктора, в фазе раннего конструирования. Блоки инициализации экземпляра — редко используемая возможность — выполняются в фазе позднего конструирования; задавать в них поля экземпляра value-классов нельзя.
Если конструктору value-класса нужен код, работающий с this, можно явно вызвать super(...) или this(...), чтобы обозначить переход от фазы раннего конструирования к фазе позднего конструирования. Однако до вызова super(...) всем полям класса должны быть присвоены значения без обращения к this:
value class Name {
String name;
int length;
private static int strLength(String n) {
return n.length();
}
Name(String n) {
name = n;
length = strLength(name); // OK, strLength is now static
super(); // All fields must be set at this point
System.out.println("Name: " + this);
}
}
Другие улучшения конструирования
Мы ослабляем некоторые ограничения на доступ к полям всех larval-объектов, будь то объекты value-классов или identity-объекты. В Java 27 поля нового объекта можно было задавать в фазе раннего конструирования, но не читать. В Java 28 при включённых Preview-возможностях эти поля в фазе раннего конструирования можно и записывать, и читать. Как и прежде, до фазы позднего конструирования запрещено обращаться к унаследованным полям, вызывать методы экземпляра или передавать this другому коду.
Кроме того, ради простоты и более высокой производительности в Java 28 при включённых Preview-возможностях все record-классы, будь то value-записи или identity-записи, следуют тем же правилам безопасного конструирования, что и value-классы. Конструктор record-класса всегда выполняется в фазе раннего конструирования. Поля записи должны получить значения прежде, чем их можно будет увидеть, поэтому пользователи класса всегда могут рассчитывать, что обращения к компонентам дают согласованные результаты.
Это изменение нарушает совместимость на уровне исходного кода для объявлений конструкторов identity-записей, хотя мы ожидаем, что на практике такие несовместимости будут редкими. Например, этот record-класс не компилируется, потому что его канонический конструктор обращается к this в фазе раннего конструирования:
record Node(String label, List<Node> edges) {
static void nullCheck(Object arg, Object owner) {
if (arg == null) {
String msg = "null arg for " + owner.toString();
throw new IllegalArgumentException(msg);
}
}
public Node {
nullCheck(label, this); // Error with --enable-preview
nullCheck(edges, this); // Error with --enable-preview
}
}
Такие несовместимости будут редкими, потому что, как видно из этого примера, большинство попыток использовать this в конструкторе записи — это ошибки: когда этот код вызывает toString(), поля Node ещё не заданы. Анализ существующих объявлений record-классов в большом корпусе исходного кода подтверждает, что record-классы редко нарушают новые правила.
Если конструктору записи действительно необходимо обращаться к this, можно вставить явный вызов super(), но перед ним, в фазе раннего конструирования, нужно явно задать поля записи.
Унаследованные методы java.lang.Object
Как и любой класс, value-класс наследует от java.lang.Object методы, в том числе equals, hashCode и toString, если они не переопределены. Традиционно эти методы зависят от Identity, но при работе с объектом value-класса они используют значения полей объекта. А именно:
-
Унаследованная реализация
Object.equalsпроверяет с помощью оператора==, неразличимы ли объекты. Для value-класса такое поведениеequalsможет оказаться правильным, но если нет, класс должен переопределить методequals. -
Унаследованная реализация
Object.hashCodeвычисляет хэш по значениям полей объекта. (Это значение можно также вычислить с помощьюSystem.identityHashCode— неудачное название, доставшееся от прошлого.) Как обычно, если value-класс переопределяетequals, он должен переопределять иhashCode. -
Унаследованная реализация
Object.toStringвозвращает строку вида"ClassName@hashCode". Поскольку value-классы представляют неизменяемые данные, им следует переопределятьtoString, чтобы нагляднее передавать значения, которые представляют их экземпляры.
В value-записи, как и во всех записях, методы equals, hashCode и toString по умолчанию рекурсивно применяют те же операции к компонентам записи.
Ещё несколько методов Object взаимодействуют с объектами value-классов:
-
Для value-класса, реализующего
Cloneable, методObject.cloneсоздаёт объект, неотличимый от исходного. Обычное ожидание, чтоx.clone() != x, для объектов value-классов не имеет смысла. Если вы объявляете value-класс, содержащий ссылки на identity-объекты, подумайте о переопределении методаclone, чтобы создавать глубокие копии этих объектов. -
Методы
waitиnotifyтребуют, чтобы объект был заблокирован в текущем потоке. Поскольку синхронизироваться на объекте value-класса невозможно, попытки вызвать эти методы всегда завершаются исключениемIllegalMonitorStateException. -
Сборщик мусора никогда не вызывает метод
finalizeобъекта value-класса.javacвыдаёт предупрежденияidentityдля value-классов, которые переопределяютfinalize.
Переход на value-классы
Value-классы и записи — полезные инструменты для любого класса, моделирующего простые неизменяемые данные.
Как правило, если классу с неизменяемым состоянием не нужна Identity, ему, вероятно, стоит добавить модификатор value. Это относится и к абстрактным классам, у которых часто вообще нет состояния и которые не должны навязывать своим подклассам ненужное требование Identity. (Некоторые абстрактные классы определяют API, изменяемый по своей природе, даже если не объявляют изменяемого состояния, и поэтому их не следует делать value-классами. Некоторые конкретные классы вообще не моделируют данные — например, они могут быть рассчитаны на создание только одного экземпляра.)
Если класс объявлен как final или abstract и имеет только поля final, то добавление или удаление ключевого слова value — изменение, совместимое на уровне двоичного кода.
Перевод identity-класса в value-класс всё же несёт риски несовместимости на уровне исходного кода и поведения, которые стоит учесть:
-
Если у класса есть публичные конструкторы, существующие клиенты могут полагаться на них, чтобы создавать объекты, заведомо отличимые с помощью оператора
==от любого другого объекта. Превращение класса в value-класс сделает такую логику неверной, что может привести к ошибкам во время выполнения.Если эта несовместимость вызывает серьёзные опасения, может иметь смысл объявить публичные конструкторы устаревшими и рекомендовать вместо них фабричные методы. Например, в Java 9 мы объявили устаревшими конструкторы
Integer,Longи т. д. и рекомендовали вместо них соответствующие фабричные методыInteger.valueOf,Long.valueOfи т. д. -
Если существующие клиенты синхронизируются на экземплярах класса, то после перехода это будет завершаться ошибкой — либо ошибкой компиляции, либо исключением
IdentityExceptionво время выполнения. Этот риск несовместимости выше для классов с публичными конструкторами, поскольку клиенты могут полагаться на эти конструкторы, чтобы создавать уникальные экземпляры для блокировки. -
Если методы
equalsиhashCodeещё не переопределены, после перехода они будут вести себя иначе. Хороший кандидат на переход переопределяет эти методы до перехода, чтобы их поведение не зависело от Identity. -
Если класс инкапсулирует конфиденциальные данные, учитывайте риск раскрыть эти данные через оператор
==илиSystem.identityHashCode. Злоумышленник может с помощью этих операций попытаться вывести данные, хранящиеся в экземпляре. Объекты value-классов не предназначены для защиты конфиденциальных данных от подобных атак. -
Если класс реализует
Serializable, ему может потребоваться особая обработка, как описано ниже. Кроме того, перевод его в value-класс может нарушить некоторые способы использования API рефлексии и сборки мусора, которые также описаны ниже, или другой специализированный код, чувствительный к Identity.
Value-классы в платформе Java
В API платформы Java 30 классов теперь объявлены как value-классы:
| Пакет | Классы |
|---|---|
java.lang |
Integer,
Long,
Float,
Double,
Byte,
Short,
Character,
Boolean,
Number,
Record
|
java.util |
Optional,
OptionalInt,
OptionalLong,
OptionalDouble
|
java.time |
LocalDate,
LocalTime,
LocalDateTime,
ZonedDateTime,
OffsetTime,
OffsetDateTime,
Duration,
Instant,
Period,
Year,
YearMonth,
MonthDay
|
java.time.chrono |
MinguoDate,
HijrahDate,
JapaneseDate,
ThaiBuddhistDate
|
Чтобы свести к минимуму риски несовместимости, спецификации этих классов давно не рекомендуют полагаться на идентичность их экземпляров, и сами классы давно определены как value-based (основанные на значениях). Спецификации также не рекомендовали или даже не позволяли создавать экземпляры через конструкторы. Начиная с Java 16, предупреждения для value-based-классов не рекомендуют синхронизироваться на экземплярах этих классов.
Подавляющее большинство API платформы без проблем работает с объектами value-классов. Методы, принимающие параметры типа Object или Object[], принимают объекты value-классов. Почти везде, где нужно предоставить реализацию интерфейса, этой реализацией может быть value-класс. Обобщённые типы, такие как List<T> и Comparable<T>, можно параметризовать value-классами в качестве аргументов типа.
Другие изменения в API платформы дополнительно поддерживают объекты value-классов:
-
Два новых метода класса
java.util.Objects,hasIdentityиrequireIdentity, позволяют отличать identity-объекты от объектов value-классов. -
Новая константа в
java.lang.reflect.AccessFlag,IDENTITY, показывает, является ли класс identity-классом.Является ли класс identity-классом или value-классом, записывается в его class-файле. У identity-классов установлен флаг
ACC_IDENTITY, у value-классов — нет. Этот флаг заменяет устаревший флагACC_SUPER. Спецификация JVM всегда рекомендовала компиляторам и инструментам устанавливать флагACC_SUPERв class-файлах, поэтому по умолчанию компиляторы и инструменты будут и дальше устанавливать этот флаг в новых class-файлах и, следовательно, генерировать identity-классы. -
Сериализация value-записей работает автоматически, а сериализация value-классов, не являющихся записями, требует ручного вмешательства. Value-классы, реализующие
Serializable, должны реализовывать методыwriteReplaceиreadResolve, чтобы вместо самого объекта value-класса сериализовался и десериализовался объект-заменитель. Если эти методы не реализованы, попытки сериализовать или десериализовать объект value-класса завершатся исключениемInvalidClassException.Эти методы необходимо реализовывать, потому что value-классы компилируются с полями со строгой инициализацией, а десериализация не может безопасно инициализировать такие поля. Объекты value-классов можно создавать, а их поля инициализировать, только вызовом конструктора. Мы ожидаем, что в будущем механизм сериализации будет улучшен так, чтобы сериализуемые value-классы могли сериализоваться и десериализоваться автоматически.
javacвыдаёт предупрежденияserialдля value-классов, сериализация которых завершится ошибкой во время выполнения. -
Изменение полей объекта value-класса с помощью глубокой рефлексии, реализованной в методах
setAccessibleиsetAPIjava.lang.reflect.Field, не поддерживается. Библиотеки, изменяющие поляfinalс помощью глубокой рефлексии, несовместимы с безопасным конструированием. Им не разрешается изменять поля объектов value-классов, даже если в командной строке указан параметр--enable-final-field-mutation. Библиотеки должны инициализировать экземпляры value-класса с помощью конструкторов этого класса. -
API сборки мусора в пакете
java.lang.refи классеjava.util.WeakHashMapнельзя использовать с объектами value-классов. Попытка создать объектыReferenceдля объектов value-классов приведёт к выбросу исключенияIdentityException.Начиная с JDK 25,
javacвыдаёт предупрежденияidentity, когда value-based-классы используются с этими API. Начиная с JDK 28,javacвыдаёт предупрежденияidentityи тогда, когда с этими API используются value-классы.
Оптимизации объектов value-классов во время выполнения
Во время выполнения JVM может оптимизировать объекты value-классов, кодируя ссылки на них в более компактной форме, чем ссылки на identity-объекты. Вместо того чтобы выделять место в куче для объекта value-класса, JVM может плоско разместить и скаляризовать ссылку на объект.
-
Flattening ссылок: когда поле одного объекта или элемент массива хранит ссылку на другой объект, JVM может закодировать значения полей другого объекта непосредственно в ссылке. В этом случае ссылка не является указателем на другой объект в памяти. Такую ссылку называют плоско размещённой.
-
Скаляризация ссылок: когда параметр метода или локальная переменная хранит ссылку на объект, JVM может закодировать значения полей объекта в дополнительных локальных переменных. В этом случае ссылка, опять же, не является указателем на объект в памяти. Такую ссылку называют скаляризованной.
Если ссылка плоско размещена или скаляризована, ей не нужно отдельное представление объекта в куче. Это значит, что она никак не влияет на сборку мусора, а её данные всегда расположены в памяти рядом с объектом, который на неё ссылается, или в стеке вызовов.
Flattening ссылок
Например, JVM может плоско разместить массив ссылок Integer так, чтобы каждый элемент массива содержал ссылку, которая напрямую кодирует соответствующее целое значение, а не указывает на место в памяти, где находится объект Integer. Каждая ссылка также показывает, была ли исходная ссылка Integer равна null: для этого к целому значению в начало добавляется флаг 0 (null) или 1 (не null):
+--------------+
| Integer[5] |
+--------------+
| 1|1996 |
| 1|2006 |
| 1|1996 |
| 0|0 |
| 0|0 |
+--------------+
Каждое значение int занимает 32 бита, а каждый флаг null требует как минимум ещё одного бита. Из-за аппаратных ограничений JVM, вероятно, будет кодировать каждую плоско размещённую ссылку Integer как 64-битное слово. Поэтому массив Integer занимает больше памяти, чем обычный массив int, но в сумме значительно меньше, чем массив указателей на объекты Integer. (Каждый указатель — 32- или 64-битное значение, а каждому объекту, на который он ссылается, требуется не менее 64 бит только на заголовок.) Ещё важнее то, что все данные Integer хранятся непосредственно в массиве, где к ним можно обращаться без дополнительных загрузок из памяти.
Как показано выше, массив ссылок LocalDate можно плоско разместить, добавив флаг null в начало тройки «год-месяц-день» объекта LocalDate (одно значение int и два byte). Как и плоско размещённые ссылки Integer, эти плоско размещённые ссылки LocalDate помещаются в 64 бита:
+--------------+
| LocalDate[5] |
+--------------+
| 1|1996|01|23 |
| 1|1996|01|23 |
| 1|2026|01|23 |
| 0|0000|00|00 |
| 1|1996|01|23 |
+--------------+
Поля тоже могут хранить плоско размещённые ссылки. Например, у объекта LocalDateTime два поля, LocalDate и LocalTime, и каждое из них может хранить плоско размещённую ссылку:
+----------------------+
| LocalDateTime |
+----------------------+
| date=1|2026|01|23 |
| time=1|09|00|00|0000 |
+----------------------+
Flattening ссылок должен сохранять целостность данных. Плоско размещённую ссылку всегда нужно читать и записывать атомарно, иначе она может быть повреждена. На распространённых аппаратных архитектурах это ограничивает размер изменяемых полей, хранящих плоско размещённые ссылки, 64 битами.
Например, при попытке плоско разместить ссылку на объект LocalDateTime в неё были бы встроены поля из соответствующих объектов LocalDate и LocalTime, флаг null для каждого из них, а также флаг null для самого LocalDateTime. Такая плоско размещённая ссылка, скорее всего, была бы слишком велика для атомарного чтения и записи, поэтому JVM не могла бы хранить её в изменяемом поле типа LocalDateTime, например во времени lastClicked identity-класса Button:
+--------------------------------------------+
| Button |
+--------------------------------------------+
| lastClicked=1|1|2026|01|23|1|09|00|00|0000 | // Not possible
| ... |
+--------------------------------------------+
Вместо этого JVM выберет — незаметно, по своему усмотрению — размещение ссылки, совместимое с изменяемым полем lastClicked. Например, поле может хранить указатель на объект LocalDateTime, собственные поля которого могут хранить плоско размещённые ссылки, как показано выше:
+----------------------+
| Button |
+----------------------+
| lastClicked=87fa50a0 |------> +----------------------+
| ... | | LocalDateTime |
+----------------------+ +----------------------+
| date=1|2026|01|23 |
| time=1|09|00|00|0000 |
+----------------------+
У полей value-класса, напротив, такого ограничения атомарности нет, поскольку изменение полей объектов value-классов никогда не может быть наблюдаемо. Поэтому, например, поле timestamp value-класса Event может хранить плоско размещённую ссылку на объект LocalDateTime:
+------------------------------------------+
| Event |
+------------------------------------------+
| timestamp=1|1|2026|01|23|1|09|00|00|0000 | // OK in a value class
| ... |
+------------------------------------------+
Будущие улучшения могут расширить Flattening на ссылки на 64-битные и даже более крупные объекты value-классов. Например, дополнительные возможности языка могут позволить value-классам отказываться от ограничений атомарности, или, возможно, на некоторых аппаратных архитектурах станут практичными 128-битные атомарные изменяемые поля.
Скаляризация ссылок
Когда JVM загружает плоско размещённую ссылку из поля объекта в куче, она должна декодировать ссылку в форму, с которой ей удобно работать. Для кода, скомпилированного JIT-компилятором (just-in-time) JVM, такой формой может быть скаляризованная ссылка.
Рассмотрим, например, такой фрагмент кода:
LocalDate d = dates[0];
dates[0] = d.plusYears(30);
Сам метод LocalDate.plusYears может быть объявлен так:
public LocalDate plusYears(long yearsToAdd) {
int newYear = YEAR.checkValidIntValue(this.year + yearsToAdd);
return new LocalDate(newYear, this.month, this.day);
}
В псевдокоде результат JIT-компиляции метода plusYears может выглядеть так, как показано ниже; запись { ... } обозначает, что JIT-скомпилированный метод возвращает несколько значений (это только обозначение, во время выполнения никакой обёртки нет):
static { boolean, int, byte, byte }
plusYears(boolean this_null,
int this_year, byte this_month, byte this_day,
long yearsToAdd)
{
if (this_null) throw new NullPointerException();
int newYear = YEAR.checkValidIntValue(this_year + yearsToAdd);
return { false, newYear, this_month, this_day };
}
Тогда результат JIT-компиляции фрагмента, обновляющего массив dates, может выглядеть так:
{ d_null, d_year, d_month, d_day } = dates[0];
dates[0] = plusYears(d_null, d_year, d_month, d_day, 30);
Благодаря оптимизациям JVM этот код ни разу не обращается к указателю на объект LocalDate, размещённый в куче:
-
Плоско размещённая ссылка в
dates[0]при чтении преобразуется в скаляризованную ссылку, -
Из
plusYearsвозвращается новая скаляризованная ссылка, и -
Затем эта ссылка при записи преобразуется в другую плоско размещённую ссылку.
В отличие от Flattening ссылок, скаляризация ссылок не ограничена размером данных. Локальным переменным, которые помещаются в стек и извлекаются из него, гонки данных не грозят. Поэтому можно регулярно работать со скаляризованными представлениями ссылок LocalDateTime: три значения и флаг null для соответствующего LocalDate, четыре значения и флаг null для соответствующего LocalTime и флаг null для самого LocalDateTime.
JVM и раньше применяли похожие приёмы для скаляризации ссылок на identity-объекты, когда могли доказать, что Identity объекта никогда не используется. Скаляризация ссылок на объекты value-классов более предсказуема и имеет гораздо более широкий охват, даже за границами методов.
Когда возможны Flattening и скаляризация?
Flattening и скаляризация ссылок — это оптимизации, а не возможности языка. Напрямую управлять ими нельзя. Как и все оптимизации, они выполняются по усмотрению JVM. Однако вы можете кое-что сделать, чтобы повысить вероятность того, что JVM сможет применить эти оптимизации.
Во-первых, для Flattening и скаляризации требуется, чтобы переменная хранила ссылки только на экземпляры конкретного value-класса; например, поле date объекта LocalDateTime всегда хранит ссылку LocalDate. К переменной, объявленной с супертипом value-класса, например Object, Flattening и скаляризацию, как правило, применить нельзя.
Например, следующие два массива при создании хранят одни и те же значения Integer, но поскольку во второй массив в будущем могут быть записаны произвольные ссылки Object, JVM должна кодировать его элементы как указатели на обычные объекты в куче:
Integer[] ints = { 1996, 2006, 1996, null, null }; // flattenable
Object[] objs = { 1996, 2006, 1996, null, null }; // not flattenable
Объекты value-классов, записываемые в массив objs, приходится преобразовывать в обычные объекты в куче:
Integer i = -1;
ints[3] = i; // write a flattened reference
objs[3] = i; // write a heap pointer
Поле обобщённого типа T обычно имеет стёртый тип Object и поэтому во время выполнения ведёт себя так же, как поле типа Object:
record Box<T>(T field) { } // field is not flattenable
var b = new Box<Integer>(i); // field stores a heap pointer
Эти преобразования между представлениями никак не влияют на семантику — объекты Integer, на которые ссылаются objs и field, по-прежнему остаются объектами value-классов и не имеют Identity. JVM просто кодирует один и тот же объект value-класса по-разному.
Те же принципы относятся и к параметрам методов: параметр типа LocalDate надёжно скаляризуется, а параметр типа Object или T — нет. (Однако если вызов метода можно встроить, JIT может полностью обойтись без присваивания и выделения памяти в куче.)
Второй фактор, влияющий на то, применит ли JVM Flattening и скаляризацию, — содержимое class-файла, в котором используются value-классы. При компиляции класса имена value-классов, упомянутых в сигнатурах его полей и методов, записываются в новый атрибут class-файла LoadableDescriptors. Благодаря этому атрибуту JVM может загрузить указанные value-классы достаточно рано, чтобы подготовить плоско размещённые поля и скаляризованные параметры методов.
Если value-класс V не указан в атрибуте LoadableDescriptors класса C.class, то при загрузке C JVM может не знать, что V — value-класс. На практике это значит, что если существующий класс V переводится в value-класс, то для оптимальной производительности классы, скомпилированные со старыми версиями V, следует перекомпилировать.
Когда Flattening и скаляризация ссылок невозможны, JVM использует обычную ссылку на объект, размещённый в куче. В неоптимальных случаях это может приводить к выделению нового объекта при каждом чтении поля или вызове метода. Чаще всего так происходит на этапе прогрева программы, пока JIT-компилятор JVM ещё не сгенерировал оптимизированный код.
Дальнейшая работа
-
JEP 402, Enhanced Primitive Boxing, улучшит обработку примитивных типов, чтобы воспользоваться более лёгковесным характером упаковки в объекты value-классов.
-
JEP 218, Generics over Primitive Types (с доработками), позволит обобщённым классам и методам специализировать размещение полей, массивов и локальных переменных при параметризации типами value-классов.
Альтернативы
-
Как уже говорилось, JVM давно выполняют escape-анализ, чтобы находить объекты, которые на протяжении всего своего существования не зависят от Identity и могут быть скаляризованы. Однако эти оптимизации в какой-то мере непредсказуемы и не помогают с объектами, выходящими за область действия оптимизации, например при сохранении в полях и массивах.
-
Язык C и родственные ему языки поддерживают плоское хранение для
structи аналогичных абстракций, похожих на классы. Например, в языке C# есть типы значений. В отличие от объектов value-классов, экземпляры этих абстракций имеют Identity, то есть поддерживают такие операции, как изменение полей. Поэтому семантику копирования при присваивании, вызове и т. д. приходится тщательно специфицировать, что усложняет модель для пользователя и оставляет меньше гибкости реализациям среды выполнения. Мы предпочитаем подход, при котором эти низкоуровневые детали остаются на усмотрение разработчиков реализаций JVM.
Риски и допущения
-
Эта возможность существенно меняет объектную модель Java. Изменения в поведении оператора
==и ключевого словаsynchronizedмогут удивить разработчиков или привести к ошибкам. Мы ожидаем, что такие проблемы будут редкими и устранимыми. -
Некоторые изменения могут повлиять на производительность identity-объектов. Например, байт-код
if_acmpeq(==) обычно занимает всего один такт процессора, но теперь потребует дополнительной проверки, чтобы распознавать объекты value-классов. Однако случай identity-класса можно оптимизировать как быстрый путь, и мы считаем, что свели любые регрессии производительности к минимуму. -
Существует риск для безопасности: оператор
==и методidentityHashCodeмогут косвенно раскрывать значения полейprivate. Кроме того, оператор==может работать неограниченно долго при сравнении двух больших деревьев объектов value-классов. Разработчикам нужно понимать эти риски.
Зависимости
JEP 539, Strict Field Initialization (строгая инициализация полей) in the JVM, предоставляет механизм, позволяющий с помощью верификации байт-кода требовать, чтобы поля объектов value-классов инициализировались в ранней фазе конструирования.