openjdk.ruOpenJDK на русском

JEP 169: Larval State for Value Objects

Larval-состояние для Value Objects (объекты-значения)

ОтветственныйJohn Rose
ТипFeature
ОбластьSE
СтатусDraft
Компонентhotspot / compiler
Обсуждениеmlvm dash dev at openjdk dot java dot net
ТрудоёмкостьL
ДлительностьL
Создан2012/10/22 20:00
Обновлён2021/12/09 21:35
Задача8046159

Аннотация

Предоставить в JVM инфраструктуру для работы с временными изменяемыми копиями неизменяемых Value Objects в Valhalla (JDK-8277163).

Цели —--

Следующие цели взяты из проекта Valhalla:

  • Поддержать абстрактные типы данных, определяемые пользователем и библиотеками, с характеристиками производительности, близкими к примитивным типам Java.

  • Сблизить семантику int и java.lang.Integer.

  • Позволить представлять повсеместно используемые типы, которые сейчас плохо поддерживаются в JVM, включая комплексные числа, векторные значения и кортежи.

  • Расширить возможности совместного использования структур данных Java.

  • Дать ясную и явную семантику для совместно используемых данных массивов, доступных только для чтения.

  • Сделать возможными вычисления в функциональном стиле над чистыми данными для оптимизированных параллельных вычислений.

  • Повысить надёжность и безопасность и сократить «защитное копирование» в приложениях, которые должны передавать структурированные данные через границы доверия.

Эта цель есть только у этого JEP:

  • Эффективно поддерживать временные «черновые версии» неизменяемых объектов Valhalla, которые можно свободно изменять (в одном потоке), прежде чем они вернутся в свою совместно используемую, свободно копируемую форму без Identity (идентичность объекта).

Мотивация

В Java примитивные типы — один из главных факторов при написании кода с приемлемой производительностью. Например, программисты ожидают, что работать с массивом значений int дешевле, чем с List из объектов Integer, и пишут код соответственно.

В современных JVM выделение памяти под объект обходится недорого: его стоимость сопоставима с вызовом невстроенной процедуры. Но даже эти затраты часто оказываются болезненными по сравнению с отдельными операциями над примитивными значениями. Поэтому перед программистами на Java стоит двоичный выбор между существующими примитивными типами (которые не требуют выделения памяти) и другими типами (которые дают абстракцию данных и другие преимущества классов). Когда нужно определить небольшие составные значения, такие как комплексные числа, пиксели или пары возвращаемых значений, не подходит ни один из подходов. У этой дилеммы часто нет хорошего решения, а обходные пути искажают программы и API на Java. Возьмём, например, отсутствие хорошего типа комплексных чисел для тех, кто программирует численные алгоритмы на Java.

В отличие от времён, когда проектировалась Java, современное оборудование теперь обычно оперирует значениями длиннее 64 бит, которые мы можем собирательно называть «векторами». С векторными значениями трудно работать из Java, потому что их нельзя напрямую представить в коде Java без создания временного объекта. Для хранения компонентов такого векторного значения можно создать изменяемый объект (часто массив), но это лишь обходной путь, который нельзя считать прямым представлением значения, поскольку один и тот же объект будет (в общем случае) последовательно хранить ряд значений. Можно также создать неизменяемый объект, который будет прямым представлением, но каждое новое значение требует создания нового объекта. Затраты часто настолько высоки, что отбивают у программистов желание использовать прямое представление.

Существуют оптимизации, которые могут устранить выделение памяти под объекты в некоторых участках кода. Например, объекты иногда можно «скаляризовать», разложив на составляющие поля, если удаётся провести escape-анализ. Однако у таких оптимизаций ограничены область действия и применимость. При невстроенных вызовах объекты по-прежнему приходится упаковывать в память, пока соблюдается существующая семантика ссылок Java. Без новых правил, ослабляющих семантику ссылок, нельзя рассчитывать, что локальный escape-анализ уберёт издержки упаковки объектов, какими бы похожими на примитивные типы они ни были.

Нам нужны новые правила, которые позволят обычным образом представлять некоторые объекты Java как неупакованные группы скалярных значений и/или векторных регистров, а (когда это не удаётся) — представлять значения напрямую и эффективно как неизменяемые упакованные значения.

Описание

В Valhalla класс из категории Value Classes (классы-значения) определяется с особой конфигурацией режима в файле класса, которая предписывает JVM сделать все экземпляры класса свободно копируемыми, без Identity, не поддерживающими блокировку и допускающими широкие пополевые сравнения (acmp, ==).

Мы добавляем вторую конфигурацию режима, которая позволяет создавать larval-экземпляры. Контекстный модификатор __CanBeLarval (написание будет выбрано позже) помечает класс с такой конфигурацией как так называемый «value-класс, допускающий larval-состояние».

