JEP draft: enhanced checkcast for Valhalla type unification
расширенная инструкция checkcast для унификации типов в Valhalla
| Ответственный | John Rose |
| Тип | Feature |
| Область | JDK |
| Статус | Draft |
| Создан | 2022/11/18 02:39 |
| Обновлён | 2025/05/28 05:54 |
| Задача | 8297236 |
Мотивация
Невидимые преобразования повсюду, густо рассыпанные по вашему class-файлу...
В языке Java проект Valhalla унифицирует систему типов так, что всем значениям (кроме, возможно, null) назначаются типы — подклассы Object, в частности int, double и другие унаследованные примитивные типы (в Java до Valhalla их также называют нессылочными типами). Эта унификация достигается за счёт того, что упакованная и нативная формы любого примитивного значения теснее отождествляются как «одно и то же». Благодаря этому унаследованные примитивы могут работать в обобщённых классах и методах (при достаточно свободных ограничениях переменных типа).
Но JVM знает, что это не так: нативная форма double занимает 64 бита и два слота стека под дескриптором "D", а упакованная форма хранит 64-битное значение в одном или нескольких буферных объектах в куче, и ссылка на неё занимает всего один слот стека под дескриптором "Ljava/lang/Double;". Внутри JVM нет способа задним числом унифицировать эти два представления «одного и того же» объекта со значением типа double.
Вместо этого статический компилятор Java (например, javac) должен жонглировать как минимум двумя представлениями значений double: двухслотовым нативным и однослотовой упакованной ссылкой. Он должен передавать каждой операции правильный формат: для нативной арифметики (например, байт-кода dadd) значение должно быть в нативном формате "D", а при взаимодействии с обобщённым кодом или при записи в массив Object или Number — в формате упакованной ссылки.
Статический компилятор Java исторически выполнял такое жонглирование исторически, чтобы реализовать правила языка о неявной автоупаковке и автораспаковке. Для этого он делает неявные вызовы (которых не видно в исходном коде) чётко определённых точек API, таких как Double::valueOf (для упаковки) и Double::doubleValue (для распаковки).
Заметим также, что статический компилятор часто неявно меняет ссылочный тип значений на границах (стёртых) обобщённых API Java, например незаметно приводит результат вызова List<String>::get от Object (стёртой границы переменной типа, возвращаемой методом get) к String (типу, известному во время компиляции). Для этого он генерирует неявные использования байт-кода checkcast.
В случае обобщённой точки API, такой как List<double>::get, значение Object, возвращённое методом, нужно неявно преобразовать к типу double в два операционных шага: привести к Double как ссылку, а затем распаковать в нативный двухслотовый double с помощью Double::doubleValue.
Для пользовательских примитивов Valhalla представление упакованных и распакованных значений одинаково, но неявные изменения их типов всё равно будут, и эти изменения (вероятно, только в направлении распаковки) будут отражаться операционными проверками null с помощью Objects::requireNonNull или эквивалента.
Мы ожидаем, что с Valhalla частота (или плотность) таких преобразований и проверок в некотором коде может вырасти, поскольку пользователям не придётся беспокоиться о том, упакованы их значения или нет. Но JVM придётся беспокоиться об этом ещё больше, особенно в случае унаследованных примитивов. Кроме того, статический компилятор должен будет передавать ей правильные указания в виде неявных инструкций байт-кода для управления неявной упаковкой и распаковкой.
Valhalla не планирует расширять систему типов верификатора сверх того, что есть сегодня. В частности, мы не планируем распространять результаты проверок null в системе типов верификатора. Это означает, что если javac забудет вставить вызов Objects::requireNonNull, значения null будут проверены позже, если вообще будут, — когда будет достигнута переменная, которая явно отвергает null. (Аргумент Double::doubleValue отвергает null именно так, поскольку получатель null вызывает NullPointerException.)
Итак, это означает, что следующие операции фактически будут использоваться как инструкции виртуальной машины для управления низкоуровневыми изменениями типов в коде, который генерирует javac:
Double::doubleValue— для распаковкиDoubleвdoubleDouble::valueOf— для упаковкиdoubleвDouble.<Primtype>::<primtype>Value— аналогично для распаковки любого унаследованного примитивного типа.<Primtype>::valueOf— аналогично для упаковки любого унаследованного примитивного типа.Objects::requireNonNull— для распаковки любого пользовательского примитивного типа- (нет кода) — для упаковки любого пользовательского примитивного типа (верификатор не видит изменения типа)
Кроме того, во многих случаях за вызовами requireNonNull должен следовать checkcast, чтобы убедить верификатор, что из метода вышел тот же тип (на самом деле та же ссылка), что и вошёл. (На уровне языка этот эффект не виден, поскольку requireNonNull обобщённо возвращает тип своего аргумента. Но JVM здесь требует checkcast.)
И ещё: во многих случаях перед вызовами doubleValue (или любого <primtype>Value) должен стоять checkcast к типу-обёртке. (Это либо явные пользовательские приведения от супертипа, например Object, либо неявные приведения, вставленные вокруг обобщённой точки API.)
Перспектива гораздо большего объёма неявных байт-кодов преобразования или пар таких преобразований наводит на мысль, что стратегии трансляции для Valhalla, возможно, пошла бы на пользу новая поддержка в наборе инструкций байт-кода JVM, позволяющая выражать эти преобразования проще.
(Это большое «возможно»: добавлять новые байт-коды дорого. В этой заметке рассматривается этот дорогой вариант. Запасной вариант и принятый на данный момент план — использовать столько вызовов библиотечных процедур, сколько потребуется для решения задачи, и на этом остановиться.)
Описание
Расширить инструкцию checkcast в трёх направлениях:
- полиморфно выдавать унаследованные примитивы, а также ссылки (прецедент см. в
getstatic) - полиморфно принимать унаследованные примитивы, а также ссылки (прецедент см. в
putstatic) - при необходимости выполнять операции проверки на null
Все три расширения включаются условием, которое раньше было недопустимым. Это условие выполняется, когда поле операнда инструкции checkcast — индекс в пуле констант — ссылается на элемент CONSTANT_Utf8, а не на элемент CONSTANT_Class, как допустимо уже сейчас.
Написание элемента CONSTANT_Utf8 определяет функцию:
">D"снимает со стека ссылкуObject, приводит её кDouble, а затем вызываетdoubleValue">I"снимает со стека ссылкуObject, приводит её кInteger, а затем вызываетintValue"<D"снимает со стекаdouble(два слота) и вызываетDouble::valueOf"<I"снимает со стекаintи вызываетInteger::valueOf- (и так далее для других
">x"и"<x", гдеxвходит в[BSIJZCFD]) "!"смотрит на значение на вершине стека, не снимая его, и выбрасываетNPE, если оно равно null
Любой другой операнд (любое другое написание или другой тип элемента пула констант) не пройдёт верификацию инструкции checkcast и тем самым зарезервирован для использования в будущем.
Приведённые выше описания тщательно сформулированы так, чтобы из них следовали такие взаимодействия с системой типов верификатора:
">x"требует на стеке ссылку (Object) и оставляет на стеке примитив (x)"<x"требует на стеке примитив (x) и оставляет на стеке его тип-обёртку (а не простоObject)"!"требует на стеке ссылку и оставляет эту ссылку без изменений, с тем же типом верификатора
Очевидно, что эффективный интерпретатор, вероятно, будет переводить эти новые формы checkcast, использующие UTF8, во внутренний, больше нигде не используемый байт-код и через поле операнда этого байт-кода эффективно выбирать нужное поведение, соответствующее строке UTF8.
Вероятно, компилятору javac стоит генерировать эти новые варианты checkcast в некоторых или во всех случаях, где он раньше генерировал вызовы методов (неявные или явные в исходном коде).
Для представления этого байт-кода другими низкоуровневыми инструментами предлагается использовать имя checkbox вместо checkcast и показывать инструкцию с неизменённым строковым операндом. Но код операции checkcast (десятичное 192) следует повторно использовать (перегрузить) для этой новой цели, а не выделять новый код операции.
Альтернативы
Предлагаемые здесь отдельные операции можно было бы также выразить статическими методами в новом вспомогательном классе, например java.lang.runtime.Checks. Их можно определить так, чтобы свести к минимуму сложность байт-кода. Например, void Checks::requireNonNull(Object) выполнял бы работу Objects::requireNonNull, но не возвращал бы неудобное значение (T), для которого нужна последующая очистка checkcast. Это неудобно для программистов, но удобно для генератора байт-кода, который и является целевым пользователем java.lang.runtime. Вместо специального байт-кода у нас были бы специальные статические методы в java.lang.runtime. Любую специализированную оптимизацию или диагностику можно придать этим методам времени выполнения, сделав их интринсиками VM в интерпретаторе и JIT-компиляторах. Эти компоненты VM всё равно пришлось бы изменять (и с большим трудом), если бы мы ввели новые синтаксисы байт-кода. Поэтому использование интринсик-методов вместо новых синтаксисов байт-кода в целом выглядит выигрышным.