JEP draft: Null-Restricted Value Class Types (Preview)
Null-Restricted Types (типы, запрещающие null) для value-классов (Preview (предварительная версия))
| Ответственный | Dan Smith |
| Тип | Feature |
| Область | SE |
| Статус | Draft |
| Обсуждение | valhalla dash dev at openjdk dot org |
| Трудоёмкость | XL |
| Длительность | XL |
| Создан | 2023/09/22 23:57 |
| Обновлён | 2026/05/28 23:28 |
| Задача | 8316779 |
Аннотация
Разрешить исключать null из типа переменной, хранящей Value Objects (объекты-значения). Так становятся возможны более компактное хранение и другие оптимизации во время выполнения. Это возможность языка и VM в статусе Preview.
Цели
-
Ввести для Value Classes (классы-значения) новый вид типа, множество значений которого не включает
null. Это похоже на примитивные типы, значения которых не могут бытьnull. -
Разрешить value-классу по собственному выбору («opt in») включить автоматическое создание подходящего значения по умолчанию, которым инициализируются поля и массивы, не хранящие
null. -
Разрешить более крупным value-классам дополнительно выбрать («opt in») неатомарное представление в полях и массивах, не хранящих
null. -
Поддержать совместимую миграцию существующих классов. Применить эти свойства к value-классам платформы Java, в том числе к классам, которые используются для упаковки примитивов.
Что не является целью
- Поддержка Null-Restricted Types для identity-классов или классов, не предоставляющих значения по умолчанию, не является целью.
Мотивация
Value Objects — особые объекты, у которых нет Identity (идентичность объекта), поэтому JVM может во время выполнения свободно их дублировать и менять их представление. Одна из особенно полезных оптимизаций, применимых к этим объектам, — Flattening (плоское размещение) в куче: ссылка на такой объект кодируется как компактный битовый вектор из значений полей объекта, без указателя на другую область памяти. Этот битовый вектор можно хранить прямо в поле или массиве типа value-класса. Такая стратегия кодирования обычно даёт меньший расход памяти и лучшую локальность, чем стандартное представление с объектами, размещёнными в куче, и указателями.
Однако по сравнению с примитивными типами Flattening в куче для типов value-классов может быть неэффективным, потому что оно должно учитывать ссылки null. Обычно их кодируют, резервируя несколько битов под «флаг null», и эти биты уже нельзя использовать для кодирования значений полей объекта. Так, например, упакованному Integer нужно 32 бита для кодирования значения int и ещё как минимум 1 бит для флага null, что, вероятно, приведёт к 64-битному представлению.
Кроме того, Flattening в куче для типов value-классов ограничено требованиями целостности объектов и ссылок: плоско размещённые данные должны быть достаточно малы, чтобы их можно было читать и записывать атомарно, иначе закодированные данные могут быть повреждены. На распространённых платформах «достаточно малы» может означать всего 32 или 64 бита. Поэтому, хотя многие небольшие value-классы можно разместить плоско, большинство value-классов, объявляющих 2 или более полей, придётся представлять как обычные объекты в куче (если только в полях не хранятся примитивы типов boolean, char, byte или short). Даже упакованному Double нужно не менее 65 битов (с учётом одного бита для флага null), что превышает возможности атомарного чтения и записи на многих системах.
У примитивных типов этих ограничений нет: поле примитивного типа при создании неявно инициализируется нулевым значением (или его эквивалентом), а не null, а крупные примитивные переменные типов long или double разрешено обновлять неатомарно (см. JLS 17.7). Поэтому, например, большой массив типа int занимает вдвое меньше памяти, чем плоско размещённый массив типа Integer.
Если бы в языке Java был тип, представляющий ссылки на экземпляры value-класса, но не null, флаг null был бы не нужен, и плоское хранилище могло бы занимать не больше памяти, чем поля класса.
Это хранилище нужно было бы инициализировать чем-то, поэтому классы, которые собираются поддерживать эту возможность, должны были бы допускать значение по умолчанию — что-то вроде значения 0, которым инициализируется хранилище типа int.
Некоторые value-классы, возможно, готовы даже мириться с повреждёнными данными, возникающими при неатомарных чтениях и записях. Если отслеживать флаги null не нужно, требования JVM к целостности данных можно ослабить и позволить классам, которые это выбрали, воспроизводить специфицированное поведение long и double. При таком выборе ответственность за управление конкурентностью и за обработку ошибок, возникающих из-за гонок, переходит к пользователям этих классов.
Описание
Описанные ниже возможности находятся в статусе Preview и включаются флагами компиляции и выполнения --enable-preview. Более полные требования и подробности реализации для языка, JVM и стандартных библиотек можно найти в подзадачах этого JEP.
Null-Restricted Types
Null-Restricted-тип — ссылочный тип, который записывается как имя value-класса, за которым следует символ !. Он утверждает, что значение данной переменной или выражения не будет null.
Null-Restricted-типы могут встречаться в объявлениях переменных, в выражениях создания массивов (как тип компонента) и в приведениях типов.
void printAll(Range! r) {
for (int i = r.start; i < r.end; i++)
System.out.println(i);
}
printAll(new Range(5, 50));
printAll(null); // compiler error
Обычный тип класса можно преобразовать в Null-Restricted-тип и обратно — примерно так же, как тип Integer можно преобразовать в тип int и обратно. При преобразовании в Null-Restricted-тип во время выполнения происходит проверка на null.
Range r = new Range(1, 3);
printAll(r);
r = null;
printAll(r); // NullPointerException
Object o = null;
r = (Range!) o; // NullPointerException
Массивы Null-Restricted-типов можно присваивать переменным неограниченных супертипов, и проверка на null по-прежнему выполняется во время выполнения, как и другие проверки записи в массив.
Range![] a1 = new Range![3];
a1[0] = new Range(-3, 0);
Range[] a2 = a1;
a2[1] = null; // ArrayStoreException
Object[] a3 = a2;
a3[2] = new Object(); // ArrayStoreException
a3[2] = null; // ArrayStoreException
Нулевые экземпляры
При создании объектов и массивов каждое содержащееся в них поле или компонент массива автоматически инициализируется подходящим значением по умолчанию. Так гарантируется, что если программа попытается прочитать переменную до первой записи в неё, она получит предсказуемое значение (а не, скажем, мусорные данные).
У каждого примитивного типа есть значение по умолчанию, подобное нулю: 0, 0.0, false и т. д. Для обычных ссылочных типов значение по умолчанию — null. Но переменная Null-Restricted-типа не может хранить null, так каково же её значение по умолчанию?
Range![] a = new Range![100];
Range r = a[5]; // not null...
Ответ таков: значение по умолчанию Null-Restricted-типа — нулевой экземпляр данного value-класса. Нулевой экземпляр создаётся простым присваиванием каждому полю экземпляра класса его собственного значения по умолчанию. В отличие от null, нулевой экземпляр — настоящий, полностью работоспособный объект.
Range r = a[5];
System.out.println(r); // Range[start=0, end=0]
int size = r.size(); // 0
Неявные конструкторы
Заметьте, что нулевой экземпляр value-класса создаётся автоматически, без выполнения какого-либо кода класса. Не всем value-классам подойдёт такое поведение, и не все готовы считать нулевой экземпляр допустимым объектом своей предметной области. Например, для записи Name с несколькими полями String нулевым экземпляром было бы имя, у которого все поля равны null.
value record Name(String first, String last) {
public String toString() { return "%s %s".formatted(first, last); }
// zero instance toString: 'null null'
}
Поэтому создание нулевого экземпляра должно быть разрешено классом, и многие value-классы решат этого не делать. Чтобы разрешить автоматическое создание нулевого экземпляра во время выполнения, мы используем конструктор без аргументов с модификатором implicit.
value record Range(int start, int end) {
public implicit Range();
public Range(int start, int end) {
if (start > end) throw new IllegalArgumentException();
}
}
Конструктор implicit всегда должен быть public, и его можно вызывать напрямую: он возвращает нулевой экземпляр без выполнения какого-либо кода. Во время выполнения конструктор implicit даёт JVM право создавать нулевые экземпляры вообще без вызова конструктора.
Если value-класс объявляет конструктор implicit, он не должен быть внутренним классом, а его нулевой экземпляр не должен содержать сам себя через циклические поля Null-Restricted-типов.
value class ListNode {
implicit ListNode();
Object val;
ListNode! next; // error
}
Если value-класс с неявным конструктором расширяет абстрактный класс, этот суперкласс тоже должен объявлять неявный конструктор.
Поскольку конструкторы implicit необходимы для размещения полей и массивов Null-Restricted-типов, value-классы, которые не объявляют конструкторов implicit, нельзя использовать как Null-Restricted-типы.
Неатомарные обновления
Value-класс с конструктором implicit может также объявить, что допускает неявное создание экземпляров в результате неатомарных обновлений полей и массивов. Это значит, что при гонке новые экземпляры класса могут случайно появиться из смешения значений полей других экземпляров — без выполнения какого-либо кода и без иного дополнительного участия value-класса.
Value-класс разрешает такое поведение, реализуя интерфейс LooselyConsistentValue:
value class Point implements LooselyConsistentValue {
double x;
double y;
public implicit Point();
public Point(double x, double y) {
this.x = x;
this.y = y;
}
}
Это черновой синтаксис, он может измениться.
Пользователи класса LooselyConsistentValue отвечают за целостность своих данных и могут избежать нежелательного создания экземпляров, ограничив доступ одним потоком, соблюдая протокол синхронизации или объявив поле volatile. В противном случае могут появиться неожиданные экземпляры:
Point![] ps = { new Point(0.0, 1.0) };
new Thread(() -> ps[0] = new Point(2.0, 3.0)).start();
Point p = ps[0]; // may be (2.0, 1.0), among other possibilities
У некоторых неявно конструируемых value-классов есть сложные ограничения целостности для ненулевых значений полей (например, индекс start в объявленном выше Range не должен превышать индекс end). В таком случае классу, возможно, не стоит реализовывать интерфейс LooselyConsistentValue. Эта возможность рассчитана на ту часть value-классов, которые без проблем работают с произвольными сочетаниями значений полей.
Модель производительности
Как описано в JEP о Value Objects, обычно для стандартного value-класса локальные переменные, параметры методов и результаты выражений используют встроенное представление, а поля и компоненты массивов встраиваются, только если объект вместе с флагом null помещается в атомарное машинное слово (например, 64 бита).
Если добавить value-классу неявный конструктор, становится возможным хранение Null-Restricted-типа, и выделять биты под флаг null не нужно. Так, например, переменная типа Long может оказаться слишком большой для встроенного хранения, а тип Long! на 64-битной JVM должен безопасно встраиваться.
Для более крупных классов (что считать крупным, определяет реализация JVM) для встраивания таких полей и компонентов массивов Null-Restricted-типов может также понадобиться реализовать LooselyConsistentValue.
При плоском размещении Null-Restricted-тип класса должен занимать в куче столько же памяти и выполняться (при полной оптимизации) примерно так же быстро, как примитивные типы. Например, можно ожидать, что Point! при приведённом выше объявлении класса будет напрямую занимать 128 битов в полях и компонентах массивов и не потребует никакого выделения памяти при вычислениях на стеке. Обращение к полю просто обращается к первым или вторым 64 битам. Никаких дополнительных указателей нет.
Примечательно, что использование Null-Restricted-типа value-класса с неявным конструктором и единственным полем экземпляра, как ожидается, будет иметь минимальные накладные расходы по сравнению с работой непосредственно со значением типа этого поля.
Однако в конечном счёте JVM вправе кодировать экземпляры классов так, как считает нужным. Некоторые классы могут считаться слишком большими для встроенного представления. Отдельные компоненты JVM, особенно менее оптимизированные по производительности, могут предпочитать работать с экземплярами как с объектами, размещёнными в куче. Представление может содержать кэшированный указатель на кучу, чтобы снизить накладные расходы на будущие выделения памяти. И т. д.
Неявно конструируемые value-классы в стандартной библиотеке
Следующие классы, которые при включённых Preview-возможностях считаются value-классами согласно JEP 401, согласно этому JEP дополнительно считаются имеющими неявный конструктор, хотя такой конструктор в них не объявлен:
- java.lang.Byte
- java.lang.Short
- java.lang.Integer
- java.lang.Long
- java.lang.Float
- java.lang.Double
- java.lang.Boolean
- java.lang.Character
- java.util.Optional
Рефлексия и стирание
Как и параметризованные типы, Null-Restricted-типы стираются в скомпилированных сигнатурах полей и методов. Экземпляра java.lang.Class, представляющего Range!, не существует, а добавление или удаление ! в API — рефакторинг, сохраняющий двоичную совместимость.
Однако, в отличие от параметризованных типов, ограничения на null по-прежнему соблюдаются во время выполнения. Это достигается проверками на null, которые генерирует компилятор, и новым механизмом под названием CheckedType, выполняющим динамическую проверку при записи в поля и массивы.
CheckedType массива выражает динамическую проверку записи в массив, в том числе проверку на null.
String[] a1 = new String[100];
CheckedType t1 = Array.getComponentType(a1);
t1.cast("abc"); // success
t1.cast(new Range(8, 12)); // ClassCastException
t1.cast(null); // success
Range![] a2 = new Range![100];
CheckedType t2 = Array.getComponentType(a2);
t2.cast("abc"); // ClassCastException
t2.cast(new Range(8, 12)); // success
t2.cast(null); // NullPointerException
Новые массивы можно создавать с помощью CheckedType, и этот механизм следует предпочитать выделению массивов, при котором тип элементов представлен объектами Class.
Range[] a3 = (Range[]) Array.newInstance(t2, 100);
a3[10] = null; // ArrayStoreException
Если поле объявлено с Null-Restricted-типом, метод Field.getCheckedType вернёт соответствующий проверяемый тип.
Альтернативы
Если использовать примитивные типы вместо объявления value-классов, программа часто получится с такой же или немного более высокой производительностью. Однако при таком подходе теряются ценные абстракции, которые дают классы. Легко, например, интерпретировать double в неверных единицах, передать в библиотечный метод int вне допустимого диапазона или не удержать два флага boolean вместе в правильном порядке.
Value-классы дают ощутимый выигрыш в производительности и без неявных конструкторов и неатомарного хранения с ограничением на null. В некоторых случаях хранение в полях и массивах уже может быть встроенным. Но многие классы не помещаются в атомарное машинное слово или не имеют свободного места для флага null; и даже если дальнейшая инженерная работа позволит увеличить размер атомарного слова до приемлемого уровня, флаги null во многих сценариях без необходимости увеличивают расход памяти. Этот JEP позволяет добиться такого же расхода памяти, как у примитивных типов.
Мы рассмотрели множество разных подходов к объектной модели и системе типов, прежде чем остановиться на модели, в которой компактное хранение в куче с Flattening — просто оптимизация JVM для ссылочного Null-Restricted-типа. Такая стратегия избавляет от концептуальных издержек, которые возникают при обобщении существующей модели примитивных типов. Разработчики уже понимают объекты и классы, а Null-Restricted Types — простое улучшение языка, полезное как возможность общего назначения.
Риски и допущения
Разрешение создавать экземпляры вне конструкторов — через нулевые экземпляры и неатомарные чтение и запись — сопряжено с рисками для безопасности. Разработчикам нужно будет понимать последствия и распознавать случаи, когда объявлять неявный конструктор или реализовывать интерфейс LooselyConsistentValue небезопасно.
Зависимости
Этот JEP зависит от Value Classes and Objects (Preview), где определяется семантика объектов без Identity и реализуется встраивание Value Objects.
На основе этого JEP JEP 402: Enhanced Primitive Boxing (Preview) перерабатывает классы-обёртки примитивных типов в value-классы с неявными конструкторами.
В будущем специализация классов и методов в JVM (JEP 218, с доработками) позволит обобщённым классам и методам специализировать размещение полей и массивов при параметризации Null-Restricted-типами value-классов.