В классе, допускающем larval-состояние, методы и конструкторы можно объявлять как __CanBeLarval. В теле такого метода или конструктора язык позволяет изменять поля напрямую при условии, что текущий экземпляр должен находиться в larval-состоянии (изменяемом), а не в adult-состоянии (неизменяемом). Это будет обеспечиваться сочетанием статических и динамических проверок (TBD), которые также будут обеспечивать привязку к потоку там, где это нужно.

Объект в larval-состоянии (изменяемом) может принимать вызовы как larval-методов, так и обычных методов. Larval-конструктор возвращает объект в larval-состоянии. (Публичные larval-конструкторы изящно вписываются в некоторые варианты проектирования API — для шаблонов, подобных builder.)

Объекты в larval-состоянии обладают Identity, а объекты того же класса в adult-состоянии — нет. При необходимости мы можем определить отдельные статические типы C и C.larval, чтобы их не путать. При необходимости мы можем также определить отдельные динамические типы или просто разрешить единственному динамическому типу иметь «бит режима» в заголовке каждого экземпляра.

У классов, допускающих larval-состояние, есть протокол, который управляет переходом между larval- и adult-фазами. По близкой аналогии с предложениями о замороженных массивах есть интерфейс (внедряемый в каждый класс, допускающий larval-состояние), который выглядит так:

interface LarvalCapable<T extends LarvalCapable<T>> {
  T freeze();  // adult snapshot of larval state; fast no-op on adult
  T clone();   // new confined larval state (on either larval or adult input)
}

(Если основать этот протокол на интерфейсе, методы должны быть публичными. Неясно, нарушает ли это инкапсуляцию. В конце концов, если у класса нет публичных методов, работающих с larval-состоянием, какой вред от вызова freeze или clone?)

Можно применить freeze к одному и тому же larval-объекту несколько раз и получить, возможно, разные версии adult-значения объекта.

Некоторые концепции привязки к потоку допускают передачу между потоками. Если larval-объекты будут поддерживаться таким образом, примитивы синхронизации (monitorenter и т. д.) дают естественный язык для передачи владения larval-объектом между потоками. Сразу после создания larval-объект находился бы в заблокированном состоянии (в отличие от сегодняшних объектов без привязки к потоку). Операция unlock/park сбрасывала бы состояние блокировки, не замораживая объект и не переводя его в adult-состояние. После этого обычные синтаксические конструкции синхронизации позволяли бы потокам захватывать ранее «припаркованный» объект. В конечном итоге larval-объект переводился бы в adult-состояние своим методом freeze.

Альтернатива вне Valhalla

Некоторые преимущества этого предложения, возможно, удастся получить и вне проекта Valhalla, без изменений языка или JVMS, если сделать изменяемую форму класса той, что определена в файле класса, и добавить неизменяемость. В присутствии Valhalla это не кажется желательным, поскольку обычный способ взаимодействия с value-классом — через его неизменяемую (свободно копируемую) форму, а не через специальную larval-форму.

Для полноты картины приведём набросок.

Будет определён новый оператор lockPermanently, который принимает Object и помечает его как неизменяемый и не допускающий псевдонимов.

(Этот оператор двойствен операции unlock/park из предложения выше.)

Формально этот оператор используется так же, как Object.clone, за исключением того, что будет возвращён исходный объект в новом заблокированном состоянии. Как и clone, оператор будет идемпотентным.

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

В общем случае к навсегда заблокированному объекту нельзя осмысленно применить никакую операцию, зависящую от идентичности ссылки на объект. Операция зависит от идентичности ссылки, если она даёт разные результаты в зависимости от того, применяется ли она к исходному объекту или к одному из его клонов. Поэтому:

  • Поля и элементы массивов нельзя изменять.
  • Синхронизацию выполнять нельзя.
  • Методы ожидания и уведомления вызывать нельзя.
  • Идентификационный хэш-код получить нельзя.
  • Проверки равенства указателей выполнять не следует.

За исключением проверок равенства указателей, запрещённые операции будут выбрасывать то или иное исключение. Как контролировать проверки равенства указателей — открытый вопрос, на который есть несколько возможных ответов.

Постоянная блокировка будет применяться к типам-обёрткам Integer, Boolean и т. д. Стандартные методы упаковки примитивов valueOf будут создавать навсегда заблокированные объекты.

Постоянная блокировка будет применяться ко всем типам массивов. Вероятно, будет предоставлен перегруженный метод, например Arrays.lockedCopyOf.

На статическую форму классов, поддерживающих блокировку, будут наложены ограничения. (Например: все поля должны быть final и/или должен быть объявлен интерфейс-маркер.) Для классов, ориентированных на значения (таких как Complex) и поддерживающих блокировку, будет свой шаблон проектирования.

Когда объект заблокирован, JVM может гораздо свободнее упаковывать и распаковывать его. В частности, JVM сможет вставлять виртуальные операции переупаковки при любом вызове метода для любого упакованного объекта, который является аргументом метода или возвращаемым значением. Операция переупаковки может заменить одну ссылку на эквивалентную ей. Переупаковка может как создавать совершенно новые копии объекта, так и повторно использовать старую. Повторное использование может быть глобальным, поэтому JVM разрешено (но не обязательно) выполнять интернирование.

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

Специализированные последовательности вызова, ориентированные на значения, можно ограничить скомпилированным кодом. Интерпретатор может продолжать работать так же, как сегодня.

Дополнительные заметки по проектированию хранятся в репозитории MLVM.

Альтернативы

Сейчас при работе с составными значениями программистам приходится использовать то, что Рич Хикки (Rich Hickey) называет программированием, ориентированным на места (place-oriented programming), в изменяемых массивах или буферных объектах. Они могли бы просто продолжать так делать.

Следует подчеркнуть, что программирование, ориентированное на места, в Java — антипаттерн, потому что оно размывает соответствие между переменными Java и значениями приложения. Если значение неявно определяется тем, что хранится в двух или более переменных Java, с ним нельзя работать напрямую как с одной переменной, аргументом метода или возвращаемым значением. Программист вынужден управлять «местами», допускающими псевдонимы, для хранения компонентов значения, а также значениями, хранящимися в них. Любому оптимизирующему компилятору трудно отличить одно от другого.

Локальный escape-анализ можно было бы расширить (героическими усилиями) до межпроцедурного escape-анализа, позволив методам передавать поля объектов в регистрах и откладывая создание объекта, возможно, бесконечно. Без новых правил для навсегда заблокированных объектов существующие правила изменяемости полей и отслеживания идентичности объектов потребовали бы передавать между методами вместе со значениями полей объекта сложные и, вероятно, неработоспособные дополнительные данные.

Мы могли бы добавить в JVM новые явные типы кортежей, получив более явный третий вариант между классами и примитивами.

В случае массивов мы могли бы ввести новый набор типов для представления неизменяемых значений-массивов вместо того, чтобы предоставлять средства блокировки массивов. Это сбивало бы программистов с толку и приводило бы к дополнительному копированию между рабочими изменяемыми значениями-массивами и новыми неизменяемыми массивами.

Мы могли бы также использовать для массивов, неизменяемых de facto, существующие правила (довольно тонкие правила Java Memory Model), согласно которым массив, доступ к которому осуществляется только через final-поле, может считаться стабильным, а изменения считаются состояниями гонки. Эти текущие правила требуют обязательного защитного копирования массивов перед сохранением их в final-полях объектов, которые могут быть общими. Текущее предложение лучше, потому что делает шаг стабилизации (часто это барьер памяти) более явным. Кроме того, оно энергичнее защищает от состояний гонки, выбрасывая исключение при попытке записи в навсегда заблокированный массив. Наконец, в текущем предложении можно избавиться от защитных копий.

Концепцию постоянной блокировки можно было бы применять к классу (или другим типам), а не к отдельным объектам. Это могло бы упростить некоторые оптимизации JVM, хотя на практике некоторые классы (например, Integer) будут заблокированы на 99,99 %.

Преимущества блокировки на уровне отдельных экземпляров:

  • Понятная модель изменяемости во время инициализации.

  • Простое взаимодействие с существующими типами массивов Java.

  • Совместимость со ссылочным использованием обёрток (new Integer(n) и Integer.valueOf(n)).

JIT-компиляторы могут применять оптимистичные техники, чтобы специализировать код для Value Objects, даже если некоторые случаи использования ссылочные.

(Подробнее об изменяемости во время инициализации объектов, доступных только для чтения, см. larval objects.)

Примечание: ни одна из этих альтернатив, по-видимому, не обещает механизма для регулярной распаковки составных значений (или даже значений Integer) через границы вызовов методов.

Тестирование

  • Существующие тесты операций с массивами следует адаптировать, чтобы они проверяли корректное соблюдение блокировки.

  • Существующие тесты операций с полями объектов (где это применимо) следует адаптировать, чтобы они проверяли корректное соблюдение блокировки.

  • Аналогично следует адаптировать существующие тесты синхронизации, identity-хэширования и сравнения указателей.

  • Тесты конкурентности должны проверять, что блокировка объекта корректно сбрасывает его поля для публикации.

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

  • Бета-версии системы следует предоставить разработчикам и конечным пользователям, чтобы убедиться, что существующие приложения не нарушают новые ограничения на значения-обёртки (Integer и т. д.). Могут потребоваться обходные решения.

  • Для проверки механизма следует реализовать примеры value-типов и экспериментально их использовать.